fix: blend hybrid retriever scores with weighted RRF instead of batch-max normalization - #1024
Open
GutamaKev wants to merge 5 commits into
Open
fix: blend hybrid retriever scores with weighted RRF instead of batch-max normalization#1024GutamaKev wants to merge 5 commits into
GutamaKev wants to merge 5 commits into
Conversation
…-max normalization (ascherj#24) Dividing keyword scores by the max BM25 score in the current result batch let a single chunk that repeats query terms (e.g. tech names) inflate its own normalization scale to 1.0, regardless of actual relevance. Switch to weighted Reciprocal Rank Fusion, which blends by rank position instead of raw magnitude, so one outlier score can no longer dominate the batch. Also fixes KeywordSearcher._tokenize() stripping only edge punctuation (so "React." and "Python," match query tokens) while preserving tokens like "c++" and "node.js", and makes tie-breaking deterministic instead of relying on Python's hash-randomized set iteration order. Adds minimal missing type annotations to vector_store.py/keyword_search.py required for the mypy pre-commit hook to pass on these touched files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GutamaKev
marked this pull request as ready for review
August 10, 2026 05:45
Update PLAN.md wording and local dev environment config (Postgres port, Chroma image version). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Summary
HybridRetriever.retrieve()inrag/retriever/hybrid.pynormalized keyword scores by dividing by the max BM25 score in the current result batch, so a chunk that simply repeats query terms (e.g. a tech-stack list) could inflate its own score to 1.0 and outrank a genuinely relevant chunk. It now blends vector and keyword results with weighted Reciprocal Rank Fusion (RRF) instead, so no single outlier score can dominate the batch.Issue
Closes #24
Changes
Root cause: BM25 raw scores are unbounded and grow with in-chunk term repetition. The old normalization divided each keyword score by the max score in the batch, so whichever chunk repeated query terms the most became the denominator, guaranteeing itself a normalized score of 1.0 regardless of actual relevance. A secondary bug compounded it:
_tokenize()only split on whitespace, so "Python," and "React." (trailing punctuation) never matched the query tokens "python"/"react", suppressing genuinely relevant chunks' scores further.rag/retriever/hybrid.py— replaced batch-max normalization with weighted RRF (score = vector_weight/(k+vector_rank) + keyword_weight/(k+keyword_rank),k=10); added a deterministic_rank_map()helper (ties broken by chunk id, since Python's set/dict iteration order for strings is hash-randomized); chunks absent from one side's results get that side's worst rank instead of a raw score of 0; changedmin_scoredefault from0.3to0.0since RRF scores are small and always positive, not 0-1 percentagesrag/retriever/keyword_search.py—_tokenize()now strips leading/trailing punctuation per token, so "Python," and "React." match, while preserving tokens like "c++"/"node.js"rag/retriever/vector_store.py— added a missing return type annotation onget_collection(), required by the mypy pre-commit hook on files touched heretests/unit/test_hybrid_retriever.py— removed thexfailfrom the issue Hybrid retriever over-weights keyword results when query contains technology names #24 regression test (now genuinely passes); added a test confirming an exact rare-term keyword match still beats a near-tied vector competitor; added a determinism test for tied scoresPLAN.md— solution plan with root cause, approach, and risksTesting
make test-unit) —test_hybrid_retriever.py(3/3) andtest_keyword_search.py(19/20; the 1 failure is pre-existing and unrelated, see Notes)Reproduce the original bug (before the fix):
Run on
main:(Note: this needs at least 4 chunks with mixed term-document-frequencies to reproduce —
with only 2 chunks, "python"/"react" each land in exactly 1 of 2 documents, and BM25's IDF
for that ratio computes to exactly
log(1.5) - log(1.5) = 0, masking the bug entirely.)Observe: prints
readme_1— the keyword-stuffed, irrelevant chunk outranks the genuinely relevant one.Verify the fix (on this branch):
Run the same snippet on
fix/24-hybrid-retriever-keyword-weighting.Expected: prints
resume_1— the genuinely relevant chunk now wins.Or just run:
pytest tests/unit/test_hybrid_retriever.py -vNotes for Reviewers
make check/make test-unitreport pre-existing, unrelated failures (53 failing unit tests across other modules, plus repo-wide ruff/black/mypy debt) — confirmed present onmainbefore this change (viagit stash) and unaffected by it.RRF k=10was validated against this reproduction case at k=60/20/10/5; it should be re-checked against real profile data before this ships. Considered downweighting terms common across a profile's own chunks instead of RRF, but verified numerically it wouldn't have fixed this case — the defect is repetition within one chunk, not commonality across chunks (details inPLAN.md).