RAG / retrieval / ranking / fusion
Hybrid Retrieval and Reciprocal Rank Fusion
That hybrid search means blending a BM25 score with a cosine score, that RRF is a weighted average of the two, and that a reranker is a small quality tweak at the end. BM25 is unbounded and corpus-dependent while cosine is bounded and anisotropic, so any weighted sum is silently dominated by whichever column has the larger numbers on that query. RRF never sees either number: it fuses positions, which is why a document that is rank 1 by a factor of three in one list can lose to a document that is merely second in both. And the reranker teams skip is the only stage that reads the query and the chunk together — but it can only reorder what the candidate window already returned.
Reciprocal rank fusion does not average your BM25 score and your cosine score. It throws both of them away and keeps only the positions. That is the entire idea, it is the reason hybrid search works at all, and it is also the reason hybrid search sometimes returns a worse answer than either retriever did alone.
The two retrievers fail in opposite directions, which is why anyone bothers
combining them. A dense retriever maps text into a learned space where
"money back" lands near "refund" — and where ERR_CONN_2043 lands
near ERR_AUTH_1180, because a subword tokenizer shreds both into
fragments the encoder never saw in training and they end up pointing in
almost the same direction. BM25 has no notion of meaning at all, but it knows
that ERR_CONN_2043 occurs in exactly one document out of
fourteen, which is the single most useful fact available about that query.
Below is a fourteen-chunk support corpus with both retrievers running on it. BM25 is Lucene's, formula for formula. The dense side is real vector arithmetic over a synthetic term space. Start with the query ERR_CONN_2043 retry and read the two lists before you touch anything.
candidates taken from each list is what Elasticsearch calls
rank_window_size — how deep each retriever is read before
fusion happens. It also caps what the re-scorer is allowed to see, which is
the more expensive of its two effects.
the chunk that answers the question · whatever outranks it · everything else. Rows below the rule in the first two columns are outside the candidate depth and never reach fusion at all.
The corpus is synthetic; the retrieval is not. BM25 is Lucene's
BM25Similarity with its shipped defaults —
k1 = 1.2, b = 0.75,
idf = ln(1 + (N − n + 0.5) / (n + 0.5)) and
tf = f / (f + k1(1 − b + b·dl/avgdl)) — computed
over the real token streams of the fourteen chunks, with Lucene's English
stop-word list applied to the lexical side only. The dense side assigns
each vocabulary term a vector by hand over nine named axes, which is the
synthetic part; identifiers get a shared "opaque" direction with a small
deterministic per-token offset, standing in for subwords the encoder never
learned. Everything after that is real: chunk vectors are mean-pooled and
L2-normalised, dense scores are cosines, RRF is
∑ w / (k + rank), and the joint re-scorer is ColBERT's
MaxSim — ∑q maxd cos(q, d) — over
the same term vectors. Nothing is scripted; the numbers come out of the
arithmetic.
Read the dense column for ERR_CONN_2043 retry. Rank 1 is
faq#retry — "Should I retry a failed request?" — which is a
question, not an answer, and contains no error code. Rank 2 is
runbook#auth, the runbook for ERR_AUTH_1180: a
different error, a different subsystem, a different fix. The chunk that
literally contains ERR_CONN_2043 is third. The embedding is not
broken; it simply has no dimension in which those four error codes differ.
Their pairwise cosines in this space are 0.96 to 0.98 — an encoder that has
never seen a token cannot distinguish it from another token it has never
seen.
Now read the BM25 column. One term, err_conn_2043, appears in 1
document of 14, so its IDF is ln(1 + 13.5/1.5) = 2.303 against
1.455 for retry, which appears in three. That single term
contributes 0.957 of the chunk's 1.561 total and puts the right answer
first, decisively, using no semantics whatsoever.
And the fused list, at the default k = 60, puts
faq#retry first — score 0.03252 against the correct chunk's
0.03227. Hybrid retrieval, with both retrievers behaving exactly as designed,
is worse here than the lexical retriever was on its own. The rest of this
lesson is about why, and about what the checkbox at the bottom right of the
panel does to it.
Why not just add the scores
Switch how the two lists are combined to weighted sum of scores
and look at what happens across the four queries. On this query it fixes the
problem: 2.082 for the right chunk against 1.716, a clean win. Now switch the
query to the paraphrase, how do I get my money back. The fused top hit
scores 8.227 and it is faq#refund — "How do I get my money back?
See the refunds page." — a link, not an answer. The chunk that actually
states the refund policy scores 0.938.
Those numbers are not comparable and the sum does not care. BM25 gave
faq#refund 7.255 because a short document matched six query terms;
the dense retriever gave it 0.972 because cosine cannot exceed 1. Adding them
produces 8.227, of which 88% is BM25's opinion. The weighting you chose is
not the weighting you got. BM25's scale depends on the length of the query,
the length of the document, the size of the corpus and the document
frequency of every term in it — change any of those and the effective weight
changes with no config edit. Min-max normalising each list before summing
only moves the problem: the maximum of a list is whatever the top hit
happened to score, so on a query where nothing matches well you normalise
noise up to 1.0.
Cormack, Clarke and Büttcher's 2009 paper proposed the alternative in one line:
RRFscore(d) = ∑r ∈ R 1 / (k + r(d)), with k = 60
Their stated advantage is the one people skip when they describe RRF as a weighted average: it "combines ranks without regard to the arbitrary scores returned by particular ranking methods." Qdrant's implementation says the same thing in a code comment — "the input scores are irrelevant, only the order matters." A retriever that returns 7.255 and one that returns 0.972 are put on the same footing because neither number is used.
That immunity is bought with information loss, and the loss is exactly what
hurt us on the first query. In the BM25 list, rank 1 scored 1.561 and rank 2
scored 1.031 — a 51% gap that says the top hit is not merely first but
far first. RRF sees 1 and 2. Meanwhile faq#retry is
rank 2 in BM25 and rank 1 in dense; the right chunk is rank 1 in BM25 and
rank 3 in dense. Compare the two sums term by term and the outcome is
settled before you pick a k:
faq#retry = 1/(k+1) + 1/(k+2)
runbook#conn = 1/(k+1) + 1/(k+3)
The first terms are identical and the second differs only in the denominator,
so faq#retry wins for every value of k. Drag the
rrf rank constant from 0 to 100 and watch the fused order refuse to
change. This is worth knowing before you spend an afternoon tuning k: when
one document dominates another on ranks, no rank constant will reverse it,
because k is a monotone reparameterisation and not a re-ranking.
What k actually controls
k sets how quickly the value of a position decays. At k = 0 the top hit is worth 1.0 and the tenth is worth 0.1 — a factor of ten. At k = 60 the top hit is worth 0.0164 and the tenth 0.0143 — a factor of 1.15. The paper is candid about how it landed on the number: k = 60 "was fixed during a pilot investigation and not altered during subsequent validation", and their own table shows MAP moving from 0.2072 at k = 0 to a plateau of about 0.2145 across k = 50 to 100, "near-optimal, but... not critical."
So k is a smoothing constant, not a relevance knob. What it decides is how much weighting can do. Leave the query on the error code and drag weight on the lexical list to 2.0:
- At k = 60 the right chunk takes first place at 0.04866 against 0.04865 — correct by eight parts in a million. A single document entering or leaving either list would undo it.
- At k = 2 the same weight gives 0.8667 against 0.8333 — a 4% margin, visible, stable, and meaning the same thing.
The margin readout makes this visible without arithmetic. Both settings give
the same ordering; only one of them gives an ordering you could deploy. This
is why the two production systems you are most likely to use disagree by a
factor of thirty on the default. Elasticsearch's RRF retriever ships
rank_constant: 60, following the paper. Qdrant's query API ships
DEFAULT_RRF_K = 2, with the comment "mitigates the impact of high
rankings by outlier systems" — and it counts positions from zero with a
shifted formula, so its k = 2 is the paper's k = 1. Neither is wrong. But if
you tune weights against one and migrate to the other, every weight you
picked now does something different, and nothing in either API will tell you.
The boundary. Set k to 0. Now only the top of each list has any real value, so a retriever that is confidently wrong about its first result — as the dense side is here — has maximum influence. Set k to 100 and every candidate in both lists is worth nearly the same, which turns fusion into an approximate popularity vote over the union of the two candidate sets. Both ends are degenerate. The plateau between them is wide, which is the honest version of "RRF requires no tuning": it does not need tuning because tuning it does not do very much.
The candidate window is a trapdoor
Set the query to the paraphrase and pull candidates taken from each list down to 1. The answer disappears from the fused list entirely — not demoted, absent. It is unranked in BM25 and rank 2 in the dense list, so depth 2 is the shallowest setting that includes it at all; one notch further down and nothing in the pipeline can know it ever existed.
This is a real default in a real system rather than a hypothetical.
Elasticsearch's rank_window_size "defaults to the
size parameter", so a search asking for 10 results reads only 10
from each child retriever before fusing. The documentation is explicit that
rank_window_size is "all the results that we'll get to see from
the individual query components". A document sitting at rank 12 in both lists
— which is a completely ordinary place for the right answer to sit when the
query is hard — is not ranked low by fusion. It is not an input to fusion.
Setting it well above your size is close to free, and leaving it
at the default is the most common silent recall bug in a hybrid stack.
The same slider caps the re-scorer, and there it costs more. Turn on re-score the candidates jointly, keep the paraphrase query, and step the depth up from 2: at 2 the answer is still missing, at 3 it appears at rank 2, and it stays there. A reranker cannot recover a document the retriever never returned. It is a precision instrument bolted onto a recall problem, which is why "we added a reranker and it did not help" is usually a report about the retrieval stage.
What the joint scorer sees that neither retriever can
Go back to ERR_CONN_2043 retry and tick the re-score box. The right chunk moves from rank 2 to rank 1, and the margin goes from 0.8% to about 2.5%. Nothing was retrieved that was not already there; the same candidates were put in a different order by a scorer that compares the query and the chunk term by term instead of comparing two averages.
That is the whole architectural difference, and it is worth stating precisely because "reranker" gets used for three different things. A bi-encoder — the thing that filled your vector index — must commit each chunk to a single vector before any query exists. That is what makes it fast: the vectors are computed once, offline. It is also what makes it lossy, and the mean-pooled vector is where the information goes. A cross-encoder does the opposite. It concatenates the query and the chunk and runs them through the network together, so every query token can attend to every chunk token and the output is a single relevance score for that specific pair. Nothing can be precomputed, so the cost is one forward pass per candidate — which is exactly why it runs on 50 documents and not on 5 million. Nogueira and Cho's 2019 "Passage Re-ranking with BERT" was a straightforward implementation of that idea and it improved MS MARCO MRR@10 by 27% relative over the previous state of the art.
The re-scorer in the panel is the middle option, ColBERT's late interaction: it keeps a vector per term, and scores a pair as the sum over query terms of the best-matching chunk term. Khattab and Zaharia's argument for it is architectural — delay the interaction, but keep it fine-grained, so document representations can still be precomputed offline. In the panel it is enough to fix the error-code query, because the query's identifier term is compared against the chunk's identifier term directly, instead of both being averaged into a mean that no longer distinguishes them.
Where it fails. Set the query back to the paraphrase, with re-scoring
on and depth at 10. The answer is rank 2, at 0.996, behind
faq#refund at 1.000. Every configuration in this panel leaves it
at rank 2. The FAQ entry restates the user's question in the user's own
words, so it is the closest match by lexical overlap, by pooled cosine and by
term-level alignment simultaneously — all three measure resemblance, and it
resembles the query more than the answer does. Only a scorer trained on
query-passage relevance judgements, which has learned what an answer looks
like as distinct from what a restatement looks like, demotes it. That
training signal is what you are buying from a cross-encoder, and it is not
something late interaction reproduces.
Do not read the last three sections as "dense retrieval is bad". Karpukhin et al. established the opposite in 2020: a dual-encoder trained on question-passage pairs beat a strong Lucene BM25 baseline by 9 to 19 points absolute on top-20 retrieval accuracy. The complication arrived with generalisation. BEIR evaluated ten retrieval systems across eighteen datasets and concluded that "BM25 is a robust baseline" while dense models "often underperform" out of domain; Sciavolino et al. built a set of simple entity-centric questions and found dense retrievers "drastically underperform sparse methods", generalising "only to common entities unless the question pattern is explicitly observed during training". Your product names, error codes, SKUs and internal acronyms are rare entities by construction. That is not a defect you can fine-tune away cheaply, and it is the reason the lexical list stays in the pipeline.
Checking it on a real system
Every diagnosis here is the same shape: get the two lists separately, before fusion, and compare positions rather than scores.
Elasticsearch. Run each child retriever as its own query and record
the rank of the known-good document in both. Then check
rank_window_size — if it is absent from your request body it is
equal to size, and that is your real recall ceiling. Use
_explain on the standard retriever to see the BM25 breakdown;
the explanation names the parts directly, including "k1, term saturation
parameter" and "tf, computed as freq / (freq + k1 * (1 - b + b * dl /
avgdl))". If a term you expected to dominate has a low IDF, your analyzer
split it — check whether ERR_CONN_2043 survives your tokenizer
as one term or arrives as err, conn,
2043, in which case its rarity is gone and BM25 cannot help you
either.
Qdrant. A prefetch with fusion: rrf returns
fused scores; run the prefetches as top-level queries to see the inputs. Note
the default k is 2 and the weights are per-prefetch, and that
fused scores are not comparable to either input's scores or to the fused
scores of a differently-configured collection. Do not alert on them.
Postgres with pgvector. There is no fusion primitive; you write the
RRF join yourself, usually two CTEs with
row_number() and a sum of 1.0 / (60 + rn). Two
things bite here. The lexical side is exact and the vector side is not —
hnsw.ef_search defaults to 40, so an HNSW scan returns at most
40 candidates before your LIMIT applies, and
ivfflat.probes defaults to 1, which reads a single list out of
however many you built. Both silently return fewer or worse rows rather than
erroring. And filtering is applied after the index scan, so, in
pgvector's own words, "if a condition matches 10% of rows, with HNSW and the
default hnsw.ef_search of 40, only 4 rows will match on
average". A tenant filter on a hybrid query can quietly reduce your dense
candidate list to nothing. That whole failure mode is
the recall of the approximate index, and it sits
underneath everything in this lesson.
The measurement that decides it. Build a set of fifty real queries with the correct chunk labelled — deliberately including the ones with error codes, part numbers and internal names, because those are the queries that separate the two retrievers — and report recall@k four ways: dense alone, lexical alone, fused, and fused-then-reranked. You are looking for the queries where fused is worse than the better of its two inputs. Those exist in every corpus, they are the ones this lesson is about, and averaging them into a single nDCG hides them completely. If a large fraction of your queries are exact-identifier lookups, routing those to the lexical retriever and skipping fusion for them is a legitimate answer.
Two costs to keep in view. A cross-encoder is a full forward pass per candidate, so reranking 100 chunks is 100 sequence-length-limited inferences on the critical path before generation starts; the depth slider is a latency budget as much as a quality knob. And every chunk that survives to the prompt is paid for again in the KV cache during generation, which is the real argument for reranking down to five good chunks rather than passing twenty mediocre ones. Neither of those is a reason to skip the stage — they are the reason the stage is a funnel: retrieve wide, fuse, rerank narrow. Whether a chunk deserves to be in that funnel at all is decided earlier still, by where you cut the document.
A document is rank 1 in your BM25 list with a score of 40.2, and the runner-up scores 3.1. In the dense list it is rank 4. A second document is rank 2 in BM25 and rank 1 in dense. You fuse with RRF at k = 60. Which wins, and what would raising k to 200 do?