Skip to content

#825 knn beside query scores as a sum over the union, aggs included - #879

Merged
xerj-org merged 8 commits into
xerj-org:mainfrom
MavenRain:fix/825-knn-query-union
Aug 31, 2026
Merged

#825 knn beside query scores as a sum over the union, aggs included#879
xerj-org merged 8 commits into
xerj-org:mainfrom
MavenRain:fix/825-knn-query-union

Conversation

@MavenRain

@MavenRain MavenRain commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

fix(engine): #825 knn beside query scores as a sum over the union, aggs included

Closes #825.

Contract

ES 8.x semantics, option 1 on the issue as pinned by the follow-up comment: knn beside query is a disjunction. The hit set is the union of both halves, each document scores query_score + knn_score (boost weights each side), and aggregations are computed over the union. Not RRF.

Two wire-visible consequences, stated up front rather than buried:

Root cause

The es-compat layer folded knn + query into bool.should, but the generic scorer has no Knn arm: doc_matches_query_typed falls to the catch-all false and score_query_against_doc contributes 0.0. With the default minimum_should_match, a knn-only document was excluded entirely and a lexical match lost its vector score. The #458 hybrid_safe carve-out routed one narrow shape through RRF, which diverges from ES scoring and rejects aggs with a 400; every other shape (aggs, sort, collapse, rescore) hit the silent drop.

Fix

Engine-side rewrite so every request feature works through the one generic path:

  • pin_knn_clause (index.rs) pre-executes the knn leg with the existing executors. HNSW only when the leg is plain (no filter, boost, or similarity cutoff, and no aggs on the request, preserving the rc.6 exact-aggs rule); otherwise brute force. An empty non-timed-out leg still raises the knn against an index with no dense_vector field returns 0 hits / HTTP 200 with no error (silent false-negative) #498 unanswerable-field 400.
  • The top-k comes back as bool.should of Constant{score, Ids{[id]}} pinned clauses. Bool sums clause scores, so each pinned doc adds exactly its knn score to whatever the query half scores. The aggregation pass re-runs the matcher over the full corpus, so pinned docs reach facets with no extra plumbing. Sort, collapse, rescore, highlight, and min_score all see the rewritten tree through the normal path.
  • compound_bool_direct_knn / replace_direct_knn_with_pinned do the tree surgery; the rewrite only fires for a compound bool with exactly one direct Knn child (the shape the compat fold emits). The native hybrid query type and the multi-knn-array 400 are untouched.
  • es_compat.rs: the fix(es-compat): knn beside query returned the lexical answer and called it hybrid #458 hybrid_safe block and RRF fold are deleted; the fold is now unconditionally bool.should[query, knn].

Review round 2 — the pinned score survives the post-scan rescore

The summed-score contract held only while the lexical half was a single scoring text clause. With a bool carrying two or more ({"query":{"bool":{"must":[{"match":{"title":…}},{"match":{"body":…}}]}},"knn":{…}}) the post-scan IDF heuristic rescore in search_inner fired on the pinned tree and rewrote _score from term frequencies alone — dropping the vector half for exactly the documents reached by both halves, while a vector-only document (tf 0, heuristic 0) kept its kNN score and could then outrank them. Watched fail-before on the two-document fixture: both came back at 0.61739457, byte-identical, vector contribution gone.

query_uses_bool_text could not see the difference: the user's inner bool supplies the ≥2 text clauses and the pinned sub-tree contributes 0 text children, so it neither disqualifies nor suppresses. Nothing else declines either — scored_fast_plan bails on Constant{Ids} and the FTS projection declines on Ids, so both scored_fast_applied and exact_bm25_page are false.

Fixed by carrying the pinned clauses' id → knn-score map (collect_pinned_knn_scores, read back out of pin_knn_clause's own output only — a user-supplied constant_score{filter:{ids}} keeps its pre-existing behaviour) and adding it back when the rescore rewrites a hit, rather than suppressing the rescore: the lexical half keeps the IDF weighting the pass exists to provide. The neighbouring TF-IDF max_score < 0.001 fallback is inert here — its extract_query_text answers None for any Bool — verified by reading, not assumed.

Review round 2 — the semantic_text hint stops nagging the hybrid

Build + Test was red on the reviewed head: es_compat::semantic_text_lexical_hint_tests::a_top_level_knn_over_the_companion_vector_suppresses_the_hint. The hand-rolled ES hybrid over a semantic_text companion vector came back carrying the lexical_on_semantic_text hint, telling the caller their embedding "was NOT consulted" — when it was. That hint runs on the effective body, after knn is folded into query and body.knn is None, so suppression depends entirely on dispatching_vector_fields recognising the folded tree; its bool arm only descended into a bool holding exactly ONE candidate. Under #458 the request folded to {"hybrid":…} and the hybrid arm walked it; unconditional bool.should[query, knn] is two candidates. The bool arm now mirrors compound_bool_direct_knn clause for clause, scoped to knn; semantic keeps the #394 rule.

Tests

