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.
今日目标
- 能实现关键词检索和向量检索结合的 hybrid search
- 能给检索结果接入一个 rerank 步骤,提升排序质量
- 能设计一套 recall 评估方法,衡量检索是否召回了应该召回的内容
昨天把「接一个能力」变便宜了,于是你会很自然地接进来一堆知识库和检索服务。今天回头修上游——因为接得再多,也救不回一个召回率本身就低的检索。读完回到页面顶部把三条目标勾掉。
小白版讲解
招聘那两轮筛选,恰好就是检索的两轮
一家公司要招一个人,收到八百份简历。HR 不会逐份细读——那要花两周。真实流程是两轮:
第一轮量大且宽,同时开两条渠道。一条是关键词粗筛:在系统里搜「Kubernetes」,命中的进池子。它很准,但有个致命毛病——候选人如果把它写成「K8s」,就整份漏掉了。另一条是猎头推荐:他见过这个人,觉得「感觉像」,说不出具体命中了哪个词。他懂同义、懂上下文,但你让他找「用过 SF-3000 这个型号的人」,他会给你一堆做过类似型号的人,唯独漏掉那个真正写了 SF-3000 的。
两条渠道各有各的盲区,而且盲区不重叠——所以把两边的人合到一起进第二轮。第二轮是技术面试官逐份细看,一份看二十分钟。准,但贵,所以只能对少量候选人做。
检索是一模一样的两轮。关键词检索(BM25 那一路)就是 HR 的搜索框:字面命中,对型号、错误码、订单号极准,对同义词完全无能。向量检索就是猎头:它把文本压成一串数字,比的是语义距离,懂「运费」和「邮费」是一回事,但对 E4032 这种没有语义的字符串,它只能给你一堆「看起来像编号」的东西。重排(rerank)就是那位技术面试官:拿到几十个候选,一条一条和问题细对,重新排个序。
这个类比最值钱的一点在最后:召回率(recall)就是「真正合适的人有没有进到第一轮」。 第一轮漏掉的人,第二轮再厉害也救不回来——面试官只能在给到他的名单里排序,不能凭空变出一个没进池子的人。
所以整条链路的形状是固定的:上游要宽,下游才收紧。 两路各取 20 条,融合,重排之后只留 5 条进上下文。
两路的盲区不重叠,而它们的分数不能相加
D12 已经把向量检索这一路做完了:切块、算 embedding、余弦相似度、包成 memory_search 工具。那些今天全部沿用,一个字不重讲——1536 维、text-embedding-3-small、0.02 美元每百万 token,还是那套。
今天要做的是在旁边加一路关键词检索,然后回答一个问题:它到底补上了什么?
今天实验里的知识库是 54 篇电商客服文档、切成 64 块。拿一条真实 query 去打:
query:E4032 是什么意思,可以让买家重试吗
向量 top5 —— 没有 s07#0(那篇讲支付错误码的文档)
关键词 top1 —— s07#0这就是盲区的样子:向量那一路把正确答案挤出了前五名,而关键词那一路把它排在第一。 反过来的例子也有:问「邮费什么时候可以免掉」,文档里写的是「运费」,关键词那一路一个字都对不上,只有向量能捞回来。
先说清楚关键词那一路算的是什么。BM25 的直觉只有两条:一个词在这篇文档里出现得越多,越相关;但这个词在整个语料里越常见,它就越不值钱。 所以「退款」在一个电商知识库里几乎没有区分度,而 E4032 只出现在一篇文档里,命中它几乎等于确定答案。向量检索恰恰对这类低频、无语义的字符串最不敏感——它被压进 1536 维空间之后,和其他一堆编号挤在一起。两种方法的强弱正好互补,这不是巧合,是它们的计算原理决定的。
关键词那一路怎么做?教学实现直接用 Postgres 的全文检索(tsvector 加 ts_rank),不额外引一个搜索引擎。但中文有个坑必须提前说破:Postgres 默认的分词器对中文等于不分词——整句话会被当成一个 token,于是你搜什么都搜不到。教学实现的兜底是按相邻两字切开(bigram),「运费规则」切成「运费」「费规」「规则」;英文单词和编号(sf-3000、e4032)按整词保留,不能切碎。生产环境要上专门的中文分词扩展,别拿 bigram 当终点。
// 中文按相邻两字切;英文与编号按整词保留——切碎了 e4032 就再也搜不到了
export function bigrams(text) {
const lowered = text.toLowerCase()
const tokens = []
// 先把连续的字母数字(含连字符)整段抠出来当一个 token
for (const word of lowered.match(/[a-z0-9][a-z0-9-]*/g) ?? []) tokens.push(word)
const han = lowered.replace(/[^一-鿿]/g, ' ')
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
}import re
_WORD = re.compile(r"[a-z0-9][a-z0-9-]*")
_NON_HAN = re.compile(r"[^一-鿿]")
def bigrams(text: str) -> list[str]:
lowered = text.lower()
# 字母数字整词保留,中文再按相邻两字切
tokens = _WORD.findall(lowered)
for run in _NON_HAN.sub(" ", lowered).split():
if len(run) == 1:
tokens.append(run)
tokens.extend(run[i : i + 2] for i in range(len(run) - 1))
return tokens// 依赖:JDK 17+ 标准库
static final Pattern WORD = Pattern.compile("[a-z0-9][a-z0-9-]*");
static List<String> bigrams(String text) {
var lowered = text.toLowerCase(Locale.ROOT);
var tokens = new ArrayList<String>();
WORD.matcher(lowered).results().forEach(m -> tokens.add(m.group()));
// \p{IsHan} 是 JDK 自带的汉字字符类,不用手写码点区间
for (var run : lowered.replaceAll("[^\\p{IsHan}]", " ").split("\\s+")) {
if (run.isEmpty()) continue;
if (run.length() == 1) tokens.add(run);
for (int i = 0; i + 1 < run.length(); i++) tokens.add(run.substring(i, i + 2));
}
return tokens;
}import Foundation
func bigrams(_ text: String) -> [String] {
let lowered = text.lowercased()
var tokens: [String] = []
// Swift 的 String 是字符集合,按 Character 切天然按字形,不会切坏组合字符
for word in lowered.split(whereSeparator: { !$0.isLetter && !$0.isNumber && $0 != "-" })
where word.allSatisfy({ $0.isASCII }) {
tokens.append(String(word))
}
let han = lowered.map { $0.unicodeScalars.first.map(isHan) == true ? $0 : " " }
for run in String(han).split(separator: " ") {
let chars = Array(run)
if chars.count == 1 { tokens.append(String(chars[0])) }
for i in 0 ..< max(0, chars.count - 1) { tokens.append(String(chars[i ... i + 1])) }
}
return tokens
}
private func isHan(_ scalar: Unicode.Scalar) -> Bool {
(0x4E00 ... 0x9FFF).contains(Int(scalar.value))
}现在有两份候选名单,怎么合成一份?
直觉做法是加权求和:0.6 × 余弦相似度 + 0.4 × BM25 分数。这条路走不通,原因不是权重难调,是两个分数根本不可比:余弦相似度落在 0 到 1 之间、分布密集,同一批候选之间常常只差 0.02;BM25 没有上界,一条命中三个稀有词的文档可以拿到 12 分。把它们相加,等于让 BM25 单方面决定结果——你调权重调到天亮,也只是在调「BM25 说了算的程度」。
正确做法是只用名次,不用分数:RRF(Reciprocal Rank Fusion,倒数排名融合)。每一路给出的名次 rank 换算成 1 / (k + rank),同一个文档在几路里的得分相加,k 固定取 60。
k 为什么是 60?它是这个方法被提出时用实验定下来的经验值,作用是压平前几名之间的差距:没有它(k = 0)时第一名拿 1 分、第二名 0.5 分,一路陡降,等于让每一路的冠军独裁;k 取到 60 之后,第一名 1/61、第十名 1/70,差距只有一成多,于是「两路都排进前二十」比「一路排第一」更有分量。这正是我们想要的加权方式——共识优先于单点自信。60 不是什么魔法数字,但在你有自己的评估集之前,没有理由动它。
为什么这么做有效:名次是无量纲的。第一名就是第一名,不管它的原始分是 0.83 还是 12.6。于是「两路都排进前列」的文档自然浮到最上面,而「只有一路认得它」的文档也能靠那一路的高名次挤进来——这正是我们要的,因为两路的盲区不重叠。
实验里有个可以手算的例子:两路的排名分别是 [a, b, c] 与 [c, d, a],融合后第一名是 a,得分 1/61 + 1/63 ≈ 0.03227。而 starter 里那个「分数直接相加」的版本,同一组输入算出的第一名是 c,得分 12.635——c 只是在第二路里排第一而已,它的 BM25 原始分把一切都压过去了。
这个例子里还藏着一个必须处理的细节:a 和 c 是同分的(两者都是「一路第一、一路第三」,1/61 + 1/63 完全相等)。同分时谁在前,如果交给哈希表的遍历顺序去决定,同一份输入在不同语言、不同运行里会给出不同的结果——今天的四份实现里,只有显式加了「同分按 id 排」这一步的版本才处处一致。检索结果必须可复现,否则你的评估集量出来的数字每次都不一样。
export const RRF_K = 60
// 输入是若干路排好序的 id 列表(每路已按自己的分数降序)。分数一律丢掉,只用名次。
export function rrfFuse(rankings) {
const scores = new Map()
for (const ranking of rankings) {
ranking.forEach((id, index) => {
// index 从 0 开始,名次从 1 开始
scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + index + 1))
})
}
// 同分时按 id 定序:不加这一步,结果就取决于 Map 的遍历顺序,不可复现
return [...scores.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([id]) => id)
}RRF_K = 60
def rrf_fuse(rankings: list[list[str]]) -> list[str]:
scores: dict[str, float] = {}
for ranking in rankings:
for index, doc_id in enumerate(ranking):
# 名次从 1 开始;分数一律丢掉
scores[doc_id] = scores.get(doc_id, 0.0) + 1 / (RRF_K + index + 1)
# 同分时按 id 升序:先按 id 排一遍,再按分数降序稳定排序
return sorted(sorted(scores), key=scores.__getitem__, reverse=True)// 依赖:JDK 17+ 标准库
static final int RRF_K = 60;
static List<String> rrfFuse(List<List<String>> rankings) {
var scores = new HashMap<String, Double>();
for (var ranking : rankings) {
for (int index = 0; index < ranking.size(); index++) {
// merge 比 getOrDefault + put 少一次查找,也不会漏掉初始值
scores.merge(ranking.get(index), 1.0 / (RRF_K + index + 1), Double::sum);
}
}
// HashMap 无序,同分时必须显式定序,否则换一次 JVM 结果就变了
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed()
.thenComparing(Map.Entry.comparingByKey()))
.map(Map.Entry::getKey)
.toList();
}let rrfK = 60
func rrfFuse(_ rankings: [[String]]) -> [String] {
var scores: [String: Double] = [:]
for ranking in rankings {
// enumerated() 给出的 offset 从 0 开始,名次要加 1
for (index, id) in ranking.enumerated() {
scores[id, default: 0] += 1 / Double(rrfK + index + 1)
}
}
// Dictionary 无序且 sorted(by:) 不保证稳定,同分必须显式用 id 兜底
return scores.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.map(\.key)
}重排:贵,所以只对少量候选做
融合之后的名单还是「按检索信号排的」,不是「按和问题的相关程度排的」。重排这一步就是把候选逐条和问题细对一遍。
教学实现用 LLM 批量打分:把 40 条候选和问题一次性发给模型,让它给每条打 0 到 10 分,按分数重排。这么做的好处是不用额外引一个模型,坏处是慢、贵,而且分数会随提示词的措辞漂移。
生产环境的做法不一样,这个口径差别一定要说清楚:生产用的是专门训练的 cross-encoder 重排模型(把问题和候选拼在一起送进同一个编码器,直接输出相关度)。代价是多一次 100 到 300 毫秒的调用,外加一台推理机器——它不是 API 调用那种「按 token 付费」的东西,你得为它准备资源。所以重排的候选数要卡住:40 条是教学量级,生产里常见的是 50 到 100 条封顶。
别把教学版当成标准做法。 面试里说「我用 LLM 给检索结果打分做重排」,追问一定是「延迟和成本怎么算的、为什么不用专用重排模型」——答不上来就露馅了。
引用:模型会引用一个你没给过它的编号
检索到的东西进了上下文,模型给出回答——然后你要让它标出每句话的依据来自哪一条。做法很朴素:给每个候选块编号,上下文里写成 [1] 正文……,让模型在回答里用 [1] 标注,末尾列来源清单。
固定的形状是每个块带三样:chunkId(块的唯一标识,形如 s07#0)、sourceId(它属于哪篇文档)、sourceTitle(文档标题,用来渲染来源清单)。
这一节真正要讲的坑是幻觉引用:模型会引用一个根本不存在的编号。 今天实验里就有一条真实输出,你给了它 8 条候选,它在回答里写了 [2][9]——9 号从来不存在。这不是罕见现象,它在长回答和候选数多的时候相当稳定地出现。
后处理要做两件事,缺一不可:
- 把不存在的编号从回答里剔除,别原样发给用户。用户点开来源清单发现只有 8 条,会认为整个系统在胡说。
- 把「这一条回答里出现过幻觉引用」计进一个指标(呼应 D21 的可观测面板)。剔除只是止血,指标才能告诉你这件事最近是变多还是变少——它的曲线一抬头,通常意味着检索质量或提示词最近被改坏了。
没有评估集,你连「改好了」都不知道
前面几节的每一个改动,凭什么说它是改进?靠感觉试几条 query,是这个领域最常见的自欺。
评估集的形状很简单:20 条 query,每条人工标注 1 到 3 个「必须召回」的 chunkId。 注意标注的是块不是文档——检索的粒度就是块,标到文档级会让指标虚高。
三个指标各回答一个不同的问题:
- recall@5:进上下文的 5 条里,覆盖了多少应该召回的内容。这是你真正关心的数,因为模型只看得到这 5 条。
- recall@20:第一轮捞上来的 20 条里覆盖了多少。它是天花板——recall@20 上不去,说明问题在召回侧,重排再强也没用。
- MRR(平均倒数排名):第一个正确结果平均排在第几位。它对排序质量敏感,recall 一样时用它分高下。
今天实验里三档的真实数字,三行放在一起看最有说服力:
只用向量 recall@5 57% recall@20 83% MRR 0.677
向量 + 关键词 + RRF recall@5 80% recall@20 95% MRR 0.732
向量 + 关键词 + RRF + 重排 recall@5 91% recall@20 98% MRR 0.908这三行要横着读,也要竖着读。 横着读:加了关键词那一路,recall@5 从 57% 涨到 80%——那 23 个百分点就是向量的盲区。竖着读第二列:recall@20 从 83% 涨到 95%,天花板被抬高了;而重排那一行 recall@20 只从 95% 到 98%(重排不召回新东西,它只重排已有的 20 条),它涨的是 recall@5(80% → 91%)和 MRR(0.732 → 0.908)。
这就是三个指标分工的意义:混合检索抬天花板,重排把天花板上的东西挪到前五名。 两件事不能互相替代,也不能用一个数字概括。
三个还没说的坑:切块、条数、拼接顺序
切块。 固定 512 字符、重叠 64 字符是一个够用的起点(今天实验里 54 篇文档切成 64 块,最长的一块 509 字符)。重叠是为了防止一个完整的意思正好被切在中间——一条退款规则的前半句在第 1 块、后半句在第 2 块,两块都不完整,两块都召不回来。更好的做法是按语义段落切(按标题、按列表项),代价是要写解析逻辑,而且不同来源的文档结构不一样。先用固定长度跑通、拿评估集量出来,再决定值不值得上语义切分。
条数。 进上下文的条数不是越多越好。多了三件事同时变坏:token 成本线性上升、无关内容稀释注意力、以及编号变多之后幻觉引用也跟着变多。5 条是一个经验起点,调它的依据应该是 recall@5 和 recall@10 的差值——差值很小就说明加条数没用。
拼接顺序。 把最相关的一条放在最后、紧挨着问题。原因是模型对长上下文中间部分的注意力明显弱于头尾(业内叫 lost-in-the-middle)。既然要选一头,就选离问题最近的那一头。这条改动一行代码,不花钱,属于白捡的收益。
源码导读
动手实验
starter/ 挖了 4 个练习点,MOCK=1 下完全离线跑通,不需要 Postgres、Docker 或 API key——基础设施走内存实现,embedding 由文本内容确定性生成,所以召回率是真算出来的,改一条 query 数字就会变。想连真 pgvector 就 docker compose up -d 之后设置 DATABASE_URL(宿主端口 5524),跑的是同一份检索代码。
- 原样跑一次
MOCK=1 SELFTEST=1 pnpm start,记下基线:五项里只有第 1 项 ✅,recall@5 三档是 57% → 69% → 69%(后两档涨不动,因为关键词那一路和重排都还没接上)。 - 实现
bigrams(练习 1),让中文 query 在关键词那一路真的能召回,第 2 项变 ✅。 - 把融合改成 RRF(练习 2)——丢掉两路的原始分数、只用名次,第 3 项变 ✅,同时 recall@5 的第二档跳到 80%。
- 接上
rerankTopK(练习 3),第 4 项的三档递进补齐到 57% → 80% → 91%。 - 实现
stripHallucinatedCitations(练习 4),把不存在的编号剔除并计进指标,第 5 项变 ✅;顺手看一眼那条被剔除的[9]长什么样。
面试题
今天 4 道题在下方题库区,侧重混合检索为什么必要、重排解决什么、召回率怎么量。展开后先看"分析过程"再看要点——第 1 题里「两路盲区不重叠」是题眼,第 4 题的评估集设计是国内面试的极高频追问,别跳过。
检查清单与明日预告
- 能实现关键词检索和向量检索结合的 hybrid search
- 能给检索结果接入一个 rerank 步骤,提升排序质量
- 能设计一套 recall 评估方法,衡量检索是否召回了应该召回的内容
- 能说清为什么两路的分数不能加权求和,只能用 RRF 融合名次
- 能说清 recall@5、recall@20、MRR 三个指标各自回答什么问题
- 实验的 5 条验收标准全部通过
- 4 道面试题不看要点也能答出至少 3 道
明天(D25)我们把这套后端第一次接到人眼前。到今天为止它会分诊、会拆任务、会自我评审、会主动关怀、找得准、还挡得住攻击——但你从头到尾没有从浏览器里看过它一眼。明天写一个 React 聊天前端:流式渲染、把工具调用过程可视化、以及打断和重试这两个看起来简单、实际上前后端必须配合才做得对的交互。顺序是有意的:先把后端做对,再谈怎么把它呈现出来——反过来做,你会把一堆后端缺陷用前端的加载动画盖住。
Interview questions
Why isn't pure vector search enough — what does keyword search add?为什么单纯的向量检索不够,还要加一路关键词检索?
Common in ChinaCommon overseasBasic#rag#hybrid-search#retrievalHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- Expected follow-up 1: how do you merge the two rankings? Answer RRF, and explain why weighted sums fail (see q02).
- 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.
分析过程 · 先想清楚再作答
- 这题的区分度不在「你知不知道有 hybrid search」,而在**你能不能说出一个向量检索一定会漏的具体例子**。答不出例子的,一听就是只看过架构图。
- 推导链只有一句:向量检索比的是语义距离,所以它的强项和弱项都来自「压缩成语义」这一步——同义词能对上(运费 / 邮费),而没有语义的字符串会被压到一起(E4032、SF-3000、订单号、人名)。
- 关键词那一路(BM25)的性质正好相反:一个词在本文档里越频繁越相关、在全语料里越常见越不值钱,所以它对低频稀有词极准,对同义改写完全无能。
- 结论要说成「两者的盲区不重叠,而且是由计算原理决定的不重叠」——不是「多一路更保险」这种模糊说法。举一个实测例子最有说服力:查「E4032 是什么意思」,向量 top5 里没有那篇讲支付错误码的文档,关键词 top1 就是它。
- 可预期的追问一:那怎么合并两路结果?答 RRF,并说清为什么不能加权求和(见 q02)。
- 可预期的追问二:中文怎么做关键词检索?答 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#rankingHow to reason about it · think before answering
- 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.
- 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.
- Worse, it is unstable. Weights tuned on one corpus drift on the next, so you re-tune forever.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 题眼在后半句。前半句答「RRF」谁都会,后半句「为什么不能加权求和」才是筛人的地方——它考的是你有没有真的看过两路分数的分布。
- 怎么拆:先问自己两个分数是不是同一个量纲。余弦相似度有界(0 到 1)且分布密集,同一批候选常常只差 0.02;BM25 无上界,命中几个稀有词就能到 12 分。**不同量纲的数相加,等于让量纲大的那一路单方面决定结果**,权重只是在调「它说了算的程度」。
- 更麻烦的是它不稳定:权重在这批语料上调好了,换一批语料分布就变了,得重调。这是一个永远还不完的技术债。
- 结论:改用名次。RRF 把每一路的名次折算成 `1/(k + rank)` 再相加,k 取 60。名次是无量纲的,不需要任何标定。k 的作用是压平头部差距,让「两路都进前列」压过「一路排第一」——共识优先于单点自信。
- 一个能当场手算的例子很加分:两路排名 [a,b,c] 与 [c,d,a],a 得 1/61 + 1/63 ≈ 0.0323;而分数直接相加的版本会把 BM25 里 12 分的 c 顶到第一。
- 可预期的追问:同分了怎么办?必须显式定序(比如按 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#latencyHow to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
分析过程 · 先想清楚再作答
- 这题最容易答成「再排一次序,更准」。面试官想听的是**为什么第一轮不能直接排准**,以及**为什么重排不能对全库做**。
- 怎么拆:第一轮的排序依据是「检索信号」——余弦距离或词频统计,它们是为了能在百万条里快速筛选而设计的,代价就是粗。重排换了一种算法:把 query 和候选**拼在一起**送进同一个模型算相关度(cross-encoder),精度高得多,但复杂度是每条候选一次前向,没法对全库做。所以它必须跟在一个宽召回后面。
- 结论要区分两种实现:教学 / 原型可以用 LLM 批量打分(一次调用给 40 条打 0 到 10 分),生产用专门训练的 cross-encoder 重排模型。**代价说清楚:多一次 100 到 300 毫秒的调用,外加一台推理机器**——它不是按 token 计费的 API,是要占资源的。
- 把它放进漏斗里说最清楚:召回决定天花板,重排决定天花板上的东西能不能排到前五。实测的样子是——加了关键词那一路,recall@20 从 83% 涨到 95%(天花板抬高);再加重排,recall@20 只到 98%,但 recall@5 从 80% 跳到 91%、MRR 从 0.732 到 0.908。
- 可预期的追问一:重排能不能提高召回?不能。它不引入新候选,只重排已有的那批——所以看 recall@20 判断重排效果是错的指标。
- 可预期的追问二:为什么不用 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#recallHow to reason about it · think before answering
- 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?
- 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.
- 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.
- 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.
- 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.
- Expected follow-up: is 20 enough given the labelling 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.
分析过程 · 先想清楚再作答
- 这题是国内面试的极高频题,也是最容易暴露「只搭过没调过」的一题。判据很简单:你的回答里有没有出现**具体的指标名和标注粒度**,没有就是没做过。
- 先把评估对象分清楚——这是最容易混的一步:**检索评估问「找得到找不到」,生成评估问「答得对不对」**。两套评估集要分开维护。混成一套的后果是分数掉了你分不清是检索漏了还是模型答砸了,而这两件事的修法完全不同。
- 评估集的形状:20 条左右的 query,每条**人工标注 1 到 3 个必须召回的 chunkId**。注意标注粒度是**块**不是文档——检索的单位就是块,标到文档级会让指标虚高。query 要覆盖真实分布,尤其要包含那些你知道会翻车的类型(编号、同义改写、跨文档)。
- 三个指标各回答一个问题:recall@5 是「进上下文的那几条覆盖了多少」,也就是你真正关心的数;recall@20 是天花板,它上不去说明问题在召回侧、重排再强也没用;MRR 对排序质量敏感,recall 打平时用它分高下。
- 生产视角:评估集一旦定下来就要冻结,换了样本分数就没有可比性——这和产线质检必须用固定的标准样品是同一个道理。同时线上要有对照指标(引用为空率、幻觉引用率、转人工率),因为离线过了不等于线上没事。
- 可预期的追问:标注成本这么高,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 labelled 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
Sign in to join the discussion
No comments yet — be the first.