[MOD-17795] Compare merge scores exactly, so a mid-ingest label is not returned twice - #1022
Open
dor-forer wants to merge 6 commits into
Open
[MOD-17795] Compare merge scores exactly, so a mid-ingest label is not returned twice#1022dor-forer wants to merge 6 commits into
dor-forer wants to merge 6 commits into
Conversation
dor-forer
marked this pull request as ready for review
August 23, 2026 12:13
…t returned twice A tiered top-K or range query merges the frontend and the backend result lists, and for a single-value index it relies on a shared id reaching both merge cursors at the same time rather than tracking seen ids. That holds only while the comparator agrees with the order the two lists arrive in. It did not. Both lists are ordered by exact score - HNSWIndex::topKQuery and BruteForceIndex::topKQuery drain a max-heap keyed on (distance, label), and the batch iterators sort by score then id - while cmpVecSimQueryResultByScoreThenId() called any two scores within 1e-6 equal and fell back to ordering by id. Wherever those two orders disagree, the copy of a label held by the flat buffer and the copy held by the backend miss each other, and both end up in the reply. A label is in both indexes for as long as an ingest job takes to get from inserting into HNSW to erasing from the flat buffer, so a corpus dense enough to put unequal distances within 1e-6 of each other duplicates documents under any sustained write load - and each duplicate costs a real document out of a full reply. Drop the tolerance. The rewrite also fixes the id comparison, which truncated a 64-bit label difference to int and so gave the wrong sign for ids more than 2^31 apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dor-forer
force-pushed
the
dor-mod-17795-tiered-topk-dedup
branch
from
August 23, 2026 12:14
1511424 to
144216b
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1022 +/- ##
==========================================
+ Coverage 97.21% 97.30% +0.09%
==========================================
Files 141 141
Lines 8432 8657 +225
==========================================
+ Hits 8197 8424 +227
+ Misses 235 233 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
dor-forer
marked this pull request as draft
August 23, 2026 13:29
dor-forer
marked this pull request as ready for review
August 23, 2026 14:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Describe the changes in the pull request
An
FT.SEARCH ... =>[KNN ...]reply can carry the same document key twice, and on a full reply each duplicate costs a real document (reported: 2000 keys, 1999 distinct, over a 2000-document corpus; worst seen 18 repeats in one reply). The duplicate is produced inside VecSim, in the tiered frontend/backend result merge.VecSimTieredIndex::topKQueryImpqueries the flat buffer and backend separately and merges the two lists. The old single-value path passedwithSet=false, so it relied on a shared id reaching both cursors at the same time. This PR now gives every public one-shot tiered top-K and range query explicit cross-tier ID deduplication whenever both tiers can contribute more than one result.It did not agree. Both lists are ordered by exact score —
HNSWIndex::topKQueryandBruteForceIndex::topKQuerydrain a max-heap keyed on(distance, label), and the batch iterators sort by score then id — while the comparator called any two scores within1e-6equal and fell back to ordering by id. Wherever those two orders disagree, the flat buffer's copy of a label and the backend's copy miss each other and both land in the reply.A label is in both indexes for as long as an ingest job takes to get from inserting into HNSW to erasing it from the flat buffer, so a corpus dense enough to put unequal distances within
1e-6of each other duplicates documents under any sustained write load. That matches the report: it reproduces during the background scan and also after it (percent_indexed=1.0, ingest queue not yet drained), on bothFLOAT32andFLOAT16, with a bit-identical__diston both rows and neighbouring distances ~1.2e-7 apart — inside the tolerance.This drops the tolerance. The rewritten comparator also fixes the id comparison, which computed
(int)(res1->id - res2->id)and so truncated a 64-bit label difference toint, giving the wrong sign for ids more than 2^31 apart.The exact comparator fixes inconsistent merge ordering everywhere it is used. Public one-shot
topKQueryandrangeQueryadditionally use a smaller-side ID state map, so a shared ID is returned once even when its two scores differ. The batch iterator still uses its existing merge/persistent-state logic and remains a separate follow-up for arbitrary cross-tier score disparity.Which issues this PR fixes
Main objects this PR modified
cmpVecSimQueryResultByScoreThenId()— exact score comparison and sign-correct ID comparison.merge_results_with_cross_tier_dedup()— an allocator-awareunordered_mapcontaining only the smaller input's IDs and their emitted state.merge_result_lists()— skips deduplication state when the limit is at most one or one input is empty, because those shapes cannot emit a cross-tier duplicate.topKQuery()/rangeQuery()— perform cross-tier ID deduplication for non-trivial one-shot queries.BY_SCOREandBY_ID).Testing
The new test builds the minimal shape: two labels whose L2 distances differ by less than
1e-6but are not equal, with the farther one also sitting in the flat buffer, as an in-flight ingest job leaves it. It asserts the distance gap as a precondition so it cannot silently stop exercising the case.Verified on
dorer-intel(Ubuntu 24.04, gcc 13.3):id: 1twice at a bit-identical score:100% tests passed, 0 tests failed out of 2752(8 expected skips).Why exact comparison and explicit deduplication are both needed
The comparator must match the lists' exact
(score, id)ordering; a tolerance makes the merge order inconsistent and is not transitive. Exact comparison fixes MOD-17795's near-tied-neighbour case, where the duplicated label itself has a bit-identical score.Exact comparison alone cannot deduplicate a shared ID whose frontend/backend scores differ. Public one-shot top-K and range queries therefore perform ID deduplication whenever a reply could contain the duplicate twice.
merge_result_listspreloads only the smaller input's IDs into a reserved allocator-awareunordered_mapand marks the first occurrence emitted. IDs absent from the smaller input cannot be cross-tier duplicates and pass without insertion. Iflimit <= 1or either input is empty,merge_result_listsuses the compile-timemerge_results<false>path because that reply cannot emit a cross-tier duplicate.Seven-repetition
dorer-intelmeasurements compare this standard map with the removed custom flat hash in the same binary: the map was +0.8%, +3.1%, and +6.2% for top-K 10/100/1000, and +0.6% for a 1,026-result range query. The isolated merge is 2–6× slower, but vector search dominates end-to-end latency. The custom table can return in a separate optimization PR if those query-level costs prove material.The trivial-merge dispatch was then measured in a 15-repetition, randomized-interleaved run on
dorer-intelusing the same release binary for both paths. In the isolated merge, K=1 improved from 128 ns to 57 ns (~55%); merging 1,000 results with an empty side improved from 3,102 ns to 2,654 ns when the first input was empty and from 3,042 ns to 1,394 ns when the second was empty. End-to-end top-K K=1 changed from 278.695 us to 278.120 us (-0.21%). K=10 through K=1,000 stayed within -0.21% to +0.22%, showing no meaningful cost from the runtime condition on ordinary queries.The remaining
merge_results<false>calls are the trivial one-shot cases above, internal same-iterator merges where duplicate IDs are impossible by construction, plus the existing single-value HNSW batch path. Exact comparison also fixes the single-value batch case when bit-identical cross-tier copies meet in the same merge: they now align and are consumed together. It does not make batch iteration robust to arbitrary score disparity or copies arriving in different batches; that requires persistent cross-batch state and remains a separate follow-up.This reverts #694, and why the implementation changed
Before #694 the comparator used exact score comparison. #694 added the tolerance so a shared label with slightly different frontend/backend scores could align and deduplicate without an ID set. That solved one case but made the comparator disagree with the exact ordering of the input lists, allowing different near-tied labels to move the cursors out of alignment.
This PR restores exact ordering and gives deduplication a separate owner. The smaller-side state map handles unequal scores directly for public one-shot queries, rather than encoding approximate identity into the ordering comparator.
Known debt left behind: MOD-17920
spaces::normalizeVector_imp()divides by the vector norm with no zero-norm guard, so a zero vector in a cosine index normalizes to all-NaN. The integer merge comparator does not treat such a score as equal:NaN != scoreis true andNaN > scoreis false, so it returns-1regardless of which operand is NaN. That makes the merge order inconsistent and can select or truncate the wrong results. Separately, the score comparators passed tostd::sortdo not provide a strict weak ordering in the presence of NaN. This predates the PR and is untouched by it; filed as MOD-17920 with a proposed fix.The explicit
!=form avoids treatingdouble'sstd::partial_ordering::unorderedresult as equality and then advancing both merge cursors. It does not make NaN scores supported; MOD-17920 must either prevent them or define a total ordering for them. The comparator can be simplified after NaN scores are made unreachable.Mark if applicable
🤖 Generated with Claude Code
Note
Medium Risk
Changes core tiered KNN/range result merging for all backends (HNSW, SVS), which affects search correctness under background indexing; batch iterator merge behavior is largely unchanged.
Overview
Fixes duplicate labels in tiered top-K and range replies when the same document sits in both the flat buffer and backend during ingest. The merge comparator no longer treats scores within
1e-6as equal; it compares exact(score, id)order to match how HNSW and brute-force lists are built, and compares IDs without truncating toint.Public one-shot tiered queries always merge through
merge_result_lists, which usesmerge_results_with_cross_tier_dedup(IDs tracked from the smaller list) when more than one result can come back. That emits a shared label once even when frontend and backend scores differ. Range queries ordered by ID usefilter_results_by_idthat keeps the better score for cross-tier duplicates.Removes the
withSettemplate split onVecSimTieredIndexand the tiered SVS overrides that forced set-based merge. Adds regression tests for near-tied scores, merge dedup edge cases, andBY_IDrange on tiered search.Reviewed by Cursor Bugbot for commit 8bd95c7. Bugbot is set up for automated code reviews on this repo. Configure here.