fix(search): knn beside query takes the indexed route again (#892) - #895
Conversation
Motivation: rc.72 shipped this regression knowingly. #825/#879 made a top-level `knn` beside a `query` CORRECT — union hit set, `query_score + knn_score`, aggs over the union — but every such request was then answered by a full stored-document scan of every segment, measured at 265.9 ms against 0.45 ms for the lexical half alone on 100 000 documents. Root cause: `pin_knn_clause` pre-executes the vector leg and splices its top-k back in as `Bool{should:[Constant{score, Ids{[id]}} × k], filter:[Ids{all}], msm:1}`. `Ids` has no arm in the FTS projection — `_id` is not in the term dictionary — so `query_node_to_fts_*`'s `should` loop `?`-aborted on it, `fts_query` came back `None`, `needs_fts` was false, and the request fell through to `scan_stored_section_into` on every segment. It was not only slow. The stored scan admits documents in STORED-LAYOUT order and, once `all_hits` reaches `materialisation_limit`, materialises nothing further — it `continue`s to keep counting, with no score comparison (index.rs, the `if all_hits.len() >= materialisation_limit` arm inside `scan_stored_section_into`). On any index with more lexical matches than the page cap, the documents reached by BOTH halves — exactly the ones #825 exists to score `query_score + knn_score` and rank first — never entered the page if they sat late in the segment. `hits.total` stayed exact; the page was wrong. Live-verified on 100 000 documents, `match` + `knn` k=10 whose vector top-10 held exactly two lexical matches: rc.72's top 20 was twenty lexical-only documents tied at 1.693147 and neither both-halves document appeared; with this fix they rank 1 and 2 at 3.152876 and 3.143735. Fix: give the FTS query language a leaf whose match set and per-document scores are supplied by the caller, and project the pinned disjunct onto it per segment. * xerj-fts/src/search.rs — new `Query::DocScores(DocScoresQuery)` carrying `(doc_id, score)` pairs. It emits one `ScoredHit` per pair; `execute_bool` is a set-and-score combiner over `Vec<ScoredHit>`, so nothing else in the searcher changed. The vector leg has already run, so these scores are known facts — no term dictionary, no postings decode, no scorer. * xerj-engine/src/index.rs — `pinned_constant_ids_pairs` recognises exactly the sub-tree `pin_knn_clause` emits and returns its `(id, score)` pairs; `PinnedIds` carries either a shape probe (no segment in hand) or one segment's `_id` → stored-position map; `query_node_to_fts_projected` checks for that shape before the `match` and lifts it to a `DocScores` leaf. Positions come from `id_pos_map_for` — the existing, cached per-segment id index that `ids` queries and `_mget` already resolve through. The query tree is UNCHANGED, so `hits.total`, the aggregation corpus, sort, collapse, pagination, `min_score`, rescore, highlight and the memtable arm all keep the code paths they already had; only the projection learned to lift one more clause. Two supporting edits: `bool_has_nonprojectable_nonscoring` short-circuits on the pinned sub-tree (its own redundant `filter:[Ids]` would otherwise arm the residual gate, forcing `fts_cap = usize::MAX` plus an O(matches) `doc_matches_query` sweep — the same scan one layer down), and `build_collection_stats` gets the same projection so a pinned hybrid scores against index-wide BM25 statistics (#188) instead of per-segment ones. Gated on `!deletes_present`, the rule `build_ids_prefilter_cached` already applies to the `ids` prefilter: `id_pos_map_for` keeps ONE position per `_id`, so a segment physically holding two copies of a document can hand back the superseded one, and an FTS leaf never re-reads `_id` per position to notice. With ghosts present the request keeps rc.72's scan — correct, no slower than it was. Per segment, an incomplete id index falls back the same way. Measured — corpus built for this change: 100 000 documents, `text` + 8-dim `dense_vector`, ~10 % lexical selectivity, one flushed segment. Closed-loop, fresh query vector per request so the query cache cannot answer twice, medians of 2 rounds × 7 requests, both arms on the same (shared, busy) box: request rc.72 this change query alone (control) 0.45 ms 0.52 ms knn alone k=10 (control) 1.99 ms 2.12 ms bool.should[match,match] (control) 0.52 ms 0.55 ms query + knn k=10 265.9 ms 6.4 ms (41x) query + knn k=100 326.9 ms 8.4 ms (39x) query + knn k=1000 505.0 ms 27.5 ms (18x) query + knn + aggs k=10 784.1 ms 534.4 ms (1.5x) The agg row keeps most of its cost: a terms agg over this shape still materialises the whole corpus (`need_full_corpus`), a different path. Correctness capture over the same corpus, fixed query vector: `hits.total` is identical in every shape (10008 / 10891 / 10000 / 10), the agg buckets are identical, the field-sorted page is identical, and `query`-alone and `knn`-alone are identical down to the score. The hybrid page changes — that is the fix above. `_score` for the hybrid changes too: the lexical half is now exact BM25 (2.171479, the same number the identical query returns without a `knn` beside it) instead of the scan's heuristic (1.693147). CHANGELOG says so. Test proven to fail on unfixed code: `index::pinned_knn_fts_892_tests::pinned_knn_hybrid_is_answered_from_the_inverted_index` — 1 200 documents, 400 lexical matches (more than the page cap), vectors fanned so the k=10 neighbourhood is the last ten and three of them are also lexical matches. Asserts `hits.total == 407` and that the page leads with d1197, d1194, d1191, plus a route assertion on the new `#[cfg(test)]` `SEG_FTS_HANDLED`/`SEG_STORED_SCANS` instruments (the #577 `RESIDUAL_HITS_PEAK` pattern; which route a search took is invisible from the response). FAIL-BEFORE, watched: reverting ONLY the fix (`pinned_probe` forced to `None` in `search_inner`, instruments left in place) gives left: ["d0", "d45", "d102"] right: ["d1197", "d1194", "d1191"] page = ten lexical-only docs, all 1.6931472 with the `hits.total == 407` assertion still passing — the union was right, the page was not. Restoring the one line: 3 passed. Gates: cargo fmt --all --check clean; cargo clippy --release -j 8 -p xerj-fts -p xerj-engine -- -D warnings clean; xerj-engine --lib pinned_knn_fts_892 3 passed; xerj-api --test knn_beside_query_with_aggs_stays_200 (the #825 contract) 4 passed, unchanged. The ES-YAML suite was NOT run locally — its runner does DELETE /_all on a shared box; CI runs it. Its one case for this shape, yaml/vectors/96_knn_beside_query_aggs.yml, asserts hits.total and agg buckets, both shown unchanged above. xerj-engine's full lib suite was not run to completion on this box; the in-file adjacent suites were. Closes #892.
Vinz2168
left a comment
There was a problem hiding this comment.
Reviewed the full diff (index.rs + search.rs + CHANGELOG). No blocking issues — this looks correct and safe to merge.
Traced the scoring contract end-to-end: execute_bool sums should-clause scores, so once the pinned kNN disjunct lifts to a DocScores leaf and lands back inside the outer bool.should, the query_score + knn_score sum from #825 is preserved through the indexed path, not just the stored scan.
Checked for double-counting: the old heuristic IDF rescore pass (the one that manually re-added pinned_knn_scores) is gated by exact_bm25_page = fts_scored_applied && !heuristic_scored_applied. When #892 routes every segment through FTS, that gate is true and the whole rescore block — including the manual add-back — is skipped, so there's no risk of adding the vector score twice. In the mixed case (memtable non-empty, or a segment whose id_pos_map_for comes back incomplete), the gate correctly falls back to the pre-#892 heuristic + add-back path, so that case degrades to exactly the previously-correct behavior rather than a new one.
!deletes_present gate: reuses the same conservative signal build_ids_prefilter_cached already gates on, for the same reason (id_pos_map_for keeps one position per id, so a segment holding a superseded duplicate could hand back the wrong one). Good that this didn't try to be cleverer than the existing precedent.
pinned_constant_ids_pairs shape match: guard rails look right — rejects must/must_not, non-1 minimum_should_match, non-Ids filters, and duplicate ids. only_the_pinned_shape_is_lifted covers these. Agree with the docstring's framing that this is a sound shape-match (not an identity check) rather than a loophole.
DocScores as a no-op in collect_fts_query_fields/collect_fts_query_terms: correct — doesn't pollute the BM25 collection-stats pre-pass, and doesn't accidentally trip the count-only single-Term fast path (that one pattern-matches FtsQuery::Term specifically).
Test coverage: the pinned_knn_hybrid_is_answered_from_the_inverted_index fail-before test is well constructed — it builds a corpus where the both-halves documents sit late enough in the segment that the old scan's page-cap-without-score-comparison behavior would silently drop them, and asserts both hit ordering and SEG_FTS_HANDLED > 0 && SEG_STORED_SCANS == 0.
One non-blocking note: the "mixed" fallback (some segments FTS, some scan, within the same request) is correct but relies on exact_bm25_page degrading cleanly rather than being exercised by a dedicated test — a test with a non-empty memtable alongside a pinned hybrid query would make that path explicit rather than implicit. Not a reason to hold this up.
CI is green across the board. LGTM.
Vinz2168
left a comment
There was a problem hiding this comment.
Reviewed the full diff (index.rs + search.rs + CHANGELOG). No blocking issues — this looks correct and safe to merge.
Traced the scoring contract end-to-end: execute_bool sums should-clause scores, so once the pinned kNN disjunct lifts to a DocScores leaf and lands back inside the outer bool.should, the query_score + knn_score sum from #825 is preserved through the indexed path, not just the stored scan.
Checked for double-counting: the old heuristic IDF rescore pass (the one that manually re-added pinned_knn_scores) is gated by exact_bm25_page = fts_scored_applied && !heuristic_scored_applied. When #892 routes every segment through FTS, that gate is true and the whole rescore block — including the manual add-back — is skipped, so there's no risk of adding the vector score twice. In the mixed case (memtable non-empty, or a segment whose id_pos_map_for comes back incomplete), the gate correctly falls back to the pre-#892 heuristic + add-back path, so that case degrades to exactly the previously-correct behavior rather than a new one.
!deletes_present gate: reuses the same conservative signal build_ids_prefilter_cached already gates on, for the same reason (id_pos_map_for keeps one position per id, so a segment holding a superseded duplicate could hand back the wrong one). Good that this didn't try to be cleverer than the existing precedent.
pinned_constant_ids_pairs shape match: guard rails look right — rejects must/must_not, non-1 minimum_should_match, non-Ids filters, and duplicate ids. only_the_pinned_shape_is_lifted covers these. Agree with the docstring's framing that this is a sound shape-match (not an identity check) rather than a loophole.
DocScores as a no-op in collect_fts_query_fields/collect_fts_query_terms: correct — doesn't pollute the BM25 collection-stats pre-pass, and doesn't accidentally trip the count-only single-Term fast path (that one pattern-matches FtsQuery::Term specifically).
Test coverage: the pinned_knn_hybrid_is_answered_from_the_inverted_index fail-before test is well constructed — it builds a corpus where the both-halves documents sit late enough in the segment that the old scan's page-cap-without-score-comparison behavior would silently drop them, and asserts both hit ordering and SEG_FTS_HANDLED > 0 && SEG_STORED_SCANS == 0.
One non-blocking note: the "mixed" fallback (some segments FTS, some scan, within the same request) is correct but relies on exact_bm25_page degrading cleanly rather than being exercised by a dedicated test — a test with a non-empty memtable alongside a pinned hybrid query would make that path explicit rather than implicit. Not a reason to hold this up.
CI is green across the board. LGTM.
Brings rc.72 plus the three PRs that landed after it: #899 (the #751 cold-segment hydration deadlock, same file), #900 (#873 idle-cost work, same file), and #894 (autoindex scan-counter scoping). CONFLICTS AND HOW THEY WERE RESOLVED CHANGELOG.md — the only textual conflict, and a pure additive collision at the top of `## [Unreleased]`: both sides append a `### Fixed` bullet at the same anchor. Resolved by keeping BOTH, this branch's #892 entry first and main's two #751 entries after it. Verified afterwards by diffing the merged file against origin/main: the delta is exactly the #892 block and nothing else. engine/crates/xerj-engine/src/index.rs — auto-merged. #899 rewrites `stored_values_for_async` (its decode now runs on `background_pool()`) and #900 adds `per_index_map_shards` plus the WAL-descriptor tests; #892 touches the projection and `search_inner`'s gates. Disjoint regions, so the merge is a pure line-offset shift. Checked rather than assumed: the merged tree's diff against origin/main is BYTE-IDENTICAL to this branch's own diff against its merge base, hunk headers aside. Both siblings verified present after the merge (`background_pool().spawn` in the hydration path, `per_index_map_shards` at the DashMap constructor) and their tests re-run green here. REVIEW FINDINGS ADDRESSED IN THIS COMMIT BLOCKING 1 — the CHANGELOG said the indexed route is "skipped while the index has tombstones or overwrites IN FLIGHT". That is false, and it described a one-way door as a transient window. The gate is snap.segments.iter().any(|m| m.has_tombstones) || self.store.version_map.ghost_events() > 0 and `ghost_events` is documented at version_map.rs as "Monotonic by design (never decremented on merge): once an index has seen updates, the delete-aware slow paths stay on." One overwrite or one delete anywhere in an index's history disables this route for the life of the open index; the merge that purges the superseded copies does not clear it, and `force_ghost_event` re-arms it at open while `live < physical` still holds on disk. The CHANGELOG bullet now says exactly that. BLOCKING 2 — the scope of the measurement was nowhere stated. Every row of the table was taken on a freshly bulk-loaded, never-updated 100k corpus, which is the only state in which the route engages, so a reader running a mutable ES-compat index would have expected 41x and got rc.72's stored scan. The measurement lead-in now says the corpus was bulk-loaded once and never updated, and the scope bullet says an index taking updates or deletes keeps rc.72's scan with none of the speedups. The gate was NOT narrowed. Doing so means proving the #825 union/sum contract with tombstones present: `id_pos_map_for`'s `map.len() == expect_docs` guard already rejects a duplicate-bearing segment, but a tombstoned-yet-present row still resolves to a position and an FTS leaf never re-reads `_id` to notice, so the deleted document would score. That is a separate piece of work with its own proof obligation and is named as such in both the CHANGELOG and the code. NON-BLOCKING 1 — the `deletes_present` hoist moved a LIVE counter's read time ~850 lines and two `.await` points earlier. Documented at the binding: the two pre-existing F1 consumers now see a marginally staler value; the race pre-dates this change and the hoist widens it rather than creating it, and the #892 gate itself is paired with the `snap` taken on the line above, so the route it admits reads a consistent segment set. NON-BLOCKING 2 — the PR body claimed every gate "keeps the code paths they already had". Not strictly true: `query_needs_fts` flips false to true for this shape and RE-GATES three branches (the `size:0` columnar agg fast path, `count_authoritative`, and the F1 `total_count` overwrite). All three are inert here and the outcome is unchanged, but the gates are re-evaluated, so the reasoning is now recorded at the `query_needs_fts` binding instead of being left for the next reviewer to re-derive. NON-BLOCKING 4 — "byte-identical" downgraded to "identical ... one request shape per column, compared as JSON", which is what the capture actually supports. No behaviour change in this commit: it is the merge plus comment and CHANGELOG text. The #825 contract tests are untouched — `git diff origin/main -- engine/crates/xerj-api/ engine/tests/` is empty. GATES cargo fmt --all --check clean cargo build --release -j 8 -p xerj-fts -p xerj-engine clean cargo clippy --release -j 8 -p xerj-fts -p xerj-engine -- -D warnings clean cargo test --release -p xerj-engine --lib pinned_knn_fts_892 3 passed cargo test --release -p xerj-api --test knn_beside_query_with_aggs_stays_200 4 passed, file unchanged cargo test --release -p xerj-engine --test agg_corpus_hydration_deadlock 1 passed (#899 sibling) cargo test --release -p xerj-engine --lib per_index_map_tests 1 passed (#900 sibling) cargo test --release -p xerj-engine --lib an_index_that_is_not_being_written_to_holds_no_wal_descriptors 1 passed (#900 sibling) ES-YAML conformance is left to CI, as before: its runner does `DELETE /_all` and this box is shared.
Review response — merged
|
# Conflicts: # CHANGELOG.md
What
knnbeside aquery— the ES 8.x canonical hybrid — has been answered by afull stored-document scan of every segment since #825/#879 landed. This
restores the indexed route without touching the correctness contract #825
established.
Root cause
pin_knn_clause(engine/crates/xerj-engine/src/index.rs) pre-executes thevector leg and splices its top-
kback into the tree asIdshas no arm in the FTS projection —_idis not in the term dictionary —so
query_node_to_fts_*'sshouldloop?-aborted on it,fts_querycameback
None,needs_ftswas false, and every segment fell through toscan_stored_section_into.It was not only slow
The stored scan admits documents in stored-layout order, and once
all_hitsreachesmaterialisation_limitit materialises nothing further —it
continues to keep counting, with no score comparison(
scan_stored_section_into, theif all_hits.len() >= materialisation_limitarm). So on any index with more lexical matches than the page cap, the
documents reached by BOTH halves — precisely the ones #825 exists to score
query_score + knn_scoreand rank first — never enter the page if they sitlate in the segment.
hits.totalstays exact; the page is wrong.Live-verified on the 100 000-document corpus below,
match+knnk=10 whosevector top-10 contained exactly two lexical matches (
96820,83150):Fix
Give the FTS query language a leaf whose match set and per-document scores are
supplied by the caller, and project the pinned disjunct onto it per segment.
xerj-fts— newQuery::DocScores(DocScoresQuery { docs: Vec<(u32, f32)> })(
crates/xerj-fts/src/search.rs). It is a leaf that emits oneScoredHitper
(doc_id, score)pair;execute_boolis a set-and-score combiner overVec<ScoredHit>, so it needed no other change. The vector leg has alreadyrun, so these scores are known facts — there is no term dictionary, no
postings decode and no scorer involved.
xerj-engine—pinned_constant_ids_pairsrecognises exactly thesub-tree
pin_knn_clauseemits and returns its(id, score)pairs;PinnedIdscarries either a shape probe (no segment in hand) or onesegment's
_id→ stored-position map;query_node_to_fts_projectedchecksfor the pinned shape before the
matchand lifts it to aDocScoresleaf.Positions come from
id_pos_map_for— the existing per-segment id indexthat
idsqueries and_mgetalready resolve through, cached per segment.Everything else stays exactly as #879 left it. The query tree is unchanged,
so
hits.total, the aggregation corpus, sort, collapse, pagination,min_score, rescore, highlight and the memtable arm all produce the sameanswers; only the projection learned to lift one more clause. That is why the
diff does not touch the 4 700-line body of
search_innerbeyond threading oneOption<PinnedIds>.One correction to an earlier version of this sentence, which said those paths
"keep the code paths they already had": they do not, quite.
query_needs_ftsflips
false → truefor this shape, which re-gates three branches — thesize:0columnar agg fast path,count_authoritative, and the F1total_countoverwrite after the segment loop. All three are inert for apinned hybrid (the fast path additionally needs
is_match_all || agg_filter.is_some()andquery_node_to_agg_filterdeclines ashouldbool;the other two only ever remove a bounded-count shortcut, leaving the FTS path's
own authoritative
seg_hits.len()tally). The outcome is unchanged; the gatesare re-evaluated. That reasoning is now recorded at the
query_needs_ftsbinding rather than left for the next reviewer to re-derive.
Two supporting details:
bool_has_nonprojectable_nonscoringshort-circuits on the pinned sub-tree.Without that, the pinned tree's own (redundant)
filter: [Ids]armed theresidual_gate, which forcesfts_cap = usize::MAXplus anO(matches)doc_matches_querysweep — the same scan, one layer down.build_collection_statsgets the same projection, so a pinned hybrid nowscores against index-wide BM25 statistics (BM25 length normalisation is per-arm: overwriting a document moves it from last to first #188) instead of silently
reverting to per-segment ones.
Scope: append-only indexes only (gated on
!deletes_present)id_pos_map_forkeeps ONE position per_id, so a segment physically holdingtwo copies of a document (an overwrite flushed alongside its predecessor) can
hand back the superseded one — and an FTS leaf, unlike the stored scan, never
re-reads
_idper position to notice. This is the same rulebuild_ids_prefilter_cachedalready applies to theidsprefilter. Withghosts present the request keeps rc.72's scan: correct, and no slower than it
was. Per segment, a missing/incomplete id index also just falls back.
This is a one-way door, not an in-flight window, and it bounds who sees the
numbers below. The gate is
and
ghost_eventsis monotonic by design (version_map.rs: "neverdecremented on merge: once an index has seen updates, the delete-aware slow
paths stay on"). One
PUTover an existing_id, or oneDELETE, anywhere inan index's history disables this route for the life of the open index — the
merge that purges the superseded copies does not clear it, and
force_ghost_eventre-arms it at open whilelive < physicalstill holds ondisk. So an index that takes updates or deletes keeps rc.72's stored scan for
knnbesidequery: still correct, still no slower, but with none of thespeedups in the table below. Append-only indexes — bulk load,
reindex-and-swap, log and event corpora — get them.
Narrowing the gate is deliberately not attempted here. It would have to
prove the #825 union/sum contract with tombstones present:
id_pos_map_for'smap.len() == expect_docsguard already rejects a duplicate-bearing segment,but a tombstoned-yet-present row still resolves to a position and the FTS leaf
would score a deleted document. That is a separate piece of work with its own
proof obligation.
Measured
Corpus built for this PR: 100 000 documents,
text+ 8-dimdense_vector,~10 % lexical selectivity on the probe term, one flushed segment, bulk-loaded
once and never updated — which, per the scope section above, is the only
state in which this route engages. Closed-loop,
fresh query vector per request so the query cache cannot answer twice;
medians of 2 rounds × 7 requests per shape; both arms on the same box (shared
with seven sibling build agents, so absolute numbers are noisy — the ratios
are the point, and the two control rows bound the noise).
queryalone (control)knnalone, k=10 (control)bool.should[match, match](control)query+knnk=10query+knnk=100query+knnk=1000query+knn+aggsk=10The agg row keeps most of its cost: a
termsagg over this shape stillmaterialises the whole corpus (
need_full_corpusinsearch_inner). That is adifferent path and is untouched here.
Correctness capture over the same corpus, rc.72 vs this PR, fixed query vector:
hits.totalquery+knnk=10query+knnk=1000query+knn+aggsquery+knn+sortqueryaloneknnalone_scorevalues change for this shapeThe lexical half is now scored by exact BM25 — the same number the identical
query returns without a
knnbeside it (2.171479 in the capture above) —instead of the stored scan's heuristic (1.693147). Ordering follows the #825
contract as before; absolute values move, so a
min_scoretuned againstrc.72's hybrid needs revisiting. This is called out in the CHANGELOG.
Test, proven to fail on unfixed code
pinned_knn_hybrid_is_answered_from_the_inverted_index(
crates/xerj-engine/src/index.rs, newpinned_knn_fts_892_tests): 1 200documents, 400 lexical matches (more than the page cap), vectors fanned so the
k=10 neighbourhood is the last ten documents and three of them are also
lexical matches. It asserts
hits.total == 407and that the page leads withd1197, d1194, d1191— the three both-halves documents in vector order —plus a route assertion on the new
#[cfg(test)]SEG_FTS_HANDLED/SEG_STORED_SCANSinstruments (same pattern as #577'sRESIDUAL_HITS_PEAK;which route a search took is otherwise invisible from the response).
FAIL-BEFORE was run by reverting only the fix —
pinned_probeforced toNoneinsearch_inner, everything else including the instruments left inplace — exactly as #577 does. Result recorded in the commit body.
Two further unit tests cover the projection arm in isolation
(
DocScorescarries the resolved positions and pinned scores; ids the segmentdoes not hold are absent) and the shape guard rails (a
must, anmsmotherthan 1, a duplicate id, or a non-
Constantdisjunct all decline).Gates
cargo fmt --all --checkclean.cargo clippy --release -j 8 -p xerj-fts -p xerj-engine -- -D warningsclean.cargo test --release -p xerj-engine --lib pinned_knn_fts_892— 3 passed.cargo test --release -p xerj-api --test knn_beside_query_with_aggs_stays_200— the knn beside query still silently drops the vector contribution when aggs (or sort/collapse/rescore/...) is present #825 contract tests, unchanged, 4 passed.
DELETE /_allandseven agents share this box. CI runs it. The one case that covers this shape,
yaml/vectors/96_knn_beside_query_aggs.yml, assertshits.totaland aggbuckets only, both of which the capture above shows unchanged.
xerj-engine's full lib suite was not run to completion (it exceeds thetime budget on a box shared with seven build agents); the adjacent in-file
suites were.
Closes #892.
🤖 Generated with Claude Code