- Derive and compute BM25 scores, explaining the roles of IDF, k1 saturation and b length normalization.
- Compare sparse, dense, late-interaction and cross-encoder models in accuracy, cost and where each belongs in a retrieval funnel.
- Compute reciprocal rank fusion over several ranked lists and explain why rank fusion avoids score-calibration problems.
- Apply query rewriting, HyDE and multi-query retrieval, and identify when each helps or hurts.
- Calculate recall@k, MRR and nDCG@k by hand and implement them in Python.
- Design a production retrieval pipeline with filters and an explicit latency budget.
Before this: Vector Databases and Indexing, Document Processing and Chunking
A basic RAG pipeline embeds the question, takes the top five chunks, and hopes. Production systems do much better by combining several retrievers, re-scoring candidates with stronger models, rewriting questions before searching, and measuring all of it with proper ranking metrics. These techniques are the difference between a demo and a system people trust, and they are standard in search engineering, long predating LLMs.
The picture: a funnel with specialists
Think of hiring. A recruiter skims thousands of CVs with keyword filters (fast, literal), a second recruiter skims them for overall fit (fast, fuzzy), their shortlists are merged, and then an expert interviewer spends real time on the top 50 (slow, accurate). Advanced retrieval is the same funnel: cheap, high-recall first-stage retrievers (sparse and dense) produce candidates, fusion merges their lists, and an expensive, high-precision reranker orders the final few. Query rewriting is the step before the funnel: making sure the job description itself is well written.
Sparse retrieval and BM25
Sparse retrieval represents texts as vectors over the vocabulary, where almost all entries are zero, and stores them in an inverted index: for each term, the list of documents containing it. It matches exact tokens, which makes it strong on names, codes, numbers and rare jargon, and needs no training.
BM25 (Robertson and colleagues, from the probabilistic relevance framework developed through the 1970s to 1990s) is the standard scoring function. For a query with terms and a document :
where is the number of times occurs in , is the document length in tokens, is the average document length in the collection, (typically 1.2 to 2.0) controls term-frequency saturation, and (typically 0.75) controls length normalization. A common IDF (used by Lucene) is
with the number of documents and the number containing . Intuition: rare terms are worth more (IDF); the tenth occurrence of a word adds much less than the first (saturation through ); and a match in a short document counts more than in a long one (normalization through ).
Worked example. Four documents, query "cheap paris", , :
- D1 "cheap flights to paris" (4 tokens)
- D2 "paris hotel deals and cheap paris tours" (7 tokens)
- D3 "flights and hotels in paris" (5 tokens)
- D4 "weather in london" (3 tokens)
. "cheap" is in 2 documents: . "paris" is in 3: .
Let . For D1: . Each term occurs once, so cheap contributes and paris ; total 1.122.
For D2: . Cheap: . Paris occurs twice: . Total 1.013.
For D3: ; only paris: 0.349. D4 scores 0.
D2 mentions paris twice yet ranks below D1: saturation limits the reward for repetition and D2 is longer than average. Here is the same computation in code:
import math
from collections import Counter
def bm25_scores(query, docs, k1=1.2, b=0.75):
toks = [d.lower().split() for d in docs]
N = len(toks)
avgdl = sum(len(t) for t in toks) / N
df = Counter(term for t in toks for term in set(t)) # document frequency
scores = []
for t in toks:
tf, s = Counter(t), 0.0
for q in query.lower().split():
if q not in tf:
continue
idf = math.log((N - df[q] + 0.5) / (df[q] + 0.5) + 1)
norm = k1 * (1 - b + b * len(t) / avgdl) # length normalization
s += idf * tf[q] * (k1 + 1) / (tf[q] + norm)
scores.append(s)
return scores
docs = ["cheap flights to paris", "paris hotel deals and cheap paris tours",
"flights and hotels in paris", "weather in london"]
print([round(s, 3) for s in bm25_scores("cheap paris", docs)])
# [1.122, 1.013, 0.349, 0.0]
Real systems add tokenization, stemming, stop words and an inverted index so that only documents containing a query term are scored. Learned sparse models (for example SPLADE) use a transformer to assign weights to vocabulary terms, including expansion terms not literally present, keeping the inverted-index efficiency while adding some semantic matching.
Dense retrieval
Dense retrieval uses a bi-encoder: one transformer encodes the query, one (often the same) encodes each passage, independently, into vectors compared by dot product or cosine (Karpukhin et al.'s Dense Passage Retrieval, 2020, is the classic reference). Because passages are encoded independently of the query, their vectors are computed once and indexed with ANN. Dense retrieval handles paraphrase and synonyms ("car" matches "automobile") but can miss exact identifiers and degrade on domains far from its training data.
Hybrid search and reciprocal rank fusion
Sparse and dense retrievers make different mistakes, so combining them generally improves recall. The difficulty is that their scores live on incomparable scales (BM25 is unbounded; cosine is in ). Two families of fusion:
- Score fusion: normalize each retriever's scores (for example min-max within the result list) and take a weighted sum . Flexible, but sensitive to the normalization and needs tuned.
- Rank fusion: ignore scores, use only ranks. Reciprocal rank fusion (RRF; Cormack, Clarke and Büttcher, SIGIR 2009) scores each document as
where is the set of ranked lists, is 's 1-based position in list (a document missing from a list contributes nothing for it), and is a smoothing constant, in the original paper. A large flattens the difference between rank 1 and rank 5, so consistent presence across lists beats a single top placement.
Worked example. BM25 returns ; dense returns . With :
| Doc | BM25 rank | Dense rank | RRF score |
|---|---|---|---|
| A | 1 | 2 | |
| C | 3 | 1 | |
| B | 2 | 4 | |
| E | absent | 3 | |
| D | 4 | absent |
Fused order: A, C, B, E, D. A wins by being near the top of both lists; E and D, each found by one retriever only, fall to the bottom.
from collections import Counter
def rrf(rankings, k=60):
fused = Counter()
for ranking in rankings: # each: doc ids, best first
for rank, doc in enumerate(ranking, start=1):
fused[doc] += 1.0 / (k + rank)
return fused.most_common()
print(rrf([["A", "B", "C", "D"], ["C", "A", "E", "B"]]))
# [('A', 0.0325...), ('C', 0.0322...), ('B', 0.0317...), ('E', 0.0158...), ('D', 0.0156...)]
RRF needs no training and no score calibration, which is why it is the default fusion in many search engines and vector databases.
Cross-encoder reranking
A cross-encoder feeds the query and one passage together into a transformer, so every query token can attend to every passage token, and outputs a single relevance score (Nogueira and Cho's 2019 BERT passage re-ranker established the approach). This joint reading is far more accurate than comparing two independently computed vectors, but it cannot be precomputed: scoring passages costs full forward passes. Hence the funnel: retrieve 50 to 200 candidates cheaply, rerank them with the cross-encoder, keep the top 5 to 10.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
query = "How long is parental leave?"
candidates = ["Employees receive 16 weeks of paid parental leave.",
"Sick leave is ten days per year.",
"Leave requests go through the HR portal."]
scores = reranker.predict([(query, c) for c in candidates])
for score, text in sorted(zip(scores, candidates), reverse=True):
print(f"{score:7.2f} {text}")
Rerankers are also available as hosted APIs and as LLM-based rerankers (prompting an LLM to score or order passages), which are more expensive but can follow instructions such as "prefer the most recent policy". Measure the latency: reranking 100 passages adds a noticeable delay, so tune the candidate count.
Query rewriting and expansion
User questions are often short, ambiguous, conversational ("what about for contractors?") or phrased differently from the documents. Rewriting happens before retrieval:
- Conversational rewriting: an LLM turns the latest turn plus chat history into a standalone query ("What is the parental leave policy for contractors?"). Essential for chat interfaces.
- Expansion: add synonyms, acronyms expanded, related terms; classic in BM25 systems (pseudo-relevance feedback adds terms from the top initial results).
- HyDE (Hypothetical Document Embeddings): Gao, Ma, Lin and Callan (2022) proposed asking an instruction-following LLM to write a hypothetical document that answers the query, then embedding that document and using its vector for retrieval. The fake answer may contain wrong facts, but it is shaped like a real answer, so it lands near real answers in embedding space; the encoder's bottleneck filters out much of the hallucinated detail. Their experiments used it for zero-shot retrieval with an unsupervised dense encoder (Contriever) and no relevance labels. Costs: an LLM call before every search, and possible drift when the LLM knows little about your private domain.
- Step-back and decomposition: ask the LLM for a more general question ("what are the leave policies?") or split a compound question into sub-questions retrieved separately.
Multi-query retrieval
Generate several paraphrases of the question (say 3 to 5), retrieve for each, and fuse the lists with RRF. This raises recall because different phrasings hit different relevant chunks, at the cost of several retrievals and one LLM call. Combining multi-query with RRF is sometimes called RAG-Fusion.
ColBERT and late interaction
Between bi-encoders (fast, coarse) and cross-encoders (slow, precise) sits late interaction. ColBERT (Khattab and Zaharia, 2020) encodes query and passage independently, like a bi-encoder, but keeps one vector per token instead of pooling to a single vector. Relevance is the MaxSim sum: for each query token, find its best-matching passage token, and add up those maxima:
where and are the normalized token embeddings.
Worked example. Query token embeddings , ; passage tokens , , . For the dot products are $0.8, 0, 11q_2$ they are $0.96, 0.8, 0.60.96= 1.96$.
Because passage token vectors are precomputed, late interaction scales to large collections while keeping fine-grained term matching. The price is storage: one vector per token rather than per passage, which ColBERTv2 (Santhanam et al., 2021) reduced with residual compression. Late-interaction models are used both as first-stage retrievers and as rerankers.
Filtering
Apply hard constraints (permissions, tenant, date, document type) inside every retriever, before fusion and reranking, so that disallowed content never becomes a candidate. Filters also improve quality: restricting to "current policies" removes superseded versions that would otherwise compete. An LLM can extract filters from the question ("contracts signed in 2025" becomes a date filter plus a query), a pattern often called self-querying; validate the extracted filters against a schema.
Retrieval metrics
Evaluate retrieval separately from generation. You need a set of queries with relevance judgments (human labels, or carefully reviewed LLM labels). Let be the relevance of the result at rank .
- Recall@k: fraction of all relevant documents for the query that appear in the top . Most important for RAG, because the generator cannot use what was not retrieved.
- Precision@k: fraction of the top that are relevant.
- MRR (mean reciprocal rank): for each query, of the first relevant result (0 if none), averaged over queries. Good when one right answer suffices.
- nDCG@k (normalized discounted cumulative gain): handles graded relevance and rewards putting the best results first.
where IDCG is the DCG of the ideal ordering of the relevant documents. (A common variant uses gain to emphasize highly relevant results; state which you use.)
Worked example. For one query, the collection has four relevant documents with grades 3, 2, 2, 1. The system returns five results with grades .
- Recall@5: three of four relevant documents retrieved, . Precision@5 .
- Reciprocal rank: first relevant at rank 1, so . If two other queries had their first relevant hit at rank 3 and nowhere, MRR .
- DCG@5 .
- Ideal order : IDCG@5 .
- nDCG@5 .
The best document (grade 3) at rank 3 instead of rank 1, and one relevant document missing, cost about a third of the ideal score.
Common pitfalls
- Skipping BM25. Dense-only systems fail on product codes, names and error strings that users type verbatim.
- Fusing raw scores from different retrievers without normalization.
- Reranking too few candidates (the reranker cannot rescue what was not retrieved) or too many (latency explodes).
- Evaluating only end-to-end answers, so you cannot tell whether a failure came from retrieval or generation.
- Tiny or synthetic-only test sets. Fifty real, labelled queries beat a thousand generated ones you never inspected.
- Over-rewriting: an LLM rewrite that changes the meaning of the question, or HyDE on a private domain the LLM knows nothing about.
- Applying filters after reranking, wasting reranker budget and risking leaks.
In practice
- A strong default pipeline: conversational rewrite, then BM25 and dense retrieval (about 50 candidates each) with permission filters, RRF fusion, cross-encoder reranking of the top 50 to 100, and the top 5 to 10 passed to the generator.
- Add multi-query, HyDE or late interaction only when evaluation shows a recall gap they close.
- Track recall@k for the first stage and nDCG@10 after reranking on a fixed, versioned query set; rerun it on every change to chunking, models or parameters.
- Budget latency explicitly: rewriting (one LLM call), retrieval (tens of milliseconds), reranking (tens to hundreds of milliseconds), generation (seconds).
Key takeaways
- Sparse (BM25) and dense retrieval fail on different queries, so hybrid search with fusion is a strong default.
- Reciprocal rank fusion merges ranked lists using only ranks, so it needs no score normalization and rewards documents found by several retrievers.
- Cross-encoders read the query and passage together and rerank far more accurately, but are too expensive for first-stage retrieval; use them on the top 50 to 100.
- Late-interaction models like ColBERT keep per-token vectors and score with MaxSim, sitting between bi-encoders and cross-encoders in cost and accuracy.
- Query rewriting, HyDE and multi-query raise recall at the price of extra LLM calls and the risk of changing the question's meaning.
- Measure retrieval on its own with recall@k, MRR and nDCG on a fixed set of real labelled queries before and after every change.
Terms in this lesson
- BM25
- A lexical ranking function that scores documents by summing, over query terms, an IDF weight times a saturated, length-normalized term frequency.
- Inverted index
- A map from each term to the list of documents (and often positions) containing it, enabling fast lexical retrieval.
- Bi-encoder
- A model that encodes queries and documents independently into vectors, so document vectors can be precomputed and indexed.
- Cross-encoder
- A model that reads a query and a passage together in one forward pass and outputs a relevance score.
- Reciprocal rank fusion (RRF)
- A fusion method scoring each document by the sum over ranked lists of 1/(k + rank), with k typically 60.
- HyDE
- Hypothetical Document Embeddings: retrieving with the embedding of an LLM-written hypothetical answer instead of the raw query.
- Late interaction (MaxSim)
- Scoring by summing, over query token embeddings, the maximum similarity to any document token embedding.
- nDCG@k
- Discounted cumulative gain of the top k results divided by that of the ideal ranking, giving a graded-relevance score between 0 and 1.
Check your understanding
- 1.
Using RRF with k = 60, a document is ranked 1st by BM25 and 3rd by dense retrieval. What is its fused score (to 4 decimal places)?
- 2.
Why are cross-encoders normally used only to rerank a shortlist rather than to search the whole corpus?
- 3.
Users paste exact error codes such as ERR_SSL_PROTOCOL_ERROR, and your dense-only retriever often misses the matching troubleshooting page. What is the best first fix?
- 4.
In HyDE, what is embedded and used as the search vector?
- 5.
What happens to BM25 when you set b = 0?
Exercises
Work each problem before opening the solution.
- Exercise 1Core
A collection has documents with average length 100 tokens. The term "kubelet" appears in 10 documents. Document has 200 tokens and contains "kubelet" 3 times. Compute the BM25 contribution of "kubelet" to 's score with , and . Then say how the result changes if had 100 tokens.
Show solution
IDF .
Length factor: .
TF part: .
Contribution .
With (average length): , TF part , contribution . The shorter document scores higher for the same count because three mentions in fewer words is stronger evidence that the document is about the term.
- Exercise 2Core
Three retrievers return: BM25 , dense , multi-query paraphrase . Compute RRF scores with and give the fused ranking. What do you notice about and , and how would you break the tie?
Show solution
- : ranks 3, 1, 2:
- : ranks 1, 2:
- : ranks 2, 1 (BM25 2nd, paraphrase 1st):
- : rank 3:
- : rank 3:
Fused: first (found by all three), then and tied, then and tied. Ties are common in RRF because only ranks matter. Break them deterministically, for example by the best single rank, by a preferred retriever, or, best, by passing all candidates to a reranker, which makes the tie irrelevant.
- Exercise 3Core
A query has relevant documents with grades . The system returns . Compute recall@3, reciprocal rank, and nDCG@3 using gain .
Show solution
Recall@3: top 3 contain and , so .
Reciprocal rank: first relevant result () is at rank 2, so .
DCG@3 .
IDCG@3 (ideal order ) .
nDCG@3 .
- Exercise 4Challenge
Implement
evaluate(run, qrels, k)returning mean recall@k, MRR@k and nDCG@k (linear gain) over queries, whererunmaps query IDs to ranked document IDs andqrelsmaps query IDs to{doc_id: grade}. Test it on two queries: q1 returns[a, x, b, y, c]with relevant{a:2, b:3, c:1, d:2}; q2 returns[z, w, e, f, v]with relevant{e:1}. Verify the q1 numbers against the worked example in the lesson.Show solution
import math def evaluate(run, qrels, k=10): # run: {qid: [doc ids, best first]}; qrels: {qid: {doc_id: grade > 0}} R = M = N = 0.0 for qid, ranking in run.items(): rel = qrels.get(qid, {}) top = ranking[:k] R += sum(1 for d in top if d in rel) / len(rel) if rel else 0.0 M += next((1 / i for i, d in enumerate(top, 1) if d in rel), 0.0) dcg = sum(rel.get(d, 0) / math.log2(i + 1) for i, d in enumerate(top, 1)) ideal = sorted(rel.values(), reverse=True)[:k] idcg = sum(g / math.log2(i + 1) for i, g in enumerate(ideal, 1)) N += dcg / idcg if idcg else 0.0 n = len(run) return {"recall@k": R / n, "MRR@k": M / n, "nDCG@k": N / n} run = {"q1": ["a", "x", "b", "y", "c"], "q2": ["z", "w", "e", "f", "v"]} qrels = {"q1": {"a": 2, "b": 3, "c": 1, "d": 2}, "q2": {"e": 1}} print(evaluate(run, qrels, k=5)) # {'recall@k': 0.875, 'MRR@k': 0.666..., 'nDCG@k': 0.591...}Check by hand at . Recall: q1 retrieves 3 of 4 relevant (), q2 retrieves 1 of 1 (), mean . MRR: q1 first relevant at rank 1 (), q2 at rank 3 (), mean . nDCG: q1 has grades , matching the lesson's example, ; q2 has DCG and IDCG , so ; mean . Design notes: queries with no relevant documents are scored 0 here, but many evaluation tools skip them instead, so be explicit; IDCG uses all judged relevant documents, including ones the system never retrieved, which is what makes nDCG penalize misses; and MRR is truncated at (a first hit beyond rank scores 0). For production use, established libraries such as
pytrec_evalorir-measuresimplement these metrics with standard conventions.
Further reading
- bookManning, Raghavan & Schütze, Introduction to Information Retrieval (2008)
- paperCormack, Clarke & Büttcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009)
- paperKarpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering (2020)
- paperNogueira & Cho, Passage Re-ranking with BERT (2019)
- paperGao, Ma, Lin & Callan, Precise Zero-Shot Dense Retrieval without Relevance Labels (2022)
- paperKhattab & Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT (2020)
Go further
Sources
Fact-checked and last reviewed 2026-11-05