Skip to content
advanced Phase 8 · Chunking and Retrieval Strategies

Reranking with Cross-Encoders

Re-score top candidates with a reranker for precision beyond first-stage retrieval.

55m
0 problems
Topic Progress 0%

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:

  1. 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.
  2. 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@10 at 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?

Question 1 options

2. Why normalize cross-encoder logits before applying a threshold?

Question 2 options

3. Why does the cross-encoder run on the top 20-50 candidates rather than the collection?

Question 3 options

4. A reranker that improved web-search relevance lowers precision on your legal contracts. What is the diagnosis?

Question 4 options

Flashcards

Question

Bi-encoder versus cross-encoder

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?

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?

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?

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?

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.

Related Reading