AI EngineeringZero to ProductionHome·About·What’s new·Contact
Retrieval (RAG) · Part 3.3 · Advanced

Retrieval quality & re-ranking

First-pass retrieval is fast but blunt: it hands back chunks that are roughly relevant, and the single best answer is often sitting at rank 5 instead of rank 1. This advanced chapter is about everything you do after the first search to make the final context sharper — re-ranking the top-N with a more careful scorer, transforming the query (multi-query, HyDE, decomposition) so retrieval sees the question from several angles, and packing context so the model actually reads the good parts. You will hand-build each stage in plain Python with a deterministic scorer that runs offline, and know exactly where a real cross-encoder plugs in.

⏱️ ~95 min🧲 Retrieval🎯 Advanced🐍 Runnable Python
🌱 What runs here, and what needs a real modelEvery lab in this chapter runs right here, offline, because we simulate the expensive scorer with plain Python (term-overlap, phrase bonuses, positions). The techniques named here — cross-encoders, bi-encoders, HyDE, multi-query, RRF, the “lost in the middle” effect — are all real, published methods. A real cross-encoder replaces our toy scorer; the pipeline shape is identical. Any specific numbers (latencies, score deltas) are illustrative.

Learning objectives

  • Explain why first-pass retrieval has poor precision@k and why a second pass helps.
  • Contrast a bi-encoder (fast, retrieve) with a cross-encoder (slow, re-rank) and re-order results.
  • Apply three query transformations — multi-query, HyDE, and decomposition — and say when each wins.
  • Union and dedup results from several sub-queries into one candidate set.
  • Pack context for the prompt: dedup near-duplicates, fight “lost in the middle,” and budget by tokens.
  • Decide when re-ranking is worth the latency and when it is a waste.
📋 Before you startThis page builds on: RAG foundations · Retrieval that works. Jumping in? Skim those first — or open the recap.
30-second recap
  • RAG foundations: Retrieval-augmented generation: find the relevant passages first, then give them to the model as context, so answers come from your documents instead of memory.
  • Retrieval that works: Chunking decides what a 'document' is; hybrid search combines keyword and vector matches; metadata filters scope results before ranking.

1 · Why the first pass isn't enough

Here's the failure that sends teams looking for this chapter. Your RAG bot demos beautifully. You ask it a question, the right passage comes back, the answer is crisp. Then real users arrive with real questions, and the complaints trickle in: "it gave me the wrong answer," "it's confidently citing the wrong doc." You dig in, and the maddening part is this — the correct chunk was retrieved. It was sitting at rank 5. The model just never read that far, or it anchored on the noisy chunk that landed at rank 1. Nothing is broken. Retrieval did its job. It just wasn't careful enough about which of its good-enough hits went first.

Think of first-pass retrieval like a librarian who's brilliant at speed but only skims. Ask for "books about restarting a checkout service" and in seconds she pulls twenty books off the shelves that all mention those words — that's her superpower, she can scan the whole library. But she hasn't read any of them closely enough to know which one actually tells you the steps versus which one just name-drops "checkout" and "queue" in passing. To pick the single best book she'd have to sit down and read each candidate against your exact question — slow, careful work she can't afford to do across the whole library. That split — a fast skim over everything, then a careful read of the shortlist — is the entire architecture of this chapter.

The common mistake here is to blame the wrong thing. When the top answer is wrong, the instinct is "my embeddings are bad" or "I need a bigger model," and people go re-index the whole corpus. But usually the answer is already in the candidate set — recall is fine — and the real defect is ordering. That distinction is worth its own vocabulary, because you cannot fix a problem you've misdiagnosed: recall asks "did we even fetch the right chunk?", while precision asks "of the chunks we fetched, are the good ones on top?" Confuse them and you'll spend a week improving recall when your recall was never the bottleneck. The table below is the precise language for talking about retrieval quality — memorize these three, because every decision in the rest of the chapter comes back to which one you're actually short on.

First-pass retrieval — vector search or BM25 — is built for speed over a huge corpus. It embeds each document once and compares vectors independently, so it scales to millions of chunks. The price is precision: it finds things that are topically near the question, but it can't tell that chunk #5 actually answers the question while chunk #1 merely mentions the same words. That is a precision@k problem — the right chunk is somewhere in the top 20, just not at the top.

