DAY 43 / PHASE 4 · ENGINEERING

检索质量工程

Recall vs Precision · Hybrid Fusion · Reranker · Retrieval Eval

2026-06-22 · BigCat

RAG 的天花板不是 LLM,是检索召回率——LLM 看不到的文档,再聪明也答不出。

// WHY THIS MATTERS

chunk 怎么切、embedding 用哪家,Day 10 / 11 已讲透。这一期换一个更狠的视角:检索质量本身怎么量化、怎么调、怎么不被自己骗。现实是大多数 RAG 系统的瓶颈根本不在生成端——LLM 拿到对的文档几乎都能答对,拿不到就只能编。所以 RAG 工程的真正战场在「对的文档有没有进 top-k」这件事上。但绝大多数人从不量化它:上线时拍脑袋调 top_k=5,效果差就怪「模型不行」,从没建过一个能跑数字的 golden set。本期讲四件能立刻拉开差距的事:为什么召回要优先于精度、混合检索为什么必须用 RRF 而非加权求和、reranker 的真实价值与成本边界、以及怎么把检索失败和生成失败拆开归因——附 Anthropic Contextual Retrieval 的实测数字和可直接跑的评估代码。

// 01

召回优先于精度:两阶段检索的根本权衡

论断:第一阶段的唯一 KPI 是 recall@k——宁可多召回噪声,绝不能漏掉正确文档。精度交给后面的 reranker 和 LLM。

背景与原理

检索质量是 recall 和 precision 的拉锯,但两者在 pipeline 的不同阶段地位完全不对称。关键在于:漏召回的错误不可恢复,多召回的错误可以补救。正确文档没进第一阶段候选集,后面 reranker 再强、LLM 再聪明都救不回来——它根本看不到;反过来候选集里混进无关文档,只是给下游加一点筛选负担,代价小得多。

所以工业级 RAG 几乎都是两阶段:第一阶段(recall)用便宜快速的检索器(BM25 + 向量)过召回到 top-100~200,把正确文档「圈进来」;第二阶段(rerank)用昂贵精准的 cross-encoder 压到 top-5~8,把候选「排对序」。这直接决定调参方向:第一阶段 k 往大了调、宁错杀不放过;第二阶段才追求 precision。

┌────────── 两阶段检索:召回宽进、精排准出 ──────────┐ │ │ │ query │ │ │ │ │ ├─▶ STAGE 1 · RECALL (要 recall@k 高·快·便宜) │ │ │ BM25(lexical) ─┐ │ │ │ ├─▶ RRF 融合 ─▶ top-100~200 │ │ │ dense(vector) ─┘ 候选(过召回) │ │ │ │ │ │ └─▶ STAGE 2 · RERANK (要 precision·慢·贵) │ │ cross-encoder 重排 ◀────────────┘ │ │ │ │ │ ▼ top-5~8 │ │ ┌──────────────┐ │ │ │ LLM context │ ← 只有进到这里的才可能被引用 │ │ └──────────────┘ │ │ │ │ 漏召回 = 不可恢复的错 · 多召回 = 可补救的小代价 │ └───────────────────────────────────────────────────────┘

实战示例

先量化你当前系统的「召回天花板」——这是 RAG 调优的第一个数字,绝大多数人从没测过:

# 给定一批 (query, 正确 chunk_id) 标注,测 recall@k 随 k 变化
def recall_at_k(retriever, eval_set, ks=(5,10,20,50,100,200)):
    hits = {k: 0 for k in ks}
    for q, gold_ids in eval_set:        # gold_ids: 该 query 的正确 chunk 集合
        ranked = retriever.search(q, top_k=max(ks))   # 返回 chunk_id 列表
        for k in ks:
            if set(ranked[:k]) & gold_ids:   # top-k 命中任一正确文档
                hits[k] += 1
    return {k: hits[k]/len(eval_set) for k in ks}

# 典型输出:recall@5=0.71  @20=0.88  @100=0.96  @200=0.97
# 解读:@5 只有 0.71 → 29% 的 query 正确文档没进 LLM。
#       @100 到 0.96 → 召回器本身够好,瓶颈在「排序」不在「检索」
#       → 该上 reranker,把 @100 的好东西排进 top-5,而不是换 embedding

