Skip to content
ML Academy

Core day 48 of 60 · Week 10 · Part II · application depth

Retrieval-Augmented Generation

The airline's fare-rule assistant answers baggage questions from a folder of policy documents. On one test question the right passage is in the top three results, yet the answer quotes last year's 20 kg allowance. Which stage failed, and how do you find out without reading answers by hand?

By the end: For a labelled query you trace ingestion, chunking, retrieval, context assembly and generation, compute recall@k for a lexical baseline, and say whether a failure is a retrieval miss, a context problem or an ungrounded answer.

At a glance

RAG retrieves evidence at request time; measure retrieval and grounding separately, because a retrieved passage is not yet a supported answer.

  • Evidence on demand

    Search the corpus, pass a few chunks, answer from them with citations.

  • Two failure sites

    Retrieval can miss; generation can ignore, blend or invent with evidence present.

  • Metadata first

    Status and validity filters keep superseded rules out of the context.

  • Lexical plus vector

    BM25 wins on codes, vectors on paraphrase; fuse by rank.

  • Measure each stage

    Recall@k at the production k, then a retrieval-by-answer table.

Recall · 5 min

If no cards are due, answer from memory: why must text inside an email or a retrieved passage be treated as data rather than as instructions, and which three checks in code enforce that?

Open due reviews

Sign in to record this block.

Concept · 10 min

Weights hold language ability and a frozen snapshot of facts, not your policy folder or last week's change. Retrieval-augmented generation (RAG) puts evidence in front of the model at request time, so updating a document updates the assistant. Two things can fail: retrieval can miss the passage, and the model can mis-use a passage it was given. A hit is not a Grounded answer.

Chunk size, retrieval method and k change which passages reach the context

Predict first

A query shares no words with the passage that answers it. Which retrieval method has no chance of finding it?

Text description of the figure

A picker of 40 labelled queries, or a typed query, over the academy's own glossary, insight and lesson text, re-chunked at the chosen size. Controls pick chunk size, retrieval method (BM25, TF-IDF, LSA or hybrid RRF) and k, then list the ranked passages with scores, source records and the words shared with the query, and mark which ones are labelled relevant. The lab stops at the context a generator would receive; no answer is generated.

The retrieval half: each stage sees only what the last one passed on Diagram
Ingest andparsetext, source, dateChunk + metadataid, validity, audienceIndexBM25 and/orvectorsFilter + top-kstatus = current; RRF ifhybridRerankwider list, slower modelAssemble contextdedupe, cap tokens,label idsby sectionquery100 candidatestop k (3-5)
  1. Ingest and parsetext, source, date
  2. Chunk + metadataid, validity, audience
  3. IndexBM25 and/or vectors
  4. Filter + top-kstatus = current; RRF if hybrid
  5. Rerankwider list, slower model
  6. Assemble contextdedupe, cap tokens, label ids

Lewis et al. 2020 introduced the retriever-plus-generator pattern. A filter applied after ranking, or a k cut too early, removes evidence no later stage can recover.

The answer half: two checks before an answer is shown Diagram
Generateanswer only from thelabelled chunksContextsupports it?Cited ids incontext?Show answerAbstainsay what is missingReject or reviewyesnoyesno
  1. Generateanswer only from the labelled chunks
  2. Context supports it?
    • yes: to Cited ids in context?
  3. Abstainsay what is missing
  4. Cited ids in context?
    • yes: to Show answer
  5. Reject or review
  6. Show answer

On yes the model answers with chunk ids. The id check is code; whether the chunk really supports the sentence still needs sampled review. An audit of generative search engines found many citations that did not support their sentences (Liu et al. 2023).

The retrieval-by-answer table Map

Generation or context failure

Evidence present but ignored, blended with a stale chunk, or embellished.

Working

Retrieved and used; keep it in the regression suite.

Retrieval miss

Fix chunking, metadata, index or k before touching the prompt.

Right without evidence

From weights or luck; not grounded, will not survive a policy change.

Count your labelled queries into the four cells; the largest wrong cell names the stage to fix.

Lexical, vector and hybrid retrieval Comparison
CriterionLexical (BM25)VectorHybrid (RRF)
Strong onCodes: error codes, SKUs, fare bases, exact termsParaphrase and synonymsBoth kinds of query, if each list contains the gold
Weak onParaphrase with no shared wordRare identifiers with no semantic neighboursCannot recover a chunk neither list returns
What you maintainAn inverted indexAn embedding model version and a vector indexTwo indexes and a fusion step

BM25 was a robust zero-shot baseline across BEIR's 18 datasets, where dense retrievers often fell behind out of domain (Thakur 2021).

