Reciprocal Rank Fusion
RRF adds 1 / (60 + rank) from each list. It ignores raw score scales, which is why people like it.
Min-max fusion needs comparable scores and a sensible alpha. Reciprocal Rank Fusion (RRF) does not. It looks only at ranks. For each list, add 1 / (k + rank) to the document (ranks start at 1). A common k is 60. It is a habit, not a theorem — still measure it.
Why it works as engineering: BM25 of 12.4 and cosine of 0.81 never meet. Rank 1 and rank 3 do meet. A document that is rank 1 in keywords and rank 5 in vectors gets two positive terms. A document that is rank 1 in one list only still gets a term, but less than a document that is good in both.
The constant k flattens the difference between rank 1 and rank 2. Small k (say 0) makes rank 1 huge (1/1 vs 1/2). k=60 makes 1/61 vs 1/62 a gentle nudge. That is why 60 shows up in papers. Your corpus might want 20 or 80. Eval.
1/(60+rank) does not need BM25 and cosine to share a scale.
RRF adds ranks, not raw scoresRetrieve more than you will show
Retrieve more than you will show (for example 20 + 20) then fuse, then cut to 5. If you fuse only the top 3 of each, you never give the other channel a chance. The gold id-hit might be keyword rank 4 and vector rank 40. A top-3 fuse never sees it. A top-20 fuse can promote it.
If keyword and vector disagree, show both in the trace. Disagreement is information: paraphrase vs exact token. Hiding it makes fusion look like a black box.
RRF does not replace a threshold. After fusion you still have a list. You can drop documents that were rank 50 in both lists, or apply a cosine tau on the vector channel before fusion so junk never enters. RRF of two junk lists is ranked junk.
Pin identifiers if RRF buries them
Exact identifiers should be hard to bury. If the query contains INV-17 and a chunk contains INV-17, you can force that chunk into the fused list before RRF, or add a third “id match” list of length 1. If RRF drops INV-17 out of the prompt, that is a product bug you can write as a unit test.
Run to execute this in your browser. Nothing is sent to a server.
a (the id hit in the first demo) and c share the fused top depending on ranks. RRF does not care that BM25 was 12.4 and cosine was 0.81. keyword only vs vector only shows each channel’s own ranking. wide shows that a third place id can still appear after fusion when both lists are longer than 2 — here inv-17 is first in keywords and third in vectors; cash-blog is first in vectors and second in keywords. Print the pairs. Change k to 1 and watch rank-1 dominate. That is the knob.
defaultdict(float) starts missing keys at 0.0 so a document in only one list still scores. Documents in neither list do not appear. That is correct.
RRF vs alpha fusion
Use RRF when you do not want to tune alpha or when scores are incomparable (different vendors, different BM25 implementations). Use alpha fusion when you have calibrated channels and want to turn keywords up for an SKU-heavy product. Eval both on the same golden set. Do not switch weekly on vibes.
You can RRF more than two lists: keyword, vector, and a recency list, for example. Each extra list is a chance to bury or save. Keep the traces.
Ranks, pools, and identifiers
Ranks start at 1. Rank 0 is a bug. If your language is 0-based, add 1 before RRF. A document missing from a list simply does not get that list’s term. That is how a keyword-only hit still survives: it gets 1/(k+rank) once, and a dual hit gets it twice.
Pool width is the usual production miss. Teams retrieve k=5 from each channel because the prompt wants 5. Then fusion cannot promote rank 6. Retrieve 20+20, fuse, cut to 5, then pack. The extra 15 are cheap compared to a missed id.
k=60 is gentle. If both lists are length 3, every rank is 1–3 and k=60 makes them almost equal (1/61 vs 1/63). Short lists plus large k flatten everything. Either lengthen the lists or lower k when you only have three documents. Measure on your list lengths.
Pin id-matches before RRF if evals show identifiers falling off. A third list of length 1 that is “chunks containing this SKU” is enough. RRF will then add a large term (rank 1 on that list) to those chunks.
Do not threshold RRF scores with a cosine tau. Recalibrate or threshold channels before fusion. RRF of two junk lists is ranked junk with nicer arithmetic.
Common mistakes
- Fusing top-3 + top-3, then wondering where the id went.
- Treating RRF score as cosine. It is a sum of small fractions. Do not reuse cosine
tau. - One list empty (keyword failed) and you still “fuse.” You just re-ranked vectors with extra math.
How agents use this
Default to RRF when you do not want to tune alpha. Still eval both. Pin id-matches before fusion if your golden set says identifiers fall off.
Log each list’s ranks next to the fused list. When someone says retrieve is random, you want to see “keyword: a,c,b / vector: c,b,a / rrf: …”. That is searchable in traces. The generate step should not be where you discover fusion math.
This is still search. Not the agent loop. The retrieve library returns fused ids. Later, a loop will call it twice with two queries. Fusion happens inside one retrieve call.
Retrieve wide, fuse, then cut. Ranks do not need the same numeric scale. k=60 is a habit you still measure. Short lists plus large k flatten the ranking — lengthen the pool. Pin ids if they fall off. Do not reuse a cosine tau on RRF sums. Rank 1 in keywords plus rank 5 in vectors is a dual hit. Rank 50 in both is still junk. Fuse after you drop channel-level noise, or you will rank junk politely.
Check your understanding