MetricWhat it asksWhy it matters for RAG
Recall@kOf all relevant chunks, how many are in the top-k?If recall is low, the answer never made it into the candidate set — no re-ranker can save you.
Precision@kOf the top-k you kept, how many are actually relevant?Low precision means the model wades through noise; the best chunk may be crowded out of a small budget.
MRR (mean reciprocal rank)How high up is the first good result?The LLM reads the top chunks first; getting the answer to rank 1 is the whole game.
first-pass top-8 (ranked) 1 · mentions words (noise) 2 · related but off-topic 3 · partial overlap 4 · near-duplicate 5 · the real answer ✓ 6 · … more chunks … 7 8 recall = ✓ (answer is in the set) recall@8: found it precision = ✗ (it's at rank 5) MRR: first-good is low the model reads the top, misses rank 5 Retrieval didn't fail — ordering did. The right chunk (green) is already in the candidate set, so recall is fine; the defect is that it sits at rank 5, so precision@k and MRR are poor. That is the exact gap re-ranking closes.
🗺️ How to read this diagram

Read the ranked list top-to-bottom on the left, then follow the arrow to the two verdicts on the right.

  • The left column is the first pass's top-8, in the order it returned them. Rank 1 is a chunk that merely uses the right words; the genuinely correct chunk (the green one) landed at rank 5.
  • The green box on the right says recall is satisfied — the answer is in the set, so no re-indexing is needed.
  • The red box is the real problem: precision@k and MRR are low because the good chunk is buried. The model reads from the top and never reaches rank 5.

In short: When the answer is in the candidate set but ranked low, you have an ordering (precision) problem, not a retrieval (recall) problem — and only ordering fixes it.

The re-ranking betFirst-pass retrieval optimizes recall cheaply — “get the right chunk somewhere in the top 50.” Re-ranking then spends a little more compute on just those 50 to fix precision — “now put the best one first.” You keep a wide, cheap net and add one careful sort on the survivors.

2 · Re-ranking with a cross-encoder

Everything good about your RAG answer rides on getting the right chunk to the top of the list. The model reads the first chunk most attentively and the last chunk second-most; anything crowded into the middle it may skim right past. So if the chunk that truly answers the question is at rank 5, it might as well not exist. Re-ranking is the one move that reliably fixes this — and understanding why it works comes down to a single difference in how two kinds of models look at text.

Here's the analogy that makes it click. Imagine speed-dating versus a real interview. First-pass retrieval is speed-dating: everyone fills out a profile card in advance (that's the embedding), and matching is just comparing cards — fast, and you can compare thousands of cards at once, but nobody actually talked to anybody. A cross-encoder is the real interview: it sits the query and one candidate chunk down in the same room, at the same time, and judges how well they actually respond to each other. Of course the interview is far more accurate — the two parties are genuinely interacting rather than being matched on pre-written cards. And of course it's far slower — you have to hold a fresh interview for every single candidate, and you can't prepare the answer ahead of time because it depends on this query paired with this chunk. That's the whole trade in one image: cards for scale, interviews for judgment.

In precise terms: a bi-encoder (what first-pass vector search uses) embeds the query and each chunk into vectors separately, then compares those vectors — so a chunk's vector can be computed once, at indexing time, and reused for every future query. That's why it scales to millions of chunks. But because the model never sees the query and the chunk side by side, it can only judge "are these two texts about similar topics?", not "does this text answer that question?" A cross-encoder feeds the pair (query, chunk) through the model jointly, letting every word of the query attend to every word of the chunk, and outputs one relevance score. That joint view is what catches "this chunk uses all the right words but doesn't actually give the steps."

The trap that ruins re-ranking setups is trying to run the cross-encoder on the whole corpus — "if it's more accurate, use it everywhere." You can't: it's not precomputable, so scoring a million chunks per query would take minutes and cost a fortune. The discipline is a two-stage funnel: let the cheap bi-encoder cast a wide net and hand you the top-N candidates (say 25–100), then spend the expensive cross-encoder only on rescoring those N. You get the bi-encoder's scale and the cross-encoder's judgment, and you only pay the careful cost on the tiny shortlist — never the whole library. The diagram and table below lay out that funnel; keep the "cheap-wide-then-careful-narrow" shape in mind as you read them.

Query Bi-encoderretrieve top-N cheap, wide net Cross-encoderrescore pairs careful, on N only Re-ordered top-k best chunk first
🗺️ How to read this diagram

Follow the arrows left→right: one query flows through two ranking stages and comes out re-ordered.

  • The leftmost box is the Query. The brand-colored arrow feeds it into the first stage.
  • The next two boxes are the two stages: the Bi-encoder does a cheap, wide-net retrieve of the top-N, then hands only those N to the Cross-encoder, which is careful but runs on N only — rescoring each (query, chunk) pair.
  • The rightmost box is the payoff: a Re-ordered top-k with the best chunk first, because the expensive careful pass ran only on the survivors of the cheap pass.

In short: Retrieve wide and cheap first, then rescore precisely on just the top-N — fast recall then sharp ranking, without paying the careful cost over the whole corpus.

Bi-encoder (retrieve)Cross-encoder (re-rank)
Inputquery and chunk embedded separatelyquery + chunk together, as one input
Precompute?yes — chunk vectors stored onceno — must run per (query, chunk) pair
Speedfast; scales to millionsslow; run on the top-N only (e.g. 25–100)
Accuracygood recall, rough rankingsharp ranking of the survivors

Below is the whole idea, runnable. First-pass retrieval is a simple shared-term count (standing in for the bi-encoder) — which a keyword-stuffed chunk can game. Then a richer re-ranker rescores the top-N with signals the first pass ignored — term coverage plus exact phrase/bigram matches — and re-orders them. Watch the truly-relevant chunk climb from behind a noisy one to the top.

python · re-rank the top-N (runnable — click ▶ Open in terminal)
rerank.pyimport re
from collections import Counter

def toks(s):
    return re.findall(r'[a-z]+', s.lower())

# ---- FIRST PASS: cheap shared-term count (stands in for a bi-encoder) ----
def first_pass_score(query, chunk):
    q, c = Counter(toks(query)), Counter(toks(chunk))
    return sum(min(q[w], c[w]) for w in q)   # raw shared-term count

# ---- RE-RANKER: richer joint score (stands in for a cross-encoder) ----
# A real cross-encoder replaces this function; the pipeline shape is identical.
def rerank_score(query, chunk):
    cl = chunk.lower()
    qset = set(toks(query))
    coverage = sum(1 for w in qset if w in set(toks(chunk))) / (len(qset) or 1)
    bigram = 1.0 if 'consumer group' in cl else 0.0   # meaningful phrase, not just words
    phrase = 1.0 if 'restart checkout' in cl else 0.0  # answers the actual ask
    return round(2.0*coverage + bigram + phrase, 3)

chunks = [
    'Restart the queue, restart the group, restart the checkout queue consumer.',  # keyword-stuffed noise
    'To restart checkout, drain the queue then scale the consumer group up.',       # the real answer
    'Checkout reads orders; the queue and consumer group appear in Grafana.',
    'Never restart the database to fix a queue backlog.',
]
query = 'restart checkout consumer group queue'

# stage 1: first-pass top-N (cheap, favors recall)
N = 4
first = sorted(chunks, key=lambda c: first_pass_score(query, c), reverse=True)[:N]
print('FIRST PASS (top-4):')
for i, c in enumerate(first, 1):
    print(f'  {i}. [{first_pass_score(query, c)}] {c}')

# stage 2: re-rank ONLY those N (richer, favors precision)
reranked = sorted(first, key=lambda c: rerank_score(query, c), reverse=True)
print('\nAFTER RE-RANK:')
for i, c in enumerate(reranked, 1):
    print(f'  {i}. [{rerank_score(query, c)}] {c}')
FIRST PASS (top-4):
  1. [5] Restart the queue, restart the group, restart the checkout queue consumer.
  2. [5] To restart checkout, drain the queue then scale the consumer group up.
  3. [4] Checkout reads orders; the queue and consumer group appear in Grafana.
  4. [2] Never restart the database to fix a queue backlog.

AFTER RE-RANK:
  1. [4.0] To restart checkout, drain the queue then scale the consumer group up.
  2. [2.6] Checkout reads orders; the queue and consumer group appear in Grafana.
  3. [2.0] Restart the queue, restart the group, restart the checkout queue consumer.
  4. [0.8] Never restart the database to fix a queue backlog.

