PROMETHEUS OS
NATIVE AI OPERATING SYSTEM
[INIT] Prometheus OS loading...
[CORE] Neural interface initializing...
[AI] LLM router connecting...
[MEM] Memory graph loading...
[AGT] Agent chains on standby...
[SEC] Security protocols active...
[NET] Provider mesh established...
[UI] Workspace rendering...
[DONE] All systems nominal.
Welcome to PROMETHEUS OS.
READY · OFFLINE · SECURE · PRIVATE
🎨 THEME ENGINE
PROMETHEUS OS · 23 THEMES
Trial version: Dark + Light themes only.
Full theme access with PROMETHEUS OS license.
Dark theme active
Experience all 23 themes in PROMETHEUS OS

Advanced Retrieval: Hybrid Search, Reranking and Query Rewriting

Advanced 16 min· 4 exercises

Design a multi-stage retrieval pipeline that combines BM25 and dense retrieval with reciprocal rank fusion, reranks with cross-encoders or late interaction, rewrites queries, and is measured with recall@k, MRR and nDCG.

By the end of this lesson you will be able to
  • 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 QQ with terms q1,…,qnq_1,\dots,q_n and a document DD:

BM25(D,Q)=∑i=1nIDF(qi)⋅f(qi,D) (k1+1)f(qi,D)+k1(1−b+b ∣D∣avgdl)\text{BM25}(D,Q) = \sum_{i=1}^{n} \text{IDF}(q_i)\cdot\frac{f(q_i,D)\,(k_1+1)}{f(q_i,D) + k_1\left(1 - b + b\,\frac{|D|}{\text{avgdl}}\right)}

where f(qi,D)f(q_i,D) is the number of times qiq_i occurs in DD, ∣D∣|D| is the document length in tokens, avgdl\text{avgdl} is the average document length in the collection, k1k_1 (typically 1.2 to 2.0) controls term-frequency saturation, and bb (typically 0.75) controls length normalization. A common IDF (used by Lucene) is

IDF(q)=ln⁡ ⁣(N−n(q)+0.5n(q)+0.5+1),\text{IDF}(q) = \ln\!\left(\frac{N - n(q) + 0.5}{n(q) + 0.5} + 1\right),

with NN the number of documents and n(q)n(q) the number containing qq. Intuition: rare terms are worth more (IDF); the tenth occurrence of a word adds much less than the first (saturation through k1k_1); and a match in a short document counts more than in a long one (normalization through bb).

Worked example. Four documents, query "cheap paris", k1=1.2k_1 = 1.2, b=0.75b = 0.75:

  • 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)

avgdl=(4+7+5+3)/4=4.75\text{avgdl} = (4+7+5+3)/4 = 4.75. "cheap" is in 2 documents: IDF=ln⁡(2.5/2.5+1)=ln⁡2=0.693\text{IDF} = \ln(2.5/2.5 + 1) = \ln 2 = 0.693. "paris" is in 3: IDF=ln⁡(1.5/3.5+1)=ln⁡1.4286=0.357\text{IDF} = \ln(1.5/3.5 + 1) = \ln 1.4286 = 0.357.

Let KD=k1(1−b+b∣D∣/avgdl)K_D = k_1(1 - b + b|D|/\text{avgdl}). For D1: K=1.2(0.25+0.75×4/4.75)=1.058K = 1.2(0.25 + 0.75 \times 4/4.75) = 1.058. Each term occurs once, so cheap contributes 0.693×2.2/2.058=0.7410.693 \times 2.2 / 2.058 = 0.741 and paris 0.357×2.2/2.058=0.3810.357 \times 2.2/2.058 = 0.381; total 1.122.

For D2: K=1.2(0.25+0.75×7/4.75)=1.626K = 1.2(0.25 + 0.75 \times 7/4.75) = 1.626. Cheap: 0.693×2.2/2.626=0.5810.693 \times 2.2/2.626 = 0.581. Paris occurs twice: 0.357×2×2.2/3.626=0.4330.357 \times 2 \times 2.2 / 3.626 = 0.433. Total 1.013.

For D3: K=1.247K = 1.247; only paris: 0.357×2.2/2.247=0.357 \times 2.2/2.247 = 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 [−1,1][-1, 1]). Two families of fusion:

  • Score fusion: normalize each retriever's scores (for example min-max within the result list) and take a weighted sum αsdense+(1−α)ssparse\alpha s_\text{dense} + (1-\alpha) s_\text{sparse}. Flexible, but sensitive to the normalization and needs α\alpha tuned.
  • Rank fusion: ignore scores, use only ranks. Reciprocal rank fusion (RRF; Cormack, Clarke and Büttcher, SIGIR 2009) scores each document dd as
RRF(d)=∑r∈R1k+rankr(d),\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)},

