Re-ranking: Bi-Encoders vs Cross-Encoders
Retrieve fast and approximately with a bi-encoder, then spend more compute reading only the survivors closely with a cross-encoder. Two stages, two very different costs.
Pipeline
query: "python memory management"
the cross-encoder's judgments below are shown as a labelled example of what jointly reading query+document produces — a real one runs a trained model, which cannot run in this page
Cost
Stage 1 — Bi-Encoder Retrieval (cosine)
—Stage 2 — Final Order
Reading It
Overview
The idea in brief
A bi-encoder embeds the query and each document apart, ahead of time — that is what makes the embeddings precomputable and search fast. But it too means the net never looks at a query and a document together. A cross-encoder takes both as one input and lets the net attend across them jointly, at the cost of running a full forward pass per query-document pair, at query time, with nothing precomputable. The precision gap between these two approaches is real and well-measured. On MS MARCO and close benchmarks, a cross-encoder always and much beats a bi-encoder by 5-15 points of nDCG,. This is since joint attention can net fine-grained query-document links (negation, qualification, partial match) that free embeddings compress away. The usual pattern is a two-stage pipeline: the bi-encoder (or BM25) fetches a shortlist of 20-100 candidates cheaply. And the cross-encoder rescores just that shortlist to find the final ranking. This page shows both nets scoring the same query-document pairs side by side, shows where the bi-encoder's ranking disagrees with the cross-encoder's, and covers the latency-precision tradeoff as the shortlist size changes.
Retrieve fast and approximately with a bi-encoder, then spend more compute reading only the survivors closely with a cross-encoder.
Re-ranking: A Practical Guide
Spend the expensive model only on the candidates that survived the cheap one.
The idea in brief
A bi-encoder embeds the query and each document apart, ahead of time — that is what makes the embeddings precomputable and search fast. But it too means the net never looks at a query and a document together. A cross-encoder takes both as one input and lets the net attend across them jointly, at the cost of running a full forward pass per query-document pair, at query time, with nothing precomputable. The precision gap between these two approaches is real and well-measured. On MS MARCO and close benchmarks, a cross-encoder always and much beats a bi-encoder by 5-15 points of nDCG,. This is since joint attention can net fine-grained query-document links (negation, qualification, partial match) that free embeddings compress away. The usual pattern is a two-stage pipeline: the bi-encoder (or BM25) fetches a shortlist of 20-100 candidates cheaply. And the cross-encoder rescores just that shortlist to find the final ranking. This page shows both nets scoring the same query-document pairs side by side, shows where the bi-encoder's ranking disagrees with the cross-encoder's, and covers the latency-precision tradeoff as the shortlist size changes.
The two-stage pattern
Cross-encoders are far more right but cost too much to run over an whole corpus — a million documents means a million forward passes per query. The usual fix is two stages: a cheap bi-encoder (or BM25, or both) fetches a shortlist of maybe 20-100 candidates. And just that shortlist is re-scored by the costly cross-encoder. This then decides the final rank. You pay the cross-encoder's cost dozens of times per query instead of millions of times.
Two ways to score a query against a document
A bi-encoder embeds the query and the document apart. Then checks the two vectors. Since documents can be embedded ahead of time, search is a fast vector check over a precomputed index.
A cross-encoder takes the query and one document together as a single input and outputs a fit score. The net attends across both. So each query term can interact with each document term. And nothing can be precomputed.
bi-encoder: score = cos(embed(q), embed(d))
cross-encoder: score = net(q, d)
That structural gap gives the whole trade-off:
| Bi-encoder | Cross-encoder | |
|---|---|---|
| Documents embedded in advance | Yes | No |
| Query cost | One embedding, then vector search | One model pass per document |
| Scales to millions | Yes | No |
| Accuracy | Good | Substantially better |
| Interaction between q and d | None until the comparison | Full attention across both |
Why the two-stage pipeline exists
A cross-encoder over a million documents means a million net passes per query. Not doable.
A bi-encoder over a million documents is one embedding plus an indexed vector search. Milliseconds.
So the usual setup uses each for what it is good at:
- Retrieve 50–100 candidates with the bi-encoder (and BM25, fused). Fast, broad, some noise.
- Rerank those candidates with the cross-encoder. Costly per thing, affordable on 50.
- Pass the top 3–10 to the net.
The insight is that recall and precision can be separated. Stage one needs high recall — the right document must be somewhere in the 50. Stage two gives precision, sorting them well.
Measured on common benchmarks, adding a cross-encoder reranker much improves nDCG by 10–20% over dense retrieval alone. It is one of the highest-value additions to a RAG pipeline. And it is much skipped.
In code
from sentence_transformers import SentenceTransformer, CrossEncoder
# stage 1: bi-encoder, over the whole index
bi = SentenceTransformer("all-MiniLM-L6-v2")
q_vec = bi.encode([query], normalize_embeddings=True)[0]
candidates = top_k_by_dot_product(q_vec, index, k=50)
# stage 2: cross-encoder, over the 50
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
pairs = [(query, c.text) for c in candidates]
scores = ce.predict(pairs)
ranked = [c for _, c in sorted(zip(scores, candidates), reverse=True)][:5]Note the shapes: the bi-encoder embeds once and checks against a precomputed matrix. The cross-encoder receives 50 (query, document) pairs and returns 50 scores. The second call is where the latency is, and it is batched.
Rough latency for 50 candidates with a small cross-encoder on a GPU: 20–50ms. On a CPU, a few hundred milliseconds — which is why reranker size is a real deployment decision.
Why one vector per document is not enough
The code up shows the two-stage API. What it cannot show is the reason the second stage helps at all -- that a bi-encoder commits to one vector per document before it knows the question. This builds both scorers from scratch on a case where that commitment is just the problem.
A cross-encoder never builds that per-document vector at all. So it never has this normalisation problem in the first place.
Put both scorers over both queries and the disagreement shows up right at the top of the ranking, not just more down.
That precision is just why cross-encoders are not used alone. The next block shows what running one over a whole corpus would cost, and why the two-stage split exists.
Try it yourself
- Read Stage 1. Pure cosine likeness over word overlap. One candidate about python the snake ranks deceptively high — it shares the words "python" and "memory" with the query. And a bi-encoder scoring query and document on its own has no way to notice they mean a thing else in context.
- Turn on the reranker. The final rank changes: the snake document drops sharply. And a truly on-topic document that Stage 1 under-ranked moves up. This is what "reading query and document together" catches that checking two free vectors cannot.
- Turn it back off. The rank reverts to pure Stage 1 — the reranker did not change what was fetched, just the rank the survivors are gave in.
- Read the cost panel. The bi-encoder cost is now paid, before the query ever arrived. The cross-encoder cost is just the count of candidates it re-scores — six forward passes, not a million.
Worth recalling
A bi-encoder scores query and document on its own, which is what makes it fast enough to search a whole corpus but blind to links between them — a lexical coincidence can outrank a genuine match. A cross-encoder reads both together and catches that, at a cost that just scales with the shortlist, not the corpus. Two-stage retrieval — cheap and broad, then costly and narrow — is how live search gets both speed and precision instead of choosing one.
Choosing a reranker
| Option | Character |
|---|---|
| MiniLM cross-encoder | Small, fast, strong baseline |
| Larger cross-encoders | Better, several times slower |
| Multilingual variants | When the corpus is not English |
| ColBERT (late interaction) | Between the two — token-level vectors, precomputable |
| LLM-as-reranker | Prompt a model to score relevance; flexible, expensive |
ColBERT deserves a mention as the genuine middle ground. It stores a vector per token rather than per document, and scores by matching query tokens against document tokens at query time. That gives much of a cross-encoder's fine-grained link while keeping the document side precomputable. At the cost of a far more index.
LLM reranking — asking a language net to rate each candidate's fit, or to rank a list — is flexible and adapts to cues. And it is slow and costly enough that it is much reserved for small candidate sets or offline evaluation.
How many candidates to rerank
The parameter to tune, and it is a straight latency-versus-grade trade.
| Candidates | Effect |
|---|---|
| 10 | Fast, and the reranker can only reorder what stage one already ranked highly |
| 50 | The usual choice — enough room to rescue a mid-ranked document |
| 100–200 | Better recall, noticeably slower |
The count to measure is stage one's recall@n: if recall@50 is 0.95 and recall@100 is 0.96, reranking 100 buys almost nothing. If recall@50 is 0.7, the bottleneck is retrieval, not ranking, and fixing chunking or adding hybrid search matters more than a bigger reranker.
That diagnostic sorting is worth stating plainly: reranking cannot recover a document that was never fetched.
Questions people ask
Do I need a reranker? If retrieval grade limits your system and latency lets 20–50ms, it is one of the best available improvements.
Can a cross-encoder replace the bi-encoder? Just on a quite small corpus. It cannot be indexed.
How many candidates should I rerank? 50 is the usual starting point. Tune from measured recall.
Is an LLM a good reranker? Effective and costly. Use a purpose-trained cross-encoder for live paths.
Does reranking help with the "lost in the middle" problem? Yes. It puts the most apt chunk first, where the net attends to it most.
Can I fine-tune a reranker? Yes, on (query, apt, irrelevant) triples from your own data. It much beats a general net by a wide margin on a set domain.
Recap in one screen
- A bi-encoder embeds query and document apart, so documents can be indexed and search is fast.
- A cross-encoder reads both together, which is far more right and cannot be precomputed.
- Use both: broad cheap retrieval for recall, then reranking for precision.
- Rerank around 50 candidates. Measure stage-one recall to know whether that is the bottleneck.
- Reranking cannot rescue a document that retrieval never gave.