Recall@k for BM25, LSA and their fusion on the academy's own corpus Illustration
00.250.500.7511351020Keyword queriesk = 5BM25, k = 1: 0.53BM25, k = 2: 0.64BM25, k = 3: 0.71BM25, k = 4: 0.72BM25, k = 5: 0.73BM25, k = 6: 0.80BM25, k = 7: 0.81BM25, k = 8: 0.84BM25, k = 9: 0.84BM25, k = 10: 0.84BM25, k = 11: 0.85BM25, k = 12: 0.85BM25, k = 13: 0.87BM25, k = 14: 0.87BM25, k = 15: 0.87BM25, k = 16: 0.87BM25, k = 17: 0.87BM25, k = 18: 0.87BM25, k = 19: 0.87BM25, k = 20: 0.87LSA (rank 50), k = 1: 0.55LSA (rank 50), k = 2: 0.60LSA (rank 50), k = 3: 0.66LSA (rank 50), k = 4: 0.74LSA (rank 50), k = 5: 0.76LSA (rank 50), k = 6: 0.82LSA (rank 50), k = 7: 0.82LSA (rank 50), k = 8: 0.84LSA (rank 50), k = 9: 0.84LSA (rank 50), k = 10: 0.84LSA (rank 50), k = 11: 0.88LSA (rank 50), k = 12: 0.88LSA (rank 50), k = 13: 0.88LSA (rank 50), k = 14: 0.88LSA (rank 50), k = 15: 0.88LSA (rank 50), k = 16: 0.88LSA (rank 50), k = 17: 0.88LSA (rank 50), k = 18: 0.89LSA (rank 50), k = 19: 0.91LSA (rank 50), k = 20: 0.91Hybrid: BM25 + LSA, RRF, k = 1: 0.55Hybrid: BM25 + LSA, RRF, k = 2: 0.61Hybrid: BM25 + LSA, RRF, k = 3: 0.71Hybrid: BM25 + LSA, RRF, k = 4: 0.74Hybrid: BM25 + LSA, RRF, k = 5: 0.79Hybrid: BM25 + LSA, RRF, k = 6: 0.81Hybrid: BM25 + LSA, RRF, k = 7: 0.81Hybrid: BM25 + LSA, RRF, k = 8: 0.81Hybrid: BM25 + LSA, RRF, k = 9: 0.85Hybrid: BM25 + LSA, RRF, k = 10: 0.86Hybrid: BM25 + LSA, RRF, k = 11: 0.89Hybrid: BM25 + LSA, RRF, k = 12: 0.89Hybrid: BM25 + LSA, RRF, k = 13: 0.89Hybrid: BM25 + LSA, RRF, k = 14: 0.89Hybrid: BM25 + LSA, RRF, k = 15: 0.89Hybrid: BM25 + LSA, RRF, k = 16: 0.89Hybrid: BM25 + LSA, RRF, k = 17: 0.89Hybrid: BM25 + LSA, RRF, k = 18: 0.89Hybrid: BM25 + LSA, RRF, k = 19: 0.89Hybrid: BM25 + LSA, RRF, k = 20: 0.89LSAhybridBM25k (passages passed to the model)Recall@k

Queries that reuse the corpus wording (n = 28): the methods are close; at k = 5, BM25 0.73, LSA 0.76, hybrid 0.79.

00.250.500.7511351020Paraphrase queriesk = 5BM25, k = 1: 0.19BM25, k = 2: 0.27BM25, k = 3: 0.40BM25, k = 4: 0.42BM25, k = 5: 0.44BM25, k = 6: 0.44BM25, k = 7: 0.44BM25, k = 8: 0.48BM25, k = 9: 0.48BM25, k = 10: 0.48BM25, k = 11: 0.51BM25, k = 12: 0.51BM25, k = 13: 0.51BM25, k = 14: 0.59BM25, k = 15: 0.59BM25, k = 16: 0.59BM25, k = 17: 0.59BM25, k = 18: 0.66BM25, k = 19: 0.69BM25, k = 20: 0.69LSA (rank 50), k = 1: 0.09LSA (rank 50), k = 2: 0.12LSA (rank 50), k = 3: 0.24LSA (rank 50), k = 4: 0.51LSA (rank 50), k = 5: 0.58LSA (rank 50), k = 6: 0.58LSA (rank 50), k = 7: 0.65LSA (rank 50), k = 8: 0.70LSA (rank 50), k = 9: 0.70LSA (rank 50), k = 10: 0.70LSA (rank 50), k = 11: 0.70LSA (rank 50), k = 12: 0.70LSA (rank 50), k = 13: 0.70LSA (rank 50), k = 14: 0.70LSA (rank 50), k = 15: 0.74LSA (rank 50), k = 16: 0.74LSA (rank 50), k = 17: 0.74LSA (rank 50), k = 18: 0.74LSA (rank 50), k = 19: 0.74LSA (rank 50), k = 20: 0.74Hybrid: BM25 + LSA, RRF, k = 1: 0.16Hybrid: BM25 + LSA, RRF, k = 2: 0.38Hybrid: BM25 + LSA, RRF, k = 3: 0.44Hybrid: BM25 + LSA, RRF, k = 4: 0.57Hybrid: BM25 + LSA, RRF, k = 5: 0.57Hybrid: BM25 + LSA, RRF, k = 6: 0.62Hybrid: BM25 + LSA, RRF, k = 7: 0.67Hybrid: BM25 + LSA, RRF, k = 8: 0.67Hybrid: BM25 + LSA, RRF, k = 9: 0.67Hybrid: BM25 + LSA, RRF, k = 10: 0.67Hybrid: BM25 + LSA, RRF, k = 11: 0.67Hybrid: BM25 + LSA, RRF, k = 12: 0.67Hybrid: BM25 + LSA, RRF, k = 13: 0.67Hybrid: BM25 + LSA, RRF, k = 14: 0.77Hybrid: BM25 + LSA, RRF, k = 15: 0.77Hybrid: BM25 + LSA, RRF, k = 16: 0.77Hybrid: BM25 + LSA, RRF, k = 17: 0.77Hybrid: BM25 + LSA, RRF, k = 18: 0.77Hybrid: BM25 + LSA, RRF, k = 19: 0.77Hybrid: BM25 + LSA, RRF, k = 20: 0.77hybridLSABM25k (passages passed to the model)Recall@k

Paraphrases (n = 12): BM25 flattens; at k = 5, BM25 0.44, LSA 0.58, hybrid 0.57. LSA is weak at the very top (k = 1: 0.09), so fusion borrows BM25's head and LSA's tail.

  • BM25
  • LSA (rank 50)
  • Hybrid: BM25 + LSA, RRF

The academy's own text (glossary, insights, lesson concepts; 567 documents in 120-word chunks) and its 40 labelled queries, scored with the retrieval widgets' code. Recall = share of a query's relevant documents in the top k, averaged. Twenty queries per style: the shapes are informative, small differences are not.

On queries that reuse the corpus wording the three methods are close. On paraphrases BM25 flattens, LSA is weak at the very top, and fusion takes some of each.

  • Stages: ingest -> Chunking with metadata -> index (BM25, vectors or both) -> filter and retrieve top-k -> rerank -> assemble context -> answer with citations -> validate.
  • Each stage has a symptom; measure each one. Recall@k uses the k you actually pass to the model.
  • Hybrid search merges rankings by position (Reciprocal rank fusion), not by raw scores.
  • Retrieved text is untrusted data (Day 47); cross "gold chunk in context" with "answer correct and supported" to locate failures.

