Skip to content

nova-bf: Qdrant word-tokenizer match_text semantics, single-pass + threaded filter#48

Merged
Seth Ockerman (OckermanSethGVSU) merged 3 commits into
masterfrom
filter-optimizations
Jul 17, 2026
Merged

nova-bf: Qdrant word-tokenizer match_text semantics, single-pass + threaded filter#48
Seth Ockerman (OckermanSethGVSU) merged 3 commits into
masterfrom
filter-optimizations

Conversation

@OckermanSethGVSU

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

Copy link
Copy Markdown
Collaborator

What

Switches match_text/match_text_from_query from the \b-regex substring approximation to real Qdrant word-tokenizer semantics (split on non-alphanumeric, lowercase each token, AND of query tokens vs the row's token set), and rebuilds the evaluator around a single tokenization pass per field (thread-parallel, byte-bounded batches) instead of a column scan per distinct query word. Builds on this branch's earlier commits (large_string cast, B+D single-pass hybrid), replacing the bit-identical hybrid with the full semantics switch.

Semantics change (deliberate): ground-truth bits move on hyphen/punctuation/underscore/unicode edges — high-fat now matches "high fat diet", C++ tokenizes to c, com_content contains token content. In exchange, nova-bf now computes exactly what a Qdrant word/lowercase: true full-text index computes. Old GT files with text filters should be regenerated.

Design

  • One tokenizer definition (nova_bf/tokenize.py): query strings and corpus columns run the same Arrow kernels (split_pattern_regex on [^\p{L}\p{N}]+, then utf8_lower — split-then-lower, Qdrant's order). No cross-language equivalence to maintain.
  • filters._token_row_masks: per field, one pass — split/lower each batch, index_in against the query vocabulary, one boolean scatter per batch into a shared (n_tokens, n_rows) grid from a thread pool (disjoint row ranges → race-free; Arrow kernels release the GIL). Cost is ~independent of query-vocabulary size.
  • evaluate() unions token needs across all of a filter's text conditions per field: the production ms_marco shape (must on text + 9 should slots on url) tokenizes each column once, not per condition.
  • Batches are byte-bounded (32MB text each): bounds transient memory on huge-document corpora and stays far under split_pattern_regex's 2^31 per-batch list-offset ceiling (which the large_string cast alone did not fix).

Review (8-angle, adversarially verified)

The review caught a real correctness bug in the first draft: query-side tokenization via Python str.lower/str.isalnum vs corpus-side Arrow — the two disagree on thousands of codepoints (Unicode-version skew; full-vs-simple case mapping), reproduced end-to-end (İstanbul, Greek final-sigma words silently matched nothing). Fixed by routing query strings through the same Arrow kernels; a dedicated test pins the divergent codepoints. Also from review: the residual list-offset overflow, transient-memory bounding, per-field pass sharing, and the direct-scatter design (no argsort/searchsorted).

Known residual divergence vs Qdrant: Arrow utf8_lower = simple case mapping; Qdrant (Rust) = full mapping. Differs on ~50 codepoints (word-final Σ, İ). Documented in tokenize.py.

Validation

  • 266 tests pass, incl. 90 fuzz cases asserting the Arrow pipeline against an independently formulated pure-Python reference, and pinned tests for the semantics change and the divergent codepoints.
  • Live Qdrant parity (1.18.2, word tokenizer + lowercase index): 300/300 queries EXACT — same match sets — on 20k real MS MARCO v1.1 passages × 300 real tiered queries (production filter shape). The old approximation had ~8/300 documented divergences (underscore / non-ASCII adjacency); they're gone.

Speedup (real data, 200k MS MARCO passages × 5,000 tiered queries, 3,505 distinct must-words, 24-core box, best of 3)

version time speedup
master (per-word regex + literal prefilter + word cache) 125.5s 1x
branch B+D hybrid (73628c2) 7.3s 17x
this PR (tokenized, single-pass/field, threaded) 3.0s 41x

Match-set size differs from master by only ~0.03% of true cells (the semantics change), and the parity run shows the new bits are the Qdrant-correct ones.

Follow-ups (out of scope)

  • Cross-filter (per-file) tokenization sharing in compute.py's concurrent CPU-fallback dispatch — same-field columns are still tokenized once per filter.
  • Making batch bytes / worker cap params-tunable like io_workers.

Round 2 (a753fb6): fused query-major combine

Profiling showed 83% of the remaining filter time was memory traffic: each per-query text condition expanded to its own (n_queries, rows) boolean array (10 arrays + combines ≈ 20GB traffic per 200k-row file). evaluate() now groups queries by their combo of token sets across the filter's text conditions, computes each distinct combo's combined (rows,) result once from the shared per-token masks, and writes each query's finished row exactly once. Bit-identical — 50-seed A/B fuzz vs a condition-major reference, an independent 400-seed differential fuzz vs the previous commit, full-scale bit-equality on 200k×5000, and live-Qdrant parity re-verified 300/300 exact.

version time (200k×5k, best of 3) cumulative speedup
master 125.5s
tokenized single-pass (56d554c) 2.95s 43×
+ fused combine (a753fb6) 0.87s 144×

"Move filter eval off the scoring path" turned out to already be implemented — evaluation runs inside the io_workers reader threads (verified end-to-end: wall-clock < read+filter+GPU components; with the old filter code the same pipeline scales 92s→24.5s going from io_workers 1→6), so no pipeline change was made.

Review fixes folded in: loud ValueError on mismatched query-column lengths (fused path would otherwise truncate silently), refcounted eviction for the per-combo phrase-mask cache, and reference-path docstrings on the now-production-dead per-condition builder. 316 tests pass.

🤖 Generated with Claude Code

Optimize the per-query `match_text_from_query` filter — the hot path for the
MS MARCO filtered run — which previously scanned the entire (multi-GB) text
column once per distinct query word: O(distinct_words × text_bytes).

- B (single tokenization pass): `_simple_word_masks` tokenizes lower(text) by
  splitting on `\W+` — the SAME RE2 word class the `\b` verify uses — and
  answers each plain-ASCII `\w+` query word by token set-membership instead of
  a full-column scan. For an ASCII `\w+` word, "row matches `\bword\b`
  (case-insensitive)" is exactly "word is one of the row's `\W`-tokens". Words
  with punctuation or non-ASCII letters (e.g. `high-fat`, `café`) can match a
  `\b`-span that isn't a whole token, so they KEEP the exact per-word regex
  path — output stays bit-identical.
- D (lowercase once): case-fold the column a single time via `utf8_lower`
  rather than paying `ignore_case` per word.
- Row-batched so the transient flattened-token array stays memory-bounded on a
  multi-GB reshard column; per-word masks match the old cache's footprint.

large_string casting for this path already landed in #47 (this branch's base).
Optimization E (sparse per-query masks) is intentionally NOT included: the
`(n_queries, rows)` mask is already bit-packed 8x (`_pack_query_axis`) and is
consumed across the GPU `select`/`cell_mask`/`_union_keep` contract, so true
sparse masks are a separate, cross-module change — left for its own PR.

Correctness: new `tests/test_filter_optimizations.py` fuzzes 90 randomized
cases (ASCII/Unicode/punctuation/nulls, both string and large_string, and the
real must+should config shape) asserting BIT-IDENTICAL output vs a reference of
the original `\bword\b` ignore_case regex. Full nova-bf suite: 263 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-pass + threaded

Replace the \b-regex substring approximation of Qdrant MatchText with the
real thing: split into maximal alphanumeric runs, lowercase each token
(split-then-lower, Qdrant's order), row matches iff every query token is
one of the row's tokens. This is a deliberate SEMANTICS CHANGE — GT bits
move on hyphen/punctuation/underscore/unicode edges — in exchange for
exact parity with a Qdrant word/lowercase full-text index: the live-Qdrant
harness now shows 300/300 queries EXACT on real MS MARCO passages, where
the old approximation had ~8/300 documented divergences.

One tokenizer definition (new nova_bf/tokenize.py): query strings and the
corpus column run the SAME Arrow kernels (split_pattern_regex on
[^\p{L}\p{N}]+, utf8_lower), so the two sides agree by construction. An
earlier draft tokenized queries with Python str.lower/isalnum — review
caught Python and Arrow disagreeing on thousands of codepoints
(Unicode-version skew; full-vs-simple case mapping: Greek final sigma,
Turkish İ) with end-to-end repro of queries silently matching nothing.
Residual documented divergence vs Qdrant: Arrow's simple case mapping vs
Rust's full mapping (~50 codepoints).

Evaluation (filters._token_row_masks) is one tokenization pass per FIELD
(evaluate() unions token needs across a filter's text conditions — the
production shape's 9 url slots tokenize url once, not 9 times), answering
every distinct query token via index_in + one boolean scatter per batch
into a shared (n_tokens, n_rows) grid. Batches are byte-bounded
(32MB text each — bounds transients on huge-document corpora AND stays
under split's 2^31 per-batch list-offset ceiling, which the large_string
cast alone did not) and scattered concurrently from a thread pool
(disjoint row ranges; Arrow kernels release the GIL).

Measured on real data (200k MS MARCO passages x 5,000 tiered queries,
production filter shape, 24-core box):
  master (regex + literal prefilter + word cache): 125.5s
  branch B+D hybrid (previous commit):               7.3s
  this change:                                       3.0s  (41x vs master)

Full suite: 266 passed. Fuzz tests assert the Arrow pipeline against an
independently formulated pure-Python reference; a dedicated test pins
query/corpus agreement on the Python-vs-Arrow divergent codepoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@OckermanSethGVSU Seth Ockerman (OckermanSethGVSU) changed the title perf(nova-bf): single-pass tokenized text filter (opts B + D) nova-bf: Qdrant word-tokenizer match_text semantics, single-pass + threaded filter Jul 17, 2026
evaluate() no longer expands each match_text_from_query condition into its
own (n_queries, rows) boolean array (10 such arrays + AND/OR combines on the
production filter shape — ~83% of filter time was this memory traffic, ~20GB
per 200k-row file). Instead, queries are grouped by their COMBO of token
sets across all of the filter's text conditions (real query sets dedupe
heavily), each distinct combo's combined (rows,) result is computed once
from the shared per-token masks, and each query's finished row is written
into the single output array exactly once. Non-text conditions (static, and
per-query match/range) combine condition-major exactly as before and fold
in via broadcast or per-combo gather.

Bit-identical by construction (AND/OR are elementwise; same formula per
(query, row) cell, evaluated query-major instead of condition-major) and by
measurement: a 50-seed A/B fuzz test vs a condition-major reference across
mixed must/should/must_not filters (text + static + non-text per-query,
nulls everywhere), an independent reviewer's 400-seed differential fuzz vs
the pre-change implementation, bit-equality on the full 200k x 5000 MS MARCO
workload, and live-Qdrant parity re-verified 300/300 exact.

Review fixes folded in: mismatched query-column lengths raise ValueError
again (the fused path would otherwise silently truncate where the old
broadcast raised); phrase-mask cache entries are refcounted per remaining
combo and evicted at zero (peak tracks live masks, not every distinct
phrase); _match_text_from_query_mask is documented as the reference/
direct-call path (production routes through the fused combine).

Measured (200k passages x 5,000 tiered queries, production shape, 24-core):
2.95s -> 0.87s (3.4x; cumulative 144x vs master's 125.5s). Also verified
end-to-end that filter evaluation already runs inside the io_workers reader
threads, overlapping GPU scoring (wall-clock < read+filter+GPU components;
with the pre-optimization filter code the same pipeline scales 92s -> 24.5s
from io_workers 1 -> 6) — the planned "overlap filter eval with scoring"
optimization was already in place, so no pipeline change was needed.

316 tests pass.

Co-Authored-By: Claude Fable 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