Skip to content

nova storm: add filtered-search support#46

Draft
Seth Ockerman (OckermanSethGVSU) wants to merge 2 commits into
masterfrom
worktree-nova-storm-filter
Draft

nova storm: add filtered-search support#46
Seth Ockerman (OckermanSethGVSU) wants to merge 2 commits into
masterfrom
worktree-nova-storm-filter

Conversation

@OckermanSethGVSU

@OckermanSethGVSU Seth Ockerman (OckermanSethGVSU) commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a backend-agnostic payload filter to nova storm, shaped like nova-bf's own Filter/FilterCondition (must/should/must_not of match/range/match_text, plus per-query match_from_query/range_from_query/match_text_from_query variants that pull each query's comparison value from a column in the queries parquet).
  • New crates/nova-storm/src/filter.rs owns the config schema + validation; crates/nova-storm/src/targets/qdrant.rs owns the translation into Qdrant's own Filter/Condition types (backend translation is inherently backend-specific — e.g. Qdrant has no float-equality match, so a float match value is rejected there with a clear error).
  • QueryVector now carries filter_values for any _from_query-referenced columns, read alongside the vector in queries.rs; a NULL in one of those columns is a load-time error (mirrors nova-bf's own "use a non-matching placeholder, not NULL" convention).
  • QueryTarget::query_batch now 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).
  • Docs: new ### Filtering (query.filter) section in docs/reference/cli.md under ## 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:

  • Empty match: [] bypassed validation → silently produced a Qdrant MatchAny that matches nothing, forever, with no error.
  • A large integer _from_query value (e.g. a BIGINT tenant id above 2^53) silently lost precision round-tripping through f64 before casting back to i64 for a Qdrant match — fixed via a new FilterFieldValue::Int that stays exact.
  • 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 unvalidated (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 real elapsed time, skewing the run's latency percentiles low every time that 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.
  • Filter::validate() is now also called at target-construction time (defense in depth for a hand-built StormConfig, 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_filter mutual exclusivity being convention-enforced rather than type-enforced (an enum would 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 per QueryVector (real, but requires restructuring how QdrantTarget is 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, real text/domain_bucket/text_len payload) — 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:

Filter Expected recall Actual
none (baseline) ~1.0 1.0
static match: medical ~2505/5000 = 0.501 0.501
static match: a bucket that doesn't exist 0.0 0.0
per-query match_from_query own bucket ~1.0 1.0
per-query match_from_query a guaranteed-wrong bucket 0.0 0.0
per-query match_text_from_query own first word ~1.0 1.0
per-query match_text_from_query a word that appears nowhere 0.0 0.0
per-query range_from_query: {lte: own_text_len} ~1.0 1.0
per-query range_from_query: {lt: own_text_len} (excludes self) 0.0 0.0
static should: match: [medical, academic_scientific] 3759/5000 = 0.7518 0.7518
static must_not: match: government_legal (same set, complementary) 0.7518 0.7518

Every result matched its prediction exactly, including the two independent constructions of the same 0.7518 subset (should+MatchAny vs. 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.
  • Live smoke test against a real Qdrant instance with real data (table above) — all 11 configs behave exactly as predicted, zero errors.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant