Dayward AI
Week 4 · D24About 6 hours

RAG, Level Up: Hybrid Search, Reranking, Citations, Recall Evaluation

Upgrade day twelve's memory retrieval into a hybrid-search-plus-rerank combination, add source citations, and evaluate retrieval quality with a recall metric.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Implement hybrid search combining keyword search and vector search
  2. Add a reranking step to the retrieval result to improve ranking quality
  3. Design a recall evaluation method that measures whether retrieval recalled what it should have

Yesterday made integrating a capability cheap, so you will naturally wire in a pile of knowledge bases and retrieval services. Today goes back to fix upstream — because however much you wire in, it cannot rescue a retrieval whose recall is low to begin with.

Plain-Language Walkthrough

Those two rounds of resume screening are exactly retrieval's two rounds

A company hiring one person receives eight hundred resumes. HR does not read each in detail — that would take two weeks. The real process is two rounds.

The first round is broad and high-volume, running two channels at once. One is a keyword sift: search the system for "Kubernetes" and whatever hits enters the pool. It is precise, with one fatal flaw — a candidate who wrote it as "K8s" is missed entirely. The other is a recruiter's referral: they have met this person and it "feels right," without being able to name which word matched. They understand synonyms and context, and asked to find somebody who has used model SF-3000 they will hand you a pile of people who worked on similar models while missing the one who actually wrote SF-3000.

Each channel has its own blind spot, and the blind spots do not overlap — so both channels' people go into the second round together. The second round is a technical interviewer reading each in detail, twenty minutes apiece. Precise, and expensive, so only a few candidates get it.

Retrieval is exactly those two rounds. Keyword search (the BM25 road) is HR's search box: literal matches, superb on model numbers, error codes, and order numbers, and helpless with synonyms. Vector search is the recruiter: it compresses text into numbers and compares semantic distance, understanding that "shipping fee" and "postage" are the same thing, while for a semantically empty string like E4032 it can only hand you a pile of things that look like identifiers. Reranking is that technical interviewer: take a few dozen candidates, compare each against the question in detail, and reorder.

The analogy's most valuable point comes last: recall is whether the genuinely suitable people made it into the first round. Whoever the first round missed cannot be rescued by however good the second is — an interviewer can only order the list handed to them, not conjure somebody who never entered the pool.

So the chain's shape is fixed: broad upstream so downstream can tighten. Take 20 from each road, fuse, and after reranking keep only 5 for the context.

The two roads' blind spots do not overlap, and their scores cannot be added

D12 finished the vector road: chunking, embeddings, cosine similarity, wrapped into a memory_search tool. All of that carries over unrepeated — 1536 dimensions, text-embedding-3-small, 0.02 dollars per million tokens, the same set.

Today adds a keyword road beside it and answers one question: what exactly did it fill in?

Today's lab's knowledge base is 54 e-commerce support documents cut into 64 chunks. Fire one real query at it:

TextText
query: what does E4032 mean, and can I ask the buyer to retry
  vector top5   - s07#0 (the payment error-code document) is absent
  keyword top1  - s07#0

That is what a blind spot looks like: the vector road pushed the correct answer out of the top five, and the keyword road ranked it first. The reverse exists too: ask when postage can be waived, the documents say "shipping fee," the keyword road matches not one character, and only vectors bring it back.

State what the keyword road computes. BM25's intuition is two lines: the more a term appears in a document the more relevant it is; and the more common that term is across the corpus the less it is worth. So "refund" has almost no discriminating power in an e-commerce knowledge base, while E4032 appears in one document and hitting it is nearly certain identification. Vector search is least sensitive precisely to this kind of low-frequency, semantically empty string — compressed into 1536 dimensions it crowds in with every other identifier. The two methods' strengths are exactly complementary, which is no coincidence but a consequence of how each computes.

How is the keyword road built? The teaching implementation uses Postgres's full-text search (tsvector plus ts_rank) without pulling in a search engine. There is a trap that must be stated for languages without spaces: Postgres's default tokenizer effectively does not tokenize Chinese, so a whole sentence becomes one token and nothing is ever found. The teaching fallback splits into adjacent-character bigrams, while English words and identifiers (sf-3000, e4032) are kept whole and must not be shredded. Production needs a proper CJK tokenizer extension; do not treat bigrams as the destination.

