RAG 的天花板不是 LLM,是检索召回率——LLM 看不到的文档,再聪明也答不出。
chunk 怎么切、embedding 用哪家,Day 10 / 11 已讲透。这一期换一个更狠的视角:检索质量本身怎么量化、怎么调、怎么不被自己骗。现实是大多数 RAG 系统的瓶颈根本不在生成端——LLM 拿到对的文档几乎都能答对,拿不到就只能编。所以 RAG 工程的真正战场在「对的文档有没有进 top-k」这件事上。但绝大多数人从不量化它:上线时拍脑袋调 top_k=5,效果差就怪「模型不行」,从没建过一个能跑数字的 golden set。本期讲四件能立刻拉开差距的事:为什么召回要优先于精度、混合检索为什么必须用 RRF 而非加权求和、reranker 的真实价值与成本边界、以及怎么把检索失败和生成失败拆开归因——附 Anthropic Contextual Retrieval 的实测数字和可直接跑的评估代码。
检索质量是 recall 和 precision 的拉锯,但两者在 pipeline 的不同阶段地位完全不对称。关键在于:漏召回的错误不可恢复,多召回的错误可以补救。正确文档没进第一阶段候选集,后面 reranker 再强、LLM 再聪明都救不回来——它根本看不到;反过来候选集里混进无关文档,只是给下游加一点筛选负担,代价小得多。
所以工业级 RAG 几乎都是两阶段:第一阶段(recall)用便宜快速的检索器(BM25 + 向量)过召回到 top-100~200,把正确文档「圈进来」;第二阶段(rerank)用昂贵精准的 cross-encoder 压到 top-5~8,把候选「排对序」。这直接决定调参方向:第一阶段 k 往大了调、宁错杀不放过;第二阶段才追求 precision。
先量化你当前系统的「召回天花板」——这是 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。不先画这条曲线就调参,等于闭眼开车。
向量检索(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 就是性价比最高的终点。
α·dense + (1-α)·bm25 并花一周调 α——量纲不可比,α 在不同 query 上最优值飘忽,本质是在拟合噪声;换 RRF 直接消掉这个问题。(2)以为「上了向量检索就不需要 BM25」——对 exact-match 重的领域(代码、法律条款号、SKU),纯向量召回会系统性漏掉关键字面命中,这类 query 恰恰是用户最不能容忍出错的。
第一阶段的向量检索用的是 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。
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 调到天荒地老也没用。
把四点串成一个能跑的诊断流水线——周末花两小时,从此告别「凭感觉调 RAG」:
recall_at_k,看 @5/@20/@100/@200。判断瓶颈是「检索」(@200 低)还是「排序」(@100 高 @5 低)。diagnose,统计 RETRIEVAL_FAIL vs GENERATION_FAIL 占比,锁定下一步主战场。做完这一套,你对自己 RAG 的认知会从「感觉还行」变成「recall@5=0.84,瓶颈在 exact-match 召回,下一步上 BM25」——这是工程和玄学的分界线。