where RR is the set of ranked lists, rankr(d)\text{rank}_r(d) is dd's 1-based position in list rr (a document missing from a list contributes nothing for it), and kk is a smoothing constant, 6060 in the original paper. A large kk flattens the difference between rank 1 and rank 5, so consistent presence across lists beats a single top placement.

Worked example. BM25 returns [A,B,C,D][A, B, C, D]; dense returns [C,A,E,B][C, A, E, B]. With k=60k = 60:

DocBM25 rankDense rankRRF score
A121/61+1/62=0.016393+0.016129=0.0325221/61 + 1/62 = 0.016393 + 0.016129 = 0.032522
C311/63+1/61=0.015873+0.016393=0.0322661/63 + 1/61 = 0.015873 + 0.016393 = 0.032266
B241/62+1/64=0.016129+0.015625=0.0317541/62 + 1/64 = 0.016129 + 0.015625 = 0.031754
Eabsent31/63=0.0158731/63 = 0.015873
D4absent1/64=0.0156251/64 = 0.015625

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 NN passages costs NN 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:

s(q,p)=∑i=1∣q∣max⁡j=1,…,∣p∣eqi⋅epjs(q, p) = \sum_{i=1}^{|q|} \max_{j=1,\dots,|p|} \mathbf{e}_{q_i} \cdot \mathbf{e}_{p_j}

where eqi\mathbf{e}_{q_i} and epj\mathbf{e}_{p_j} are the normalized token embeddings.

Worked example. Query token embeddings eq1=(1,0)\mathbf{e}_{q_1} = (1, 0), eq2=(0.6,0.8)\mathbf{e}_{q_2} = (0.6, 0.8); passage tokens (0.8,0.6)(0.8, 0.6), (0,1)(0, 1), (1,0)(1, 0). For q1q_1 the dot products are $0.8, 0, 1,maximum, maximum 1.For. For q_2$ they are $0.96, 0.8, 0.6,maximum, maximum 0.96.Score. Score = 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 reli\text{rel}_i be the relevance of the result at rank ii.

  • Recall@k: fraction of all relevant documents for the query that appear in the top kk. Most important for RAG, because the generator cannot use what was not retrieved.
  • Precision@k: fraction of the top kk that are relevant.
  • MRR (mean reciprocal rank): for each query, 1/rank1/\text{rank} 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.
DCG@k=∑i=1krelilog⁡2(i+1),nDCG@k=DCG@kIDCG@k\text{DCG@}k = \sum_{i=1}^{k} \frac{\text{rel}_i}{\log_2(i+1)}, \qquad \text{nDCG@}k = \frac{\text{DCG@}k}{\text{IDCG@}k}