bigrams.js
// CJK splits into adjacent-character bigrams; English and identifiers stay whole -
// shred e4032 and it can never be found again
export function bigrams(text) {
  const lowered = text.toLowerCase()
  const tokens = []
  // Pull out runs of alphanumerics (including hyphens) as single tokens first
  for (const word of lowered.match(/[a-z0-9][a-z0-9-]*/g) ?? []) tokens.push(word)
  const han = lowered.replace(/[^\p{Script=Han}]/gu, ' ')
  for (const run of han.split(/\s+/).filter(Boolean)) {
    if (run.length === 1) tokens.push(run)
    for (let i = 0; i + 1 < run.length; i++) tokens.push(run.slice(i, i + 2))
  }
  return tokens
}

Now there are two candidate lists, so how do they combine into one?

The intuitive move is a weighted sum: 0.6 times cosine similarity plus 0.4 times BM25. That road does not work, and not because weights are hard to tune but because the two scores are simply not comparable: cosine similarity sits between 0 and 1 with a dense distribution, often separating candidates by 0.02; BM25 has no upper bound and a document hitting three rare terms can score 12. Add them and BM25 unilaterally decides the outcome — tune the weights all night and you are only tuning how much BM25 gets to dictate.

The correct move uses only ranks, never scores: RRF (Reciprocal Rank Fusion). Each road's rank converts to 1 / (k + rank), one document's scores across roads are summed, and k is fixed at 60.

Why is k 60? It is an empirical value fixed by experiment when the method was proposed, and its role is flattening the gaps between the top few: without it (k = 0) first place scores 1 and second 0.5, a steep drop that lets each road's winner dictate; at 60, first place is 1/61 and tenth 1/70, a gap of barely a tenth, so "top twenty on both roads" outweighs "first on one road." That is exactly the weighting we want — consensus over single-point confidence. Sixty is no magic number, and before you have your own evaluation set there is no reason to move it.

Why this works: a rank is dimensionless. First is first, whether the raw score was 0.83 or 12.6. So documents ranked highly by both roads float to the top, and a document only one road recognizes can still push in on that road's high rank — which is what we want, because the blind spots do not overlap.

The lab has an example you can compute by hand: two rankings of [a, b, c] and [c, d, a] fuse with a first, scoring 1/61 + 1/63 which is about 0.03227. The starter's "add the scores directly" version gives c first on the same input, scoring 12.635 — c is merely first on the second road, and its raw BM25 score crushes everything.

That example hides one detail that must be handled: a and c are tied (each is first on one road and third on the other, so 1/61 + 1/63 is exactly equal). If who comes first on a tie is left to a hash table's iteration order, the same input gives different results across languages and runs — and of today's four implementations, only those explicitly adding "break ties by id" agree everywhere. Retrieval results must be reproducible, or your evaluation set measures a different number every time.

rrf.js
export const RRF_K = 60
 
// The input is several ranked id lists (each already sorted by its own score).
// Scores are discarded entirely; only ranks are used.
export function rrfFuse(rankings) {
  const scores = new Map()
  for (const ranking of rankings) {
    ranking.forEach((id, index) => {
      // index starts at 0 and ranks start at 1
      scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + index + 1))
    })
  }
  // Break ties by id: without this the result depends on Map iteration order and is
  // not reproducible
  return [...scores.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .map(([id]) => id)
}

Reranking: expensive, so do it on few candidates

The fused list is still ordered by retrieval signals, not by how relevant each item is to the question. Reranking compares each candidate against the question in detail.

The teaching implementation uses batch LLM scoring: send 40 candidates and the question to a model at once, have it score each 0 to 10, and reorder. The upside is not pulling in another model; the downside is that it is slow, expensive, and its scores drift with the prompt's wording.

Production does it differently, and the difference in convention must be stated: production uses a purpose-trained cross-encoder reranker (question and candidate concatenated into one encoder, outputting relevance directly). The price is one extra 100-to-300-millisecond call plus a machine to run inference on — it is not an API call billed per token, and you have to provision resources for it. So cap the reranked candidate count: 40 is a teaching figure and production commonly caps at 50 to 100.

