nova-bf: Qdrant word-tokenizer match_text semantics, single-pass + threaded filter#48
Merged
Merged
Conversation
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>
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>
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.
What
Switches
match_text/match_text_from_queryfrom the\b-regex substring approximation to real Qdrantword-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.Design
nova_bf/tokenize.py): query strings and corpus columns run the same Arrow kernels (split_pattern_regexon[^\p{L}\p{N}]+, thenutf8_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_inagainst 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 ontext+ 9 should slots onurl) tokenizes each column once, not per condition.split_pattern_regex's 2^31 per-batch list-offset ceiling (which thelarge_stringcast 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.isalnumvs 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 intokenize.py.Validation
Speedup (real data, 200k MS MARCO passages × 5,000 tiered queries, 3,505 distinct must-words, 24-core box, best of 3)
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)
compute.py's concurrent CPU-fallback dispatch — same-field columns are still tokenized once per filter.params-tunable likeio_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."Move filter eval off the scoring path" turned out to already be implemented — evaluation runs inside the
io_workersreader threads (verified end-to-end: wall-clock < read+filter+GPU components; with the old filter code the same pipeline scales 92s→24.5s going fromio_workers1→6), so no pipeline change was made.Review fixes folded in: loud
ValueErroron 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