曲线形状直接告诉你下一步:recall@200 都上不去(比如 <0.85)→ 问题在检索器/chunk 策略,换 embedding 或上混合检索;recall@100 高但 recall@5 低 → 问题在排序,该上 reranker。不先画这条曲线就调参,等于闭眼开车。

失败模式:把 top_k 设得太小(比如直接 5)然后抱怨 RAG 不准。小 k 在第一阶段会直接把正确文档挡在门外,而你永远不会知道——因为没有标注集,你看不到 recall。第一阶段的 k 应该由 reranker 能吃下的候选数(通常 50~200)决定,不是由最终塞进 LLM 的数量决定。
进阶资源 · Pinecone Rerankers and Two-Stage Retrieval, pinecone.io/learn/series/rag/rerankers · BEIR benchmark(IR 指标体系), arXiv:2104.08663
// 02

混合检索:BM25 + 向量用 RRF 融合,而不是加权求和

论断:lexical 和 semantic 召回的是不同的「错」,融合能补盲区——但融合方式必须用 rank-based 的 RRF,不能直接加分数。

背景与原理

向量检索(dense)擅长语义匹配,但对精确字面量是盲的:产品型号 X-42B、错误码 ERR_TIMEOUT、人名、罕见专有名词——这些 embedding 会糊成一片相近向量。BM25(lexical/sparse)正相反:精确词命中极强,但完全不懂同义和语义。两者召回的是互补的失败集合,所以混合检索几乎总是 strict 优于任一单路。Anthropic 的 Contextual Retrieval(2024)给了硬数字:仅 Contextual Embeddings 把 top-20 检索失败率降 35%(5.7%→3.7%),叠加 Contextual BM25 后降 49%(→2.9%),再叠 reranking 降 67%(→1.9%)——三层每一层都在切实补盲。

但融合不能粗暴地把两个检索器的相似度分数加起来:BM25 分数无界(可以是 8.3 也可以是 41.2),cosine 在 [-1,1],量纲不可比,直接加权等于让 BM25 的尺度碾压向量分。正确做法是 RRF(Reciprocal Rank Fusion,Cormack 2009):只用排名不用分数,天然免疫量纲问题。每个文档融合分 = Σ 1/(k+rank),k 通常取 60。

实战示例

# RRF:只看每个检索器里的「名次」,不看原始分数 → 免归一化
def rrf(rank_lists, k=60, top_n=100):
    # rank_lists: [[doc_id 按名次排序], ...] 来自 BM25 / dense / ...
    scores = {}
    for ranking in rank_lists:
        for rank, doc_id in enumerate(ranking):   # rank 从 0 起
            scores[doc_id] = scores.get(doc_id, 0) + 1/(k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)[:top_n]

bm25_hits  = bm25.search(q,  top_k=100)     # 字面精确
dense_hits = vector.search(q, top_k=100)    # 语义相近
fused = rrf([bm25_hits, dense_hits])        # 两路名次互补叠加
# 一个文档若两路都排前面 → 分数叠加冲到最顶;只一路命中 → 仍有机会进候选

RRF 的工程优势是零调参、零训练、加新检索器只是多传一个 list。k=60 是论文给的稳健默认,几乎不用动。要更进一步再考虑学习型融合,但 90% 场景 RRF 就是性价比最高的终点。

失败模式:(1)用加权求和 α·dense + (1-α)·bm25 并花一周调 α——量纲不可比,α 在不同 query 上最优值飘忽,本质是在拟合噪声;换 RRF 直接消掉这个问题。(2)以为「上了向量检索就不需要 BM25」——对 exact-match 重的领域(代码、法律条款号、SKU),纯向量召回会系统性漏掉关键字面命中,这类 query 恰恰是用户最不能容忍出错的。
进阶资源 · Anthropic Introducing Contextual Retrieval(含混合检索实测), anthropic.com/engineering/contextual-retrieval · Cormack et al. Reciprocal Rank Fusion(SIGIR 2009), dl.acm.org/.../1572114
// 03