knn_beside_query_with_aggs_stays_200.rs, reframed from #458 to #825:

  • aggs test: a vector-only document (query vector match, lexically unreachable) must appear in hits AND in its term bucket (aggs over the union), and vector scores must separate two BM25-tied lexical docs.
  • union test: the doc matching both halves must outrank a perfect vector-only doc (sum ordering).
  • rescore test: renamed, core assertion flipped. The vector doc must now be PRESENT (under fix(es-compat): knn beside query returned the lexical answer and called it hybrid #458 it was asserted absent).
  • bool-query test: two documents with identical lexical content and different vectors, behind a two-clause bool lexical half — the one shape that reaches the IDF rescore. near is indexed second so a collapsed tie would sort it last rather than accidentally passing.

New ES-compat YAML case vectors/96_knn_beside_query_aggs.yml (per CONTRIBUTING): over HTTP, knn beside query with a terms agg returns the 3-document union and buckets over it (a:2 including the lexically-unreachable vector doc's sibling, z:1 the vector-only doc); a no-aggs variant asserts the same union. It runs as part of the full suite, and the ES-compat YAML conformance job was green on the reviewed head.

Correction to the original evidence claim. The first commit body said "the union, facet, ordering, and rescore assertions fail on the unfixed code". That is only true of the aggs test and the rescore test. knn_beside_query_returns_the_vector_only_document, including its hits[0] == "lex" sum-ordering assertion, passes unchanged on the unfixed code: that request is hybrid_safe under #458, so it went to the RRF executor, which also returns both documents and also ranks "lex" first (1/61 + 1/62 = 0.0325 vs 1/61 = 0.0164). The assertion does not discriminate sum from RRF on that fixture. The real fail-before evidence is the aggs test, the rescore test, and the new bool-query test.

Known cost — measured, not asserted

A pinned tree contains ids clauses, which query_node_to_fts cannot lift (its should loop ?-aborts on the first unprojectable clause), so fts_query is None and the request is answered by a stored-document scan of every segment instead of the inverted index.

100 000 documents (text + 8-dim dense_vector, ~10 % lexical selectivity, 1 shard), closed-loop latency, fresh query vector per request so the query cache cannot answer twice (an identical-body loop reported 0.4 ms for a 213 ms request — the cache mirage). Medians over 2 interleaved rounds × 7 requests on one data directory:

request main (RRF where eligible) reviewed head this PR now
query alone 0.3 ms 0.3 ms 0.3 ms
knn alone, k=10 1.2 ms 1.1 ms 1.1 ms
query + knn k=10 2.5 ms 248 ms 208 ms
query + knn k=100 3.5 ms 436 ms 227 ms
query + knn k=1000 18.4 ms 2 309 ms 385 ms
query + knn k=10000 168 ms 24 437 ms 5 071 ms
query + knn + terms agg, k=10 476 ms (kNN half dropped — wrong answer) 643 ms 605 ms

Read honestly, that is two different trades:

  • For the shapes that already carried the silent drop (aggs/sort/collapse/rescore), the cost is about a quarter more and the answer goes from wrong to right.
  • For the no-extras shape, fix(es-compat): knn beside query returned the lexical answer and called it hybrid #458 routed to RRF over two indexed sub-searches at 2.5 ms. This PR answers it correctly at ~208 ms — an ~80× regression on the canonical hybrid. That is real and it is not hidden.

Where the floor comes from: it is not new machinery. Any bool.should with an unprojectable clause already costs the same, on main as here — control on the same corpus, no knn anywhere:

request (no knn anywhere) main reviewed head
bool.should[match, match] (projectable) 10.4 ms 9.8 ms
bool.should[match, ids] (unprojectable) 227.6 ms 237.1 ms

What changed is the routing: the no-extras hybrid now lands where every other shape of the same request already landed. Restoring an indexed route means projecting the lexical half alone and merging the ≤ k pinned documents back in afterwards (the FTS scored path already holds a score for every lexical match) — that touches hit merging, hits.total, the agg corpus, sort/collapse/pagination and the memtable arm, and is deliberately not a drive-by inside a correctness fix. Tracked with the full design and these numbers as #892.

The O(docs × k) surcharge — fixed

On the reviewed head each document also paid k clause evaluations, each re-reading _id out of the source map — the 24.4 s at k=10000 above. Two changes remove most of it:

  • pin_knn_clause emits the pinned sub-tree with a redundant filter: [Ids{all k}] accelerator (same match set, contributes nothing to _score), and the scan evaluates filter before should — so a document outside the top-k is rejected by one _id lookup plus a memcmp sweep instead of recursing into all k clauses. minimum_should_match: 1 is written out explicitly so the tree still says disjunction.
  • doc_matches_query_typed's should count short-circuits at min instead of .filter(…).count()-ing every clause on every document. Same predicate — and a document the lexical clause already matched no longer walks the pinned sub-tree at all.

k is still unbounded on the wire (knn_clause_k takes explicit k, else num_candidates, else 10), so a large-k request on a large index remains expensive — bounded by the 30 s default deadline, not by a cap. Enforcing ES's own num_candidates ≤ 10000 limit would be a separate wire-behaviour change and is not smuggled in here.

CHANGELOG

An Unreleased / Fixed entry carries both user-visible consequences — the new 400 and the measured slowdown — because the repo ships that file and the cost belongs in it.

…nion, aggs included

Motivation: a top-level knn placed beside a query silently dropped the
vector contribution whenever the request also carried aggs, sort,
collapse, or rescore. The ES 8.x contract (option 1 on the issue, as
pinned by the follow-up comment) is a disjunction: the hit set is the
union of both halves and each document scores query_score + knn_score,
with aggregations computed over the union.

Root cause: the es-compat fold turned knn+query into bool.should, but
the generic scorer has no Knn arm. doc_matches_query_typed fell to the
catch-all false and score_query_against_doc contributed 0.0, so with
the default minimum_should_match a knn-only document vanished and a
lexical match lost its vector score. The xerj-org#458 hybrid_safe carve-out
papered over one shape with RRF, which is itself a scoring divergence
from ES and rejects aggs with a 400.

Fix: pre-execute the knn leg inside the engine, then splice its top-k
back into the tree as bool.should of Constant{score, Ids{[id]}} pinned
clauses (pin_knn_clause plus compound_bool_direct_knn and
replace_direct_knn_with_pinned in index.rs). Bool sums clause scores,
so pinned docs add exactly their knn score to any query score; the
aggregation pass re-runs the matcher over the full corpus, so pinned
docs reach facets with no extra work; sort, collapse, rescore,
highlight, and min_score all operate on the rewritten tree through the
normal path. HNSW serves the leg only when it is plain (no filter,
boost, or similarity cutoff and no aggs on the request, keeping the
rc.6 exact-aggs rule); otherwise the brute-force executor runs. An
empty non-timed-out leg still raises the xerj-org#498 unanswerable-field 400.
The es_compat hybrid_safe block and its RRF fold are deleted; the fold
is now always bool.should[query, knn].

Evidence: watched fail-before/pass-after run of
knn_beside_query_with_aggs_stays_200 (sources stashed, the union,
facet, ordering, and rescore assertions fail on the unfixed code, then
pass after the pop); the aggs test now proves a vector-only document
appears in hits and in a term bucket and that vector scores separate
BM25-tied documents; the union test asserts sum ordering beats a
perfect vector-only doc; the rescore test assertion is flipped from
absent to present. A matching ES-compat YAML case
(vectors/96_knn_beside_query_aggs.yml) asserts the union and its term
buckets over HTTP as part of the suite run. cargo fmt, scoped build,
clippy --no-deps, and the ES-YAML suite at 0 failed, all logged in the
gate log.

Known cost: a pinned tree contains Ids clauses, so the request routes
through the document-scan path rather than the FTS fast path. Correct
first; the routing can be tightened in a follow-up if profiles demand.

Closes xerj-org#825.
@cla-bot cla-bot Bot added the cla-signed label Aug 30, 2026
xerj-org added a commit that referenced this pull request Aug 30, 2026
The rc.71 cut added a `## [1.0.0-rc.71]` CHANGELOG section, which advanced
"latest cut release" past the `v1.0.0-rc.18` this file said its statuses were
verified against. `docs_capability_lists` fails the build on exactly that
(issue #298: a release cut that does not roll the roadmap forward leaves the
short-term roadmap describing the past), so main has been red since 773a7be
and every PR opened against it inherited the failure. My cut, my omission.

Bumping two lines would turn CI green while leaving the file wrong, and this
file's own header says it is authoritative over every other surface — so this
is a real re-review:

- **`nested` `score_mode` is no longer a partial.** #862 shipped in rc.71 and
  makes a nested query roll its matching children's scores into the parent per
  `score_mode`. It was listed as parsed-then-ignored in BOTH the GA
  accepted-and-ignored gate and Known partials; both now say what actually
  ships, and the GA list records what retired it. An understated roadmap is as
  wrong as an overstated one.
- **The ES-native `{query, knn}` partial stays**, with a pointer to the open
  fix (#879 / #825). A roadmap describes what ships, not what is in review —
  the line comes out when that merges, not before.
- **Next release** now points at rc.72 and describes what is actually in
  flight: the #874 idle-cost budget and its three mechanisms (#871/#872/#873),
  the merge re-analysis defect (#876), the serial fan-out (#875), the four
  ES-semantics fixes (#825/#830/#790/#781), and #751 as an explicitly
  unresolved CI reliability item. Every entry cites the measurement or issue it
  came from; none are aspirational.
- **The CHANGELOG gap is now stated in the file and added to the GA gate.**
  rc.19-rc.70 shipped without entries. A project whose pitch is verified
  numbers should not ask users to reconstruct 52 releases from git log, and
  quietly leaving "rc.1 through rc.18" in the text implied a completeness the
  record does not have.

Verified: docs_capability_lists 15/15 green (both freshness tests plus the
machine-checked capability counts).
… rescore

Review finding on xerj-org#879: the summed-score contract held only while the
lexical half was a single scoring text clause.  With a `bool` carrying two
or more (`{"query":{"bool":{"must":[{"match":{"title":…}},{"match":{"body":…}}]}},
"knn":{…}}`) the post-scan IDF heuristic rescore in `search_inner` fired on
the PINNED tree and overwrote `_score` from term frequencies alone —
discarding the vector half for exactly the documents reached by BOTH halves,
while a vector-only document (tf 0, heuristic 0) kept its kNN score and could
then outrank them.  The same silent drop xerj-org#825 exists to close, one layer down.

Root cause: `query_uses_bool_text` walks the outer bool and cannot see the
difference.  The user's inner bool returns (2, true) so `any_sub_bool` is set;
the pinned sub-tree returns (0, false), so it neither disqualifies nor
suppresses.  Nothing else declines either — `scored_fast_plan` bails on
`Constant{Ids}` and the FTS projection declines on `Ids`, so both
`scored_fast_applied` and `exact_bm25_page` are false.

Fix: carry the pinned clauses' id → knn-score map (`collect_pinned_knn_scores`,
read back out of `pin_knn_clause`'s own output — never out of the user's tree,
so a user-supplied `constant_score{filter:{ids}}` keeps its pre-existing
behaviour) and ADD it back when the rescore rewrites a hit.  Adding rather than
suppressing keeps the IDF weighting the rescore exists to provide for the
lexical half while restoring `query_score + knn_score`.  Vector-only hits score
0 under the heuristic and are already left alone by the `score > 0.0` gate, so
they keep the pinned constant untouched.

The neighbouring TF-IDF `max_score < 0.001` fallback is inert on this shape:
its `extract_query_text` answers `None` for any `Bool`, so it never rewrites a
pinned tree.  Verified by reading, not assumed.

Evidence: new test `knn_beside_bool_query_keeps_the_vector_contribution`
(two documents with identical lexical content and different vectors, `near`
indexed SECOND so a collapsed tie would sort it last).  Watched fail-before:
`near=Some(0.61739457) far=Some(0.61739457)` — the identical heuristic score,
vector contribution gone.  Pass-after with the fix.  All four tests in
knn_beside_query_with_aggs_stays_200.rs green.
… the vector

CI on this branch is red: `es_compat::semantic_text_lexical_hint_tests::
a_top_level_knn_over_the_companion_vector_suppresses_the_hint` fails on the PR
head. The hand-rolled ES hybrid over a `semantic_text` companion vector —
`{"query":{"match":{"ctx":…}},"knn":{"field":"ctx_vector",…}}` — now comes back
carrying the `lexical_on_semantic_text` hint, which tells the caller their
embedding "was NOT consulted". It was consulted. That is the hint nagging the
one caller who did the most correct thing available to them, which is exactly
the failure mode xerj-org#394 rewrote this rule to avoid.

Root cause: `lexical_on_semantic_text_hint` runs on the EFFECTIVE body, after
the knn block has been folded into `query` and `body.knn` set to `None`, so
suppression depends entirely on `dispatching_vector_fields` recognising the
folded tree. Its `bool` arm only descends when `must` + `should` holds exactly
ONE candidate — the shape `peel_knn_query` used to be able to dispatch. Under
xerj-org#458 the no-extras request folded to `{"hybrid":…}`, which the `hybrid` arm
walked, so the test passed. Retiring `hybrid_safe` makes the fold
unconditionally `bool.should[query, knn]`: two candidates, no descent, no
suppression.

The rule itself is now out of date rather than merely unlucky. xerj-org#825 changed the
underlying truth: a `knn` that is a DIRECT child of a compound bool's scoring
lists is pre-executed and its top-k pinned into the tree
(`compound_bool_direct_knn` / `pin_knn_clause`), so it dispatches however many
siblings it has.

Fix: mirror the engine's own gate in the bool arm — exactly one direct `knn`
child across `must` + `should`, `must_not`/`filter` irrelevant, matching
`compound_bool_direct_knn` clause for clause. Scoped to `knn`. `semantic` keeps
the xerj-org#394 rule unchanged, because for `semantic` it still holds: nothing
pre-executes a `semantic` clause sitting beside a sibling, and
`a_semantic_clause_that_never_reached_the_vector_is_still_flagged` continues to
prove it.

Evidence: `cargo test -p xerj-api --lib` — 229 passed / 0 failed, including the
failing test and both xerj-org#394 guards (`a_semantic_clause_that_never_reached_the_
vector_is_still_flagged`, `a_hybrid_query_over_the_semantic_field_is_not_
flagged`). CI's own failure log is the fail-before.
… real cost

Review finding 2 on xerj-org#879 is accepted, not refuted: retiring the xerj-org#458
`hybrid_safe` fold moves the canonical `knn`-beside-`query` request off the
RRF route and onto the stored-document scan.  This commit measures that on a
non-toy index, removes the part of the cost that was avoidable, and rewrites
the PR's one-line "Known cost" into numbers.

MEASUREMENT.  100 000 docs (`text` + 8-dim `dense_vector`, ~10 % lexical
selectivity, 1 shard), closed-loop latency, a FRESH query vector on every
request so the query cache cannot answer twice — an identical-body loop
reported 0.4 ms for a 213 ms request, the cache mirage this repo has been
bitten by before.  Medians over 2 interleaved rounds x 7 requests on one data
directory, `main` vs branch:

    query alone                 0.3 ms  ->    0.3 ms
    knn alone k=10              1.2 ms  ->    1.1 ms
    query + knn k=10            2.5 ms  ->  208 ms      <- the regression
    query + knn k=100           3.5 ms  ->  227 ms
    query + knn k=1000         18.4 ms  ->  385 ms
    query + knn k=10000       168   ms  -> 5071 ms
    query + knn + terms agg   476   ms  ->  605 ms      (was the WRONG answer)

The ~200 ms floor is not new machinery.  Control on the same corpus with no
`knn` anywhere, identical on `main` and the branch:

    bool.should[match, match]  (projectable)    10.4 ms / 9.8 ms
    bool.should[match, ids]    (unprojectable) 227.6 ms / 237.1 ms

So the floor is the pre-existing cost of ANY unprojectable `should` clause
(`query_node_to_fts`'s should-loop `?`-aborts, `fts_query` is None, every
segment falls to `scan_stored_section_into`).  What this PR changed is the
ROUTING: the no-extras hybrid now lands where every aggs/sort/collapse/rescore
shape of the same request already landed.  Restoring an indexed route means
projecting the lexical half alone and merging the <=k pinned documents back in
afterwards — the FTS scored path already holds a score for every lexical match
— which touches hit merging, hits.total, the agg corpus, sort/collapse/
pagination and the memtable arm.  That is follow-up work, not a drive-by
inside a correctness fix; the design is written up in the review thread.

WHAT IS FIXED HERE — the O(docs x k) surcharge on top of that floor, which was
the part that turned a legal request into a core-burn (24.4 s at k=10000):

- `pin_knn_clause` emits the pinned sub-tree with a REDUNDANT
  `filter: [Ids{all k}]` accelerator.  Same match set (the filter holds exactly
  the union of the should clauses' ids) and no effect on `_score` (only
  must/should are summed).  What it buys is the shape of the scan: the matcher
  evaluates must/must_not/filter BEFORE should, so a document outside the top-k
  is now rejected by one `_id` lookup plus a memcmp sweep instead of recursing
  into all k `Constant{Ids}` clauses and re-reading `_id` from the source map k
  times.  `minimum_should_match: 1` is written out rather than left implicit:
  with a non-empty filter the default falls to 0 ("filter alone decides"),
  which is the same match set but not the same statement.

- `doc_matches_query_typed`'s Bool arm stops counting should-clauses once
  `min` is reached.  The old `.filter(...).count()` evaluated EVERY clause on
  EVERY document even when the first already settled it — free ordinarily,
  expensive against a should-list holding one clause per pinned neighbour.
  Same predicate, and a document the lexical clause already matched no longer
  walks the pinned sub-tree at all.

    k=10      248 ms -> 208 ms
    k=100     436 ms -> 227 ms
    k=1000   2309 ms -> 385 ms
    k=10000 24437 ms -> 5071 ms

`k` remains unbounded on the wire (`knn_clause_k`: explicit k, else
num_candidates, else 10), so a large-k request on a large index is still
expensive, bounded by the 30 s default deadline rather than by a cap.
Enforcing ES's own `num_candidates <= 10000` limit is a separate wire-behaviour
change and is deliberately not smuggled in here.

Verified declines unchanged with the filter present: `mem_bool_preds` (bails on
non-empty should), `query_node_to_agg_filter` (same), `scored_fast_plan`
(`scoring_clause` has no `Constant` arm), `build_bool_prefilter_cached` (CASE A
returns None when no required conjunct resolves; CASE B is not reached),
`query_node_to_fts` (a non-projecting FILTER child is skipped rather than
aborting, but the `should` loop still aborts on `Constant{Ids}`), and
`residual_gate` (gated on `needs_fts`, which is false here).

ALSO IN THIS COMMIT:

- CHANGELOG: a user-facing entry for xerj-org#825 carrying BOTH behaviour changes —
  the new 400 on an unanswerable knn field beside a query+aggs, and the
  measured slowdown.  The repo ships this file; the cost belongs in it.

- `compound_bool_direct_knn` doc comment: it claimed only "deeper-nested `Knn`
  clauses" keep the dropped behaviour.  A `Knn` sitting DIRECTLY in `filter` or
  `must_not` does too — those are direct children, and only the scoring lists
  are scanned (and swapped).  Corrected, with the reason: they are the
  non-scoring lists, a pinned constant there would contribute nothing, and the
  compat fold only ever emits `bool.should[query, knn]`.

- The round-1 commit body's evidence claim is corrected in the PR body:
  "the union, facet, ordering, and rescore assertions fail on the unfixed code"
  holds for the aggs and rescore tests, but NOT for
  `knn_beside_query_returns_the_vector_only_document`.  That request is
  `hybrid_safe` under xerj-org#458, so it went to the RRF executor, which also returns
  both documents and also ranks "lex" first (1/61 + 1/62 = 0.0325 vs 1/61 =
  0.0164) — the added `hits[0] == "lex"` assertion does not discriminate sum
  from RRF on that fixture.

Gates: cargo fmt --all --check clean; clippy -D warnings clean on xerj-engine
and xerj-api; xerj-engine lib suite 664 passed / 0 failed; the four tests in
knn_beside_query_with_aggs_stays_200.rs green.
…e over-suppresses

`dispatching_vector_fields` already documents that it is not a byte-exact
mirror of the engine's `peel` and lists where it diverges (xerj-org#777).  The `knn`
arm added in the previous commit inherits exactly one of those divergences and
it should be written down rather than discovered later.

A one-clause `bool` is erased before the engine reads the tree
(`unwrap_single_clause_bool`, xerj-org#399 — and it recurses, so nested wrappers
collapse in one walk), so descending through a bool's sole candidate into a
compound bool tracks `compound_bool_direct_knn` faithfully: the wrapper is gone
by the time the engine decides, and the compound bool IS the root.

The exception is `bool{should:[bool{should:[knn, match]}], filter:[…]}` — the
filter stops the wrapper collapsing, so the engine never sees a root compound
bool, never pins, and the `knn` really is dropped, while this walk descends
through the sole scoring candidate and marks it dispatching.  That
over-suppresses the `lexical_on_semantic_text` hint on a contrived shape.  Same
class and same contrivance as the `bool{should:[hybrid], filter:[match]}` case
already recorded two paragraphs up; no new one.

Comment only — no behaviour change.
@xerj-org

Copy link
Copy Markdown
Owner

Review response

Both blocking findings are accepted — neither is refuted. One was reproduced as a failing test before being fixed; the other was measured on a 100 000-document index and partly mitigated, with the residual cost written into the PR body and the CHANGELOG instead of a one-line disclaimer. Four commits on top of the reviewed head (892304df, cef2dba1, 5fea5df7, baaca1a0), including a fix for a CI-red failure the review could not see because the checks were still pending at the time.


Blocking 1 — pinned kNN score dropped by the post-scan IDF rescore

Confirmed exactly as described, and it is worse than "silent": the two documents come back with a byte-identical score.

892304df fix(engine): #825 pinned kNN scores survive the post-scan IDF rescore

Fail-before, on the reviewed head, with a two-clause bool as the lexical half and two documents that differ only in their vector:

near=Some(0.61739457) far=Some(0.61739457)

0.61739457 is the IDF heuristic score. The vector contribution is gone for both. Your trace was right in every step: query_uses_bool_text walks the outer bool, the user's inner bool returns (2, true), the pinned sub-tree returns (0, false) and neither disqualifies nor suppresses; scored_fast_plan bails on Constant{Ids} and the FTS projection declines on Ids, so scored_fast_applied and exact_bm25_page are both false and nothing else stops the block.

Fixed by adding the pinned constant back rather than suppressing the rescore — the lexical half keeps the IDF weighting the pass exists to provide, and query_score + knn_score is restored. collect_pinned_knn_scores reads the map out of pin_knn_clause's own output only, never out of the user's tree, so a user-supplied constant_score{filter:{ids}} keeps its pre-existing behaviour. Vector-only hits score 0 under the heuristic and were already skipped by the score > 0.0 gate, so they keep the pinned constant untouched.

Also checked, since it is the same class: the neighbouring TF-IDF max_score < 0.001 fallback is inert on a pinned tree — its extract_query_text answers None for any Bool. Read, not assumed.

New guard: knn_beside_bool_query_keeps_the_vector_contribution. Your point that the existing suite could not catch this is why it uses a two-clause bool, and near is indexed second so a collapsed tie would sort it last rather than accidentally passing.


Blocking 2 — performance regression on the canonical hybrid

Accepted, measured, and partly fixed. baaca1a0 perf(engine): #825 bound the pinned-kNN doc scan; measure the real cost

100 000 documents (text + 8-dim dense_vector, ~10 % lexical selectivity, 1 shard), closed-loop, fresh query vector on every request so the query cache cannot answer twice — an identical-body loop reported 0.4 ms for a 213 ms request, so the first version of this measurement was a mirage. Medians over 2 interleaved rounds × 7 requests on one data directory:

request main reviewed head now
query alone 0.3 ms 0.3 ms 0.3 ms
knn alone k=10 1.2 ms 1.1 ms 1.1 ms
query + knn k=10 2.5 ms 248 ms 208 ms
query + knn k=100 3.5 ms 436 ms 227 ms
query + knn k=1000 18.4 ms 2 309 ms 385 ms
query + knn k=10000 168 ms 24 437 ms 5 071 ms
query + knn + terms agg k=10 476 ms (wrong answer) 643 ms 605 ms

Two separate things were in that number, and only one of them is this PR's doing.

The O(docs × k) surcharge is fixed. pin_knn_clause now emits the pinned sub-tree with a redundant filter: [Ids{all k}] accelerator — same match set, no effect on _score, and the matcher evaluates filter before should, so a document outside the top-k is rejected by one _id lookup plus a memcmp sweep instead of recursing into all k Constant{Ids} clauses and re-reading _id k times. minimum_should_match: 1 is written out explicitly so the tree still says disjunction. And doc_matches_query_typed's should count now short-circuits at min instead of .filter(…).count()-ing every clause on every document — same predicate, and a document the lexical clause already matched no longer walks the pinned sub-tree at all. That is the 2 309 → 385 ms and 24 437 → 5 071 ms column.

The ~200 ms floor is not new machinery, and it is not fixed. Control on the same corpus, no knn anywhere:

request (no knn anywhere) main reviewed head
bool.should[match, match] (projectable) 10.4 ms 9.8 ms
bool.should[match, ids] (unprojectable) 227.6 ms 237.1 ms

Any bool.should with an unprojectable clause already costs that, on main as here. What this PR changed is the routing: the no-extras hybrid now lands where every aggs/sort/collapse/rescore shape of the same request already landed. Read honestly that is two different trades — the previously-broken shapes cost about a quarter more and go from wrong to right; the no-extras shape is an ~80× regression against an RRF route that returned ES-divergent scores. Both are now in the PR body and in the CHANGELOG, with the numbers, not as "routing can be tightened in a follow-up if profiles demand".

Not fixed, and why. Restoring an indexed route means projecting the lexical half alone and merging the ≤ k pinned documents back in afterwards — the FTS scored path already holds a score for every lexical match, so the pieces exist — but it touches hit merging, hits.total, the agg corpus, sort/collapse/pagination and the memtable arm inside a 4 700-line function. Doing that as a drive-by inside a correctness fix is how the next silent-drop bug gets written. Filed as #892 with the full design and these numbers.

On the k cap: k is still unbounded on the wire (knn_clause_k: explicit k, else num_candidates, else 10), bounded in practice only by the 30 s deadline. ES's own limit is num_candidates ≤ 10000 with k ≤ num_candidates — enforcing it is a separate wire-behaviour change and I did not smuggle it into this PR. Worth its own decision.


Not in the review — CI went red after you looked (your NB5)

cef2dba1 fix(api): #825 a pinned knn inside a compound bool DOES reach the vector

Build + Test failed on the reviewed head: es_compat::semantic_text_lexical_hint_tests::a_top_level_knn_over_the_companion_vector_suppresses_the_hint. The hand-rolled ES hybrid over a semantic_text companion vector came back carrying the lexical_on_semantic_text hint — telling the caller their embedding "was NOT consulted", when it was. That hint runs on the effective body, after knn is folded into query and body.knn is None, so suppression depends entirely on dispatching_vector_fields recognising the folded tree; its bool arm only descended into a bool holding exactly ONE candidate. Under #458 the request folded to {"hybrid":…} and the hybrid arm walked it. Unconditional bool.should[query, knn] is two candidates.

The rule was out of date, not merely unlucky — #825 changed the underlying truth. The bool arm now mirrors compound_bool_direct_knn clause for clause (exactly one direct knn child across must + should), scoped to knn; semantic keeps the #394 rule, and a_semantic_clause_that_never_reached_the_vector_is_still_flagged still proves it. 229 xerj-api lib tests pass.


Non-blocking notes

  • NB1 (inaccurate evidence claim) — accepted and corrected in the PR body. You are right: knn_beside_query_returns_the_vector_only_document, including the new hits[0] == "lex" assertion, passes unchanged on the unfixed code, because RRF also returns both documents and also ranks "lex" first (1/61 + 1/62 = 0.0325 vs 1/61 = 0.0164). The assertion does not discriminate sum from RRF on that fixture. The real fail-before evidence is the aggs test, the rescore test, and now the bool-query test.
  • NB2 (new wire-visible 400) — promoted out of a parenthetical. It is now the first bullet of the Contract section and a called-out upgrade note in the CHANGELOG.
  • NB3 (explain over-claim) — corrected. The PR body now says plainly that explain applies to membership, and that the explanation tree renders the pinned constant_score(ids) clauses rather than an ES-shaped knn explanation.
  • NB4 (doc nit) — fixed. compound_bool_direct_knn's comment now says that a Knn sitting directly in filter or must_not also keeps the dropped behaviour, with the reason (they are the non-scoring lists, a pinned constant there would contribute nothing, and the compat fold only ever emits bool.should[query, knn]).
  • NB5 (CI) — see above; the one real failure is fixed. The ES-compat YAML conformance job was green on the reviewed head, 96_knn_beside_query_aggs.yml included.
  • NB6 — thank you for listing what you had already checked; it is why this pass went into the rescore interaction and the routing rather than re-deriving the union argument.

Follow-up: #892

The union is FTS(lexical) ∪ {k known documents}. The FTS scored path already computes a score for every lexical match (seg_hits) and materialises only the top prefix, so:

  1. project the lexical half alone (exact BM25, existing fast path);
  2. add the pinned kNN score to any seg_hits entry in the pinned set;
  3. materialise the pinned documents FTS did not return, at knn_score plus their own lexical score (0 when the lexical half does not match them);
  4. hits.total = lexical total + |pinned \ lexical|;
  5. the agg corpus pass keeps the rewritten-tree route, or takes the same union.

The alternative — teaching query_node_to_fts an Ids arm — needs _id in the FTS term dictionary, which is an on-disk format change and strictly larger.

Gates: cargo fmt --all --check clean; cargo clippy --release -p xerj-engine -p xerj-api -- -D warnings clean; xerj-engine lib 664 passed / 0 failed; xerj-api lib 229 passed / 0 failed; all four tests in knn_beside_query_with_aggs_stays_200.rs green.

The entry said the indexed route was "tracked as follow-up work" without
naming anything trackable. It is xerj-org#892, which carries the design sketch and
the same measurement table.
@xerj-org

Copy link
Copy Markdown
Owner

CI verdict on the repaired head (9da056a8)

19 of 20 checks green, including both gates that matter here:

  • ES-compat YAML conformance: passvectors/96_knn_beside_query_aggs.yml runs in it. I also replayed that case by hand against a throwaway node on a private port after the pinned-tree change (the redundant filter: [Ids{all k}]), to be sure the accelerator did not move the union: hits.total 3, buckets a:2 / z:1, and the no-aggs variant hits.total 3. Unchanged.
  • Format + Clippy: pass. Also Release build (fat LTO), API smoke, ONNX, Fuzz, Use-case harnesses, Autoindex FD smoke on all three OSes.

Build + Test is red, and it is not this PR. The xerj-api --lib failure the review's NB5 could not see is fixed — 229 passed; 0 failed, with a_top_level_knn_over_the_companion_vector_suppresses_the_hint green in both passes, and all four knn_beside_query_with_aggs_stays_200 tests green including the new knn_beside_bool_query_keeps_the_vector_contribution. What kills the job now is one step further on:

test script_bucketed_agg_past_the_call_depth_limit_is_an_error_not_empty_buckets has been running for over 60 seconds
##[error]The action 'Workspace tests (all targets) at default parallelism (contributor's `cargo test`)' has timed out after 12 minutes.

That same test, in that same step, hangs the same way on main at 5f3b6ea3 — this branch's merge base — in run 33327063643 (job 99298970619), which is why main itself is red. It passes in the job's earlier restricted-parallelism pass and only hangs at default parallelism on the 2-core runner, so it reads as the CI-runner blind-spot class rather than a product bug — but it is pre-existing either way, and unrelated to knn, aggregations over the union, or anything this PR touches. It needs its own fix on main; this branch cannot be green until it lands.

Local runs on the repaired head, for completeness:

  • xerj-engine --lib: 664 passed / 0 failed
  • xerj-api full suite (60 binaries): 513 passed / 0 failed
  • cargo fmt --all --check clean, cargo clippy --release -p xerj-engine -p xerj-api -- -D warnings clean

Brings rc.72's eight merged siblings under the xerj-org#825 knn-beside-query work:
xerj-org#877 concurrent multi-index fan-out, xerj-org#881 event-driven merge scheduling,
xerj-org#882 match_phrase slop transpositions, xerj-org#883 lock-free memtable byte
accounting, xerj-org#888 date epoch scale from the mapping, xerj-org#885/xerj-org#886 autoindex and
xerj-org#880/xerj-org#884 docs.

One textual conflict, in CHANGELOG.md: both sides inserted a new bullet at
the head of `### Fixed` — the xerj-org#825 union entry here, the xerj-org#830 sloppy-phrase
entry on main. They describe unrelated fixes, so both are kept, xerj-org#825 first.

The two files the siblings and this branch share — engine/crates/
xerj-engine/src/index.rs and engine/crates/xerj-api/src/es_compat.rs —
merged without conflict, and that was verified rather than assumed: the
merge result diffed against origin/main is byte-identical to this branch's
own diff against the merge base (5f3b6ea), so main's side is carried
through intact and no sibling hunk was dropped. The two edits that share a
function are non-overlapping by construction: xerj-org#877 rewrites the per-index
fan-out loop near the end of `search_impl`, while xerj-org#825 rewrites the
`knn`-beside-`query` fold ~1500 lines earlier, before any index is
resolved — the fold still runs once per request, and the pinned tree is
what each concurrently spawned per-index search receives.

Gates on the merge result: `cargo build --release -p xerj-api` clean;
`cargo fmt --all --check` clean; `cargo clippy --release -p xerj-engine -p
xerj-api -- -D warnings` clean.
@xerj-org
xerj-org merged commit d2f6801 into xerj-org:main Aug 31, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

knn beside query still silently drops the vector contribution when aggs (or sort/collapse/rescore/...) is present

2 participants