First-pass tied a keyword-stuffed noise chunk with the real answer (both score 5), and a plain sort left the noise at #1. The re-ranker — rewarding coverage plus the meaningful consumer group and restart checkout phrases rather than raw repeats — pushed the actionable answer to #1 and demoted the noise to #3. That is precision@k improving in one sort — and we only paid the richer cost on 4 candidates, not the whole corpus.

Re-ranking can't fix bad recallThe re-ranker only re-orders what the first pass gave it. If the answer wasn't in the top-N, no rescoring brings it back. So set N generously (favor recall in stage 1) and let the re-ranker fix precision in stage 2. Recall first, precision second.

3 · Query transformation

Re-ranking fixes the order of what you found — but what if the answer was never found at all? No amount of rescoring can rescue a chunk that the first pass didn't retrieve. When recall itself is the problem, you have to attack it from the other end: not the results, but the query. And the reason queries fail is almost poetic — the person asking and the document answering rarely speak the same way.

Picture the mismatch. A stressed engineer types "checkout is stuck" into your bot. The runbook that solves it says "Drain the order queue, then scale the consumer group to resume processing." Not one important word overlaps — "stuck" vs. "drain," "checkout" vs. "queue" and "consumer group." To a keyword search these look unrelated, and even to an embedder they can drift apart, because the terse panic of a question is phrased nothing like the calm procedural prose of an answer. It's like searching a cookbook for "dinner is boring" — the recipe you need never uses those words. The fix is to stop searching with the raw question and instead search with something that looks more like the answer you're hoping to find.

That single insight branches into three published techniques, and the mistake to avoid is reaching for all three at once. They solve different failures, so you pick by diagnosis. Multi-query handles "the question could be phrased ten ways" — you generate several paraphrases and union their hits, widening recall cheaply. HyDE handles the question-vs-answer vocabulary gap directly — you have an LLM draft a fake answer and search with that, because an answer is phrased like your documents. Decomposition handles the compound question — "how do I restart checkout and where are invoices stored?" needs facts from two different chunks, so you split it into sub-questions and retrieve each. Match the technique to the symptom; don't bolt on all three and hope. The table names each precisely, and the runnable lab below builds multi-query so you can watch the union gather chunks no single query returned.

The other way to improve retrieval is to fix the query before you search. A user's question is often short, ambiguous, or phrased unlike the documents. Three published techniques rewrite it:

TechniqueIdeaBest when…
Multi-queryAsk an LLM for several paraphrases of the question, retrieve for each, and union the results.the question can be phrased many ways; widens recall cheaply.
HyDE (Hypothetical Document Embeddings)Have the LLM write a hypothetical answer, then embed that and retrieve — an answer looks more like the target document than the question does.questions and documents are phrased very differently (Q vs. A gap).
DecompositionBreak a complex, multi-part question into sub-questions, retrieve for each, then combine.the question needs facts from several different chunks to answer.
Why HyDE worksA question (“how do I restart checkout?”) and its answer (“Drain the queue, then scale the consumer group…”) use different words. Embeddings match text that looks alike, so an embedded answer sits closer to the real answer chunk than the embedded question does. HyDE trades one extra LLM call for better-aimed retrieval. The hypothetical answer can be wrong and still help — it only needs to be phrased like the target document.
raw user query Multi-query HyDE Decomposition N paraphrases→ union hits fake answer→ embed that sub-questions→ retrieve each phrased many ways Q ≠ A vocabulary multi-part question Pick the transform by the symptom, not all three at once. Each column attacks a different reason a query fails to retrieve — the green row is the failure it fixes, so you diagnose first and then reach for one.
🗺️ How to read this diagram

Read each column top-to-bottom: the same raw query fans into three techniques, and under each is what it does and the failure it targets.

  • The top box is the untouched user query. The three blue arrows show it being rewritten three different ways before search.
  • Each blue box is a technique and the amber box below it is its mechanism: multi-query fires N paraphrases and unions the hits, HyDE drafts a fake answer and embeds that, decomposition splits a compound question and retrieves each part.
  • The green row is the payoff — the specific symptom each one cures: many phrasings, a question-vs-answer vocabulary gap, or a question whose facts live in several chunks.

In short: Three techniques, three distinct failures — match the tool to the symptom rather than bolting on all three.

Here is a runnable multi-query simulation: several paraphrases each run first-pass retrieval, and we union + dedup the hits into one candidate set. In production the paraphrases come from an LLM; here we hand-write them so it runs offline.

