Advanced retrieval
Naive top-k retrieval quietly fails on hard questions. This lesson builds the modern retrieval pipeline — contextual chunking, hybrid dense+BM25 search fused with RRF, cross-encoder reranking, and embedding fine-tuning — and measures every stage with recall@k, beginner to tech lead.
Learning objectives
- Explain why naive top-k retrieval fails: ambiguous chunks, vocabulary mismatch, lost context.
- Apply contextual retrieval — prepend a chunk-situating summary before embedding (the Anthropic technique).
- Combine dense + sparse (BM25) retrieval and fuse them with Reciprocal Rank Fusion (RRF).
- Add a cross-encoder reranker to narrow a wide recall set to a precise top-k.
- Decide when embedding fine-tuning and structure-aware chunking are worth it.
- Design the full retrieval pipeline as a tech lead and measure recall@k before/after.
1 · Why naive top-k retrieval fails essential
Basic RAG splits documents into fixed-size chunks, embeds each one, and at query time returns the top-k chunks whose embedding is closest to the query embedding. That works for easy questions and quietly fails for hard ones. Three failure modes dominate:
| Failure mode | What goes wrong | Example |
|---|---|---|
| Ambiguous chunk | A chunk lost the context that made it meaningful when it was split off | "It grew 3%" — grew what? which quarter? |
| Vocabulary mismatch | The query and the answer use different words for the same thing; dense similarity misses it | query "refund" vs doc "reimbursement" |
| Lost context / exact terms | Semantic search ignores rare exact tokens (IDs, error codes, product names) that keyword search nails | query "ERR_5021" buried in prose |
The fix is not one trick but a pipeline: situate each chunk before you embed it, retrieve with two methods that fail differently, fuse their rankings, then rerank the survivors with a model that actually reads query and chunk together. Here is the whole shape:
This is the query-time path — what happens each time a user asks a question. Read it left to right: one query fans out into two different retrievers, their results are combined, then a sharper model reorders the survivors before the LLM sees anything.
- Query → Dense retrieve and Query → Sparse (BM25) — the same question is sent to both retrievers. Dense matches on meaning; sparse matches on exact words. They deliberately fail on different queries.
- Fuse (RRF) — the two ranked lists are merged into one by Reciprocal Rank Fusion, so a doc that either ranks high in one list or shows up in both floats to the top.
- Rerank — a cross-encoder reads the query and each surviving candidate together and scores relevance far more accurately than the first-stage retrievers could.
- Top-k — only the best few chunks are handed to the LLM. Everything upstream exists to make these few as relevant as possible.
In short: two retrievers that fail differently, fused, then reranked — recall wide, then narrow to a precise top-k.
And the ingestion path that feeds it — the key move is contextual chunking, where each raw chunk gets a one-line situating summary prepended before it is embedded and indexed:
This is the offline path — what happens once, when you load documents into the system, long before any query. Getting this right is what makes the query-time path above work.
- Document → Split into chunks — the source is broken into pieces small enough to embed, ideally on structure boundaries (headings, paragraphs) rather than a blind character count.
- Prepend context line — each chunk gets a one-sentence summary of where it sits in the document glued to its front. This is the contextual-retrieval step; it rescues chunks that lost their meaning when they were split off.
- Embed + BM25 index — the contextualized chunk is stored twice: as an embedding (for dense search) and in a keyword index (for BM25). Both retrievers at query time read this dual index.
In short: split smartly, situate each chunk with a context line, then index it for both dense and keyword search.
2 · Contextual retrieval — situate the chunk essential
A chunk ripped out of a 40-page document often can't stand on its own. Contextual retrieval (the Anthropic technique) fixes this at ingestion time: for each chunk, generate a short sentence that says where this chunk sits in the document, and prepend it before embedding. The stored text becomes "context line + original chunk", so both the embedding and the BM25 index capture what the chunk is actually about. Below we model the transform with plain string work and show how it changes token overlap with the query.
contextual_chunk.pydef contextualize(doc_title, section, chunk):
"""Prepend a one-line situating summary before the chunk (Anthropic technique).
In production the context line is written by a cheap LLM; here we template it."""
context = f"This chunk is from '{doc_title}', section '{section}'."
return context + " " + chunk
def overlap(query, text):
"""Fraction of query words that appear in the text (a crude retrieval proxy)."""
q = set(query.lower().split())
t = set(text.lower().replace("'", "").replace(".", "").split())
return round(len(q & t) / len(q), 2)
raw = "It grew 3% quarter over quarter."
ctx = contextualize("Acme FY24 Report", "EMEA revenue", raw)
query = "acme emea revenue growth"
print("raw chunk :", raw)
print("ctx chunk :", ctx)
print("overlap raw :", overlap(query, raw))
print("overlap ctx :", overlap(query, ctx))
raw chunk : It grew 3% quarter over quarter.
ctx chunk : This chunk is from 'Acme FY24 Report', section 'EMEA revenue'. It grew 3% quarter over quarter.
overlap raw : 0.0
overlap ctx : 0.75
This lab models the single most important ingestion move: prepending a situating context line to a chunk before embedding it. The overlap() helper is a crude stand-in for a retriever — it measures how many words of the query actually appear in the chunk text.
contextualize()builds a one-line summary — which document, which section — and glues it in front of the raw chunk. In production a cheap LLM writes that line; here we template it so the lab runs offline.overlap()returns the fraction of the query's words found in the text. It is a proxy for "could a keyword search find this?".- The raw chunk ("It grew 3% quarter over quarter.") shares none of the query words — "acme", "emea", "revenue" all lived in the surrounding document, not this sentence.
- After contextualizing, three of the four query words now appear in the chunk, so overlap jumps from 0.0 to 0.75.
What the output means: Overlap goes from 0.0 (raw) to 0.75 (contextualized) — the context line pulled the document's vocabulary into the chunk, so a retriever can now find it.
Try this: Change the query to "how did revenue change" and watch the raw overlap stay low while the contextualized chunk still carries "revenue".
3 · Hybrid search — dense + sparse, fused with RRF intermediate
Dense (embedding) search captures meaning but misses exact tokens; sparse search — classically BM25, a keyword-frequency score — nails exact terms but misses paraphrase. They fail on different queries, so run both and combine. The standard combiner is Reciprocal Rank Fusion (RRF): score each document by the sum of 1/(k + rank) across the two ranked lists. It needs no score calibration — only ranks — which is why it is the default fusion in most hybrid stacks.
hybrid_rrf.pydef rrf(rankings, k=60):
"""rankings: list of ranked lists (each is doc-ids best-first). Returns fused
scores. RRF adds 1/(k + rank) for each list a doc appears in; rank is 0-based."""
scores = {}
for ranked in rankings:
for rank, doc in enumerate(ranked):
scores[doc] = scores.get(doc, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=lambda d: scores[d], reverse=True)
# A "dense-ish" ranking (by meaning) and a keyword (BM25-ish) ranking disagree.
dense = ["d_reimburse", "d_shipping", "d_refund", "d_hours"] # got paraphrase
sparse = ["d_refund", "d_err5021", "d_reimburse", "d_shipping"] # got exact terms
fused = rrf([dense, sparse])
print("dense top1 :", dense[0])
print("sparse top1:", sparse[0])
print("fused order:", fused[:3])
dense top1 : d_reimburse
sparse top1: d_refund
fused order: ['d_reimburse', 'd_refund', 'd_shipping']
This lab fuses two ranked lists — one from a meaning-based retriever, one from a keyword retriever — into a single ordering using Reciprocal Rank Fusion. RRF needs only the ranks, not the raw scores, so you never have to make embedding distances and BM25 scores comparable.
rrf()walks each ranked list and, for every doc, adds1/(k + rank)to that doc's running score.rankis 0-based, so the #1 doc contributes1/60, #2 contributes1/61, and so on.- A doc that appears in both lists accumulates two contributions, so agreement between the retrievers is rewarded.
d_reimbursewas found only by the dense list (it's a paraphrase of "refund");d_refundwas found by both. Both end up at the top of the fused order.- The final
sorted(..., reverse=True)turns the score dict back into a ranked list.
What the output means: The fused order ['d_reimburse', 'd_refund', 'd_shipping'] surfaces the dense-only paraphrase and the doc both retrievers agreed on — better than either list alone.
Try this: Raise k to 600 and re-run: the ordering flattens because a single top rank matters less. k is a robustness knob, not a correctness one.
Notice d_reimburse (only dense found it) and d_refund (both found it, ranked high by sparse) both surface at the top — RRF rewards documents that either rank rank highly in one list or appear in both. No embedding-vs-BM25 score normalization was needed.
k (default 60) dampens the influence of top ranks so a single #1 hit can't dominate. Larger k = flatter, more democratic fusion. It is a robustness knob, not a correctness one — 60 is a fine starting default.4 · Reranking — a cross-encoder narrows a wide recall set advanced
Retrieval optimizes for recall: cast a wide net (fetch, say, top-50) so the answer is somewhere in the set. But the LLM only wants the best few. A reranker — typically a cross-encoder that reads the query and each candidate together in one forward pass — scores relevance far more accurately than the bi-encoder embeddings used for first-stage recall. The pattern is retrieve wide, rerank to narrow: cheap recall over the whole corpus, expensive precision over the shortlist only.
rerank_stub.pydef first_stage_recall(query, corpus):
"""Cheap keyword-frequency proxy: count query-word occurrences. Repetition
inflates the score (fast, wide, imprecise) — like a first-stage BM25 net."""
q = query.lower().split()
scored = [(doc, sum(text.lower().split().count(w) for w in q)) for doc, text in corpus]
return [doc for doc, _ in sorted(scored, key=lambda x: x[1], reverse=True)]
def cross_encoder_score(query, text):
"""Stub for a cross-encoder: richer than word-frequency. Rewards the exact
phrase and penalizes length (a real model reads query+doc jointly)."""
q = query.lower()
score = 2.0 if q in text.lower() else len(set(q.split()) & set(text.lower().split()))
return score - 0.01 * len(text.split()) # slight brevity preference
def rerank(query, candidates, corpus_text):
return sorted(candidates, key=lambda d: cross_encoder_score(query, corpus_text[d]),
reverse=True)
corpus = [
("d1", "refund refund refund refund policy and refund window notes"),
("d2", "shipping times and delivery windows for your orders"),
("d3", "how to request a refund from the orders page"),
]
text = dict(corpus)
query = "request a refund"
recall = first_stage_recall(query, corpus) # wide, keyword-frequency biased
final = rerank(query, recall, text) # precise, phrase-aware
print("first-stage:", recall)
print("reranked :", final)
first-stage: ['d1', 'd3', 'd2']
reranked : ['d3', 'd1', 'd2']
This lab shows the two-stage "retrieve wide, rerank narrow" pattern. The first stage is cheap and imprecise (it just counts keyword hits); the reranker is a stand-in for a cross-encoder that reads the whole query against each document and scores relevance properly.
first_stage_recall()scores each doc by how often the query words appear — pure frequency. Becaused1repeats "refund" five times, it wins first place despite not actually answering the question.cross_encoder_score()rewards the exact phrase "request a refund" and gently penalizes length. It spots that phrase ind3.rerank()re-sorts the candidates by that richer score, promotingd3past the keyword-stuffedd1.- This is the recall-vs-precision split: the first stage must not lose the answer; the reranker decides what the LLM actually reads.
What the output means: First-stage order ['d1', 'd3', 'd2'] (fooled by repetition) becomes ['d3', 'd1', 'd2'] after reranking — the doc with the exact phrase wins.
Try this: Add a fourth doc that also contains "request a refund" but is very long, and see the brevity penalty push it below the shorter phrase match.
First-stage recall put d1 on top because it repeats the word "refund" five times — keyword frequency, not relevance. The reranker reads the whole query against each doc, spots the exact phrase "request a refund" in d3, and promotes it. That is the recall-vs-precision division of labor: the first stage must not lose the answer; the reranker decides what the LLM actually sees.
5 · Embedding fine-tuning — adapt to your domain advanced
Off-the-shelf embeddings are trained on general web text. If your domain has its own vocabulary (legal, medical, internal product codes), a general embedder may place "related" documents far apart in vector space. Fine-tuning the embedding model on your own (query, relevant-doc) pairs pulls domain-related items closer together. It is powerful but the last lever to pull — contextual chunking, hybrid search, and reranking are cheaper and usually get you most of the way.
| Option | Effort | Reach for it when… |
|---|---|---|
| Better chunking + hybrid + rerank | low–medium | always try first; fixes most gaps |
| Fine-tune the embeddings | high (needs labeled pairs, eval, retrain) | domain vocabulary is genuinely off-distribution AND you have training pairs |
finetune_decision.pydef should_finetune_embeddings(domain_vocab_gap, have_labeled_pairs,
tried_hybrid_and_rerank, volume_high):
if not tried_hybrid_and_rerank:
return "NO — exhaust contextual chunking + hybrid + rerank first (cheaper)"
if not have_labeled_pairs:
return "NO — no (query, relevant-doc) pairs to train/eval on"
if domain_vocab_gap and volume_high:
return "YES — off-distribution domain + volume justifies the retrain cost"
return "MAYBE — measure the recall gain vs the retrain/maintenance cost first"
print(should_finetune_embeddings(True, True, False, True)) # skipped the basics
print(should_finetune_embeddings(True, True, True, True)) # the real case
print(should_finetune_embeddings(True, False, True, True)) # no data
NO — exhaust contextual chunking + hybrid + rerank first (cheaper)
YES — off-distribution domain + volume justifies the retrain cost
NO — no (query, relevant-doc) pairs to train/eval on
This lab encodes when fine-tuning the embedding model is worth it. It is a gate, not a recommendation engine: each early return stops you before you spend the effort unless the cheaper options are exhausted and you actually have training data.
- The first check fails fast if you haven't tried contextual chunking + hybrid + rerank — those are cheaper and usually enough.
- The second fails if you have no labeled pairs: without (query, relevant-doc) examples there is nothing to train or evaluate against.
- Only when both gates pass, a genuine off-distribution domain plus high volume returns YES; otherwise you get MAYBE — measure the gain first.
- The three print lines walk a skipped-basics case, the real case, and a no-data case.
What the output means: Only the middle call (basics tried, data available, real domain gap, high volume) returns YES. The other two are correctly rejected.
Try this: Flip volume_high to False in the middle call and watch YES become MAYBE — at low volume the retrain cost may not pay off.
finetune_embeddings.py# Illustrative — requires sentence-transformers + a GPU + labeled pairs. Does NOT run offline.
from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader
model = SentenceTransformer("all-MiniLM-L6-v2") # base embedder
train = [InputExample(texts=[q, pos]) for q, pos in your_query_doc_pairs]
loader = DataLoader(train, batch_size=32, shuffle=True)
loss = losses.MultipleNegativesRankingLoss(model) # pulls (query, relevant) closer
model.fit(train_objectives=[(loader, loss)], epochs=1)
model.save("domain-embedder") # then re-embed your corpus
6 · Smarter chunking — beyond fixed size professional
Fixed-size chunking ("every 500 tokens") is blind: it slices mid-sentence, splits a table from its header, and separates a claim from its evidence — manufacturing the ambiguous chunks from section 1. Structure-aware chunking respects document boundaries (headings, paragraphs, list items, code blocks); semantic chunking starts a new chunk when the topic shifts. Below we model a structure-aware splitter that never breaks a paragraph and merges tiny fragments up to a target size.
chunking.pydef fixed_size(text, size=40):
"""Naive: cut every `size` characters, ignoring meaning."""
return [text[i:i+size] for i in range(0, len(text), size)]
def structure_aware(text, target=60):
"""Split on paragraph boundaries; merge small paras up to ~target chars,
but never cut a paragraph in half."""
paras = [para.strip() for para in text.split("\n\n") if para.strip()]
chunks, buf = [], ""
for para in paras:
if buf and len(buf) + len(para) + 1 > target:
chunks.append(buf); buf = para
else:
buf = (buf + " " + para).strip()
if buf:
chunks.append(buf)
return chunks
doc = ("Refund policy overview.\n\n"
"Refunds are issued within 30 days.\n\n"
"Contact support for exceptions.")
fx = fixed_size(doc)
sa = structure_aware(doc)
print("fixed-size chunks :", len(fx), "| first:", repr(fx[0]))
print("structure chunks :", len(sa))
for c in sa:
print(" -", c)
fixed-size chunks : 3 | first: 'Refund policy overview.\n\nRefunds are iss'
structure chunks : 2
- Refund policy overview. Refunds are issued within 30 days.
- Contact support for exceptions.
This lab contrasts blind fixed-size chunking with structure-aware chunking. The fixed-size version cuts every N characters; the structure-aware version respects paragraph boundaries and only merges small paragraphs together up to a target size.
fixed_size()slices every 40 characters with no regard for meaning — its first chunk ends mid-word at "...Refunds are iss".structure_aware()splits on blank lines (paragraph boundaries), then accumulates paragraphs intobufuntil adding the next one would exceedtarget.- It never cuts a paragraph in half: when the buffer is full it flushes the whole thing and starts fresh with the next paragraph.
- The two short opening paragraphs merge into one coherent chunk; the third stays separate.
What the output means: Fixed-size produces 3 meaningless slices (one splitting a word); structure-aware produces 2 chunks, each a whole thought — fewer ambiguous chunks to rescue later.
Try this: Lower target to 20 and the merge stops happening — every paragraph becomes its own chunk. The target trades chunk size against how much context stays together.
The fixed-size cut slices straight through the middle of "issued" (…Refunds are iss) — the exact ambiguous-chunk problem, mid-word and mid-thought. The structure-aware splitter keeps whole paragraphs together and merges the tiny intro line up into a coherent chunk. Better boundaries mean less need for the context line to rescue meaning later.
7 · Measure it — recall@k before and after professional
Every technique here is a hypothesis; prove it with an eval. The workhorse metric for retrieval is recall@k: of the queries whose known-relevant document was retrieved in the top-k, what fraction? (Covered as an eval discipline in FA5 · RAG evaluation.) Compare the naive pipeline against the advanced one on the same query set — if recall@k doesn't move, the added complexity isn't earning its keep.
recall_at_k.pydef recall_at_k(results, relevant, k):
"""results: {query: ranked doc-id list}. relevant: {query: gold doc-id}.
Returns fraction of queries whose gold doc is in the top-k."""
hits = sum(1 for q, gold in relevant.items() if gold in results[q][:k])
return round(hits / len(relevant), 2)
relevant = {"q1": "d_reimburse", "q2": "d_err5021", "q3": "d_refund"}
# Naive dense-only top-k misses the paraphrase and the exact error code.
naive = {"q1": ["d_shipping", "d_hours", "d_reimburse"],
"q2": ["d_refund", "d_hours", "d_shipping"],
"q3": ["d_refund", "d_reimburse", "d_hours"]}
# Advanced (contextual + hybrid + rerank) surfaces the right doc first.
advanced = {"q1": ["d_reimburse", "d_shipping", "d_hours"],
"q2": ["d_err5021", "d_refund", "d_hours"],
"q3": ["d_refund", "d_reimburse", "d_hours"]}
print("recall@1 naive :", recall_at_k(naive, relevant, k=1))
print("recall@1 advanced :", recall_at_k(advanced, relevant, k=1))
print("recall@3 naive :", recall_at_k(naive, relevant, k=3))
print("recall@3 advanced :", recall_at_k(advanced, relevant, k=3))
recall@1 naive : 0.33
recall@1 advanced : 1.0
recall@3 naive : 0.67
recall@3 advanced : 1.0
This lab measures the whole point of the lesson: did the retriever actually put the right document in front of the LLM? recall@k asks, across a set of queries, for what fraction was the known-correct doc inside the top-k results.
relevantmaps each query to its one gold document — the answer you're hoping to retrieve.recall_at_k()counts the queries whose gold doc appears inresults[q][:k](the top-k), then divides by the number of queries.naiveis a dense-only baseline that misses the paraphrase and the exact error code;advancedis the full pipeline that surfaces the right doc first.- Comparing the two at k=1 and k=3 tells you whether the extra pipeline stages earned their complexity.
What the output means: Recall@1 jumps from 0.33 (naive) to 1.0 (advanced) — the gap is largest at small k, exactly where it matters since the LLM only reads the top few.
Try this: Break the advanced pipeline by putting the wrong doc first for q2 and watch recall@1 drop — this is how you'd catch a regression before shipping.
At k=1 the naive pipeline finds the right doc for only 1 of 3 queries; the advanced pipeline gets all three. The gap is largest at small k — exactly where it matters, because the LLM only reads the top few. Without this measurement you would be guessing whether the extra stages help.
8 · Tech-lead — designing the retrieval pipeline tech-lead
A lead owns the whole pipeline as a system, not a bag of tricks. The disciplined build order is: get chunking right (structure-aware), add a context line per chunk at ingestion, index for both dense and BM25, fuse with RRF, rerank the shortlist with a cross-encoder, and gate every change on recall@k over a fixed eval set. Fine-tune embeddings only if a measured domain gap survives all of that. Choose the vector store and framework that make this composition cheap to run and evolve (AE7 · Vector DB frameworks).
The build order a lead enforces
- Chunk by structure — never cut mid-thought (
chunking.py). - Contextualize — prepend a situating line before embedding (
contextual_chunk.py). - Dual-index — embed for dense search and build a BM25 index over the same contextualized text.
- Fuse — retrieve wide from both, combine ranks with RRF (
hybrid_rrf.py). - Rerank — cross-encoder narrows the wide set to a precise top-k (
rerank_stub.py). - Measure — recall@k before/after on a fixed query set (
recall_at_k.py); only keep stages that move it. - Fine-tune embeddings — last resort, only for a proven off-distribution domain gap.
Exercise FA6.1 — Build and measure the pipeline
Context: Building the full pipeline and measuring it end-to-end is the only way to see which stage actually moved recall — the difference between cargo-culting techniques and engineering them.
Your task: On a small doc set, run chunking.py, contextual_chunk.py, hybrid_rrf.py, and rerank_stub.py, then use recall_at_k.py to compare recall@1 and recall@3 against a naive dense-only baseline.
Requirements:
- Split by structure, add context lines, fuse dense+keyword, then rerank
- Measure recall@1 and recall@3 for the full pipeline
- Compare against a naive dense-only baseline on the same queries
- Report which stage moved the metric most
💡 Hint: Add stages one at a time and re-measure so you can attribute the recall gain to a specific stage rather than the whole pipeline.
Exercise FA6.2 — Justify (or reject) embedding fine-tuning
Context: Fine-tuning embeddings is the decision most teams get wrong by reaching for it too early. Running the honest decision on your own domain usually reveals a cheaper fix first.
Your task: For your own domain, run finetune_decision.py with honest inputs (off-distribution vocabulary? labeled pairs? already tried hybrid+rerank?) and write a one-paragraph decision — and if it's "not yet", say what you'd try instead.
Requirements:
- Answer each precondition honestly for your domain
- Run the decision function on those inputs
- Write a one-paragraph verdict
- If "not yet", name the cheaper stage you'd try first
💡 Hint: The common honest outcome is "not yet" — hybrid + rerank usually closes the gap before fine-tuning is worth its cost.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A chunk pulled out of its document loses the context that made it meaningful. Anthropic's contextual-retrieval technique prepends a one-line situating summary before embedding, which sharply cuts ambiguous-chunk failures.
Your task: Implement contextualize(title, section, chunk) that prepends a one-line situating summary to a chunk before embedding, and show the enriched text.
Requirements:
- Prepend a short situating line naming the document/section context
- Return the enriched chunk text ready for embedding
- The original chunk content is preserved after the context line
- Show a before/after of the enriched text
💡 Hint: The added line answers "what is this chunk about and where is it from" so the embedding captures context the raw chunk omits.
Show solution
The ingestion move that fixes 'ambiguous chunk' failures:
def contextualize(doc_title, section, chunk):
context = f"This chunk is from '{doc_title}', section '{section}'."
return context + " " + chunk
raw = "It grew 3% quarter over quarter."
print(contextualize("FY24 Report", "Revenue", raw))
# This chunk is from 'FY24 Report', section 'Revenue'. It grew 3% quarter over quarter.
The bare chunk ("It grew 3%") is ambiguous once split from its document; the prepended context restores what "it" and "which quarter" mean, so the embedding lands near the right queries.
Context: Dense (meaning) and sparse (keyword) retrievers fail on different queries. Reciprocal Rank Fusion combines their ranks — not their incompatible scores — into one robust ranking.
Your task: Implement RRF where each doc scores Σ 1/(k+rank) over the lists it appears in, then sort, and fuse two disagreeing rankings.
Requirements:
- Score by rank position, never the retrievers' raw scores
- Sum
1/(k+rank)across every list a doc appears in - A doc in only one list still contributes
- Sort by fused score, highest first
- Fuse two rankings that disagree and show a consensus winner
💡 Hint: Working from ranks sidesteps the fact that dense and sparse scores live on totally different scales; the constant k damps the very top ranks.
Show solution
RRF, exactly as the lesson defines it (0-based rank, k=60):
def rrf(rankings, k=60):
scores = {}
for ranked in rankings:
for rank, doc in enumerate(ranked):
scores[doc] = scores.get(doc, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=lambda d: scores[d], reverse=True)
dense = ["d2", "d1", "d5"] # by meaning
sparse = ["d5", "d3", "d2"] # by keyword
print(rrf([dense, sparse]))
# d2 first: ranks high in one list AND appears in both -> floats to the top
A doc that either ranks high in one list or shows up in both rises under RRF. It needs no score calibration between retrievers — only ranks — which is why it fuses dense and sparse so robustly.
Context: Retrieve wide and cheap, then rerank narrow and expensive: a cross-encoder that scores the query and chunk together catches relevance a first-stage keyword pass misses.
Your task: Model a cheap first-stage keyword recall, then a richer cross-encoder-style reranker that scores query+chunk together, and show the top-k change after reranking.
Requirements:
- First stage recalls a wide candidate set cheaply
- The reranker scores each query+chunk pair jointly
- Reorder the candidates by the reranker score
- Show the top-k changing versus the first-stage order
💡 Hint: The reranker is more expensive per item, which is exactly why it runs only on the narrowed candidate set, not the whole corpus.
Show solution
Wide recall then precise rerank, both modeled offline:
def first_stage(query, corpus): # cheap: raw keyword frequency
q = query.lower().split()
scored = [(d, sum(t.lower().split().count(w) for w in q)) for d,t in corpus]
return [d for d,_ in sorted(scored, key=lambda x:x[1], reverse=True)]
def cross_encoder(query, text): # richer: fraction of query terms covered
q, t = set(query.lower().split()), set(text.lower().split())
return len(q & t) / len(q) # stand-in for a real cross-encoder
corpus = [
("d1", "refund refund refund refund process"), # spams 'refund', misses 'window'
("d2", "how to enable two factor authentication"),
("d3", "the refund window is five business days"), # actually answers the query
]
wide = first_stage("refund window", corpus) # wide, imprecise
texts = dict(corpus)
reranked = sorted(wide, key=lambda d: cross_encoder("refund window", texts[d]),
reverse=True)
print("first-stage:", wide) # ['d1', 'd3', 'd2'] — repetition inflates d1
print("reranked :", reranked) # ['d3', 'd1', 'd2'] — d3 promoted (covers both terms)
The first stage over-rewards repetition, floating the keyword-spamming d1 to the top; the cross-encoder reads query and chunk together and promotes d3, which actually covers both query terms. Retrieve wide for recall, rerank to a precise top-k.
Context: Every retrieval improvement must be justified by recall@k on a held-out eval set — the honest measure of whether the extra stages actually help.
Your task: Implement recall@k over an eval set and compare a naive retriever against a hybrid+rerank one on the same queries.
Requirements:
- Recall@k = fraction of queries whose relevant chunk appears in the top-k
- Run the identical query set through both retrievers
- Report recall@k for each
- State which pipeline wins and by how much
💡 Hint: Hold the query set fixed across both retrievers so the only thing that changes is the pipeline being measured.
Show solution
recall@k averaged over an eval set — the before/after number:
def recall_at_k(retrieved, relevant, k):
return len(set(retrieved[:k]) & set(relevant)) / len(set(relevant))
evalset = [
# query id: (naive top-k ids, hybrid+rerank top-k ids, relevant ids)
("q1", ["d5","d2","d9"], ["d1","d5","d2"], ["d1"]),
("q2", ["d3","d7","d8"], ["d3","d4","d7"], ["d4"]),
]
def mean_recall(idx, k=3):
return sum(recall_at_k(row[idx], row[3], k) for row in evalset) / len(evalset)
print(f"naive recall@3: {mean_recall(1):.0%}") # 0% (missed both)
print(f"hybrid recall@3: {mean_recall(2):.0%}") # 100% (both in top-3)
Recall@k turns "the pipeline feels better" into a defensible delta on the same queries. Every advanced-retrieval trick must earn its keep on this number, or it is complexity for nothing.
Context: Fine-tuning embeddings is expensive and a genuine last resort. The lesson's decision rule fires only after cheaper fixes and only under specific conditions.
Your task: Encode the fine-tune decision: recommend it only after contextual chunking + hybrid + rerank, only with labeled (query, relevant-doc) pairs, and only for an off-distribution domain at volume.
Requirements:
- Require that contextual chunking, hybrid, and rerank were tried first
- Require labeled (query, relevant-doc) training pairs
- Require an off-distribution domain and enough volume to justify it
- Recommend fine-tuning only when all conditions hold, else advise a cheaper fix
💡 Hint: Treat it as an AND of preconditions; if any is missing the answer is "not yet — try the cheaper stage first".
Show solution
The last-resort gate, straight from the lesson:
def should_finetune_embeddings(domain_vocab_gap, have_labeled_pairs,
tried_hybrid_and_rerank, volume_high):
if not tried_hybrid_and_rerank:
return "NO — exhaust contextual chunking + hybrid + rerank first (cheaper)"
if not have_labeled_pairs:
return "NO — no (query, relevant-doc) pairs to train/eval on"
if domain_vocab_gap and volume_high:
return "YES — off-distribution domain + volume justifies the retrain cost"
return "MAYBE — measure recall@k gain vs cost before committing"
print(should_finetune_embeddings(True, False, True, True)) # NO — no pairs
print(should_finetune_embeddings(True, True, True, True)) # YES
Fine-tuning embeddings only pays off after the cheap pipeline tricks are exhausted, you have labeled pairs to train and evaluate on, and the domain vocabulary genuinely differs at volume. Otherwise it is cost without payoff.
Context: As tech lead you assemble the retrieval pipeline from the failure modes it must fix, rather than bolting on every technique. Each stage earns its place by addressing a specific failure.
Your task: Given which failures are present (ambiguous chunks, vocabulary mismatch, exact-term misses), recommend which pipeline stages to include and justify each choice.
Requirements:
- Map each failure mode to the stage that fixes it
- Ambiguous chunks → contextual chunking; vocab mismatch → hybrid/dense; exact-term misses → sparse/keyword
- Recommend only the stages the present failures justify
- Justify each included stage by the failure it addresses
💡 Hint: Don't include a stage unless a named failure motivates it; the right pipeline is the minimal set that covers the failures you actually have.
Show solution
Map each observed failure to the stage that fixes it:
def design_pipeline(ambiguous_chunks, vocab_mismatch, exact_term_misses):
stages = ["dense retrieval (baseline)"]
if ambiguous_chunks:
stages.insert(0, "contextual chunking (situate before embed)")
if exact_term_misses:
stages.append("sparse/BM25 + RRF fusion (catch exact tokens)")
elif vocab_mismatch:
stages.append("hybrid dense+sparse + RRF (bridge vocabulary)")
stages.append("cross-encoder rerank -> precise top-k")
stages.append("measure recall@k before/after")
return stages
for s in design_pipeline(ambiguous_chunks=True, vocab_mismatch=True,
exact_term_misses=True):
print("-", s)
Do not bolt on every trick — add the stage each observed failure demands: contextual chunking for ambiguity, hybrid+RRF for vocabulary/exact-term gaps, reranking to narrow, and recall@k to prove it worked.
✓ Checkpoint — you can move on when you can…
- Explain the three naive-top-k failure modes and which pipeline stage addresses each.
- Build a contextual chunk and show how it changes query overlap.
- Fuse a dense and sparse ranking with RRF and explain the k constant.
- Describe retrieve-wide-then-rerank and why a cross-encoder is more accurate but costlier.
- Decide when embedding fine-tuning and structure-aware chunking are worth it.
- Compare recall@k before/after and gate pipeline changes on it.
Knowledge check check yourself
What is contextual retrieval, and why does prepending a situating line before embedding improve recall?
Show answer
Why combine dense and sparse (BM25) search with RRF, and what does a cross-encoder reranker add afterward?