Reranking
By the Syncfusion DevTutorials team · Last updated
Two-stage retrieval and rerankers: high-recall first stage, high-precision second stage, pool sizing and latency, MMR diversity, cross-encoders vs bi-encoders, joint attention, reranker models and score normalization.
Hiring for a job usually happens in two rounds. First, someone quickly reviews hundreds of CVs and selects the applicants who look most promising. Then an interviewer spends more time with those selected candidates and chooses the best few. Interviewing every applicant would take too long, but choosing people based only on a quick scan would not be very accurate.
Two-stage retrieval works in the same way. A fast first stage finds a broad set of possible matches. Then a slower, smarter reranker looks at those matches more carefully and puts them in a better order.
The main idea: Stage 1 (vector search, keyword search, or hybrid search) focuses on high recall, which means getting the correct chunks somewhere into a pool of candidates, usually 20–100 chunks. Stage 2 uses a reranker, often a cross-encoder, to read the question and each candidate chunk together and score how well they match. This improves precision, meaning the most relevant chunks move to the top. You can adjust the pool size to balance accuracy, latency, and cost. You can also normalise scores and keep the final results diverse instead of returning several nearly identical chunks.
A Real-World Example
A Real-World Example
Brightgrid's gold set revealed an important pattern. For 90% of questions, the correct chunk appeared somewhere in the top 20 results returned by hybrid search. However, it appeared in the top 3 results only 70% of the time, and those top 3 chunks were the ones sent to the model.
For the question, "Is water damage covered by the warranty?", the top 3 results were the warranty overview, the safety article, and a general returns article. The chunk that actually stated "Damage from water … is not covered" was ranked 7th.
The team added a reranker. Hybrid search first returned the top 30 candidates. A small cross-encoder then rescored those 30 candidates, and only the best 3 were sent to the model.
The correct chunk moved to rank 1. Top-3 accuracy increased from 70% to 86%, while adding only about 120 ms of extra latency.
In simple words: search widely first, then examine the results carefully.
What You Will Learn
In this lesson, you will learn:
- Two-stage architecture: fast broad retrieval, then deep reranking.
- Trade-offs: latency vs accuracy, candidate pool size, cost and throughput.
- Pool management: diversity controls (MMR) and over-retrieval.
- Cross-encoders: joint query-chunk attention, bi-encoder vs cross-encoder, reranker models.
- The reranking pipeline: rescoring, score normalization and final ranking.
In the RAG Lab, turn on Rerank (two-stage) in Retrieval settings. The lab then takes a bigger pool (at least 10) from the first stage and rescores it with a simple Tier 1 reranker that reads the question and each chunk together and rewards word coverage and closeness. Real systems use a cross-encoder model for this step. The idea and the pipeline are the same.
Two-stage retrieval: a fast first stage narrows thousands of chunks to a pool of 20–100 with high recall, then a cross-encoder reranker reads each question-chunk pair and picks the best few with high precision.
Two-Stage Architecture
Stage 1: Fast Broad Retrieval (High Recall)
The first stage must search the entire index quickly. To do that, it uses methods such as vector search, BM25, or hybrid search, which rely on pre-computed representations.
Its goal is not to produce the perfect top result. Instead, its goal is to make sure the correct chunks are included somewhere in the candidate pool.
This stage is usually measured using recall@pool, which tells you how often the correct chunk appears anywhere within the top N retrieved chunks.
Stage 2: Deep Candidate Reranking (High Precision)
The second stage only works with the candidate pool returned by stage 1. Because the pool is much smaller, it can spend more time analysing each candidate.
The reranker reads both the question and a candidate chunk together and produces a new relevance score. It then reorders the candidates based on those scores.
This stage is often measured using precision@k or hit@k, where k is the number of chunks ultimately sent to the model.
Stage 1 finds them, stage 2 ranks them
// Two-stage retrieval: a fast first stage keeps many candidates (high recall),
// a careful second stage reorders them (high precision).
const relevant = new Set(["warranty#1", "warranty#3"]);
const stage1 = [
"cancel-order#1",
"glow-strip-setup#1",
"brightgrid-plus#1",
"warranty#3",
"returns-policy#2",
"safety#2",
"warranty#1",
"energy-use#1",
];
// Stand-in for a reranker's scores: it reads question and chunk together, so it's more accurate.
const rerankScore = {
"warranty#1": 0.94,
"warranty#3": 0.9,
"returns-policy#2": 0.41,
"cancel-order#1": 0.22,
"safety#2": 0.2,
"brightgrid-plus#1": 0.05,
"glow-strip-setup#1": 0.04,
"energy-use#1": 0.03,
};
const stage2 = [...stage1].sort((a, b) => rerankScore[b] - rerankScore[a]);
const precisionAt = (list, k) =>
list.slice(0, k).filter((id) => relevant.has(id)).length / k;
const recallAt = (list, k) =>
list.slice(0, k).filter((id) => relevant.has(id)).length / relevant.size;
console.log(
`stage 1 top 3: ${stage1.slice(0, 3).join(", ")} precision@3 ${precisionAt(stage1, 3).toFixed(2)}`,
);
console.log(
`stage 1 top 8 recall@8: ${recallAt(stage1, 8).toFixed(2)} (both relevant chunks are somewhere in the pool)`,
);
console.log(
`stage 2 top 3: ${stage2.slice(0, 3).join(", ")} precision@3 ${precisionAt(stage2, 3).toFixed(2)}`,
);
Expected output:
stage 1 top 3: cancel-order#1, glow-strip-setup#1, brightgrid-plus#1 precision@3 0.00
stage 1 top 8 recall@8: 1.00 (both relevant chunks are somewhere in the pool)
stage 2 top 3: warranty#1, warranty#3, returns-policy#2 precision@3 0.67
Stage 1's top 3 contained neither relevant chunk, but its pool of 8 held both. Stage 2 moved them to the top.
Tradeoff Optimization
Latency vs Accuracy Balancing
A reranker adds extra processing time because it must evaluate each candidate individually. Small cross-encoders running on a CPU may take a few milliseconds per question-chunk pair. Larger models running on a GPU may provide better accuracy but require additional resources.
When designing a system, set a latency budget for the entire response and decide how much of that budget reranking can use.
Candidate Pool Sizing
A bigger pool raises the chance the right chunk is in it, but recall gains shrink while reranking time keeps growing:
Recall and time for different pool sizes
// Sizing the candidate pool: a bigger pool finds more, but reranking costs time per candidate.
const rerankMsPerCandidate = 4; // a small cross-encoder on a CPU
const recallAtPool = { 10: 0.82, 20: 0.9, 50: 0.96, 100: 0.98, 200: 0.985 }; // measured on a gold set
console.log("pool recall rerank time");
for (const [pool, recall] of Object.entries(recallAtPool)) {
const ms = Number(pool) * rerankMsPerCandidate;
console.log(
`${pool.padStart(4)} ${recall.toFixed(3)} ${String(ms).padStart(6)} ms${ms > 250 ? " (over a 250 ms budget)" : ""}`,
);
}
console.log(
"Here a pool of about 50 is the sweet spot: most of the recall, inside the budget.",
);
Expected output:
pool recall rerank time
10 0.820 40 ms
20 0.900 80 ms
50 0.960 200 ms
100 0.980 400 ms (over a 250 ms budget)
200 0.985 800 ms (over a 250 ms budget)
Here a pool of about 50 is the sweet spot: most of the recall, inside the budget.
Measure recall@pool on your gold set, and pick the smallest pool that gets most of the available recall within your budget. 20–100 is typical.
Compute Cost Allocation across Stages
Spend computing resources where they provide the most benefit. In many systems, this means using a fast and scalable first stage and a stronger reranker on a much smaller pool.
Hosted reranking services are often priced according to the number of candidates processed, so unnecessarily large pools can increase costs.
Throughput Scaling in Two-Stage Search
Throughput means the number of questions a system can process each second.
Rerankers are often the bottleneck. Common strategies for improving throughput include:
Batching multiple candidate pairs into a single reranker call. Caching previously computed reranking results. Skipping reranking when the first-stage results already show strong confidence.
Candidate Pool Management
Over-Retrieval Sizing Strategies
Over-retrieval means retrieving more candidates than you plan to keep. For example, you might retrieve 30 candidates but only send the best 3 to the model.
This gives the reranker room to improve the ranking and helps compensate for filtering or deduplication later in the pipeline.
A common starting point is:
Pool size = k × 5 to k × 10
Then adjust it using results from your gold set.
Candidate Pool Diversity Controls
A reranker scores each candidate independently. Because of this, very similar chunks often receive very similar scores and may dominate the final results.
MMR (Maximal Marginal Relevance) helps solve this problem. It selects results one at a time, balancing:
Relevance to the question. Difference from already selected results.
A parameter called lambda controls the balance:
1.0 = relevance only. Lower values = more diversity.
MMR picks relevant and different chunks
// MMR (Maximal Marginal Relevance): pick results that are relevant AND different from
// what's already picked, so the top 3 isn't three copies of one fact.
const cands = [
{ id: "reset-password#1", rel: 0.9, words: ["forgot", "password", "email", "link"] },
{ id: "forgot-password#1", rel: 0.89, words: ["forgot", "password", "email", "link"] },
{ id: "two-step#2", rel: 0.7, words: ["backup", "codes", "phone"] },
{ id: "reset-password#2", rel: 0.6, words: ["spam", "folder", "email"] },
];
const sim = (a, b) =>
a.words.filter((w) => b.words.includes(w)).length /
Math.max(a.words.length, b.words.length);
function mmr(list, k, lambda) {
const picked = [];
const rest = [...list];
while (picked.length < k && rest.length) {
let best = 0;
let bestScore = -Infinity;
rest.forEach((c, i) => {
const redundancy = picked.length ? Math.max(...picked.map((p) => sim(c, p))) : 0;
const s = lambda * c.rel - (1 - lambda) * redundancy;
if (s > bestScore) {
bestScore = s;
best = i;
}
});
picked.push(rest.splice(best, 1)[0]);
}
return picked.map((c) => c.id).join(", ");
}
console.log("relevance only (lambda 1.0):", mmr(cands, 3, 1));
console.log("MMR (lambda 0.7):", mmr(cands, 3, 0.7));
Expected output:
relevance only (lambda 1.0): reset-password#1, forgot-password#1, two-step#2
MMR (lambda 0.7): reset-password#1, two-step#2, reset-password#2
Cross-Encoder Mechanics
Bi-Encoder vs Cross-Encoder Tradeoffs
A bi-encoder converts the question and each chunk into vectors separately. Chunk vectors are generated ahead of time and stored in the index, making retrieval fast.
However, the question and chunk never directly interact. Similarity is usually measured using cosine similarity between vectors.
A cross-encoder receives both the question and chunk together as a single input and outputs a relevance score.
Because it can directly compare the words and meaning of both texts, it is usually more accurate. However, it must run for every question-chunk pair at search time, making it too slow for searching an entire index.
Where the model work happens
// Bi-encoder vs cross-encoder: where the model work happens.
// A bi-encoder embeds every chunk once, ahead of time, and only the question at search time.
// A cross-encoder must read the question together with each candidate, at search time.
function modelCalls({ chunks, questions, pool }) {
const bi = { ahead: chunks, perQuestion: 1 };
const cross = { ahead: 0, perQuestion: pool };
return {
bi: bi.ahead + bi.perQuestion * questions,
cross: cross.ahead + cross.perQuestion * questions,
crossOnAll: chunks * questions, // reranking everything, with no first stage
};
}
const c = modelCalls({ chunks: 100000, questions: 1000, pool: 50 });
console.log(`bi-encoder search: ${c.bi.toLocaleString("en-US")} model calls`);
console.log(
`cross-encoder on a pool of 50: ${c.cross.toLocaleString("en-US")} model calls`,
);
console.log(
`cross-encoder on every chunk: ${c.crossOnAll.toLocaleString("en-US")} model calls`,
);
console.log(
"That's why the cross-encoder only reranks a small pool from a fast first stage.",
);
Expected output:
bi-encoder search: 101,000 model calls
cross-encoder on a pool of 50: 50,000 model calls
cross-encoder on every chunk: 100,000,000 model calls
That's why the cross-encoder only reranks a small pool from a fast first stage.
| Bi-encoder | Cross-encoder | |
|---|---|---|
| Input | One text at a time | Question + chunk together |
| Output | A vector | A relevance score |
| Work at search time | Embed the question once | One model run per candidate |
| Accuracy | Good | Better, especially on details |
| Used for | Stage 1 search | Stage 2 reranking |
Joint Query-Chunk Attention Scoring
To understand why a cross-encoder is usually more accurate, it helps to understand attention.
Attention is a mechanism used by transformer models that helps the model decide which words are important when understanding text. Instead of looking at words in isolation, the model looks at how words relate to one another.
In a cross-encoder, the question and the chunk are placed into one input sequence. This allows words in the question to directly interact with words in the chunk.
For example, imagine the question is:
Is water damage covered by the warranty?
And the chunk contains:
Damage from water is not covered under the warranty.
Because both texts are processed together, the model can directly connect:
"water damage" in the question "damage from water" in the chunk
It can also recognise important details such as:
exact matches similar meanings negative statements such as "not covered" which sentence actually answers the question
This direct interaction between the question and the chunk is one of the main reasons cross-encoders often produce better relevance rankings than bi-encoders.
Self-Attention Across Query and Passage Pairs
A cross-encoder combines the question and chunk into a single input that looks like this:[CLS] question [SEP] chunk [SEP]
Where:
[CLS] is a special token placed at the beginning. [SEP] is a special token used to separate different pieces of text.
The model reads the entire sequence as one piece of input. During processing, words from the question can attend to words in the chunk, and words in the chunk can attend to words in the question.
After processing the sequence, the model produces an output for the [CLS] token. This output is converted into a single relevance score that represents how well the chunk answers the question.
Cross-encoders also have an input length limit, often around 512 tokens. If a chunk is too long, part of it may be cut off before processing.
This is another reason why good chunk sizes matter. If important information is truncated, the reranker may not see the answer and could assign a lower score.
Specialized Reranker Models (Cohere, BGE)
Rerankers are trained using large collections of questions paired with both relevant and irrelevant passages. This training helps them learn what makes a passage a good answer.
Common options include:
Hosted APIs, such as Cohere Rerank. These are easy to use, support multiple languages, and are managed by the provider. Open-source models, such as BGE rerankers and MS MARCO cross-encoders like ms-marco-MiniLM-L-6-v2. These can be run on your own infrastructure and some smaller models can even run in a browser using Transformers.js.
When choosing a reranker, evaluate:
retrieval quality on your gold set supported languages latency operating cost
The best choice depends on your application's requirements.
Reranking Pipeline
Rescoring Candidate Chunks
The pipeline:
A typical reranking pipeline works as follows:
Stage 1 retrieves a candidate pool, such as 30 chunks, after applying any filters. A (question, chunk) pair is created for every candidate. These pairs are sent to the reranker, preferably in a single batch. The reranker returns one relevance score for each pair. The candidates are reordered using those new scores.
Score Normalization
Rerankers often return raw scores called logits.
A logit is simply the model's raw relevance score before any conversion. Logits can be positive or negative and may vary greatly between models.
Raw logits are useful internally, but they are difficult to interpret directly. To make them easier to use, they are often normalised.
Two common approaches are:
Sigmoid
Converts logits into values between 0 and 1. Scores closer to 1 indicate higher relevance. A score above 0.5 is often treated as relevant.
Min-max normalisation
Rescales scores within a single result list. The lowest score becomes 0 and the highest becomes 1. Useful for display and blending scores. Does not represent absolute relevance.
Sigmoid and min-max normalization
// Score normalization: rerankers output raw scores (logits). Turn them into 0..1 values
// so you can use one threshold and compare across questions.
const logits = {
"warranty#1": 7.2,
"warranty#3": 5.9,
"returns-policy#2": -1.4,
"safety#2": -4.8,
};
const sigmoid = (x) => 1 / (1 + Math.exp(-x));
const values = Object.values(logits);
const lo = Math.min(...values);
const hi = Math.max(...values);
console.log("chunk logit sigmoid min-max");
for (const [id, x] of Object.entries(logits)) {
console.log(
`${id.padEnd(18)} ${x.toFixed(1).padStart(5)} ${sigmoid(x).toFixed(3).padStart(7)} ${((x - lo) / (hi - lo)).toFixed(3).padStart(7)}`,
);
}
console.log(
"Keep chunks with sigmoid >= 0.5:",
Object.keys(logits)
.filter((id) => sigmoid(logits[id]) >= 0.5)
.join(", "),
);
Expected output:
chunk logit sigmoid min-max
warranty#1 7.2 0.999 1.000
warranty#3 5.9 0.997 0.892
returns-policy#2 -1.4 0.198 0.283
safety#2 -4.8 0.008 0.000
Keep chunks with sigmoid >= 0.5: warranty#1, warranty#3
Final Context Rank Assignment
After reranking:
Sort candidates by reranker score. Remove candidates that fall below a relevance threshold. Apply MMR or deduplication if needed. Keep the top k chunks. Send those chunks to the context-packing stage.
It is also useful to keep both:
Stage 1 retrieval scores Stage 2 reranker scores
in your trace or logs. This makes it easier to understand what changes the reranker made and to troubleshoot retrieval problems.
A reranker can only reorder chunks that are already in the candidate pool. If the correct chunk never reaches Stage 2, the problem is usually in Stage 1. In that case, improve chunking, embeddings, hybrid search, or query transformation rather than tuning the reranker. Reranking adds latency to every question. Always measure its impact using your gold set. If accuracy improves only slightly, consider skipping reranking or enabling it only when Stage 1 results have low confidence.
Compare retrieval with and without reranking in the lab, then open the Trace view to inspect both stages: First stage → Candidate pool → Reranker → Final top-k chunks
A reranker can't rescue a chunk that never made it into the pool. If the right chunk isn't in stage 1's top N, fix stage 1 first: chunking, embeddings, hybrid search or query transformation.
Reranking adds latency to every question. Measure the gain on your gold set. If top-k accuracy barely moves, skip the reranker, or only use it when stage 1 isn't confident.
Compare search with and without the rerank step in the lab, then open the Trace to see the two stages:
First stage only
Two-stage: pool, then rerank
Common mistakes
- A pool that's too small, so the reranker has nothing good to promote.
- Chunks longer than the reranker's input limit, so the key sentence gets cut off.
- Using min-max scores as absolute relevance: the best of bad results still scores 1.0.
- Forgetting filters on the pool, so the reranker promotes chunks the user can't see.
- No latency budget, so reranking quietly doubles response time.
Exercises
Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.
What to Remember
- Two stages: a fast first stage for recall (a pool of 20–100), then a reranker for precision at the top.
- Size the pool from recall@pool and your latency budget. Batch reranker calls, cache, or skip when stage 1 is confident.
- Over-retrieve to leave room for filters and deduplication. Use MMR for variety.
- A cross-encoder reads the question and chunk together (joint attention), so it's more accurate but too slow for whole-index search. Options include hosted (Cohere) and open (BGE, MS MARCO) rerankers.
- Normalize reranker scores (sigmoid), apply a threshold, and keep both stages' scores in the trace.
Next: Context Packing, fitting the best chunks into the prompt, in the right order.