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.
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.
| Metric | What it asks | Why it matters for RAG |
|---|---|---|
| Recall@k | Of 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@k | Of 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. |
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.
| Bi-encoder (retrieve) | Cross-encoder (re-rank) | |
|---|---|---|
| Input | query and chunk embedded separately | query + chunk together, as one input |
| Precompute? | yes — chunk vectors stored once | no — must run per (query, chunk) pair |
| Speed | fast; scales to millions | slow; run on the top-N only (e.g. 25–100) |
| Accuracy | good recall, rough ranking | sharp 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.
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.
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:
| Technique | Idea | Best when… |
|---|---|---|
| Multi-query | Ask 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). |
| Decomposition | Break a complex, multi-part question into sub-questions, retrieve for each, then combine. | the question needs facts from several different chunks to answer. |
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.
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:
| Force | What it means | The fix |
|---|---|---|
| Lost in the middle | A 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-duplicates | Retrieval often returns the same fact phrased twice (overlapping chunks, repeated docs). | Dedup before packing — duplicates waste budget and add no information. |
| Token budget | The 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.
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.
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:
| Situation | Re-rank / transform? | Why |
|---|---|---|
| High-stakes answers (support, legal, medical) | Yes | A wrong top chunk is expensive; precision matters more than a few hundred ms. |
| First-pass recall is already good but ranking is noisy | Yes — re-rank | Exactly 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-query | Transformation closes the vocabulary gap the embedder misses. |
| Complex multi-part questions | Yes — decompose | One query can't retrieve facts that live in different chunks. |
| Low-latency, high-QPS, low-stakes (autocomplete, hints) | No / cheap only | Extra passes blow the latency budget; simple top-k is enough. |
| Recall is the bottleneck (answer never retrieved) | Fix retrieval first | Re-ranking can't recover a chunk that isn't in the candidate set. |
Your answer chunk is at rank 8 of a first-pass top-20. Does re-ranking help, and why?
Show answer
Why can embedding a hypothetical answer (HyDE) retrieve better than embedding the question?
Show answer
🪜 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.
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
SetN = 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.
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.
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
Usehyp = '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.
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.
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
Buildnaive = 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.
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
Composeretrieve() (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.