The Right Passage at Rank 37
The assistant answered "the plan covers physical therapy in general" when the customer asked whether Restore Plus covers physical therapy after knee surgery. The paragraph that answers it — Post-operative PT requires a referral and is uncapped — sat at rank 37 in the vector index. Retrieval asked for ten, so generation never saw it.
Nothing was broken in the usual sense. A bi-encoder embeds the query and each passage separately and compares the two vectors afterwards:
bi-encoder (stage 1) cross-encoder (stage 2)
q --> [encoder] --> v_q (query, passage) --> [transformer] --> logit
p --> [encoder] --> v_p
score = cos(v_q, v_p) every query token attends to every passage token
embed once, search millions one forward pass per pair
The two texts never meet in stage 1, so lexical proximity does all the ranking work it can: the plan overview mentions coverage, plan, and therapy three times each and wins, while the clause says post-operative where the query says after knee surgery. The passage was recalled — it is in the candidate list — but ordered below nine passages that talk around it.
Recall and precision are separate jobs, and they are handed to separate stages:
- Stage 1 must get the target inside the candidate set. If the clause sits at rank 37 and the set holds 10, no amount of rescoring helps.
- Stage 2 must put it at the top. A cross-encoder reads query and passage jointly, so "post-operative PT requires a referral" and "after knee surgery" can agree — relevance computed with both texts in view.
Reranking is stage 2. It reorders candidates stage 1 produced, and its quality ceiling is exactly the recall of the list it was given.
The Rerank Loop
The loop is short: build pairs, predict one score per pair, sort descending, take the top of the list.
from sentence_transformers import CrossEncoder
ranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank(query: str, passages: list[str], top: int = 5) -> list[str]:
scores = ranker.predict([(query, p) for p in passages]) # one forward pass per pair
order = sorted(range(len(passages)), key=lambda i: scores[i], reverse=True)
return [passages[i] for i in order[:top]]
The model returns raw logits — unbounded numbers whose scale means nothing on its own. Put them on a comparable footing before anything downstream reads them:
import math
logits = [-2.41, 1.18, -0.05, -3.90, 0.64] # candidates arrive in stage-1 order c0..c4
def softmax(xs):
peak = max(xs)
exps = [math.exp(x - peak) for x in xs] # subtracting the peak keeps exp() from overflowing
total = sum(exps)
return [e / total for e in exps]
probs = softmax(logits)
for i in sorted(range(len(logits)), key=logits.__getitem__, reverse=True):
print(i, logits[i], round(probs[i], 3))
Output:
1 1.18 0.524
4 0.64 0.305
2 -0.05 0.153
0 -2.41 0.014
3 -3.9 0.003
The stage-1 order is not preserved: c1 takes the top slot, and c4 — last in the first-stage list — moves to second. That reversal is the entire product of the second stage.
Two things follow from the output. peak = max(xs) is subtraction for numerical safety, not style: without it, math.exp(1.18 - (-3.90)) on a larger logit set overflows to inf and every normalized score becomes nan. And the normalized column is the only one a threshold may read — raw logit scale shifts with the model, the layer, and the query itself, so "keep passages scoring above 0.5" is meaningless until the numbers share a range. Even then, calibrate the cutoff against labeled pairs from your own corpus rather than trusting the default.
Budgeting the Second Stage
Reranking buys precision with time, and the trade is only worth stating with numbers:
| Stage | Scores by | Candidates | Added latency | What it fixes |
|---|---|---|---|---|
| Bi-encoder + ANN | cosine between separate embeddings | top-k of 1M+ | ~5-20 ms | recall: the target is somewhere in the list |
| Cross-encoder rerank | joint attention over (query, passage) | top 20-50 | +50-200 ms | precision: the target moves to the top |
| Full-corpus cross-encode | joint, per document | every row | seconds to minutes | never acceptable on a request path |
The depth of the candidate set is a measurement, not a convention. The failure in the first chapter — target at rank 37 — means a top-10 reranker would have been paid for and useless: it can only reshuffle the ten it was handed. Run your own labeled queries, compute recall@k for the values of k you are considering, and pick the smallest k where recall clears roughly 0.95. Everything past that point is latency spent re-scoring passages the first stage was already getting right.
Cost grows linearly in candidates, and each unit is a forward pass over the concatenated pair — which is why stage 1 exists at all, and why the reranker is capped rather than merely limited.
Failure modes worth naming in a design review:
- Reranking too shallow — top-10 with
recall@10at 0.70; the fix cannot see the target. - Thresholds on raw logits — scale drifts per model and per query; normalize, then calibrate on labeled pairs.
- Passage truncation — the cross-encoder's maximum length cuts the clause that mattered; check tokenized length before trusting a low score.
- Latency stacked, never summed — rewrite + retrieve + rerank each look fine alone; measure the total against the budget (Latency Profiling).
- Domain mismatch — a reranker trained on web relevance labels applies its own idea of "relevant" to legal or medical text; re-evaluate on your own pairs before blaming the retriever.
Quiz
1. The relevant clause is at rank 37 and the reranker reorders only the top 10. What is the correct diagnosis?
2. Why normalize cross-encoder logits before applying a threshold?
3. Why does the cross-encoder run on the top 20-50 candidates rather than the collection?
4. A reranker that improved web-search relevance lowers precision on your legal contracts. What is the diagnosis?
Flashcards
Question
Bi-encoder versus cross-encoder
Click to reveal answer
Answer
A bi-encoder embeds query and passage independently and compares vectors afterwards — cheap, searchable at scale, imprecise. A cross-encoder reads both texts jointly through one transformer — far more accurate, one forward pass per pair, so it can only run on a small candidate set.
Question
Why rerank only the top 20-50?
Click to reveal answer
Answer
Cost is linear in candidates and each unit is a forward pass over the concatenated pair. The depth itself is measured: pick the smallest k where recall@k on your own labeled queries clears about 0.95.
Question
What do reranker logits need before a threshold?
Click to reveal answer
Answer
Normalization. Raw logit scale shifts with the model and the query, so a fixed cutoff is meaningless; softmax puts scores on one range, and the cutoff itself must still be calibrated on labeled pairs from your corpus.
Question
What latency does a rerank stage add?
Click to reveal answer
Answer
Roughly 50-200 ms for 20-50 candidates depending on model size and passage length — a second-stage cost that must be summed with rewrite and retrieval time, not evaluated alone.
Question
What is the reranker's quality ceiling?
Click to reveal answer
Answer
The recall of the candidate list it receives. A passage outside the top-k is never scored, so a perfect reranker cannot recover a target the first stage did not retrieve.
Revision Notes
Key Takeaways
- 1. A bi-encoder never lets the query and passage meet, which is why lexically similar noise outranks the passage that actually answers
- 2. Reranking reorders the candidate list it is given — its ceiling is stage 1's recall, so k comes from a measured recall@k
- 3. Normalize logits before thresholding; the raw scale belongs to the model and the query, not to a calibrated scale
- 4. Budget the second stage explicitly: 50-200 ms on top of retrieval, measured as a sum rather than in isolation
Interview Tips
- • Draw the two-architecture diagram — two towers versus one joint pass — and tie each to the job it does: recall versus precision
- • Say where k comes from and refuse the default: measure recall@k on your own queries, then justify the latency
- • Explain why thresholds need normalized, calibrated scores, and name what raw logits actually vary with
- • Handle the follow-up honestly: what a reranker cannot fix when the target never entered the candidate set
Cheat Sheet
Reranking with Cross-Encoders — Cheat Sheet
Two stages, two jobs
stage 1 bi-encoder + ANN recall -> get the target into the candidate set (top 20-50)
stage 2 cross-encoder precision -> move it to the top, score computed jointly
Loop
scores = ranker.predict([(query, p) for p in passages]) # one forward pass per pair
order = sorted(range(len(passages)), key=lambda i: scores[i], reverse=True)
Normalization (before any threshold)
peak = max(logits); exps = [math.exp(x - peak) for x in logits]
probs = [e / sum(exps) for e in exps] # peak subtraction prevents exp() overflow
| Stage | Candidates | Added latency | Role |
|---|---|---|---|
| bi-encoder + ANN | top-k of millions | ~5-20 ms | recall |
| cross-encoder | top 20-50 | +50-200 ms | precision |
| full-corpus cross-encode | all | seconds-minutes | never on a request path |
Choose k: smallest depth where recall@k >= ~0.95 on your own labeled queries.
Failure modes: shallow k below the target's rank · thresholds on raw logits · passage truncation at the model's max length · latencies summed late · a reranker whose training labels do not match your domain.
Next: two ranked lists that must be merged — Fusing Multiple Retrievers.
Continue Learning
Before You Start
- No prerequisites listed.