python · multi-query union + dedup (runnable — click ▶ Open in terminal)
multi_query.pyimport re
from collections import Counter

def toks(s):
    return re.findall(r'[a-z]+', s.lower())

def score(query, chunk):
    q, c = Counter(toks(query)), Counter(toks(chunk))
    return sum(min(q[w], c[w]) for w in q)

def retrieve(query, chunks, k=1):
    ranked = sorted(range(len(chunks)), key=lambda i: score(query, chunks[i]), reverse=True)
    return [i for i in ranked if score(query, chunks[i]) > 0][:k]

chunks = [
    'Restart checkout by draining the order queue.',
    'The vehicle will not crank when the battery is flat.',
    'Reboot the stuck payment worker to clear the consumer group.',
    'Invoices are stored in Postgres by the billing service.',
]

# One LLM would generate these paraphrases; we hand-write them for an offline run.
paraphrases = [
    'how to restart checkout queue',
    'reboot the payment worker consumer group',
    'clear a stuck checkout',
]

seen, union = set(), []          # dedup by chunk index, keep first-seen order
for pq in paraphrases:
    hits = retrieve(pq, chunks, k=1)
    print(f'{pq!r:45} -> chunks {hits}')
    for i in hits:
        if i not in seen:
            seen.add(i); union.append(i)

print('\nUNION (deduped):', union)
for i in union: print('  -', chunks[i])
'how to restart checkout queue'               -> chunks [0]
'reboot the payment worker consumer group'    -> chunks [2]
'clear a stuck checkout'                      -> chunks [2]

UNION (deduped): [0, 2]
  - Restart checkout by draining the order queue.
  - Reboot the stuck payment worker to clear the consumer group.

Each paraphrase, taking the single best hit, saw the question from a different angle: the first surfaced the “drain the queue” chunk, the other two both surfaced the “reboot the worker” chunk. The union gathered both good chunks that no single query returned together, and dedup kept the repeated one just once. That is multi-query in miniature: several angles on the question, merged. (RAG 3.2 covered fusing rankings with RRF; here we fuse sets from paraphrased queries — a complementary move.)

4 · Context packing — order, dedup, budget

You've done the hard work — you have the right chunks, in the right order. Here's where teams throw it away. They take those beautiful ranked results and just paste all of them into the prompt, in rank order, and assume the model will find the good parts. It often doesn't. The chunk that answers the question sits at position 6 of 12, buried in the middle of a long context, and the model glides right over it — while a redundant near-duplicate two slots up ate the budget that could have held a genuinely new fact. Retrieval succeeded; packing failed.

The mental model to unlearn is "context is a bucket — pour everything in." It's closer to arranging a one-page briefing for a busy executive who reads the top and bottom carefully and skims the middle. Where you place a fact matters as much as whether you include it. This isn't a metaphor for the model's laziness — it's a real, published effect called "lost in the middle," where language models reliably attend best to text at the start and end of a long context and measurably worse to what's stranded in the center. So the naive instinct — "put the single best chunk right in the middle where it's central" — is exactly backwards; the center is the weakest seat in the house.

Three forces fight over the prompt, and packing is the art of balancing them. First, lost-in-the-middle: position is not neutral, so the strongest chunks belong at the edges. Second, near-duplicates: retrieval loves to hand you the same fact phrased twice — overlapping chunks, the same doc indexed under two headings — and each copy burns budget while adding zero information, so you dedup before packing. Third, the token budget: the context window is finite and every token you spend costs latency and money, so past a point you truncate and drop the tail. The common failure is to optimize only the third (stuff in as much as fits) while ignoring the first two — you end up paying for a bloated, duplicate-ridden context whose best fact is hidden dead-center. The table names each force and its fix; the runnable packer below does all three at once so you can watch a near-duplicate get dropped and the second-best chunk migrate to the end.

You now have good candidates. The last decision is how to lay them into the prompt, and it is not “paste all of them.” Three forces shape the packing:

ForceWhat it meansThe fix
Lost in the middleA real, published finding: models attend best to text at the start and end of a long context and can miss facts buried in the middle.Put the strongest chunks first and last; don't stack the best one in the dead center.
Near-duplicatesRetrieval often returns the same fact phrased twice (overlapping chunks, repeated docs).Dedup before packing — duplicates waste budget and add no information.
Token budgetThe context window is finite and every token costs money and latency.Truncate to a budget; keep the highest-ranked chunks, drop the tail.
model attention across the context window high low — "lost in the middle" high so pack the strongest chunks at the edges: best weaker weakest weaker 2nd The middle is the worst seat. The amber curve shows attention dipping in the center, so the two strongest chunks (green) go first and last; the weakest ride the middle where a skim costs the least.
🗺️ How to read this diagram

