nova storm: add filtered-search support#46
Draft
Seth Ockerman (OckermanSethGVSU) wants to merge 2 commits into
Draft
nova storm: add filtered-search support#46Seth Ockerman (OckermanSethGVSU) wants to merge 2 commits into
Seth Ockerman (OckermanSethGVSU) wants to merge 2 commits into
Conversation
Mirror nova-bf's Filter/FilterCondition schema (must/should/must_not of match/range/match_text, plus per-query match_from_query/range_from_query/ match_text_from_query variants) in a new backend-agnostic filter.rs, wire it into QueryConfig, and translate it to Qdrant's own Filter/Condition types in targets/qdrant.rs. Per-query filter values are read alongside each query's vector in queries.rs and carried on QueryVector, which required changing QueryTarget::query_batch to take &[&QueryVector] instead of &[&[f32]] so a backend can see both the vector and any per-query filter values. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adversarial code review (8 finder angles + verification) surfaced several real bugs, all fixed here: - Empty `match: []` bypassed validation and silently produced a Qdrant MatchAny that matches nothing forever, with no config or runtime error. - A large integer `_from_query` value (e.g. a BIGINT tenant id above 2^53) silently lost precision round-tripping through f64 before being cast back to i64 for a Qdrant match -- now kept exact via a new FilterFieldValue::Int. - DECIMAL-typed `_from_query` columns hard-failed the whole query load. - A blank/whitespace `match_text_from_query` resolved value reached Qdrant's full-text match with no validation (the literal match_text case was already checked; the per-query variant wasn't). - A per-query filter translation failure reported a fake zero latency for the whole batch instead of the real elapsed time, skewing the run's latency percentiles low every time the same query recurred. - A filter with one per-query condition deferred validation of its OTHER, fully-static sibling conditions to request time instead of failing fast at startup like a purely-static filter does -- now validated eagerly too. - Filter::validate() is now also called at target-construction time (not just YAML parse time) as defense in depth for a hand-built StormConfig. Also verified the whole feature end-to-end against a live Qdrant instance using real MS MARCO data (5000 real points, static + per-query filters across match/match_text/range and must/should/must_not), confirming every filter combinator narrows results exactly as expected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
nova storm, shaped likenova-bf's ownFilter/FilterCondition(must/should/must_notofmatch/range/match_text, plus per-querymatch_from_query/range_from_query/match_text_from_queryvariants that pull each query's comparison value from a column in the queries parquet).crates/nova-storm/src/filter.rsowns the config schema + validation;crates/nova-storm/src/targets/qdrant.rsowns the translation into Qdrant's ownFilter/Conditiontypes (backend translation is inherently backend-specific — e.g. Qdrant has no float-equality match, so a floatmatchvalue is rejected there with a clear error).QueryVectornow carriesfilter_valuesfor any_from_query-referenced columns, read alongside the vector inqueries.rs; a NULL in one of those columns is a load-time error (mirrorsnova-bf's own "use a non-matching placeholder, not NULL" convention).QueryTarget::query_batchnow takes&[&QueryVector]instead of&[&[f32]]so a backend can see per-query filter values alongside the vector (internal trait, one implementor — no back-compat shim).### Filtering (query.filter)section indocs/reference/cli.mdunder## nova storm, with a static and a per-query example.Adversarial review
Ran an 8-angle adversarial review (line-by-line, removed-behavior, cross-file trace, reuse, simplification, efficiency, altitude, conventions) with 1-vote verification on every surviving candidate. Confirmed and fixed:
match: []bypassed validation → silently produced a Qdrant MatchAny that matches nothing, forever, with no error._from_queryvalue (e.g. a BIGINT tenant id above 2^53) silently lost precision round-tripping throughf64before casting back toi64for a Qdrant match — fixed via a newFilterFieldValue::Intthat stays exact._from_querycolumns hard-failed the whole query load.match_text_from_queryresolved value reached Qdrant's full-text match unvalidated (the literalmatch_textcase was already checked; the per-query variant wasn't).Filter::validate()is now also called at target-construction time (defense in depth for a hand-builtStormConfig, not just the YAML-parse path).Deferred (reported, not fixed — lower severity / larger scope): a few DRY-ability nits (duplicated int-width enumeration, duplicated
has_bound/MatchAny-homogeneity logic, a "lookup or panic" pattern now at least consolidated into one helper),static_filter/per_query_filtermutual exclusivity being convention-enforced rather than type-enforced (anenumwould be stronger), and a confirmed-but-secondary perf finding: a per-query filter is re-translated from scratch on every dispatch instead of cached once perQueryVector(real, but requires restructuring howQdrantTargetis constructed to fix properly).Live end-to-end test
Verified against a live Qdrant instance with a real 5000-point MS MARCO collection (
bf_test_cos, 768-dim cosine, realtext/domain_bucket/text_lenpayload) — not synthetic data. Built a queries parquet from the collection's own real vectors/payload (each point queries itself; ground truth = its own id,top_k=1, so recall is a clean binary "did the filter still let us find ourselves" signal) and ran 11 configs covering every filter combinator:match: medicalmatch:a bucket that doesn't existmatch_from_queryown bucketmatch_from_querya guaranteed-wrong bucketmatch_text_from_queryown first wordmatch_text_from_querya word that appears nowhererange_from_query: {lte: own_text_len}range_from_query: {lt: own_text_len}(excludes self)should: match: [medical, academic_scientific]must_not: match: government_legal(same set, complementary)Every result matched its prediction exactly, including the two independent constructions of the same 0.7518 subset (
should+MatchAnyvs.must_not) agreeing with each other — strong evidence the filter is actually narrowing the candidate set correctly for every condition kind and combinator, not just passing config validation.Test plan
cargo test -p nova-storm— 55 tests pass (16 new: filter schema/validation incl. empty-MatchAny/precision/decimal regressions, per-query column loading + NULL handling, Qdrant translation for every condition kind + float/mixed-type/blank-text rejection, eager static-sibling validation, static-vs-per-query baking at construction).cargo clippy -p nova-storm --all-targets— clean.cargo build --workspace— clean.🤖 Generated with Claude Code