Skip to the solution
MediumAI Engineering11 min read

What is reranking and why add it after retrieval?

443 views

The problem

Question presented to candidate: "Our RAG feature already does vector search. Someone proposed adding a reranker after retrieval. What is a reranker, how is it different from the search we already do, and why would putting one in front of the model help?"

What a strong answer should cover:

  • Retrieval is two jobs: recall (do not miss the right chunk among millions) and precision (put the best few first); a reranker is a second, slower and more accurate scorer that only reorders the shortlist the first stage returns
  • Vector search is a bi-encoder: query and document are each turned into one vector separately and only meet in a dot product; a cross-encoder reranker reads the query and the document together in one model pass, so it can see word order, negation and which part of the chunk actually answers
  • Cost is why it comes second: one model pass per (query, document) pair is fine for 20 to 200 candidates and impossible for a whole corpus
  • What it buys in RAG: the answer chunk moves into the few slots you send to the model, fewer distractors in the prompt, and a relevance score you can threshold so off-topic questions get no context and an honest "I do not know"
  • Kinds of reranker: cross-encoder models (hosted APIs or open models), an LLM asked to rank, late-interaction models, plus business rules such as recency boosts; and a reranker cannot rescue a chunk the first stage never returned

Code / implementation expected: Optional โ€” sketching retrieve-then-rerank (top 50 in, top 5 out, with a score floor) or calling a rerank API is enough.

  • reranking
  • cross-encoder
  • retrieval
  • rag

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read ยท 12 sections

Read the code

Two-stage retrieval: a lossy first stage, then a pair scorer
// Two-stage retrieval: a lossy bi-encoder recalls candidates, a pair scorer reranks them.
const DOCS = [
  "Refunds are available within 30 days of purchase.",
  "Join today and get 30 days of free shipping on every order.",
  "You can change your delivery address after ordering, within 2 days.",
  "Refund processing takes 5 to 10 business days after we receive the item.",
  "After 30 days a refund is not possible, but you can get store credit.",
  "Gift cards can not be refunded or exchanged for cash.",
  "We match competitor prices for 30 days after purchase.",
  "Returns after 30 days need a manager to approve store credit.",
];
const STOP = new Set(["a", "an", "the", "i", "can", "you", "of", "on", "or", "to", "for", "we", "and", "is", "be", "get", "your", "my", "but", "it", "are", "every", "need"]);
const words = (t) => t.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(" ").filter((w) => w);
const stem = (w) => (w.endsWith("s") && w.length > 3 ? w.slice(0, -1) : w).replace(/ed$/, "");
const terms = (t) => words(t).map(stem).filter((w) => !STOP.has(w));

// Stage 1 stand-in: each text is squeezed into 12 numbers on its own (hashing trick).
const DIM = 12;
function bucket(w) { let h = 0; for (const c of w) h = (h * 31 + c.charCodeAt(0)) % 1000003; return h % DIM; }
function embed(t) {
  const v = new Array(DIM).fill(0);
  for (const w of terms(t)) v[bucket(w)] += 1;
  const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
  return v.map((x) => x / n);
}
const dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0);

// Stage 2 stand-in: reads query and document together and scores the pair.
function pairScore(q, d) {
  const qt = terms(q), dt = new Set(terms(d)), dw = words(d).map(stem);
  const cover = qt.filter((w) => dt.has(w)).length / qt.length;
  const qw = words(q).map(stem);
  let bigrams = 0;
  for (let i = 0; i + 1 < qw.length; i++) {
    for (let j = 0; j + 1 < dw.length; j++) if (qw[i] === dw[j] && qw[i + 1] === dw[j + 1]) { bigrams++; break; }
  }
  const neg = dw.includes("not") ? 0.5 : 0; // a direct yes/no answer to a "can I" question
  return Math.floor((2 * cover + bigrams + neg) * 1000 + 0.5) / 1000;
}
const f3 = (x) => (Math.floor(x * 1000 + 0.5) / 1000).toFixed(3);

function run(query, k, keep, floor, gold) {
  const qv = embed(query);
  const docVecs = DOCS.map(embed); // computed once at index time in a real system
  const stage1 = DOCS.map((d, i) => ({ i, s: dot(qv, docVecs[i]) })).sort((a, b) => b.s - a.s || a.i - b.i).slice(0, k);
  console.log(`query: "${query}"`);
  console.log(`  stage 1 (bi-encoder, top ${k} of ${DOCS.length})`);
  stage1.forEach((c, r) => console.log(`    ${r + 1}. doc ${c.i}  cos ${f3(c.s)}${c.i === gold ? "  <- answers it" : ""}`));
  const reranked = stage1.map((c) => ({ i: c.i, s: pairScore(query, DOCS[c.i]) })).sort((a, b) => b.s - a.s || a.i - b.i);
  console.log(`  stage 2 (pair scorer on ${k} pairs, keep ${keep}, floor ${f3(floor)})`);
  reranked.slice(0, keep).forEach((c, r) => console.log(`    ${r + 1}. doc ${c.i}  score ${f3(c.s)}${c.s < floor ? "  below floor, dropped" : ""}${c.i === gold ? "  <- answers it" : ""}`));
  const sent = reranked.slice(0, keep).filter((c) => c.s >= floor);
  console.log(`  sent to the model: ${sent.length ? sent.map((c) => "doc " + c.i).join(", ") : "nothing (say I do not know)"}`);
  return { stage1, sent };
}

console.log("1) the gold document is recalled but ranked low, then promoted");
const a = run("Can I get a refund after 30 days?", 5, 3, 1.0, 4);
const r1 = a.stage1.findIndex((c) => c.i === 4) + 1;
const r2 = a.sent.findIndex((c) => c.i === 4) + 1;
console.log(`  gold rank: ${r1} -> ${r2}   reciprocal rank: ${f3(1 / r1)} -> ${f3(1 / r2)}`);
console.log("");
console.log("2) an off-topic question: the floor sends nothing");
run("What is the capital of France?", 3, 3, 1.0, -1);
console.log("");
console.log("3) why not rerank everything (assume 2 ms of model time per pair)");
for (const n of [50, 200, 10000000]) {
  const ms = n * 2;
  const shown = ms < 60000 ? `${ms} ms` : `${(ms / 3600000).toFixed(1)} hours`;
  console.log(`  ${String(n).padStart(9)} pairs  ${shown}`);
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 45 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

How do you design good tool definitions for an LLM?

Continue