Read the amber curve first (top), then map its shape onto the row of packed slots below it.

  • The amber curve is how well the model attends to text at each position: high at the start and end, sagging in the middle — the published "lost in the middle" effect.
  • The green slots at the far left and far right hold the strongest chunks (best first, second-best last), landing them where attention is highest.
  • The grey middle slots hold the weaker chunks, because that's the region the model is most likely to skim — the least costly place to lose information.

In short: Position isn't neutral — put your best facts at the edges and let the filler ride the low-attention middle.

Here is a runnable packer that takes ranked chunks, drops near-duplicates, orders them so the best sit at the edges (fighting lost-in-the-middle), and truncates to a token budget. We approximate tokens as words to keep it offline; a real tokenizer swaps in without changing the shape.

python · pack the context (runnable — click ▶ Open in terminal)
pack.pyimport re

def approx_tokens(s):
    return len(s.split())          # a real tokenizer replaces this

def near_dup(a, b, thresh=0.8):
    wa, wb = set(re.findall(r'[a-z]+', a.lower())), set(re.findall(r'[a-z]+', b.lower()))
    if not wa or not wb: return False
    jaccard = len(wa & wb) / len(wa | wb)
    return jaccard >= thresh

def pack(ranked, budget=40):
    # ranked: chunks already sorted best -> worst (e.g. from the re-ranker)
    kept = []
    for c in ranked:                                   # 1) dedup near-duplicates
        if not any(near_dup(c, k) for k in kept):
            kept.append(c)
    trimmed, used = [], 0                              # 2) truncate to token budget
    for c in kept:
        t = approx_tokens(c)
        if used + t > budget: break
        trimmed.append(c); used += t
    # 3) order: best first, 2nd-best LAST, rest in the middle (fight lost-in-middle)
    ordered = trimmed[:]
    if len(ordered) >= 3:
        ordered = [trimmed[0]] + trimmed[2:] + [trimmed[1]]
    return ordered, used

ranked = [
    'Drain the queue then scale the consumer group to restart checkout.',
    'Drain the queue then scale the consumer group to restart the checkout.',  # near-dup of #0
    'Never restart the database to fix a queue backlog.',
    'Checkout orders flow through the queue into the consumer group.',
    'Grafana dashboards track queue depth and consumer lag over time.',
]
ordered, used = pack(ranked, budget=40)
print(f'packed {len(ordered)} chunks, ~{used} tokens:')
for i, c in enumerate(ordered, 1): print(f'  [{i}] {c}')
packed 4 chunks, ~40 tokens:
  [1] Drain the queue then scale the consumer group to restart checkout.
  [2] Checkout orders flow through the queue into the consumer group.
  [3] Grafana dashboards track queue depth and consumer lag over time.
  [4] Never restart the database to fix a queue backlog.

The near-duplicate second chunk (identical words) was dropped, the packer filled right up to the 40-token budget, and the second-strongest chunk was moved to the end so the two most important chunks sit at the edges where the model reads best. Same facts, no redundancy, better placement — the middle holds the weaker context that matters least if the model skims it.

Dedup before you re-rank, tooNear-duplicate chunks don't just waste prompt space — they can also crowd the re-ranker's top-N, pushing a genuinely different (and useful) chunk out. Deduping early keeps your candidate set diverse.

5 · When re-ranking is worth the latency

Now the honest part, the one that separates engineers from technique-collectors. Every method in this chapter makes retrieval better — and every one of them costs you something: an extra model pass, sometimes an extra LLM call, tens to hundreds of milliseconds added to every single query. It is genuinely tempting, having just learned all this machinery, to bolt it onto everything. That's the trap. The mark of a senior engineer isn't knowing re-ranking exists; it's knowing when not to use it.

Think of it like airport security. A wrong "top result" at a border checkpoint or in a medical triage system is catastrophic, so you happily accept the slow, careful screening — precision is worth any reasonable delay. But you would never put full body-scan security on the turnstile into a subway, where a hundred thousand people a minute need to pass and the cost of an occasional wrong guess is trivial. Same tool, opposite verdict, and the deciding factor is the same two questions every time: how expensive is a wrong top answer, and how tight is the latency budget? High-stakes and slack latency → screen everything carefully. Low-stakes and high traffic → wave them through with cheap top-k.

