Rescore expanded nested docs on Lucene engine - #3483
Conversation
When rescoring is enabled together with a nested knn_vector field using expand_nested_docs on the Lucene engine (auto-enabled by on_disk / 4x mode), the query dropped child documents. Rescoring was silently skipped because RescoreKNNVectorQuery reduces results to k, which would truncate the fully expanded child set. Move rescoring inside ExpandNestedDocsQuery so it mirrors the native engine ordering: run approximate search over the oversampled candidates, rescore them at full precision and reduce to the top k parents, then expand all child documents of the surviving parents. The oversampled parent candidates are now retained during segment merging so the rescore step has candidates to work with. Fixes opensearch-project#3125 Signed-off-by: ved naykude <vnaykude@amazon.com>
PR Reviewer Guide 🔍(Review updated until commit d6e14af)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to d6e14af Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 9aa0debSuggestions up to commit b4235a1
Suggestions up to commit 039a4c2
Suggestions up to commit d53006c
Suggestions up to commit d54959c
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3483 +/- ##
============================================
+ Coverage 83.97% 83.99% +0.01%
- Complexity 4505 4518 +13
============================================
Files 461 462 +1
Lines 16138 16169 +31
Branches 2104 2111 +7
============================================
+ Hits 13552 13581 +29
- Misses 1797 1798 +1
- Partials 789 790 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Build the filter weight once in ExpandNestedDocsQuery and share it across the rescore and expansion passes instead of rewriting the filter twice. Document and assert the luceneK >= rescoreK invariant that keeps the per-leaf rescore from silently truncating candidate parents. Signed-off-by: ved naykude <vnaykude@amazon.com>
|
Persistent review updated to latest commit fb96455 |
Add tests for the three uncovered lines: - InternalNestedKnnVectorQuery.knnRescoreSearch default throws UnsupportedOperationException for non-rescore implementations. - OSDiversifyingChildrenFloatKnnVectorQuery.mergeLeafResults asserts the luceneK >= rescoreK invariant. - ExpandNestedDocsQuery equals/hashCode account for rescoreK. Signed-off-by: ved naykude <vnaykude@amazon.com>
- Document that expansion re-runs full-precision knnExactSearch, so the rescored scores are recomputed (not propagated) and the final child scores remain full precision; consistent with the native engine. - Cap the rescore merge budget with Math.min(rescoreK, luceneK) so a future invariant violation degrades gracefully instead of silently truncating per-leaf when assertions are disabled. Assert retained for dev-time signal. Signed-off-by: ved naykude <vnaykude@amazon.com>
|
Persistent review updated to latest commit a4445d2 |
|
Persistent review updated to latest commit 449826c |
|
Persistent review updated to latest commit d9e7706 |
| // before expanding all of their child documents below. | ||
| perLeafResults = rescore(searcher, leafReaderContexts, perLeafResults, filterWeight); | ||
| } | ||
| TopDocs[] topDocs = retrieveAll(searcher, leafReaderContexts, perLeafResults, filterWeight); |
There was a problem hiding this comment.
Are we running excat search twice , one on rescore and retrieveAll ?
There was a problem hiding this comment.
Yes — with rescore enabled there are two exact-search passes, and they serve different purposes:
rescore()runs a diversified exact search (knnRescoreSearch→ best child per parent) purely to select the top-k parents at full precision.retrieveAll()then runs the non-diversified exact search (knnExactSearch) to expand and score every child of those surviving parents.
Both read raw float vectors, so a child scored in both passes gets the identical score — the second scoring is redundant, not inconsistent, and the final child scores are full precision. This mirrors the native engine (NativeEngineKnnVectorQuery), which also re-runs exact search during expansion rather than propagating rescored scores. Without rescore, only retrieveAll() runs. I added an inline comment at the call site (and expanded the rescore() javadoc) to make this explicit.
| final LeafReaderContext leafReaderContext = leafReaderContexts.get(leafIndex); | ||
| survivingPerLeaf.get(leafIndex).put(scoreDoc.doc - leafReaderContext.docBase, scoreDoc.score); | ||
| } | ||
| return survivingPerLeaf; |
There was a problem hiding this comment.
survivingPerLeaf stores scoreDoc.score, but retrieveAll only consumes keySet(), do we even deed to compute score
There was a problem hiding this comment.
Good catch — addressed in 0c91ea4. rescore() no longer computes or stores a real score: retrieveAll consumes only keySet() (it re-scores every child via exact search), so the map value is now a NO_SCORE placeholder. The Map<Integer, Float> shape is kept only to match the type retrieveAll already expects. See the updated comments at the top of rescore() and around the survivingPerLeaf population.
Vikasht34
left a comment
There was a problem hiding this comment.
Please make all IT Passes for this
Signed-off-by: naykudev <vnaykude@amazon.com>
|
Persistent review updated to latest commit 6f161ff |
Signed-off-by: ved naykude <vnaykude@amazon.com>
|
Persistent review updated to latest commit 0c91ea4 |
Signed-off-by: ved naykude <vnaykude@amazon.com>
|
Persistent review updated to latest commit 1b020c4 |
|
Persistent review updated to latest commit 0d7dd39 |
|
Persistent review updated to latest commit 38a6c6b |
|
Persistent review updated to latest commit d54959c |
|
Persistent review updated to latest commit d53006c |
|
Persistent review updated to latest commit 039a4c2 |
Signed-off-by: naykudev <vnaykude@amazon.com>
|
Persistent review updated to latest commit b4235a1 |
Signed-off-by: naykudev <vnaykude@amazon.com>
|
Persistent review updated to latest commit 9aa0deb |
|
Persistent review updated to latest commit d6e14af |
Description
Fixes a bug where enabling rescoring together with a nested
knn_vectorfield usingexpand_nested_docson the Lucene engine (auto-enabled byon_disk/4xmode) fails to return all child documents.Previously,
KNNQueryFactorysilently skipped rescoring for this combination and logged a warning, becauseRescoreKNNVectorQueryreduces results tokviaTopDocs.merge(k, ...)— which would truncate the fully expanded child set produced byExpandNestedDocsQuery.This change moves rescoring inside
ExpandNestedDocsQuery, mirroring the already-correct native-engine ordering inNativeEngineKnnVectorQuery:kparents.kparents.The oversampled parent candidates are now retained during segment merging (
OSDiversifyingChildrenFloatKnnVectorQuery.mergeLeafResults) whenever rescoring is enabled, so the rescore step has candidates to work with. Only the float Lucene path rescores; the byte path is unchanged.The filter weight is built once in
ExpandNestedDocsQueryand shared across the rescore and expansion passes instead of being rewritten twice. Per-leaf rescore correctness relies on the invariantluceneK >= rescoreK(luceneKis defined asmax(rescoreK, efSearch)inKNNQueryFactory); this is now documented and guarded with an assertion so a broken invariant surfaces as a test failure rather than a silent per-leaf truncation.Why this approach (mirroring the native engine)
The native engines (
NativeEngineKnnVectorQuery) already handle nested + rescore +expand_nested_docscorrectly, and the fix deliberately makes the Lucene path follow the same ordering rather than teaching the sharedRescoreKNNVectorQueryabout parents/nesting (which would widen the blast radius to every engine). The key property in the native flow is that the reduce-to-khappens on parents, before expansion — expansion always runs last, on the already-reduced set.Native flow (
NativeEngineKnnVectorQuery#createWeight):searchK = max(firstPassK, effectiveK), then trims tofirstPassK(the oversampled candidate pool).doRescore(...)runs exact search withuseQuantizedVectorsForSearch(false); for nested it gathers the sibling set viagetAllSiblings(parentsFilter).kparents —reduceToTopK(..., finalK), before the expand block.retrieveAllgathers all siblings of the surviving parents and returns every child.One-to-one mapping to this change:
NativeEngineKnnVectorQuery)ExpandNestedDocsQuery)searchK = max(firstPassK, effectiveK)luceneK = max(rescoreK, efSearch)reduceToTopK(..., firstPassK)mergeLeafResults→TopDocs.merge(rescoreK, ...)doRescore(...)w/useQuantizedVectorsForSearch(false)rescore(...)→diversifyingExactSearch(full precision)reduceToTopK(..., finalK)then expandTopDocs.merge(k, rescored)thenretrieveAllretrieveAll/getAllSiblingsretrieveAll/getAllSiblingsIt is the same sequence, not shared code: the native path uses
KNNWeight.exactSearch+ExactSearcherContext+ResultUtil.reduceToTopK, while the Lucene path uses Lucene-nativeDiversifyingChildrenFloatKnnVectorQuery.exactSearch+TopDocs.merge.Related Issues
Resolves #3125
Check List
KNNQueryFactoryTests,ExpandNestedEDocsQueryTests,OSDiversifyingChildrenFloatKnnVectorQueryTests) and an integration test (ExpandNestedDocsIT) reproducing [BUG] ExpandNestedDocs with Rescoring enabled for Lucene engine does not return all the docs #3125.ExpandNestedDocsITsuite across every engine/mode.--signoff.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.