Reranker:cross-encoder 的真实价值与成本边界

论断:reranker 是性价比最高的单点质量提升,但它的代价是 latency——它只该处理几十个候选,不该当检索器用。

背景与原理

第一阶段的向量检索用的是 bi-encoder:query 和 doc 各自独立编码成向量再算相似度。独立编码意味着可以离线把全库向量算好、查询时只算一次 query 向量——快,但 query 和 doc 之间没有任何 token 级交互,语义匹配是粗粒度的。Reranker 是 cross-encoder:把 (query, doc) 拼成一条喂进模型,做完整的 cross-attention,query 的每个 token 都能「看到」doc 的每个 token——匹配精度高一个量级。

代价是 cross-encoder 不能预计算:每个 (query, doc) 对都要现场跑一次完整 forward,100 个候选就是 100 次推理,latency 和成本随候选数线性涨。所以它天然是第二阶段精排:只处理过召回的几十~一两百个候选,排对序、砍到 top-5。绝不能拿 reranker 当检索器扫全库——百万文档 × 每条 forward,延迟和账单都会爆炸。

实战示例

# 两阶段:bi-encoder 召回(快) → cross-encoder 精排(准但只吃候选)
import cohere
co = cohere.Client()

candidates = rrf([bm25_hits, dense_hits])[:100]   # 阶段1:过召回 100
docs = [chunk_text[c] for c in candidates]

reranked = co.rerank(                              # 阶段2:cross-encoder 精排
    model="rerank-v3.5", query=q, documents=docs, top_n=5)
final = [candidates[r.index] for r in reranked.results]
# 100 → 5:只有这 5 条进 LLM。reranker 把 recall@100 的好东西
# 「翻译」成 precision@5。这是 §1 召回曲线里最划算的一跳。

什么时候 reranker 值得?看 §1 那条召回曲线:recall@100 高、recall@5 低的缺口,就是 reranker 的收益空间——正确文档已经在候选里,只是没排进前列。Anthropic 实测里 reranking 那一层把失败率从 2.9% 再降到 1.9%,印证它是叠加在混合检索之上的有效一层。开源选 bge-reranker-v2 自托管,托管选 Cohere Rerank / Pinecone Rerank。

失败模式:(1)候选数给太大(比如 rerank 500 条)——latency 线性飙升,而 recall@500 vs @100 的边际增益极小,纯亏延迟;先用召回曲线定候选数。(2)盲目上 reranker 而不先看召回曲线:如果 recall@100 本身就低(检索器漏召回),reranker 排的是一堆错的,排得再准也没用——这种情况该先修第一阶段。reranker 提升 precision,救不了 recall。
进阶资源 · Pinecone Rerankers and Two-Stage Retrieval(bi- vs cross-encoder), pinecone.io/learn/series/rag/rerankers · Anthropic Contextual Retrieval(reranking 实测层), anthropic.com/engineering/contextual-retrieval
// 04

检索评估:建 golden set,把检索失败和生成失败拆开归因

论断:RAG 答错时,99% 的人去调 prompt——但你根本不知道是检索没给对文档,还是 LLM 拿到对文档也答错了。不拆开,就是瞎调。

背景与原理

RAG 是检索 + 生成两段串联,任一段失败都表现为「答错」,但修复方向南辕北辙。检索失败(正确文档没进 top-k)要修 chunk/embedding/混合检索/reranker;生成失败(文档在 context 里 LLM 却没用对)要修 prompt/模型。混在一起调,你会在错的那一半上浪费所有时间。所以 RAG 评估第一原则是分层归因:

关键工程动作是先建一个 golden set:50~200 条 (query, 正确 chunk_id, 理想答案) 标注。这是 RAG 工程里 ROI 最高的一次性投入——有了它,每次改 chunk 策略、换 embedding、调 reranker 都能跑出 recall 数字对比,而不是凭「感觉好像准了点」上线。Pinecone 那句话很到位:RAG evaluation —— don't let customers tell you first

实战示例

