Module 6 — Reranking retrieved passages
Module 5 handed us a shortlist of 20 to 40 candidates from hybrid search. Some of them genuinely answer the question, some are close in embedding space but off-topic, some duplicate each other. This module tightens that shortlist to the 4 to 8 passages that will actually go into the prompt. Reranking is the least glamorous stage of the pipeline and, on many corpora, the one with the highest cost-to-benefit ratio.
Bi-encoder versus cross-encoder in one picture
The retrievers we built in modules 4 and 5 are bi-encoders. The query and each candidate passage are embedded independently, and their vectors are compared. This is why the passage vectors can be precomputed and stored — the query is the only thing to encode at search time. It is fast, and it approximates.
A cross-encoder feeds the query and one candidate passage into the model together, and outputs a single scalar: how well this passage answers this query. There is no way to precompute anything because the score depends on both sides at once. It is slow — one model call per candidate — and much more accurate.
| Bi-encoder | Cross-encoder | |
|---|---|---|
| Interaction | Query and passage encoded separately | Query and passage encoded together |
| Latency for one query on 20 candidates | ~5 ms | ~500 ms on CPU, ~50 ms on GPU |
| Precomputable | Yes (passage side) | No |
| Accuracy on the top-5 | Baseline | +10 to +25 points nDCG |
The natural division of labour: bi-encoders scan the whole corpus, cross-encoders rescore a small shortlist. Trying to skip the bi-encoder stage and cross-encode the entire index is a common beginner temptation and is infeasible at any real scale — half a million candidates times half a second is 70 hours per query.
A cross-encoder in ten lines
from sentence_transformers import CrossEncoder
reranker = CrossEncoder(
"BAAI/bge-reranker-v2-m3",
max_length=512,
)
def rerank(question: str, candidates: list[dict], keep: int = 6) -> list[dict]:
pairs = [(question, c["text"]) for c in candidates]
scores = reranker.predict(pairs, batch_size=32)
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [c for c, _ in ranked[:keep]]
Three practical points hide in that snippet.
max_length=512 is not an accident. A cross-encoder is a transformer, and its cost scales quadratically with input length. Passages longer than the model's window are truncated silently — set the length yourself, be explicit, and truncate the passage rather than the question.
Batch size matters a lot. On a laptop CPU, batch_size=1 reranks 20 candidates in around 3 seconds; batch_size=32 does it in around 0.5 second. On a GPU, higher batch sizes bring another factor of five.
BAAI/bge-reranker-v2-m3 is a good multilingual default in the Apache-licensed open ecosystem, as of writing. cohere/rerank-v3 and voyage-rerank-2 are hosted alternatives that are stronger in absolute terms but send passages off-premise — which for the red thread is the wrong trade-off.
How many passages to keep?
This is a real hyperparameter, not a rule. Two forces pull in opposite directions.
Fewer passages, more focus. A prompt with three sharp passages gives the model less room to hallucinate a compromise between contradictory sources. Faithfulness — the metric of module 8 — improves.
More passages, more coverage. A question whose answer sits in a paragraph the reranker slightly mis-scored is lost forever when keep=3. Recall improves with a higher keep.
On the red thread, empirically:
keep | Faithfulness (auto-judged) | Recall of gold passage | Prompt tokens |
|---|---|---|---|
| 3 | 0.82 | 0.71 | ~800 |
| 5 | 0.86 | 0.83 | ~1400 |
| 8 | 0.83 | 0.90 | ~2300 |
| 12 | 0.77 | 0.93 | ~3400 |
The sweet spot for this corpus is around 5. It will be different on yours. Module 8 gives the evaluation set-up to find it in an afternoon rather than guess it.
Source diversity and the redundancy trap
Hybrid retrieval frequently brings back three overlapping chunks from the same document — the paragraph, its neighbour, and the version with a slightly reworded sentence. A cross-encoder rescores them all high, because they all answer the query. The reranked top 5 becomes five almost-identical passages, and the model, seeing one point of view repeated five times, sounds much more confident than it should.
Maximum Marginal Relevance (MMR) is a cheap fix: at each step, pick the passage that maximises a weighted sum of "relevance to the query" and "distance from what has been picked so far".
import numpy as np
def mmr(question: str, candidates: list[dict], embeddings: np.ndarray,
query_vec: np.ndarray, keep: int = 6, lambda_: float = 0.7) -> list[dict]:
selected, remaining = [], list(range(len(candidates)))
sim_to_query = embeddings @ query_vec
while len(selected) < keep and remaining:
if not selected:
i = int(max(remaining, key=lambda j: sim_to_query[j]))
else:
sel_emb = embeddings[selected]
i = int(max(
remaining,
key=lambda j: lambda_ * sim_to_query[j]
- (1 - lambda_) * float(np.max(sel_emb @ embeddings[j])),
))
selected.append(i)
remaining.remove(i)
return [candidates[i] for i in selected]
lambda_ = 0.7 gives 70 % weight to relevance and 30 % to diversity — a good starting point. Lower it toward 0.5 on corpora with many near-duplicates, raise it toward 0.9 on corpora where every document says something different.
Cap the number of passages coming from any single source document — two is usually plenty. This is a hard constraint that costs nothing:
def cap_per_source(passages: list[dict], max_per_source: int = 2) -> list[dict]:
counts, kept = {}, []
for p in passages:
src = p["source_name"]
if counts.get(src, 0) < max_per_source:
kept.append(p)
counts[src] = counts.get(src, 0) + 1
return kept
The complete retrieval pipeline
Wiring modules 4, 5 and 6 together:
def retrieve_and_rerank(question: str, user_context: dict,
first_pass_k: int = 40, rerank_keep: int = 5) -> list[dict]:
dense = dense_search(question, user_context, k=first_pass_k)
lex = bm25_search(question, k=first_pass_k)
fused_ids = [cid for cid, _ in rrf(
[[c["chunk_id"] for c in dense],
[c["chunk_id"] for c in lex]]
)][: 2 * rerank_keep + 6]
candidates = [chunk_by_id(cid) for cid in fused_ids]
reranked = rerank(question, candidates, keep=rerank_keep + 2)
return cap_per_source(reranked, max_per_source=2)[:rerank_keep]
The 2 * rerank_keep + 6 is a small over-fetch, and rerank_keep + 2 gives the source-cap step room to drop duplicates without falling below the target. These little slacks are what make the pipeline robust in practice.
On some corpora — very clean, well-indexed, uniform in style — reranking adds only a point or two of quality for its latency cost. Measure it. If it does not move the metrics of module 8, drop it and keep the pipeline simpler.
The scalar a cross-encoder outputs is a ranking signal, not a calibrated confidence. Two questions with different absolute scores are not comparable, and a threshold learnt on one corpus rarely transfers. Filter by rank, not by score.
In summary
- Bi-encoders compare precomputed vectors and scale to the whole index; cross-encoders feed query and passage together and score much more accurately but only fit a shortlist.
- Set
max_lengthandbatch_sizeexplicitly, use a multilingual open reranker for internal corpora, and treat how many passages to keep as a hyperparameter measured on your set. - Apply MMR and a per-source cap to avoid handing the model five near-identical passages that inflate its apparent confidence.
- Reranking is a knob with a real latency cost; if it does not move the module-8 metrics on your corpus, drop it.
Next module: taking these 4 to 8 reranked passages and turning them into a prompt that instructs the model, feeds it the context in the right order, and produces citations users can verify.