where IDCG is the DCG of the ideal ordering of the relevant documents. (A common variant uses gain 2reli−12^{\text{rel}_i} - 1 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 [2,0,3,0,1][2, 0, 3, 0, 1].

  • Recall@5: three of four relevant documents retrieved, 3/4=0.753/4 = 0.75. Precision@5 =3/5=0.6= 3/5 = 0.6.
  • Reciprocal rank: first relevant at rank 1, so 1.01.0. If two other queries had their first relevant hit at rank 3 and nowhere, MRR =(1+1/3+0)/3=0.444= (1 + 1/3 + 0)/3 = 0.444.
  • DCG@5 =2/1+0+3/log⁡24+0+1/log⁡26=2+1.5+0.387=3.887= 2/1 + 0 + 3/\log_2 4 + 0 + 1/\log_2 6 = 2 + 1.5 + 0.387 = 3.887.
  • Ideal order [3,2,2,1,0][3, 2, 2, 1, 0]: IDCG@5 =3/1+2/1.585+2/2+1/2.322=3+1.262+1+0.431=5.693= 3/1 + 2/1.585 + 2/2 + 1/2.322 = 3 + 1.262 + 1 + 0.431 = 5.693.
  • nDCG@5 =3.887/5.693=0.683= 3.887 / 5.693 = 0.683.

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. 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. 2.

    Why are cross-encoders normally used only to rerank a shortlist rather than to search the whole corpus?

  3. 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. 4.

    In HyDE, what is embedded and used as the search vector?

  5. 5.

    What happens to BM25 when you set b = 0?

Exercises

Work each problem before opening the solution.

  1. Exercise 1Core

    A collection has N=1,000N = 1{,}000 documents with average length 100 tokens. The term "kubelet" appears in 10 documents. Document DD has 200 tokens and contains "kubelet" 3 times. Compute the BM25 contribution of "kubelet" to DD's score with k1=1.2k_1 = 1.2, b=0.75b = 0.75 and IDF(q)=ln⁡((N−n+0.5)/(n+0.5)+1)\text{IDF}(q) = \ln\big((N - n + 0.5)/(n + 0.5) + 1\big). Then say how the result changes if DD had 100 tokens.

    Show solution

    IDF =ln⁡(990.5/10.5+1)=ln⁡(94.333+1)=ln⁡95.333=4.557= \ln(990.5/10.5 + 1) = \ln(94.333 + 1) = \ln 95.333 = 4.557.

    Length factor: K=1.2 (1−0.75+0.75×200/100)=1.2×1.75=2.1K = 1.2\,(1 - 0.75 + 0.75 \times 200/100) = 1.2 \times 1.75 = 2.1.

    TF part: 3×(1.2+1)/(3+2.1)=6.6/5.1=1.2943 \times (1.2 + 1)/(3 + 2.1) = 6.6/5.1 = 1.294.

    Contribution =4.557×1.294=5.898= 4.557 \times 1.294 = 5.898.

    With ∣D∣=100|D| = 100 (average length): K=1.2K = 1.2, TF part =6.6/4.2=1.571= 6.6/4.2 = 1.571, contribution =4.557×1.571=7.162= 4.557 \times 1.571 = 7.162. 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.

  2. Exercise 2Core

    Three retrievers return: BM25 [d1,d2,d3][d_1, d_2, d_3], dense [d3,d1,d4][d_3, d_1, d_4], multi-query paraphrase [d2,d3,d5][d_2, d_3, d_5]. Compute RRF scores with k=60k = 60 and give the fused ranking. What do you notice about d1d_1 and d2d_2, and how would you break the tie?

    Show solution
    • d3d_3: ranks 3, 1, 2: 1/63+1/61+1/62=0.015873+0.016393+0.016129=0.0483951/63 + 1/61 + 1/62 = 0.015873 + 0.016393 + 0.016129 = 0.048395
    • d1d_1: ranks 1, 2: 1/61+1/62=0.0325221/61 + 1/62 = 0.032522
    • d2d_2: ranks 2, 1 (BM25 2nd, paraphrase 1st): 1/62+1/61=0.0325221/62 + 1/61 = 0.032522
    • d4d_4: rank 3: 1/63=0.0158731/63 = 0.015873
    • d5d_5: rank 3: 1/63=0.0158731/63 = 0.015873

    Fused: d3d_3 first (found by all three), then d1d_1 and d2d_2 tied, then d4d_4 and d5d_5 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.

  3. Exercise 3Core

    A query has relevant documents with grades {a:3,b:2,c:1}\{a: 3, b: 2, c: 1\}. The system returns [x,b,a,y,c][x, b, a, y, c]. Compute recall@3, reciprocal rank, and nDCG@3 using gain reli/log⁡2(i+1)\text{rel}_i/\log_2(i+1).

    Show solution

    Recall@3: top 3 contain bb and aa, so 2/3=0.6672/3 = 0.667.

    Reciprocal rank: first relevant result (bb) is at rank 2, so 1/2=0.51/2 = 0.5.

    DCG@3 =0/log⁡22+2/log⁡23+3/log⁡24=0+2/1.585+3/2=1.262+1.5=2.762= 0/\log_2 2 + 2/\log_2 3 + 3/\log_2 4 = 0 + 2/1.585 + 3/2 = 1.262 + 1.5 = 2.762.

    IDCG@3 (ideal order a,b,ca, b, c) =3/1+2/1.585+1/2=3+1.262+0.5=4.762= 3/1 + 2/1.585 + 1/2 = 3 + 1.262 + 0.5 = 4.762.

    nDCG@3 =2.762/4.762=0.580= 2.762 / 4.762 = 0.580.

  4. Exercise 4Challenge

    Implement evaluate(run, qrels, k) returning mean recall@k, MRR@k and nDCG@k (linear gain) over queries, where run maps query IDs to ranked document IDs and qrels maps 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 k=5k = 5. Recall: q1 retrieves 3 of 4 relevant (0.750.75), q2 retrieves 1 of 1 (1.01.0), mean 0.8750.875. MRR: q1 first relevant at rank 1 (1.01.0), q2 at rank 3 (0.3330.333), mean 0.6670.667. nDCG: q1 has grades [2,0,3,0,1][2, 0, 3, 0, 1], matching the lesson's example, 0.6830.683; q2 has DCG =1/log⁡24=0.5= 1/\log_2 4 = 0.5 and IDCG =1= 1, so 0.50.5; mean 0.5910.591. 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 kk (a first hit beyond rank kk scores 0). For production use, established libraries such as pytrec_eval or ir-measures implement these metrics with standard conventions.

Further reading

Go further

Sources

Fact-checked and last reviewed 2026-11-05

Stay in the loop
Prometheus OS™ · Native AI OS · Built for the future © 2025 - 2026