Do not present the teaching version as standard practice. Saying in an interview that you rerank by having an LLM score retrieval results invites the follow-up "how did you account for latency and cost, and why not a dedicated reranker" — and being unable to answer gives you away.

Citations: the model will cite a number you never gave it

Retrieved material enters the context, the model answers, and you want it to mark which source each sentence rests on. The method is plain: number each candidate chunk, write them into the context as [1] text..., have the model annotate with [1], and list sources at the end.

The fixed shape gives each chunk three things: chunkId (the chunk's unique identifier, in the form s07#0), sourceId (which document it belongs to), and sourceTitle (the document's title, for rendering the source list).

What this section is really about is hallucinated citations: the model will cite a number that does not exist. Today's lab holds a real output where, given 8 candidates, it wrote [2][9] in the answer — 9 never existed. That is not rare; it appears fairly consistently in long answers and with many candidates.

Post-processing does two things, neither optional:

  1. Strip nonexistent numbers from the answer rather than shipping them. A user opening the source list and finding only 8 concludes the whole system is making things up.
  2. Count "this answer contained a hallucinated citation" as a metric (echoing D21's observability dashboard). Stripping is only stanching; a metric is what tells you whether this is getting more or less frequent — its curve turning up usually means retrieval quality or a prompt was recently broken.

Without an evaluation set you cannot even tell that you improved it

On what basis is each of the preceding changes an improvement? Trying a few queries by feel is this field's most common self-deception.

An evaluation set's shape is simple: 20 queries, each labeled by hand with 1 to 3 chunkIds that must be recalled. Note that the labels are on chunks rather than documents — retrieval's granularity is the chunk, and labeling at document level inflates the metric.

Three metrics each answer a different question:

  • recall@5: how much of what should be recalled is covered by the 5 that enter the context. This is the number you actually care about, because the model sees only those 5.
  • recall@20: how much is covered by the 20 fished up in the first round. It is the ceiling — if recall@20 will not rise, the problem is on the recall side and no amount of reranking helps.
  • MRR (mean reciprocal rank): on average, where the first correct result ranks. It is sensitive to ordering quality and breaks ties when recall is equal.

The three tiers' real figures from today's lab are most persuasive read together:

TextText
vector only                         recall@5 57%  recall@20 83%  MRR 0.677
vector + keyword + RRF              recall@5 80%  recall@20 95%  MRR 0.732
vector + keyword + RRF + rerank     recall@5 91%  recall@20 98%  MRR 0.908

Read those three rows across and also down. Across: adding the keyword road took recall@5 from 57% to 80% — those 23 points are the vector road's blind spot. Down the second column: recall@20 rose from 83% to 95%, so the ceiling was raised; and the rerank row moved recall@20 only from 95% to 98% (reranking recalls nothing new, it only reorders the existing 20), while what it raised is recall@5 (80% to 91%) and MRR (0.732 to 0.908).

That is the point of the three metrics' division of labor: hybrid search raises the ceiling and reranking moves what is under the ceiling into the top five. Neither substitutes for the other, and neither can be summarized by one number.

Three traps not yet mentioned: chunking, count, and concatenation order

Chunking. A fixed 512 characters with 64 of overlap is a sufficient starting point (today's lab cuts 54 documents into 64 chunks, the longest 509 characters). The overlap prevents a complete idea being cut in the middle — a refund rule's first half in chunk 1 and its second in chunk 2 leaves both incomplete and neither recallable. Better is splitting semantically (by heading, by list item), at the price of writing parsing logic and different sources having different structures. Get fixed-length working first, measure it with an evaluation set, and then decide whether semantic splitting is worth it.

Count. More entering the context is not better. Past a point three things worsen together: token cost rises linearly, irrelevant content dilutes attention, and more numbers means more hallucinated citations. Five is an empirical starting point, and the basis for changing it should be the difference between recall@5 and recall@10 — a small difference means adding entries achieves nothing.

Concatenation order. Put the most relevant one last, right next to the question. Because a model's attention over long context is measurably weaker in the middle than at either end (the industry calls it lost-in-the-middle). Since you must pick an end, pick the one nearest the question. That change is one line, costs nothing, and is free gain.

Source Reading

Hands-On Lab

🧪 D24 lab: upgrading memory retrieval plus evaluation

Code location: labs/agent-30days/day-24-rag-advanced

Acceptance criteria:

  1. All five self-checks under MOCK=1 SELFTEST=1 pnpm start pass with exit code 0 (starter/ as-is is 1/5).
  2. Check 2 proves the two roads' blind spots do not overlap: for the E4032 query the vector top5 lacks s07#0 while the keyword top1 is it; and a CJK query recalls nothing on the keyword road before the bigram implementation.
  3. Check 3 proves RRF fuses ranks rather than scores: [a,b,c] and [c,d,a] fuse with a first (0.03227), while the add-the-scores version gives c (12.635).
  4. Check 4 prints the three tiers' strict progression: recall@5 from 57% to 80% to 91%, with recall@20 from 83% to 95% to 98%.
  5. Check 5 proves citation post-processing works: a nonexistent number in the answer is stripped, and the hallucinated-citation count goes from 0/1 to 1/1 (counted as a metric rather than shipped as-is).

starter/ has 4 exercise points and runs fully offline under MOCK=1 with no Postgres, Docker, or API key — the infrastructure is in-memory and embeddings are generated deterministically from the text, so recall is genuinely computed and changing one query changes the number. To connect real pgvector, docker compose up -d and set DATABASE_URL (host port 5524), and the same retrieval code runs.

  1. Run MOCK=1 SELFTEST=1 pnpm start as-is and note the baseline: only check 1 passes of five, and the three recall@5 tiers are 57%, 69%, 69% (the last two cannot rise because neither the keyword road nor reranking is wired up).
  2. Implement bigrams (exercise 1) so a CJK query genuinely recalls on the keyword road, turning check 2 green.
  3. Change the fusion to RRF (exercise 2) — discard both roads' raw scores and use ranks only — turning check 3 green while recall@5's second tier jumps to 80%.
  4. Wire up rerankTopK (exercise 3), completing check 4's progression to 57%, 80%, 91%.
  5. Implement stripHallucinatedCitations (exercise 4), stripping nonexistent numbers and counting the metric, turning check 5 green; then look at what that stripped [9] was.

Interview Questions

Today's four questions are in the bank below, weighted toward why hybrid search is necessary, what reranking solves, and how recall is measured. Expand a question and read the analysis before the key points — "the two roads' blind spots do not overlap" in question 1 is the crux, and question 4's evaluation-set design is a very frequent follow-up, so do not skip it.

Checklist and Tomorrow

  • Implement hybrid search combining keyword search and vector search
  • Add a reranking step to the retrieval result to improve ranking quality
  • Design a recall evaluation method that measures whether retrieval recalled what it should have
  • Say why the two roads' scores cannot be summed with weights and must be fused by rank with RRF
  • Say what each of recall@5, recall@20, and MRR answers
  • All 5 acceptance criteria of the lab pass
  • Answer at least 3 of the 4 interview questions without looking at the key points

Tomorrow (D25) puts this backend in front of human eyes for the first time. As of today it triages, splits tasks, reviews itself, reaches out proactively, finds things accurately, and holds off attacks — and you have not once looked at it through a browser. Tomorrow writes a React chat frontend: streaming rendering, visualizing the tool-calling process, and interrupt and retry, two interactions that look simple and actually require the frontend and backend to cooperate to get right. The order is deliberate: get the backend right before presenting it — done the other way, you paper over a pile of backend defects with frontend loading animations.

Interview questions

  • Why isn't pure vector search enough — what does keyword search add?为什么单纯的向量检索不够,还要加一路关键词检索?
    Common in ChinaCommon overseasBasic#rag#hybrid-search#retrieval

    How to reason about it · think before answering

    1. The discriminator is not whether you know the term 'hybrid search' — it is whether you can name a concrete query that vector search will always miss. No example means you have only read architecture diagrams.
    2. One causal chain: vector search compares semantic distance, so both its strength and its weakness come from that compression step. Synonyms match (shipping fee vs postage), but strings with no semantics collapse together — error codes, SKUs, order ids, person names.
    3. BM25 has the mirror-image profile: a term matters more when it is frequent in this document and rare across the corpus. So it nails low-frequency literals and fails completely on paraphrase.
    4. State the conclusion as 'their blind spots do not overlap, and that follows from how each one computes' — not the vague 'two channels are safer'. A measured example lands best: for 'what does E4032 mean', the correct doc is absent from the vector top-5 and is the keyword top-1.
    5. Expected follow-up 1: how do you merge the two rankings? Answer RRF, and explain why weighted sums fail (see q02).
    6. Expected follow-up 2: how do you do keyword search over Chinese? Postgres's default parser effectively does not tokenize Chinese; the cheapest workable fallback is character bigrams, keeping ASCII words and codes whole. Production needs a real Chinese tokenizer extension. Answering this usually proves you actually built it.

    分析过程 · 先想清楚再作答

    1. 这题的区分度不在「你知不知道有 hybrid search」,而在**你能不能说出一个向量检索一定会漏的具体例子**。答不出例子的,一听就是只看过架构图。
    2. 推导链只有一句:向量检索比的是语义距离,所以它的强项和弱项都来自「压缩成语义」这一步——同义词能对上(运费 / 邮费),而没有语义的字符串会被压到一起(E4032、SF-3000、订单号、人名)。
    3. 关键词那一路(BM25)的性质正好相反:一个词在本文档里越频繁越相关、在全语料里越常见越不值钱,所以它对低频稀有词极准,对同义改写完全无能。
    4. 结论要说成「两者的盲区不重叠,而且是由计算原理决定的不重叠」——不是「多一路更保险」这种模糊说法。举一个实测例子最有说服力:查「E4032 是什么意思」,向量 top5 里没有那篇讲支付错误码的文档,关键词 top1 就是它。
    5. 可预期的追问一:那怎么合并两路结果?答 RRF,并说清为什么不能加权求和(见 q02)。
    6. 可预期的追问二:中文怎么做关键词检索?答 Postgres 默认分词器对中文等于不分词,最简可用的兜底是 bigram(相邻两字切开),但英文与编号必须整词保留;生产要上专门的中文分词扩展。这一条能答出来,基本就说明你真动手做过。

    Key points

    • Vector search compares semantic distance: strong on paraphrase, weak on SKUs, error codes and order ids that carry no semantics.
    • BM25 is strong on rare literal terms and weak on paraphrase — the blind spots follow from the algorithms and do not overlap.
    • So run both channels wide (top 20 each) and fuse with RRF so each covers the other's gap.
    • Give a measured example: for the E4032 query the correct chunk is missing from vector top-5 but is keyword top-1; a 'postage vs shipping fee' query is the reverse.
    • Chinese keyword search needs tokenization: character bigrams as the cheap fallback, ASCII words kept whole, a real tokenizer extension in production.

    答题要点

    • 向量检索比的是语义距离,强在同义改写,弱在型号、错误码、订单号这类没有语义的字符串。
    • BM25 强在低频稀有词的字面命中,弱在同义改写——两者的盲区由各自的计算原理决定,不重叠。
    • 所以第一轮开两路、各取 20 条,用 RRF 融合,把两边的盲区互相补上。
    • 举实测例子:E4032 那条 query 向量 top5 漏掉正确文档,关键词 top1 就是它;「邮费」那条反过来只有向量能召回。
    • 中文关键词那一路要处理分词,最简兜底是 bigram,字母数字整词保留,生产上专门的中文分词扩展。
  • How do you merge two retrieval rankings, and why not just take a weighted sum of the scores?两路检索结果怎么合并?为什么不能直接加权求和?
    Common in ChinaCommon overseasIntermediate#rag#rrf#ranking

    How to reason about it · think before answering

    1. The second half is the real question. Anyone can say 'RRF'; explaining why weighted sums fail is what separates people who have looked at the score distributions.
    2. Decompose it: are the two scores even the same unit? Cosine similarity is bounded in 0 to 1 and tightly clustered — candidates often differ by 0.02. BM25 is unbounded and a few rare-term hits reach 12. Adding them lets the larger-magnitude channel decide everything; the weight only tunes how much it dominates.
    3. Worse, it is unstable. Weights tuned on one corpus drift on the next, so you re-tune forever.
    4. Conclusion: fuse ranks, not scores. RRF maps each rank to 1/(k + rank) and sums, with k = 60. Ranks are unitless and need no calibration. k flattens the head of the list so that 'top-ranked in both channels' beats 'first in one channel' — consensus over single-source confidence.
    5. A hand-checkable example helps: rankings [a,b,c] and [c,d,a] give a = 1/61 + 1/63 ≈ 0.0323, while a raw score sum promotes c on the strength of its BM25 12.
    6. Expected follow-up: what about ties? You must break them explicitly, e.g. by id. Otherwise ordering depends on hash-map iteration order and differs across languages and runs, which makes your evaluation numbers irreproducible. Mentioning this signals you actually ran it more than once.

    分析过程 · 先想清楚再作答

    1. 题眼在后半句。前半句答「RRF」谁都会,后半句「为什么不能加权求和」才是筛人的地方——它考的是你有没有真的看过两路分数的分布。
    2. 怎么拆:先问自己两个分数是不是同一个量纲。余弦相似度有界(0 到 1)且分布密集,同一批候选常常只差 0.02;BM25 无上界,命中几个稀有词就能到 12 分。**不同量纲的数相加,等于让量纲大的那一路单方面决定结果**,权重只是在调「它说了算的程度」。
    3. 更麻烦的是它不稳定:权重在这批语料上调好了,换一批语料分布就变了,得重调。这是一个永远还不完的技术债。
    4. 结论:改用名次。RRF 把每一路的名次折算成 `1/(k + rank)` 再相加,k 取 60。名次是无量纲的,不需要任何标定。k 的作用是压平头部差距,让「两路都进前列」压过「一路排第一」——共识优先于单点自信。
    5. 一个能当场手算的例子很加分:两路排名 [a,b,c] 与 [c,d,a],a 得 1/61 + 1/63 ≈ 0.0323;而分数直接相加的版本会把 BM25 里 12 分的 c 顶到第一。
    6. 可预期的追问:同分了怎么办?必须显式定序(比如按 id),否则结果取决于哈希表遍历顺序,同一份输入在不同语言、不同运行里给出不同排序——评估集量出来的数字也就不可复现了。这一条答出来会非常加分,因为它说明你真的跑过多次。

    Key points

    • Use RRF: map each channel's rank to 1/(k + rank) and sum, with k = 60.
    • Weighted sums fail because the scores are different units — bounded, tightly clustered cosine versus unbounded BM25, so BM25 decides the outcome.
    • Weights also do not transfer: tuned on one corpus, they drift on the next.
    • Ranks are unitless and need no calibration; k flattens the head so cross-channel consensus outweighs single-channel confidence.
    • Break ties explicitly (by id) or ordering depends on hash iteration order and your evaluation numbers stop being reproducible.

    答题要点

    • 用 RRF:每一路的名次折算成 1/(k + rank) 再相加,k 取 60。
    • 不能加权求和是因为两个分数量纲不同——余弦有界密集、BM25 无上界,相加等于让 BM25 单方面决定结果。
    • 而且权重不可迁移:这批语料调好,换一批就得重调,是还不完的债。
    • 名次是无量纲的,不需要标定;k 压平头部差距,让两路共识压过单路自信。
    • 同分必须显式定序(按 id),否则结果依赖哈希表遍历顺序,评估数字不可复现。
  • How is reranking usually implemented, what problem does it solve, and what does it cost?重排(rerank)一般怎么实现?它解决了初步检索的什么问题,代价是什么?
    Common in ChinaCommon overseasIntermediate#rag#rerank#latency

    How to reason about it · think before answering

    1. The lazy answer is 'sort again, more accurately'. What the interviewer wants is why the first pass cannot rank well, and why reranking cannot run over the whole corpus.
    2. Decompose: the first pass ranks by retrieval signals — cosine distance or term statistics — which are designed to scan millions of items fast, and coarseness is the price. Reranking changes the algorithm: query and candidate go into one model together (a cross-encoder), which is far more accurate but costs one forward pass per candidate. Hence it must sit behind a wide recall stage.
    3. Distinguish two implementations. For teaching or prototypes, batch-score with an LLM (0-10 for 40 candidates in one call). Production uses a trained cross-encoder reranker. Name the cost: an extra 100-300 ms hop plus an inference box — it is not a per-token API, it consumes capacity.
    4. Framing it as a funnel is clearest: recall sets the ceiling, reranking decides whether what is under the ceiling reaches the top five. Measured: adding the keyword channel lifts recall@20 from 83% to 95%; adding reranking moves recall@20 only to 98%, but recall@5 jumps from 80% to 91% and MRR from 0.732 to 0.908.
    5. Expected follow-up 1: does reranking improve recall? No. It introduces no new candidates, so recall@20 is the wrong metric to judge it by.
    6. Expected follow-up 2: why not ship LLM scoring to production? Unpredictable latency, per-token cost, scores that drift with prompt wording, and no clean path to offline distillation.

    分析过程 · 先想清楚再作答

    1. 这题最容易答成「再排一次序,更准」。面试官想听的是**为什么第一轮不能直接排准**,以及**为什么重排不能对全库做**。
    2. 怎么拆:第一轮的排序依据是「检索信号」——余弦距离或词频统计,它们是为了能在百万条里快速筛选而设计的,代价就是粗。重排换了一种算法:把 query 和候选**拼在一起**送进同一个模型算相关度(cross-encoder),精度高得多,但复杂度是每条候选一次前向,没法对全库做。所以它必须跟在一个宽召回后面。
    3. 结论要区分两种实现:教学 / 原型可以用 LLM 批量打分(一次调用给 40 条打 0 到 10 分),生产用专门训练的 cross-encoder 重排模型。**代价说清楚:多一次 100 到 300 毫秒的调用,外加一台推理机器**——它不是按 token 计费的 API,是要占资源的。
    4. 把它放进漏斗里说最清楚:召回决定天花板,重排决定天花板上的东西能不能排到前五。实测的样子是——加了关键词那一路,recall@20 从 83% 涨到 95%(天花板抬高);再加重排,recall@20 只到 98%,但 recall@5 从 80% 跳到 91%、MRR 从 0.732 到 0.908。
    5. 可预期的追问一:重排能不能提高召回?不能。它不引入新候选,只重排已有的那批——所以看 recall@20 判断重排效果是错的指标。
    6. 可预期的追问二:为什么不用 LLM 打分上生产?延迟不可控、成本按 token 走、分数会随提示词措辞漂移,而且没法做批量离线蒸馏。

    Key points

    • The first pass ranks by retrieval signals so it can scan a large index fast; coarseness is the trade.
    • Reranking feeds query and candidate through one model together (cross-encoder): much sharper, but one forward pass per candidate, so only tens of items.
    • Batch LLM scoring works for teaching; production uses a dedicated reranker, costing an extra 100-300 ms hop plus an inference box.
    • Reranking does not raise recall — it raises recall@5 and MRR (measured 80% to 91%, 0.732 to 0.908) while recall@20 barely moves from 95% to 98%.
    • So judge a reranker by small-k metrics, never by recall@20.

    答题要点

    • 第一轮按检索信号粗排(余弦、词频),为的是能在大库里快速筛,代价是粗。
    • 重排把 query 和候选拼在一起过同一个模型(cross-encoder),精度高但每条一次前向,只能对几十条做。
    • 教学版可用 LLM 批量打 0 到 10 分;生产用专用重排模型,代价是多一次 100 到 300 毫秒的调用加一台推理机器。
    • 重排不提高召回,它提高的是 recall@5 与 MRR——实测 80% → 91%、0.732 → 0.908,而 recall@20 只从 95% 到 98%。
    • 所以判断重排效果要看前 k 小的指标,不要看 recall@20。
  • How do you evaluate retrieval quality in a RAG system, and how should the evaluation set be built?怎么评估一个 RAG 系统的检索效果?评估集应该怎么构造?
    Common in ChinaCommon overseasDeep dive#rag#evaluation#recall

    How to reason about it · think before answering

    1. This is a very common question in the Chinese market and the fastest way to expose someone who has assembled RAG but never tuned it. The test: does your answer contain concrete metric names and an annotation granularity?
    2. First separate what is being evaluated — the step people most often conflate. Retrieval evaluation asks 'was it found'; generation evaluation asks 'was the answer right'. Keep two separate sets. Merge them and, when the score drops, you cannot tell whether retrieval missed or the model fumbled — and those have completely different fixes.
    3. Shape of the set: about 20 queries, each annotated with 1-3 chunk ids that must be retrieved. Annotate at chunk level, not document level — chunks are the retrieval unit, and document-level labels inflate the numbers. Cover the real query mix, especially the types you know break: codes, paraphrase, cross-document.
    4. Three metrics, three questions. recall@5 is what actually reaches the model, so it is the number you care about. recall@20 is the ceiling — if it does not move, the problem is on the recall side and no reranker will save you. MRR is sensitive to ordering and breaks ties when recall is equal.
    5. Production view: freeze the set once agreed, because changing samples destroys comparability — the same reason a factory keeps fixed reference samples. Pair it with online counterparts (empty-citation rate, hallucinated-citation rate, escalation rate), since passing offline does not mean passing in production.
    6. Expected follow-up: is 20 enough given the labeling cost? Not for statistical significance, but enough for regression — its job is to stop retrieval silently getting worse. Scale up before you settle an A/B, and grow it from failure cases rather than random additions.

    分析过程 · 先想清楚再作答

    1. 这题是国内面试的极高频题,也是最容易暴露「只搭过没调过」的一题。判据很简单:你的回答里有没有出现**具体的指标名和标注粒度**,没有就是没做过。
    2. 先把评估对象分清楚——这是最容易混的一步:**检索评估问「找得到找不到」,生成评估问「答得对不对」**。两套评估集要分开维护。混成一套的后果是分数掉了你分不清是检索漏了还是模型答砸了,而这两件事的修法完全不同。
    3. 评估集的形状:20 条左右的 query,每条**人工标注 1 到 3 个必须召回的 chunkId**。注意标注粒度是**块**不是文档——检索的单位就是块,标到文档级会让指标虚高。query 要覆盖真实分布,尤其要包含那些你知道会翻车的类型(编号、同义改写、跨文档)。
    4. 三个指标各回答一个问题:recall@5 是「进上下文的那几条覆盖了多少」,也就是你真正关心的数;recall@20 是天花板,它上不去说明问题在召回侧、重排再强也没用;MRR 对排序质量敏感,recall 打平时用它分高下。
    5. 生产视角:评估集一旦定下来就要冻结,换了样本分数就没有可比性——这和产线质检必须用固定的标准样品是同一个道理。同时线上要有对照指标(引用为空率、幻觉引用率、转人工率),因为离线过了不等于线上没事。
    6. 可预期的追问:标注成本这么高,20 条够吗?答:20 条不够做统计显著性,但足够做**回归**——它的作用是「改了检索之后别悄悄变差」。要做 A/B 定论再上规模,而且优先扩充失败案例,不是随机加样本。

    Key points

    • Retrieval and generation evaluation are two separate sets: 'was it found' versus 'was the answer right'.
    • Around 20 queries, each labeled with 1-3 chunk ids that must be retrieved — chunk level, not document level.
    • recall@5 is what the model actually sees, recall@20 is the ceiling, MRR measures ordering quality.
    • Freeze the set once agreed or scores stop being comparable; pair it with online empty-citation and hallucinated-citation rates.
    • Twenty cases is a regression guard, not a significance test; grow it from failure cases, not random samples.

    答题要点

    • 检索评估和生成评估是两套:前者问「找得到找不到」,后者问「答得对不对」,分开维护。
    • 评估集是 20 条左右的 query,每条人工标 1 到 3 个必须召回的 chunkId——标到块级,不是文档级。
    • recall@5 是真正关心的数(模型只看得到这几条),recall@20 是天花板,MRR 衡量排序质量。
    • 评估集一旦定下来就冻结,否则分数没有可比性;线上再配引用为空率、幻觉引用率做对照。
    • 20 条不够做显著性但够做回归;扩充时优先补失败案例,不是随机加样本。

Comments