AI EngineeringZero to ProductionHome·About·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.

1 · Why the first pass isn't enough

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

A bi-encoder (what first-pass vector search uses) embeds the query and each chunk separately, then compares the two vectors. It's fast because chunk vectors are precomputed — but the model never sees the query and the chunk together. A cross-encoder does the opposite: it feeds the pair (query, chunk) through the model jointly and outputs a single relevance score. That joint view is far more accurate, but it can't be precomputed and is far slower — so you only run it on the top-N candidates the bi-encoder already found.

Query Bi-encoder retrieve top-N cheap, wide net Cross-encoder rescore pairs careful, on N only Re-ordered top-k best chunk first
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

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.

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

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

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.