#825 knn beside query scores as a sum over the union, aggs included - #879
Conversation
…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.
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.
Review responseBoth 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 ( Blocking 1 — pinned kNN score dropped by the post-scan IDF rescoreConfirmed exactly as described, and it is worse than "silent": the two documents come back with a byte-identical score.
Fail-before, on the reviewed head, with a two-clause
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 Also checked, since it is the same class: the neighbouring TF-IDF New guard: Blocking 2 — performance regression on the canonical hybridAccepted, measured, and partly fixed. 100 000 documents (
Two separate things were in that number, and only one of them is this PR's doing. The The ~200 ms floor is not new machinery, and it is not fixed. Control on the same corpus, no
Any 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, On the Not in the review — CI went red after you looked (your NB5)
The rule was out of date, not merely unlucky — #825 changed the underlying truth. The bool arm now mirrors Non-blocking notes
Follow-up: #892The union is
The alternative — teaching Gates: |
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.
CI verdict on the repaired head (
|
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.
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:
knnbesidequeryis a disjunction. The hit set is the union of both halves, each document scoresquery_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:
{query, knn:{field: <not a vector field>}, aggs}now answers 400 (the field is not answerable by knn) where it used to answer 200 with lexical-only hits.pin_knn_clauseruns the same knn against an index with no dense_vector field returns 0 hits / HTTP 200 with no error (silent false-negative) #498 unanswerable-field check as the single-knn and multi-knn arms, so an empty non-timed-out leg fails loud instead of degrading to the lexical half. That matches ES and is consistent with knn against an index with no dense_vector field returns 0 hits / HTTP 200 with no error (silent false-negative) #498/Extend the unanswerable-knn-field 400 to multi-knn arrays and nested knn #542, but it is a behaviour change for existing callers.explain: truerenders the pinned clauses. Membership and the summed_scoreare right, but the explanation tree showsconstant_score(ids)per pinned neighbour, not anything resembling ES's knn explanation. Previously wrong in a different way (the kNN half was absent from the explanation because it was absent from the answer), so this is not a regression — but "explain applies to the union" means membership, not explanation text.Root cause
The es-compat layer folded
knn+queryintobool.should, but the generic scorer has noKnnarm:doc_matches_query_typedfalls to the catch-allfalseandscore_query_against_doccontributes 0.0. With the defaultminimum_should_match, a knn-only document was excluded entirely and a lexical match lost its vector score. The #458hybrid_safecarve-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.bool.shouldofConstant{score, Ids{[id]}}pinned clauses.Boolsums 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_pinneddo the tree surgery; the rewrite only fires for a compound bool with exactly one directKnnchild (the shape the compat fold emits). The nativehybridquery type and the multi-knn-array 400 are untouched.knnbesidequeryreturned the lexical answer and called it hybrid #458hybrid_safeblock and RRF fold are deleted; the fold is now unconditionallybool.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
boolcarrying two or more ({"query":{"bool":{"must":[{"match":{"title":…}},{"match":{"body":…}}]}},"knn":{…}}) the post-scan IDF heuristic rescore insearch_innerfired on the pinned tree and rewrote_scorefrom 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 at0.61739457, byte-identical, vector contribution gone.query_uses_bool_textcould 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_planbails onConstant{Ids}and the FTS projection declines onIds, so bothscored_fast_appliedandexact_bm25_pageare false.Fixed by carrying the pinned clauses' id → knn-score map (
collect_pinned_knn_scores, read back out ofpin_knn_clause's own output only — a user-suppliedconstant_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-IDFmax_score < 0.001fallback is inert here — itsextract_query_textanswersNonefor anyBool— verified by reading, not assumed.Review round 2 — the semantic_text hint stops nagging the hybrid
Build + Testwas 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 asemantic_textcompanion vector came back carrying thelexical_on_semantic_texthint, telling the caller their embedding "was NOT consulted" — when it was. That hint runs on the effective body, afterknnis folded intoqueryandbody.knnisNone, so suppression depends entirely ondispatching_vector_fieldsrecognising the folded tree; itsboolarm only descended into a bool holding exactly ONE candidate. Under #458 the request folded to{"hybrid":…}and thehybridarm walked it; unconditionalbool.should[query, knn]is two candidates. The bool arm now mirrorscompound_bool_direct_knnclause for clause, scoped toknn;semantickeeps the #394 rule.Tests
knn_beside_query_with_aggs_stays_200.rs, reframed from #458 to #825:knnbesidequeryreturned the lexical answer and called it hybrid #458 it was asserted absent).boollexical half — the one shape that reaches the IDF rescore.nearis 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,knnbesidequerywith 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 itshits[0] == "lex"sum-ordering assertion, passes unchanged on the unfixed code: that request ishybrid_safeunder #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
idsclauses, whichquery_node_to_ftscannot lift (itsshouldloop?-aborts on the first unprojectable clause), softs_queryisNoneand the request is answered by a stored-document scan of every segment instead of the inverted index.100 000 documents (
text+ 8-dimdense_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:main(RRF where eligible)queryaloneknnalone, k=10query+knnk=10query+knnk=100query+knnk=1000query+knnk=10000query+knn+ terms agg, k=10Read honestly, that is two different trades:
aggs/sort/collapse/rescore), the cost is about a quarter more and the answer goes from wrong to right.knnbesidequeryreturned 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.shouldwith an unprojectable clause already costs the same, onmainas here — control on the same corpus, noknnanywhere:knnanywhere)mainbool.should[match, match](projectable)bool.should[match, ids](unprojectable)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 — fixedOn the reviewed head each document also paid
kclause evaluations, each re-reading_idout of the source map — the 24.4 s at k=10000 above. Two changes remove most of it:pin_knn_clauseemits the pinned sub-tree with a redundantfilter: [Ids{all k}]accelerator (same match set, contributes nothing to_score), and the scan evaluatesfilterbeforeshould— so a document outside the top-k is rejected by one_idlookup plus a memcmp sweep instead of recursing into all k clauses.minimum_should_match: 1is written out explicitly so the tree still says disjunction.doc_matches_query_typed'sshouldcount short-circuits atmininstead 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.kis still unbounded on the wire (knn_clause_ktakes explicitk, elsenum_candidates, else 10), so a large-krequest on a large index remains expensive — bounded by the 30 s default deadline, not by a cap. Enforcing ES's ownnum_candidates ≤ 10000limit would be a separate wire-behaviour change and is not smuggled in here.CHANGELOG
An
Unreleased / Fixedentry carries both user-visible consequences — the new 400 and the measured slowdown — because the repo ships that file and the cost belongs in it.