# 分层归因:先判检索对不对,再判生成对不对 —— 同一个错落到不同桶
def diagnose(sample, retriever, llm):
    q, gold_ids, gold_answer = sample
    retrieved = retriever.search(q, top_k=5)
    retrieval_ok = bool(set(retrieved) & gold_ids)   # 正确文档进了 top-5?

    answer = llm.generate(q, context=[chunk_text[c] for c in retrieved])
    answer_ok = judge_faithful(answer, gold_answer)  # LLM-judge 判忠实度

    if not retrieval_ok:
        return "RETRIEVAL_FAIL"    # → 修 chunk/embedding/hybrid/rerank
    if not answer_ok:
        return "GENERATION_FAIL"   # → 修 prompt/模型(文档在 context 里却没答对)
    return "OK"

# 跑完 golden set 统计两类占比:
# RETRIEVAL_FAIL 60% / GENERATION_FAIL 40% → 主战场在检索,别再调 prompt

这张归因表跑一次就知道你的工程时间该投哪。多数团队拍脑袋以为是「prompt 不行」,跑出来发现一大半是 RETRIEVAL_FAIL——文档压根没进 context,prompt 调到天荒地老也没用。

失败模式:(1)只看端到端答案对错、不分层——答错了不知道修哪;分层归因是这一整套的核心。(2)golden set 全是简单 query,线上真实分布是长尾难 query,离线 recall 漂亮线上崩;标注要覆盖真实 query 分布(尤其 exact-match、多跳、否定这些硬 case)。(3)用同一个 LLM 既生成又当 judge 评 faithfulness——self-eval 有 backward rationalization 偏差,judge 最好换一个模型或加 reference 锚定。
进阶资源 · Pinecone RAG Evaluation: Don't let customers tell you first, pinecone.io/.../rag-evaluation · BEIR(nDCG/MRR/recall 标准指标), arXiv:2104.08663

// 综合实战 · 一条命令量化你的 RAG 检索质量

把四点串成一个能跑的诊断流水线——周末花两小时,从此告别「凭感觉调 RAG」:

  1. 标 golden set:从线上日志抽 100 条真实 query,人工标 (query, 正确 chunk_id)。覆盖长尾:exact-match(型号/错误码)、多跳、否定各留几条。这是唯一一次性人工成本。
  2. 画召回曲线(§1):跑 recall_at_k,看 @5/@20/@100/@200。判断瓶颈是「检索」(@200 低)还是「排序」(@100 高 @5 低)。
  3. 加混合检索(§2):在纯 dense 上叠 BM25,RRF 融合,重画曲线。看 recall@100 涨多少——exact-match query 应明显改善。
  4. 加 reranker(§3):对 top-100 候选上 cross-encoder 精排,看 recall@5 / nDCG@5 跳多少。这一跳通常是最划算的。
  5. 分层归因(§4):跑 diagnose,统计 RETRIEVAL_FAIL vs GENERATION_FAIL 占比,锁定下一步主战场。
  6. 固化成 CI:把这套挂进 Day 42 讲的 eval 门——以后每次改 chunk/embedding/prompt,recall@5 掉了就红灯,杜绝「改一个修一个、坏三个不知道」。

做完这一套,你对自己 RAG 的认知会从「感觉还行」变成「recall@5=0.84,瓶颈在 exact-match 召回,下一步上 BM25」——这是工程和玄学的分界线。

// ENGLISH GLOSSARY

Recall@k
正确文档落入 top-k 候选的 query 占比。两阶段检索第一阶段的核心 KPI。
Precision
返回结果中相关文档的占比。第二阶段(reranker / LLM)负责的指标。
Two-Stage Retrieval
先用便宜检索器过召回,再用昂贵 reranker 精排的标准架构。
Bi-Encoder
query 与 doc 各自独立编码成向量,可预计算、快,但无 token 级交互。向量检索用。
Cross-Encoder
把 (query, doc) 拼成一条做完整 cross-attention,精度高但不能预计算。reranker 用。
BM25
经典 lexical/sparse 检索,精确词命中强、不懂语义。混合检索的 lexical 半边。
RRF
Reciprocal Rank Fusion。只用排名不用分数融合多路检索,免归一化、零调参。
nDCG / MRR
带位置权重的排序质量指标。评估「排得对不对」,不只是「召没召回」。
Contextual Retrieval
Anthropic 2024 方法:embed 前给 chunk 补全文档上下文,显著降低检索失败率。
Faithfulness
生成答案是否忠于检索到的文档(无幻觉)。生成层评估的核心指标。