There's also a diagnosis that overrides the whole cost-benefit question: re-ranking cannot fix a recall problem. If the answer never made it into the candidate set, no amount of rescoring conjures it back — you'd be spending latency to more carefully sort a list that doesn't contain the answer. So before you reach for a cross-encoder, confirm the chunk is actually in the top-N; if it isn't, you have a retrieval/recall problem and query transformation or better chunking is the real fix. And the closing discipline, the one that outlives any specific technique: measure, don't assume. The gains here are real but corpus-specific — run precision@k and MRR with and without each addition on your own questions before you ship it, or you may be paying latency for a number that never moved. The table below maps common situations to a verdict; read it as a decision aid, not a mandate to enable everything.

should I re-rank? wrong answer is costly low stakes · high QPS YES — screen carefullyprecision > a few hundred ms NO — cheap top-kdon't blow the latency budget first, is the answer even in top-N? if not → recall problem, re-rank can't help Two questions decide it — plus one override. High-stakes / slack latency → screen everything; low-stakes / high-QPS → wave them through. But if the answer isn't in the candidate set at all, re-ranking is the wrong tool no matter the stakes.
🗺️ How to read this diagram

Start at the top question, branch left or right on the cost-vs-latency trade, then check the override band at the bottom before committing.

  • The top box is the decision. The two arrows split on the deciding factor: how expensive a wrong top answer is versus how tight your latency budget is.
  • The green box (left) is the "yes" case — high-stakes answers where precision is worth a few hundred milliseconds. The red box (right) is the "no" case — low-stakes, high-traffic paths where extra passes blow the budget.
  • The amber band is the override that beats both branches: if the correct chunk isn't in the top-N, you have a recall problem, and re-ranking a list that lacks the answer only wastes latency.

In short: Re-rank when a wrong answer is costly and latency is slack; skip it when it's cheap and fast — but always confirm recall first.

Re-ranking and query transformation both cost time — an extra model pass, sometimes an extra LLM call. That's a real budget you spend on every query. Spend it where it pays off:

SituationRe-rank / transform?Why
High-stakes answers (support, legal, medical)YesA wrong top chunk is expensive; precision matters more than a few hundred ms.
First-pass recall is already good but ranking is noisyYes — re-rankExactly what a cross-encoder fixes: the answer is in the top-N, just not #1.
Questions phrased unlike the docs (jargon vs. plain)Yes — HyDE / multi-queryTransformation closes the vocabulary gap the embedder misses.
Complex multi-part questionsYes — decomposeOne query can't retrieve facts that live in different chunks.
Low-latency, high-QPS, low-stakes (autocomplete, hints)No / cheap onlyExtra passes blow the latency budget; simple top-k is enough.
Recall is the bottleneck (answer never retrieved)Fix retrieval firstRe-ranking can't recover a chunk that isn't in the candidate set.
Measure, don't assumeThe gains here (illustrative numbers aside) are real but corpus-specific. Before shipping a re-ranker, measure precision@k and MRR with and without it on your own questions. If the metric doesn't move, you've added latency for nothing. Evaluation is covered in RAG 3.5.
✓ Knowledge check

Your answer chunk is at rank 8 of a first-pass top-20. Does re-ranking help, and why?

Show answer
Yes. The chunk is already in the candidate set (recall is fine), it's just ranked low. A cross-encoder rescoring the top-20 can lift it toward rank 1 — precision@k is exactly what re-ranking fixes. If instead the chunk were not in the top-20 at all, re-ranking couldn't help and you'd fix retrieval/recall first.
✓ Knowledge check

Why can embedding a hypothetical answer (HyDE) retrieve better than embedding the question?

Show answer
Embeddings match text that looks alike. A document that answers the question is phrased like an answer, not like a question — so an embedded hypothetical answer lands closer to the real answer chunk than the embedded question does. The hypothetical can even be factually wrong and still aim retrieval correctly, because only its phrasing is used to search.

🪜 Practice — from a working toy to a real re-ranking pipeline beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Widen the netBeginner

In rerank.py, raise N from 4 to 5 so the re-ranker sees every chunk. Confirm the actionable chunks still land on top.

