Practice page · Week 3
Reciprocal rank fusion
Keyword search and vector search fail differently. Reciprocal rank fusion (RRF) combines their rankings with one small formula, so a result near the top of both lists wins and neither leg has to be trusted alone.
Extracted from Week 3: Search and retrieval. Northline and Mill City Permits are fictional practice. Portfolio policy.
Why hybrid
Keyword match misses "time off for a new parent" when the page says "parental leave." Vector match can miss an exact identifier: a SKU, an order id, a statute number. Hybrid search runs both over the same filtered candidates and fuses the ranks.
The formula
Each list contributes 1 / (k + rank) for every chunk it ranks, and the contributions are added. Rank 1 adds 1/61 when k = 60. k is commonly 60. It is a parameter, not a law. The lab names it RRF_K, and test_rrf_constant_is_the_usual_default pins it. Do not retune it on three documents.
for rank, row in enumerate(by_vec, start=1):
scores[row["chunk_id"]] = scores.get(row["chunk_id"], 0.0) + 1.0 / (RRF_K + rank)
for rank, row in enumerate(by_kw, start=1):
scores[row["chunk_id"]] = scores.get(row["chunk_id"], 0.0) + 1.0 / (RRF_K + rank)
ordered = sorted(scores, key=lambda cid: (-scores[cid], cid))[:k]
The arithmetic
Two live chunks, an employee query, k = 60. Vector order: leave-2 rank 1, leave-other rank 2. Keyword order: leave-other rank 1, leave-2 rank 2.
leave-2scores1/61 + 1/62, about 0.0325.leave-otherscores1/62 + 1/61, the same.
Fusion did not break the tie. RRF does not invent a winner when the two lists are reverses of each other. So the lab sorts by score descending, then chunk_id ascending, and two deploys agree. test_equal_rrf_scores_break_on_chunk_id forces the two legs into opposite orders so the fused scores match, then checks that a-chunk comes before b-chunk. Relying on dict insertion order instead would prefer whichever chunk the vector leg happened to rank first.
What the lab is not doing
- No top-k cutoff per leg.
Index.queryscores every candidate that survived the ACL and the tombstone on both lists. A production index that fuses only the top 50 of each list is where a keyword-only hit receives1/61and nothing from the vector side. - No embedding model. The lab's
embedis a feature hash of tokens into 64 dimensions. It will not map "time off for a new parent" near "parental leave," because the strings share no tokens. Swap in a real model without changing ids, filters, or fusion, then add a paraphrase case. Until that case passes, do not claim semantic search. - Not BM25. Postgres
tsvectorandts_rankgive you a keyword leg. That is not BM25. If you want BM25, use an engine that implements it, and still fuse.
Where it sits
Fusion runs inside the candidate set, after access control and tombstones. A better rank never makes a forbidden chunk allowed. A cross-encoder reranker on the top 20 is useful once a labeled set shows the first-stage rank is wrong. It is not the first thing to build.
In a case
Mill City Permits answers from a named corpus. Whatever the rank, a question about fees gets "outside this corpus," not an amount, because the fee pages were never in the store. Rank decides order among allowed pages. It does not decide scope.
Last technically reviewed: October 2026. Tested with: Python 3, stdlib, SQLite, stand-in model. Not a live provider.