// 深入思考

「召回优先于精度」是普适原则,还是有反例?什么场景下你反而该牺牲 recall 换 precision?
不普适。它成立的前提是「下游有 reranker + LLM 能消化噪声」。反例:(1)无 reranker 且 context 极贵——把噪声直接塞进小窗口 LLM,会触发 lost-in-the-middle,正确文档被无关文档稀释,这时高 precision 比高 recall 更重要。(2)直接面向用户展示检索结果(如搜索框),用户不会翻 100 条,top-3 的 precision 就是体验本身。原则的本质是「错误可恢复性」:只要下游能补救多召回,就优先 recall;一旦多召回的代价不可恢复(污染 context / 直接呈现),就反转。
RRF 完全丢弃了原始相似度分数,只用排名。这种「信息损失」难道不会丢掉有用信号吗?
会丢,但丢的是不可比的信号。原始分数跨检索器不可比(BM25 无界 vs cosine 有界),强行用反而引入尺度偏差。RRF 的设计哲学是:与其用一个污染的强信号,不如用一个干净的弱信号(排名)。代价是它对「第 1 名比第 2 名强很多」这种组内分数差不敏感——两个检索器都把某文档排第一,RRF 看不出谁更自信。需要这种细粒度时才上学习型融合(如用分数做特征训一个 LTR 模型),但那要标注、要训练、会过拟合。RRF 是「够好且零成本」,90% 场景的理性终点。
Anthropic Contextual Retrieval 在 embed 前给每个 chunk 补全文档上下文。这跟单纯把 chunk 切大有什么本质区别?
本质不同。切大 chunk 是把更多原始相邻文本塞进同一向量,会稀释主题、增加噪声、还是会丢全局上下文(chunk 仍只看到自己周围)。Contextual Retrieval 是用 LLM 给每个 chunk生成一段专门解释「这段在全文里讲什么、属于哪个实体/章节」的上下文再拼接 embed——补的是跨 chunk 的全局指代。典型例子:一个 chunk 写「该公司 Q3 收入增长 3%」,单独 embed 时「该公司」无指代,语义模糊;补上「这段出自 ACME 2023 年报」后,query「ACME 第三季度业绩」才能召回它。代价是预处理要对每个 chunk 跑一次 LLM(成本靠 prompt caching 摊薄)。
为什么检索失败和生成失败必须用「不同模型」当 judge?同一个 LLM 既答又评有什么具体偏差?
核心是 backward rationalization(事后合理化):模型评估自己刚生成的答案时,倾向为自己的输出找理由、给高分,因为生成路径和评估路径共享同样的先验和盲区——它编造的「事实」在自评时仍然「看起来对」。具体偏差:(1)self-preference,系统性高估自己的答案;(2)共享幻觉,生成时编的内容评估时检测不出。缓解:judge 换一个不同家族的模型(打破共享先验)、给 judge 提供 reference answer 做锚定(把开放评分变成对比评分)、或用 pairwise 比较替代绝对打分。Day 6 的 LLM-as-judge 去偏讲过这套,检索评估里 faithfulness 判定同样适用。
如果未来 context window 涨到 1000 万 token、且便宜到可以塞全库,检索质量工程还有意义吗?
仍有意义,但重心转移。即使能塞全库,三个问题不消失:(1)lost-in-the-middle——长 context 中段信息利用率系统性下降,塞得越多,关键文档被「淹没」的风险越大,排序/筛选反而更重要;(2)成本与延迟——每 query 处理千万 token 的账单和 TTFT 不可接受,检索是必要的预过滤;(3)归因与可控——检索给出明确的引用来源,全塞进去则无法溯源、无法做 access control。所以检索不会消失,但会从「因为塞不下所以必须选」转向「为了精度、成本、可溯源而主动选」。质量工程的 recall/precision 权衡依然成立,只是 k 的取值空间变大了。

// 延伸阅读