Show solution Set N = 5 and re-run. All chunks now enter stage 2; because the re-ranker scores by coverage and phrase match, the two “drain then scale” chunks remain highest — showing re-rank quality doesn't depend on a tight first pass, only on adequate recall.
Exercise 2 · Add a penalty signalIntermediate

Extend rerank_score() to penalize a chunk that just repeats one word (like the keyword-stuffed noise chunk). Re-run and confirm the noise sinks further.

Show solution Add e.g. from collections import Counter then rep = -0.5 if max(Counter(toks(chunk)).values(), default=0) >= 3 else 0.0 and include it in the sum. The noise chunk (three “restart”s) loses points, sharpening the ranking. A cross-encoder learns signals like this — that repetition is not relevance — from data rather than by hand.
Exercise 3 · Simulate HyDEIntermediate

Instead of retrieving with the question, hand-write a one-sentence hypothetical answer, use it as the query in multi_query.py's retrieve(), and compare which chunks come back.

Show solution Use hyp = 'Drain the queue and scale the consumer group to restart checkout.' as the query. It shares more words with the answer chunk than the terse question did, so retrieval targets it more directly — the offline analogue of embedding a hypothetical document.
Exercise 4 · Decompose a compound questionAdvanced

Take “How do I restart checkout and where are invoices stored?”, split it into two sub-questions, retrieve each with retrieve(), and union the results.

Show solution Split into ['how do I restart checkout', 'where are invoices stored'], retrieve for each, and union the chunk indices as in multi_query.py. You'll get both the checkout and the billing/Postgres chunks — neither single query surfaced both.
Exercise 5 · Prove lost-in-the-middle mattersExpert

Modify pack.py to also print a “naive” ordering (best chunk in the dead center) and argue in a comment why the edge-placed ordering is safer.

Show solution Build naive = middle_pad(trimmed) that puts trimmed[0] in the center. Note that a model prone to lost-in-the-middle may skip a centrally-placed key fact; the packer's edge placement keeps the strongest chunks where attention is highest. The effect is published and reproducible.
Exercise 6 · Budget-aware two-stage pipelineProfessional

Wire the three labs into one function: retrieve wide → re-rank the top-N → pack to a token budget. Add a switch to skip re-ranking when a fast=True flag is set, and explain the trade-off.

Show solution Compose retrieve() (large k) → rerank_score sort → pack(). When fast=True, skip the re-rank sort and pack the first-pass order directly. The trade-off: fast saves the cross-encoder latency at the cost of precision — correct for low-stakes, high-QPS paths, wrong for high-stakes answers, matching section 5's table.

Context: Your team's RAG bot has good recall (the answer is almost always in the top-20) but users complain the first answer is often the wrong one — a classic precision problem.

Your task: Write a short design note (6–9 sentences) proposing a two-stage retrieval upgrade and how you'll prove it worked, without over-engineering.

Requirements:

  • Name the two stages: cheap wide retrieval (bi-encoder, large N) then a cross-encoder re-rank of the top-N only.
  • State that a real cross-encoder replaces the toy scorer and the pipeline shape is unchanged.
  • Say which metric you'll move (precision@k / MRR) and that you'll measure with and without re-ranking on your own questions.
  • Note the latency budget — where you'd enable re-ranking and where you'd skip it (tie to section 5).
  • Mention one query-transformation option (multi-query or HyDE) and when you'd add it.

💡 Hint: You don't need code — this is about sequencing recall-then-precision and proving the change with a metric, not adding every technique at once. RAG 3.4 covers making the wide first pass scale.

✓ Checkpoint — you can move on when you can…

  • First-pass retrieval optimizes cheap recall; a second pass fixes precision@k.
  • A cross-encoder scores (query, chunk) jointly — accurate but slow, so run it only on the top-N a fast bi-encoder retrieved.
  • Re-ranking re-orders the candidate set; it can't recover a chunk that recall missed — set N generously.
  • Query transformation attacks the query side: multi-query (union paraphrases), HyDE (embed a hypothetical answer), decomposition (split compound questions).
  • Context packing dedups near-duplicates, budgets by tokens, and places the strongest chunks at the edges to fight lost in the middle.
  • Re-ranking is worth the latency for high-stakes / noisy-ranking cases and wasteful for low-stakes high-QPS paths — measure before shipping.