Reranking
Retrieval's job is to get the answer somewhere into the top 50. Reranking's job is to get it into the top 3. They're different problems, solved by different models, and knowing how they split the work lets you build search that finds the answer and puts it where the model will read it.
Helpline's answer was at rank 22
Helpline's support assistant kept giving vague answers, even though the right article was usually retrieved. Recall@50 was 0.95, but recall@3 was only 0.60: the right chunk usually landed somewhere between rank 12 and 30, below the few the model reads.
They added a cross-encoder reranker over the top 50 candidates. Recall@3 rose to 0.88. Recall@50 stayed at 0.95, as expected, since reranking only changes the order of what retrieval found.
Suppose hybrid retrieval returns 50 candidates with recall@50 of 0.94. The answer is almost always in there, but you can only afford to pass the top 3 to 5 to the model because of cost, latency and lost-in-the-middle. If the best chunk is at rank 22, the model never sees it. A reranker scores each candidate against the query again, more carefully, and reorders the list.
Bi-encoders and cross-encoders
Your retriever is a bi-encoder: it embeds the query and each document separately and compares the vectors. That's what makes it fast, since document vectors are computed once in advance. The model never sees the query and the document together, though, so it can't judge how well one answers the other.
A cross-encoder reads the query and one document together and returns a single relevance score. It can tell whether the document answers the question or just shares its topic. It has to run once per candidate, so it's far too slow to use on a whole corpus. The standard design follows from that: retrieve widely with a fast bi-encoder (top 50 to 100), then rerank that short list with a cross-encoder and pass the top 3 to 5 on.
Measuring rank as well as presence
Recall@k is yes or no: is the gold chunk in the top k? Reranking cares about position inside the list, so you need metrics that notice rank. MRR (mean reciprocal rank) averages 1 divided by the rank of the first relevant result, so it rewards getting the answer to position 1. nDCG also rewards ranking several relevant results highly.
| Setup | recall@50 | recall@3 | MRR |
|---|---|---|---|
| Hybrid retrieval only | 0.94 | 0.61 | 0.48 |
| Hybrid + cross-encoder | 0.94 | 0.88 | 0.79 |
Recall@50 doesn't move, because reranking never adds anything retrieval missed. Recall@3 jumps from 0.61 to 0.88: 27 points of questions whose answer now reaches the model, with no change to retrieval. That's why a reranker is often the most valuable single addition to a working retriever.
Add a reranker and measure the lift
Start from retrieved lists where the gold document is present but in the middle. Apply a simulated cross-encoder that rewards documents that answer the query over ones that only share its topic, and measure recall@3 and MRR before and after. Then explain in one sentence why reranking can't fix a retrieval miss.
Starter code
# Each query: the retriever's ordered candidate list (doc dicts) and the gold doc id.
# 'answers' = True means the doc actually answers the query (what a cross-encoder detects);
# 'topical' = shared-topic score the bi-encoder over-weights (why gold sits mid-rank).
QUERIES = [
{"gold": "d1", "cands": [
{"id":"d9","answers":False,"topical":0.9}, {"id":"d4","answers":False,"topical":0.8},
{"id":"d1","answers":True, "topical":0.5}, {"id":"d7","answers":False,"topical":0.4}]},
{"gold": "d2", "cands": [
{"id":"d5","answers":False,"topical":0.85},{"id":"d2","answers":True,"topical":0.6},
{"id":"d8","answers":False,"topical":0.55},{"id":"d3","answers":False,"topical":0.3}]},
{"gold": "d6", "cands": [
{"id":"d6","answers":True,"topical":0.7}, {"id":"d0","answers":False,"topical":0.65},
{"id":"d1","answers":False,"topical":0.5}, {"id":"d9","answers":False,"topical":0.2}]},
]
def rank_of_gold(order, gold):
return next(i+1 for i, c in enumerate(order) if c["id"] == gold)
def recall_at(orders, k):
return sum(rank_of_gold(o, g) <= k for o, g in orders) / len(orders)
def mrr(orders):
return sum(1.0 / rank_of_gold(o, g) for o, g in orders) / len(orders)
# cross-encoder: reads query+doc jointly, so it *knows* which doc answers.
def cross_encoder_score(c):
return (1.0 if c["answers"] else 0.0) + 0.1 * c["topical"]
# TODO: build 'before' orders (as given) and 'after' orders (sorted by cross_encoder_score desc),
# then print recall@3 and MRR for each. Pair each order with its gold id.
before = [(q["cands"], q["gold"]) for q in QUERIES]
after = [(sorted(q["cands"], key=cross_encoder_score, reverse=True), q["gold"]) for q in QUERIES]
for name, orders in (("before", before), ("after", after)):
print(f"{name}: recall@3={recall_at(orders,3):.2f} MRR={mrr(orders):.2f}")
You should see recall@3 go from 0.67 to 1.00 and MRR from 0.50 to 1.00. Every gold document was already in the list; the bi-encoder had just placed on-topic but wrong documents above it. The one-sentence answer: reranking can only reorder what retrieval found, so if the gold document isn't in the candidates, no reranker can help. Tune retrieval for recall first, then rerank for precision.
To see that for yourself, add a query whose gold document is missing from the candidate list. Recall@3 stays stuck however good the reranker is.
Acme reranks the top 50 hybrid candidates with a cross-encoder. MRR climbs from 0.34 to 0.61, so the right doc now tends to land in the first one or two chunks the model reads.
The difference between bi-encoders and cross-encoders is a favorite interview topic because it tests whether you understand cost as well as accuracy. Explain it in terms of when each model sees the query and the document, and why that makes one fast and the other accurate.
MRR is easy to compute by hand. Practice on a list of three or four queries so you can do it on a whiteboard without hesitating.
If users say answers are vague or miss the point, even though the team insists the right documents are being found, ranking is a likely cause. Ask for two numbers: how often the answer is anywhere in the retrieved set, and how often it's in the top few passed to the model. A big gap between them points to a reranker, which is a contained, affordable change.
If the answer often isn't retrieved at all, a reranker won't help, and the money should go to retrieval first.
Quiz · Chapter 6
- After adding a reranker, recall@50 is unchanged and recall@3 rises from 0.61 to 0.88. Why didn't recall@50 move?
- Why can't the more accurate cross-encoder replace the retriever?
- Your reranker is excellent, but gold documents are often missing from the top 50. What's the fix?
- Why does MRR show a reranker's value better than recall@50?
- Why is "retrieve wide, rerank narrow" the standard design?