Assumptions

  • Each query has a gold chunk label written independently of the chunk's wording; queries copied from the chunk inflate lexical scores.

  • Retrieved text is untrusted data. A passage that says "ignore your rules" is content and gets no authority (Day 47).

  • The corpus here is synthetic and tiny; five queries show mechanisms, not method rankings.

Sign in to record this block.

Application and workplace decision · 10 min

  • Six synthetic fare-rule chunks (one is last year's superseded baggage rule) and five labelled queries written without looking at the chunks.
  • The code ranks chunks with TF-IDF cosine and BM25, prints the gold chunk's rank (99 = not retrieved), recall@1 and recall@3, then inspects the baggage query's context at k = 2 before and after a status filter.
  • Predict first: which query fails, and why?

Python

import numpy as np
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
CHUNKS = {  # synthetic fare-rule chunks (not real policy): id -> (status, text)
    "c1": ("current", "Baggage: international economy fares include one checked bag up to 23 kg; domestic economy 15 kg."),
    "c2": ("superseded", "Baggage: economy fares include one checked bag up to 20 kg on all routes (valid until 2023-12-31)."),
    "c3": ("current", "Changes: flex fares can be changed free of charge until 24 hours before departure; basic fares pay 60 EUR."),
    "c4": ("current", "Refunds: unused tickets are refunded within 14 days to the original form of payment."),
    "c5": ("current", "Pets: small pets up to 8 kg in an approved carrier may travel in the cabin on domestic routes."),
    "c6": ("current", "Seats: seat selection is free on flex fares; basic fares pay 12 EUR per seat."),
}
QUERIES = [("how many kg of checked baggage are included on economy", "c1"), ("can I change my flex ticket for free", "c3"),
           ("how do I get my money back", "c4"), ("can my dog travel with me", "c5"), ("is there a charge to pick where I sit", "c6")]
ids, texts = list(CHUNKS), [v[1] for v in CHUNKS.values()]
cv = CountVectorizer().fit(texts)
TF = cv.transform(texts).toarray()
idf = np.log(1 + (len(texts) - (TF > 0).sum(0) + 0.5) / ((TF > 0).sum(0) + 0.5))
dl = TF.sum(1, keepdims=True)
def bm25(q, k1=1.5, b=0.75):
    w = TF * (k1 + 1) / (TF + k1 * (1 - b + b * dl / dl.mean()))
    return (w * idf * (cv.transform([q]).toarray()[0] > 0)).sum(1)
tv = TfidfVectorizer().fit(texts)
M = tv.transform(texts)
def tfidf_cosine(q):
    return (M @ tv.transform([q]).T).toarray().ravel()
def rank(scores, gold):
    order = [j for j in np.argsort(-scores) if scores[j] > 0]  # chunks with no term match are not retrieved
    return order.index(gold) + 1 if gold in order else 99
for name, fn in [("TF-IDF cosine", tfidf_cosine), ("BM25", bm25)]:
    r = [rank(fn(q), ids.index(g)) for q, g in QUERIES]
    print(f"{name:<14} gold ranks {r}  recall@1 {np.mean([x <= 1 for x in r]):.2f}  recall@3 {np.mean([x <= 3 for x in r]):.2f}")
s = bm25(QUERIES[0][0])
top = [ids[j] for j in np.argsort(-s)[:2]]
print("k=2 context:", top, [CHUNKS[i][0] for i in top])
print("after status filter:", [ids[j] for j in np.argsort(-s) if CHUNKS[ids[j]][0] == "current"][:2])

This code is executed in the repository's checks (pnpm lesson:py / lesson:sql). It is not run on this site; copy it into your notebook.

Sign in to record this block.

Mini-assessment · 8 min

Your first answer is your evidence. You can re-answer later in practice mode; that is labelled practice.

Question 1 · easy

A team reports "recall@3 = 0.80" for its retriever. What does that number measure?
Options

How confident are you? (optional, used for calibration)

Question 2 · medium

Six labelled queries are run through a retriever. The gold chunk ranks are 1, 3, 2, 7, 1 and not retrieved in the top 10. The production prompt receives the top 3 chunks. What recall@3 should the dashboard report?
fraction of queries

How confident are you? (optional, used for calibration)

Question 3 · hard

On 100 golden questions the assistant's results split as follows (gold chunk in the context / answer correct and supported): in context and supported 55; in context but wrong 15; not in context but correct 10; not in context and wrong 20. What is the best first diagnosis and action?
Options

How confident are you? (optional, used for calibration)

Question 4 · medium

A query about checked baggage retrieves the current rule (23 kg) at rank 1 and the superseded rule (20 kg, valid until 2023-12-31) at rank 2, and the assistant sometimes quotes 20 kg. Recall@2 is 1.00 on this query. Which change addresses the cause?
Options

How confident are you? (optional, used for calibration)

Question 5 · hard

On 100 labelled queries (hypothetical figures) the gold chunk is in the top 3 for 64, in the top 10 for 81 and in the top 50 for 92. The prompt receives 3 chunks and most wrong answers lack the gold chunk. An extra 300 ms of latency is acceptable. Which next experiment is best justified?
Options

How confident are you? (optional, used for calibration)

Sign in to complete lessons and keep your progress.

Technical depth · optional · 37 min in total

Open only what you need. Nothing here is required to complete the day; the assessment covers the core above.

Retrieval as ranking: the objects and the metrics7 minWhat a retriever computes, the three standard ranking metrics defined exactly, and all three worked by hand on a toy ranking.
What a retriever computes, and what a metric compares it with Diagram
Query qChunks Dthe whole corpusScore s(q, d)for every chunkTop k by sall later stages seeonly theseRelevancelabelsgold: binary or2/1/0Metricranking compared withlabelssort, cut at krankingrelevance
  1. Query q
    • to Score s(q, d)
  2. Chunks Dthe whole corpus
  3. Score s(q, d)for every chunk
  4. Top k by sall later stages see only these
    • ranking: to Metric
  5. Relevance labelsgold: binary or 2/1/0
  6. Metricranking compared with labels

Relevance is a label, not a score: a metric is only as good as its labels and is blind to chunks nobody labelled.

Three questions about one ranking, three metrics Hierarchy
did the gold arrive?how high is it?is the top k ordered?A ranked list and itslabelsrecall@kread it at the productionkMRRtop position dominates;ignores later…nDCG@kneeds graded labels;depends on the…
  • A ranked list and its labels
    • did the gold arrive?recall@kread it at the production k
    • how high is it?MRRtop position dominates; ignores later relevant chunks
    • is the top k ordered?nDCG@kneeds graded labels; depends on the gain convention

Each is a mean over labelled queries; the equations and two hand computations follow.

Recall at k Formula
recall@k=1∣Q∣∑q∈Q1[rank(gq)≤k]\mathrm{recall@}k = \frac{1}{|Q|} \sum_{q \in Q} \mathbb{1}[\mathrm{rank}(g_q) \le k]
gqg_q
gold chunk labelled for query q
kk
the number of chunks you actually pass to the model; recall@k never falls as k grows
∣Q∣|Q|
labelled queries; the standard error of the mean shrinks like 1 / sqrt(|Q|), so five queries give a hint, not a ranking (Day 50)

Recall at a larger k than production flatters the retriever; recall at the reranker's candidate depth tells you what reranking could recover.

Mean reciprocal rank Formula
MRR=1∣Q∣∑q∈Q1rankq\mathrm{MRR} = \frac{1}{|Q|} \sum_{q \in Q} \frac{1}{\mathrm{rank}_q}
rankq\mathrm{rank}_q
position of the first relevant chunk for query q
1/rankq1/\mathrm{rank}_q
1 at the top, 0.5 at position 2, and 0 when nothing relevant is retrieved

Recall@k asks whether the gold arrived; MRR also rewards how high it sits, which matters when the model reads the first chunk most.

Normalised discounted cumulative gain at k Formula
nDCG@k=1IDCG@k∑i=1krelilog⁡2(i+1)\mathrm{nDCG@}k = \frac{1}{\mathrm{IDCG@}k} \sum_{i=1}^{k} \frac{rel_i}{\log_2(i+1)}
relirel_i
graded label of the chunk at position i (here 2 = answers it, 1 = partly relevant, 0 = not)
log⁡2(i+1)\log_2(i+1)
position discount: 1 at the top, 0.63 at position 2, 0.5 at position 3
IDCG@k\mathrm{IDCG@}k
the same sum for the ideal ordering of the same labels, so a perfect ranking scores 1

Linear gain, as scikit-learn's ndcg_score uses; many papers use 2^rel - 1, which weights the top grade more. State which one you report. Day 51 derives the discount.

Recall@k and MRR by hand: five queries on a hospital's guideline search (synthetic) Table
QueryRank of first relevant chunkIn top 3?Reciprocal rank
Sepsis antibiotic timing1yes1.00
Paediatric paracetamol dose2yes0.50
Anticoagulant before surgery4no0.25
Hand-hygiene audit form1yes1.00
"Blood thinner, tooth out"not retrievedno0
Mean over 5 queries-recall@3 = 0.60 (recall@5 = 0.80)MRR = 2.75 / 5 = 0.55

Verified in Python. The last query is a lay paraphrase of the third: different words, no shared term. Recall@5 rises to 0.80 only if five chunks really reach the model.

nDCG@4 by hand for one query with graded labels Table
Position iLabelDiscount 1/log2(i+1)Contribution
11 (partly relevant)1.0001.000
22 (answers it)0.6311.262
300.5000
42 (answers it)0.4310.861
DCG@4--3.123
Ideal order 2, 2, 1, 0-1.000, 0.631, 0.500, 0.4312.000 + 1.262 + 0.500 = 3.762
nDCG@4--3.123 / 3.762 = 0.830

Same result as scikit-learn's ndcg_score (checked in 1.9.1). The ranking found both answering chunks but put a weaker one first and an answer last; nDCG prices exactly that.

BM25, derived term by term and computed on two documentsadvanced8 minWhere each factor of the BM25 formula comes from, one score computed by hand, the two knobs drawn, and where the model breaks.
BM25 score of chunk d for query q Formula
∑t∈qidf(t)⋅ft,d (k1+1)ft,d+k1(1−b+b ∣d∣∣d∣‾)\sum_{t \in q} \mathrm{idf}(t) \cdot \frac{f_{t,d}\,(k_1+1)}{f_{t,d} + k_1 \left(1 - b + b\,\frac{|d|}{\overline{|d|}}\right)}
∑t∈q\sum_{t \in q}
probability ranking principle + binary independence: the log-odds of relevance splits into one weight per matching term
idf(t)\mathrm{idf}(t)
rarity. With no labels the weight is log((N - n + 0.5)/(n + 0.5)), negative for terms in over half the docs; the code, like Lucene, uses ln(1 + that ratio)
ft,df_{t,d}
count of term t in chunk d
f(k1+1)f+k1\frac{f(k_1+1)}{f+k_1}
replaces the 2-Poisson eliteness model: evidence rises with the count but levels off; exactly 1 at f = 1, never above k1 + 1
k1k_1
saturation; the 10th repeat adds little (1.5 in the code)
1−b+b ∣d∣/∣d∣‾1 - b + b\,|d|/\overline{|d|}
length: long chunks repeat words by verbosity, not topic. b = 0 ignores length, b = 1 normalises fully (0.75 in the code)

Robertson and Zaragoza 2009. Guarantee: each term contributes at most (k1 + 1) x idf(t), so repeating one word cannot dominate a score.

BM25 by hand: two telecom support articles, query "reset router password" Table
Termidf (N = 2)Doc 1, 8 wordsDoc 2, 12 words
Length factor K = k1(1 - b + b|d|/avg)-1.5 x (0.25 + 0.75 x 0.8) = 1.2751.5 x (0.25 + 0.75 x 1.2) = 1.725
resetln(1 + 1.5/1.5) = 0.693f = 2: 2 x 2.5 / 3.275 = 1.527; x 0.693 = 1.058f = 0: 0
routerln(1 + 0.5/2.5) = 0.182f = 1: 2.5 / 2.275 = 1.099; x 0.182 = 0.200f = 1: 2.5 / 2.725 = 0.917; x 0.182 = 0.167
password0.693f = 0: 0f = 1: 0.917 x 0.693 = 0.636
Score-1.259 (rank 1)0.803 (rank 2)
What it showsrouter is in both: worth almost nothing (rarity)the 2nd reset adds 0.43 to the first's 1.10 (saturation)single matches earn 0.917, not 1.099: longer (length)

Doc 1: "Reset the router: hold reset for ten seconds". Doc 2: "Change the wifi password in the app; the router keeps its settings". Average length 10, k1 = 1.5, b = 0.75 and the idf of the application code; verified in Python with CountVectorizer tokens.

Why Doc 1 outranks Doc 2, term by term (the hand computation above) Bridge
  1. Doc 2 score0.8032
  2. reset: twice in Doc 1, absent in 2rare term; second occurrence saturates+1.0582
  3. router: in bothlow idf; Doc 1 shorter, so slightly more+0.0331
  4. password: only in Doc 2a rare term Doc 1 lacks−0.6359
  5. Doc 1 score (1.259 rounded)1.2586

Each step is Doc 1's contribution minus Doc 2's for one query term, to four decimals. Rarity decides most of the gap: the rare terms move the score by about 0.6 to 1.1, the common one by 0.03.

The two knobs inside BM25 Illustration
0123405101520Saturation: repeats of one query termraw countceiling k1 + 1 = 2.5k1 = 0.5k1 = 1.5 (code)k1 = 3Occurrences of the term in the chunk (average length)Term weight (before idf)

With k1 = 1.5, the 2nd occurrence adds 0.43, the 10th adds 0.031; ten occurrences weigh 2.2x one, never more than k1 + 1 = 2.5. A chunk that repeats a word cannot buy its way to the top.

00.511.520.251234Length normalisation: one occurrenceaverage lengthb = 0b = 0.5b = 0.75 (code)b = 1Chunk length ÷ average chunk lengthWeight of one occurrence

b decides how much a long chunk is penalised. With b = 0.75, one occurrence in a chunk twice the average length weighs 0.69 instead of 1.00; with b = 0 length is ignored, which favours long chunks that mention everything.

Computed from the BM25 term weight with the constants of the academy's retrieval code (k1 = 1.5, b = 0.75); the idf factor multiplies every curve and is left out. Robertson and Zaragoza (2009) describe the function.

k1 caps what repeating a term can earn; b decides how strongly a long chunk is discounted. Both curves use the constants of the academy's retrieval code.

Where BM25 breaks, and how you would see it Overview
  • Vocabulary mismatch

    A query term absent from the chunk adds exactly zero, so synonyms score nothing. Watch paraphrases and zero-hit queries.

  • Bag of words

    Word order is ignored; only which terms match, and how often, counts.

  • Scores drift with the corpus

    idf changes whenever documents change, so raw scores are not comparable across index builds.

Fusion by rank, computed by handadvanced5 minWhy ranks fuse and scores do not, one fusion computed by hand, a win and a loss of fusion on real queries, and what the slice table can show.
Reciprocal rank fusion Formula
RRF(d)=∑r∈R1c+rankr(d)\mathrm{RRF}(d) = \sum_{r \in R} \frac{1}{c + \mathrm{rank}_r(d)}
RR
the rankings being fused (BM25, vector)
cc
constant; 60 in the lab, a common default
rankr(d)\mathrm{rank}_r(d)
position of d in ranking r: one scale for every retriever

A BM25 score is unbounded and corpus-dependent, a cosine lies in [-1, 1]; adding them weights whichever has the larger spread. The price of using positions: confidence is discarded, so a crushing hit and a marginal one at the same position count the same.

Reciprocal rank fusion by hand: an online shop's search for "waterproof hiking boots" Table
ProductBM25 rankVector rankTerms, c = 60RRF scoreFused rank
A121/61 + 1/620.032521
C311/63 + 1/610.032272
B241/62 + 1/640.031753
Enot listed31/630.015874
D4not listed1/640.015635

Hypothetical lists, verified in Python. With c = 60 the step from rank 1 to rank 4 inside one list (0.0164 to 0.0156) is tiny next to appearing in a second list at all, so fusion rewards agreement. Raw BM25 and cosine scores never enter.

Fusion by rank, on two real queries Illustration
Query: "the assistant made up a booking reference that does not exist"BM25 (exact words)Fused: Σ 1 / (60 + rank)LSA (latent topics)LSA #13BM25 #20BM25 #8LSA #15BM25 #13LSA #14BM25 #21LSA #121. Store first-reported and final…2. Decision cutoff3. Never sum across snapshots of …4. Remove features that are recor…5. Store first-reported and final…6. Make tools ask for missing inp…1. Never sum across snapshots of …2. Decision cutoff3. Use bookings already on hand a…4. Verify every identifier the mo…5. Decision time6. Check everything verifiable wi…1. Day 2: Problem Framing and Dat…2. Day 2: Problem Framing and Dat…3. Use bookings already on hand a…4. Never sum across snapshots of …5. Never sum across snapshots of …6. Day 2: Problem Framing and Dat…

Fusion beats both lists: a passage that both rank moderately collects two shares of 1 / (60 + rank) and overtakes passages that only one list ranks highly. First relevant passage: BM25 rank 8, LSA rank 15, fused rank 4.

Query: "what a p-value means and what it does not"BM25 (exact words)Fused: Σ 1 / (60 + rank)LSA (latent topics)LSA #11BM25 #11LSA #7LSA #18LSA #241. Decide whether NULL means zero…2. Build training rows with as-of…3. Partition lag features by enti…4. Encode cyclical calendar featu…5. Calibrate a neural classifier …6. Decide whether NULL means zero…1. Decide whether NULL means zero…2. Decide whether NULL means zero…3. Encode cyclical calendar featu…4. Replace sentinel values like -…5. Build training rows with as-of…6. Partition lag features by enti…1. p-value2. Use NOT EXISTS instead of NOT …3. Distinguish "zero", "unknown" …4. Decide whether NULL means zero…5. Estimate the value of a model …6. Decide whether NULL means zero…

Fusion can also lose to the better list: when one retriever is right and the other is confidently wrong, the wrong list's passages dilute the right one's. First relevant passage: BM25 rank 48, LSA rank 1, fused rank 11.

  • passage from a document labelled relevant
  • line: where a fused passage came from

Two of the 40 labelled queries of the academy corpus, chosen by code as the query where fusion gains most over the better single list and the one where it loses most; on most queries all three put a relevant passage first. 120-word chunks, the retrieval widgets' code.

Reciprocal rank fusion rewards agreement between lists. It can beat both inputs, and it can also fall behind the better one when the other list is confidently wrong.

Recall@3 by query type (optional lab, 14 chunks, 12 queries) Grid
Query type →
Method ↓IdentifierKeywordParaphraseAll
BM251.001.000.170.58
LSA1.001.000.330.67
RRF(BM25+LSA)1.001.000.330.67

Six paraphrase queries cannot establish that one method is better; they show where to collect more labels.

Stage by stage, symptom by symptom4 minWhat each stage looks like when it fails, the check that exposes it, and which stage binds in different sectors.
Stage, symptom, check Table
StageSymptom when it failsCheck
Ingest and parseEvidence absent or garbled (flattened table)Spot-check parsed text against the source
ChunkRule found without its exceptionChunk along headings; keep section ids
Index and retrieveGold chunk missing from top-kRecall@k per query type
RerankGold at rank 12, never in contextRecall at the candidate depth vs final k
AssembleStale and current versions both presentStatus filter; duplicate count in context
Generate and validateClaim not in any cited chunkId check in code; sampled support review
One pipeline, a different binding constraint per sector (hypothetical) Overview
  • Telecom support articles

    Error codes and model numbers favour BM25; "my internet keeps dropping" needs a vector or hybrid index.

  • Hospital guidelines

    Versions are a safety issue: the validity filter is a clinical control; audience splits clinician and patient text.

  • Bank procedures

    Entitlements filter inside the retriever: staff-only procedures never reach a customer answer.

  • Retail catalogue

    Price and stock change hourly: filter on metadata at query time, and send "how many under 50 EUR" to SQL.

  • HR policies

    One rule per country: without jurisdiction metadata the answer cites the wrong country's leave policy.

The stages and the metrics do not change between sectors; which failure costs most, and therefore which metadata is mandatory, does.

The application's run, and grounding2 minThe five-query result of the application code, as a grid of method by cutoff.
Five labelled queries, two lexical retrievers (application output) Grid
Cutoff →
Retriever ↓recall@1recall@3
TF-IDF cosine0.600.80
BM250.800.80

The miss at k = 3 is the same query for both; five queries cannot rank methods.

Chunking and metadata4 minThe fields every chunk needs, and chunk size measured as a recall-for-words trade on real labelled queries.
Metadata every chunk should carry Overview
  • Document and section id

    Lets you cite, group and score by source.

  • Validity dates and status

    Current versus superseded; filter before ranking.

  • Audience

    Staff-only procedures never reach a customer answer.

  • Parent context

    Heading path so a rule keeps its exception nearby.

Chunk size trades recall against the words you pay for Illustration
00.250.500.75060012001800beyond a 600-word budget60-word chunks, k = 1: recall 0.47, 59 words60-word chunks, k = 2: recall 0.51, 118 words60-word chunks, k = 3: recall 0.56, 174 words60-word chunks, k = 4: recall 0.61, 231 words60-word chunks, k = 5: recall 0.64, 286 words60-word chunks, k = 6: recall 0.66, 341 words60-word chunks, k = 7: recall 0.70, 398 words60-word chunks, k = 8: recall 0.71, 455 words60-word chunks, k = 9: recall 0.73, 510 words60-word chunks, k = 10: recall 0.74, 562 words60-word chunks, k = 11: recall 0.74, 620 words60-word chunks, k = 12: recall 0.74, 675 words120-word chunks, k = 1: recall 0.42, 110 words120-word chunks, k = 2: recall 0.53, 207 words120-word chunks, k = 3: recall 0.61, 302 words120-word chunks, k = 4: recall 0.63, 395 words120-word chunks, k = 5: recall 0.64, 502 words120-word chunks, k = 6: recall 0.69, 588 words120-word chunks, k = 7: recall 0.70, 695 words120-word chunks, k = 8: recall 0.73, 792 words120-word chunks, k = 9: recall 0.73, 896 words120-word chunks, k = 10: recall 0.73, 995 words120-word chunks, k = 11: recall 0.75, 1093 words120-word chunks, k = 12: recall 0.75, 1181 wordsWhole documents, k = 1: recall 0.44, 154 wordsWhole documents, k = 2: recall 0.56, 302 wordsWhole documents, k = 3: recall 0.63, 448 wordsWhole documents, k = 4: recall 0.67, 602 wordsWhole documents, k = 5: recall 0.69, 756 wordsWhole documents, k = 6: recall 0.73, 909 wordsWhole documents, k = 7: recall 0.73, 1067 wordsWhole documents, k = 8: recall 0.73, 1230 wordsWhole documents, k = 9: recall 0.73, 1380 wordsWhole documents, k = 10: recall 0.74, 1537 wordsWhole documents, k = 11: recall 0.76, 1697 wordsWhole documents, k = 12: recall 0.79, 1860 wordsMean words placed in the context (top-k passages)Recall@k
  • 60-word chunks
  • 120-word chunks
  • Whole documents

Each dot is one more passage (k = 1, 2, 3 ...). Big chunks reach a given recall with fewer passages but many more words; the left edge of the chart is what a tight context budget can afford.

Same k, or the same word budget?60-word chunksk = 3: 0.56 (174 words)≤ 600 words: 0.74 (k = 10)120-word chunksk = 3: 0.61 (302 words)≤ 600 words: 0.69 (k = 6)Whole documentsk = 3: 0.63 (448 words)≤ 600 words: 0.63 (k = 3)

The same chunkings read two ways. At a fixed k the biggest chunks look best; at a fixed word budget the ranking changes.

BM25 on the academy corpus (567 documents) with the widgets' chunker (25% overlap) and the 40 labelled queries. Relevance is labelled per document, so a passage counts when its document is relevant; real chunking choices also depend on whether a passage alone answers the question.

Compared at the same k, whole documents look best because each passage carries more text. Compared at the same word budget, small chunks reach more relevant documents.

Prefixing chunks with document context halved retrieval failures (vendor-reported) Chart
  • Standard chunks5.7 %
  • Contextual chunks + contextual BM252.9 %

Anthropic 2024, on the vendor's own tests: each chunk prefixed with a short generated description of its document before indexing. A hypothesis to test on your golden set, not a result.

Reranking and its ceilingadvanced2 minWhat a second-stage reranker can and cannot recover.
Two-stage retrieval, bottom to top Stack
  1. First stageBM25, vectors or both return a wide candidate list (tens to a hundred) cheaply.
  2. RerankerA cross-encoder reads query and passage together and rescores only the candidates (Nogueira and Cho 2019).
  3. Final kThe top few reranked passages go into the context.
  4. CeilingRecall at the candidate depth is the most a reranker can reach; it cannot add a passage the first stage missed.

Report recall at the candidate depth and at the final k side by side; the gap is what reranking can win, at the cost of its latency.

Assembling the context2 minFive steps between the ranked list and the prompt.
Assembling the context Sequence
  1. Filter

    Status, validity and audience, before ranking.

  2. Deduplicate

    Near-copies crowd out distinct evidence.

  3. Cut weak chunks

    A relevance threshold drops padding.

  4. Order and cap

    Fit the token budget; long contexts bury the middle (Liu 2024).

  5. Label

    Each chunk gets an id and is marked as untrusted data.

Knobs and monitoring3 minEvery retrieval knob with its value in the academy code and its trade-off, and the signals to watch once it runs.
Retrieval knobs, their values in the academy code, and what each trades Table
KnobValue hereTrade-off
Chunk size and overlap120 words, 25% overlapSmaller: precise passages, more of them; larger: context per hit, fewer distinct sources
k passed to the model3 to 5Recall against tokens, cost and distraction from weak passages
Candidate depth for fusion or rerank100Ceiling for later stages against compute
BM25 k1 and b1.5 and 0.75Term saturation and length discount
RRF constant c60Smaller c: top ranks dominate; larger: flatter, more agreement-driven
LSA rank50Higher: finer topics, closer to exact words; lower: broader matches
TfidfVectorizer token pattern2+ word charactersscikit-learn drops one-character tokens by default, such as a lone digit

Values from src/lib/stats/w6-ir.ts and the retrieval widgets; the scikit-learn default checked in version 1.9.1.

What to watch in production (practitioner heuristic) Overview
  • Golden-set recall per index build

    Re-score recall@k at the production k whenever documents, chunking or the embedding model change.

  • Stale evidence in context

    Share of contexts holding a superseded or expired chunk; it should be zero.

  • Citation validity

    Share of answers whose cited ids were in the context, plus a sampled support review.

  • Abstentions and empty results

    Rising abstention or zero-hit queries point to a content gap or a broken index.

  • Latency per stage

    Retrieval, rerank and generation timed separately so a slow stage is visible.

Memory anchor

Retrieval success is not a grounded answer: score the stages separately, at the k you actually use.

Useful for

  • Giving an LLM access to documents that change, are private, or are too large for the prompt, without retraining: clinical guidelines (healthcare), product manuals and error codes (telecom, manufacturing), internal procedures (banking, HR).

  • Debugging an assistant by stage (ingestion, chunking, retrieval, context, generation) with labelled queries instead of opinions.

  • Deciding whether a lexical baseline is already enough before adding a vector index.

Not suitable for

  • Questions that need counting or aggregation over many records; route those to SQL (insight 0628).

  • Teaching a model a new behaviour or format; that is a prompting or fine-tuning question (Day 43).

  • Concluding which retriever is best from five queries; the run shows mechanisms and needs a much larger labelled set.

Traps

Counting a retrieval hit as a correct answer

The gold chunk is in the context, so the dashboard shows success, but a stale or conflicting chunk sits next to it and the answer quotes the wrong one. Complaints continue while retrieval metrics look healthy.

How to avoid it

Label evidence and answers separately, count the four cells of the two-way table, filter by status and validity dates, and check cited ids and support.

Writing queries from the chunks

The person labelling reads a chunk and writes a question using its words. Lexical retrieval looks excellent and the first paraphrased real query fails: a patient asks about a "blood thinner" and the guideline says "anticoagulant", or a customer writes "internet keeps dropping" for an article titled "intermittent WAN loss".

How to avoid it

Collect queries from real traffic or from people who have not seen the chunks, tag each by type (identifier, keyword, paraphrase) and report recall by slice.

Senior reasoning

A senior treats RAG as a set of separately measurable components, not as one prompt. They start with a lexical baseline because it is cheap, strong on identifiers and a yardstick, add vectors where a labelled paraphrase slice fails, filter on metadata in the retriever, and refuse to read an answer-accuracy number before they know which cell of the retrieval-by-answer table it came from. They ask which failure is most expensive in this sector (a superseded clinical guideline, a staff procedure shown to a customer, yesterday's price) and make the metadata that prevents it mandatory.

Interview question

"Your RAG assistant gives wrong answers on 20% of questions. Walk me through finding out why."

Recall cards scheduled after completion

  • List the RAG pipeline stages in order and give one failure symptom for each.
  • Why can retrieval succeed and the answer still be ungrounded, and how do you detect which happened?

Reviewed on a 1 · 3 · 7 · 14 · 30-day schedule; a miss returns the card to 1 day.

References

Optional sections

Deep dive · 10 min

BM25 in one line. For a query term tt in document dd of length ∣d∣|d|, with average length dˉ\bar d: score=∑tidf(t) tf (k1+1)tf+k1 (1−b+b ∣d∣/dˉ)\text{score} = \sum_t \text{idf}(t)\,\dfrac{\text{tf}\,(k_1+1)}{\text{tf} + k_1\,(1 - b + b\,|d|/\bar d)}. The fraction saturates in term frequency (a tenth occurrence adds little), bb controls length normalisation, and idf\text{idf} weights rare terms. TF-IDF cosine instead normalises vectors and has no saturation; on short chunks the two often agree, which is why a five-query difference is not a finding.

Reciprocal rank fusion. Each retriever returns a ranking; a document's fused score is ∑r1/(c+rankr)\sum_r 1/(c + \text{rank}_r) with a constant cc (60 is a common default). It ignores score scales, so BM25 and cosine scores need no calibration. It also discards confidence: a very strong lexical hit and a marginal one at the same rank count the same.

Chunking. Fixed windows with overlap are a default for unstructured text. Structured documents (clauses, headings, tables) are better split on their structure with the heading path kept, and each chunk can be prefixed with a short context header before indexing (a vendor reported fewer retrieval failures on its own tests with this; treat it as a hypothesis for your corpus).

Two-way table. Rows: gold evidence in context (yes, no). Columns: answer correct and supported (yes, no). The cell "no evidence, answer correct" means the model answered from memory; it will fail on facts that changed after training.

Security. Retrieved text is attacker-controllable whenever someone else can write to the corpus. Apply permissions inside the retriever, label chunks as untrusted, and keep tool allow-lists independent of what passages say (Days 47 and 53).

Hands-on lab: Lexical, latent-semantic and fused retrieval by query type · 15 min

Fourteen synthetic chunks and twelve queries tagged identifier, keyword or paraphrase. The code compares BM25, a latent semantic (TruncatedSVD of TF-IDF) retriever and a reciprocal-rank fusion of the two, reporting recall@3 per slice. Before running, predict which slice a lexical method fails on and whether fusion can recover it. After running, write two sentences: what the slice table shows, and why six paraphrase queries and fourteen chunks cannot establish that latent semantic retrieval is better.

import numpy as np
from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

TEXT = {  # synthetic fare-rule chunks (not real policy)
    "c01": "Baggage: international economy fares include one checked bag up to 23 kg; domestic economy 15 kg.",
    "c02": "Changes: flex fares can be changed free of charge until 24 hours before departure; basic fares pay 60 EUR.",
    "c03": "Refunds: unused tickets are refunded within 14 days to the original form of payment.",
    "c04": "Pets: small pets up to 8 kg in an approved carrier may travel in the cabin on domestic routes.",
    "c05": "Seats: seat selection is free on flex fares; basic fares pay 12 EUR per seat.",
    "c06": "Check-in: online check-in opens 48 hours before departure and closes 90 minutes before departure.",
    "c07": "No-show: a passenger who misses the outbound flight forfeits the return flight unless changed before departure.",
    "c08": "Fare basis YLOW26: lowest economy fare, no changes allowed, no refund.",
    "c09": "Fare basis YFLX26: flexible economy fare, free changes, refundable.",
    "c10": "Lounge: lounge access is included with business fares and for gold members.",
    "c11": "Delays: when a flight is delayed by more than three hours the airline offers meal vouchers at the airport.",
    "c12": "Assistance: passengers who need wheelchair help should request it at least 48 hours before departure.",
    "c13": "Infants: children under two travel on an adult's lap and pay 10 percent of the adult fare.",
    "c14": "Miles: gold members earn 50 percent bonus miles on every paid flight.",
}
QUERIES = [  # (query, gold id, type)
    ("what does fare basis YFLX26 allow", "c09", "identifier"), ("is YLOW26 refundable", "c08", "identifier"),
    ("how many kg of checked baggage are included on economy", "c01", "keyword"), ("what time does check-in close", "c06", "keyword"),
    ("can I change my flex ticket for free", "c02", "keyword"), ("how do I get my money back", "c03", "paraphrase"),
    ("can my dog travel with me", "c04", "paraphrase"), ("is there a charge to pick where I sit", "c05", "paraphrase"),
    ("my plane is late, will I get something to eat", "c11", "paraphrase"), ("do toddlers need their own ticket", "c13", "paraphrase"),
    ("who gets airport lounge access", "c10", "keyword"), ("what if I do not turn up for my outbound flight", "c07", "paraphrase"),
]
ids, docs = list(TEXT), list(TEXT.values())
cv = CountVectorizer().fit(docs)
TF = cv.transform(docs).toarray()
df = (TF > 0).sum(0)
idf = np.log(1 + (len(docs) - df + 0.5) / (df + 0.5))
dl = TF.sum(1, keepdims=True)
tv = TfidfVectorizer().fit(docs)
M = tv.transform(docs)
svd = TruncatedSVD(n_components=6, random_state=0).fit(M)
Z = svd.transform(M)
Z /= np.linalg.norm(Z, axis=1, keepdims=True) + 1e-9

def bm25(q, k1=1.5, b=0.75):
    return ((TF * (k1 + 1) / (TF + k1 * (1 - b + b * dl / dl.mean()))) * idf * (cv.transform([q]).toarray()[0] > 0)).sum(1)

def lsa(q):
    v = svd.transform(tv.transform([q]))[0]
    return Z @ v / (np.linalg.norm(v) + 1e-9)

def ranking(scores):
    return [j for j in np.argsort(-scores) if scores[j] > 1e-9]

def rrf(q, c=60):
    fused = np.zeros(len(ids))
    for fn in (bm25, lsa):
        for pos, j in enumerate(ranking(fn(q)), 1):
            fused[j] += 1 / (c + pos)
    return fused

methods = {"BM25": bm25, "LSA": lsa, "RRF(BM25+LSA)": rrf}
print(f"{'method':<15}" + "".join(f"{t:>12}" for t in ["identifier", "keyword", "paraphrase", "all"]) + "   (recall@3)")
for name, fn in methods.items():
    row = {"identifier": [], "keyword": [], "paraphrase": []}
    for q, gold, kind in QUERIES:
        row[kind].append(ids.index(gold) in ranking(fn(q))[:3])
    allhits = sum(row.values(), [])
    print(f"{name:<15}" + "".join(f"{np.mean(row[k]):12.2f}" for k in row) + f"{np.mean(allhits):12.2f}")
Interview practice · 10 min

A hospital's digital team wants to "put all our clinical guidelines into a vector database and let the model answer staff questions". What do you ask and what do you build first?

Content version 6 · last verified 2026-10-12 · depends on scikit-learn, numpy