Repository navigation
fix(search): break score ties deterministically so ranking is reproducible - #303
Merged
yoanbernabeu merged 1 commit intoSep 10, 2026
Conversation
…cible Two identical searches over an unchanged index could return results in a different order, and under a limit a different set of results. Both the GOB store and hybrid fusion build their result slice by ranging over a Go map, whose iteration order is randomized on every call, and then sort it with sort.Slice, which is not stable. GOBStore.Search (store/gob.go) ranges over s.chunks; ReciprocalRankFusion (search/hybrid.go) ranges over its scores map. Chunks with exactly equal scores therefore came back in an arbitrary order that changed between calls. Equal scores are the normal case here rather than an edge case. RRF scores a result purely by its rank, 1/(k+rank+1), so the top hit of the vector list and the top hit of the text list always tie, as does every rank below them. Identical code (boilerplate, generated files, the same helper vendored twice) embeds to identical vectors and so scores identically in the store. Both functions truncate to the limit after sorting, so the arbitrary order also decided which results the caller saw at all. store.SortResultsByScore sorts by descending score and breaks ties on the chunk ID, which is unique within a store, so the comparator is a total order and the output no longer depends on how the caller happened to build the slice. The four sorts in the search path now use it: GOBStore.Search, TextSearch, ReciprocalRankFusion and ApplyBoost. ApplyBoost is included because boost factors can map two different scores onto the same product and so create ties of their own. Scores still decide the ranking. The chunk ID only settles an exact tie.
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.
Description
Two identical searches over an unchanged index can come back in a different order, and under a limit with a different set of results.
GOBStore.Search(store/gob.go:74) builds its result slice by ranging over thes.chunksmap, andReciprocalRankFusion(search/hybrid.go:73) builds its slice by ranging over itsscoresmap. Go randomizes map iteration order on every call. Both then sort withsort.Slice, which is not stable, so any two results holding exactly the same score came out in an order that changed from one call to the next.Equal scores are the normal case here rather than a corner case. RRF scores a result purely by its rank,
1/(k+rank+1), so in a hybrid search the top hit of the vector list and the top hit of the text list always tie, and so does every rank below them. In the store, identical code (boilerplate, generated files, the same helper vendored twice) embeds to an identical vector and gets an identical cosine score. Both functions truncate to the limit after sorting, so the coin flip decided not only the order but which results the caller ever saw.Measured on main, fusing two disjoint three-result lists 1000 times produced 6 different orderings of the same 6 chunks, and the same fusion with
limit=1returned chunka604 times and chunkb396 times.The fix is
store.SortResultsByScore(new, store/ranking.go): sort by descending score, break ties on the chunk ID. Chunk IDs are unique within a store, so the comparator is a total order and the output stops depending on the order the caller happened to build the slice in. All four score sorts in the search path use it now:GOBStore.Search,TextSearch,ReciprocalRankFusionandApplyBoost.ApplyBoostis in that list because boost factors can map two different scores onto the same product and so create ties of their own, which the unstable sort then reordered.Scores still decide the ranking. The chunk ID only settles an exact tie.
Related Issue
No open issue covers this. I hit it reading the hybrid search path.
Type of Change
How Has This Been Tested?
Six new tests pin the ordering. All six fail on
mainat b355512 and pass with the change:TestGOBStore_SearchRanksByScoreFirstandTestSortResultsByScore_ScoreWinsOverIDgo the other way: they pass onmaintoo, and guard against a tiebreak that displaces scoring.On this branch:
The tests are pure functions over in-memory chunks and a
t.TempDir()GOB store, so no embedder, no network and no timing assumptions.Test Configuration:
Checklist
golangci-lint runand fixed any issuesScreenshots (if applicable)
N/A
Additional Notes
Scope: this covers the sorts that happen in Go. The Postgres and Qdrant backends order inside the query engine (
ORDER BY vector <=> $1, store/postgres.go:169) and have the same tie ambiguity. Pinning that needs a tiebreaking column in the SQL and a database to verify against, so it is not in this PR. Happy to do it as a follow-up, and happy to add a CHANGELOG line here if you want one.