一、上线第三个月,Recall@5 悄悄掉了 13 个点
这是去年一个内部技术文档问答系统的真实曲线。
第一版 RAG 上线时,离线评估集上的 Recall@5 是 71%,勉强够用。前两个月业务量不大,大家相安无事。第三个月,文档库从 8000 份涨到 2.6 万份,同时接入了法务和运维两个新部门的语料。用户开始频繁反馈"搜不到""答一半""答案对但引用错了文档"。
我们拉了一批 bad case 人工归因,发现两个模式反复出现:
模式一:chunk 被切成了"失忆的句子"。 一份《账号权限管理规范》里,一个 chunk 是"操作完成后需在 24 小时内提交审计记录。"检索命中了,但 LLM 拿到这个 chunk 根本不知道"操作"指的是什么操作、"审计记录"提交给谁------因为限定这些信息的前置段落被切到了上一个 chunk 里。传统分块在切的时候,chunk 丢掉了它在文档里的位置和指代对象,变成一个孤立、可被任意解读的句子。
模式二:向量检索在"相似"和"正确"之间反复横跳。 用户问"Python GIL 是什么",系统捞回来的是一篇《Python 多线程性能优化指南》------里面 GIL 出现了十几次,余弦相似度极高,但文章讲的是"怎么绕过 GIL",不是"GIL 是什么"。向量检索返回的是 topically related 但 factually irrelevant 的 chunk,LLM 基于它流利地编出了一个错误答案。
归因结果统计下来:约 62% 的失败可以追溯到分块导致的上下文丢失,约 28% 属于检索召回不准或排序错误,真正"模型生成能力不够"的比例不到 10% 。这和研究界的分类是一致的------Splunk 的 RAG 故障框架里,FP2(答案在语料里但没进 top-K)和 FP3(检索到了但在上下文整合阶段被丢掉)是两个核心故障点,而 chunking 和 retrieval 合计贡献了 RAG 系统失败的大头,生成阶段反而是"最不容易出错的环节"。
二、方案:两个病灶,两套互不干扰的修复
我们没有推翻任何东西。原有链路是 分块 → embedding → 向量检索 → LLM,问题出在分块和检索这两层,修复也就打在这两层。
病灶一(上下文丢失) :在分块之后、embedding 之前,给每个 chunk 前置一小段文档级上下文,让 chunk 在向量空间里"带着身份被检索"。这是 Anthropic 提出的 Contextual Retrieval 的核心动作。
病灶二(召回不准) :在向量检索旁边加一路 BM25 稀疏检索,用 RRF 融合,然后对融合后的候选做 条件触发的 cross-encoder 重排。不是每条 query 都重排,只在融合分数"不够确信"时才触发。
整体数据流:
text
原始文档
│
▼
结构感知分块 + 元数据保留
│
▼
Contextual Retrieval: LLM 生成 chunk 上下文前缀 ← 修"上下文丢失"
│
├──→ 向量检索(dense)
└──→ BM25 检索(sparse)
│
▼
RRF 融合 top-40 ← 修"召回不准"(一)
│
▼
条件触发 cross-encoder 重排 → top-5 ← 修"召回不准"(二)
│
▼
LLM
关键 trade-off:Contextual Retrieval 是索引期的一次性成本,混合检索是查询期的小幅延迟增量,条件重排把最贵的那一步限制在"真的需要"的 query 上。三者叠加,不引入新的故障面。
三、核心模块拆解与代码
3.1 结构感知分块 + 上下文前缀:让 chunk 不再"失忆"
先看传统分块怎么丢上下文的。固定长度切分不认段落、不认标题、不认表格,一个条款被从中间切开,前后条件分离,检索命中的那一半是"残缺的"。我们改成按语义边界切,保留标题和章节路径作为元数据,然后在每个 chunk 的文本前面拼一段 LLM 生成的 situating context。
python
import json
from langchain_text_splitters import RecursiveCharacterTextSplitter
def structural_chunk(document: dict) -> listdict:
"""
document: {"text": ..., "metadata": {"doc_title": ..., "section_path": ...}}
按段落/标题边界切分,保留文档元数据。
"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # 长文档(规范、手册)用 800-1000
chunk_overlap=150, # 10-20% 重叠,防止关键信息落在边界上
separators="\\n## ", "\\n### ", "\\n\\n", "\\n", "。",
)
raw_chunks = splitter.split_text(document"text")
return [
{
"text": c,
"metadata": {
**document"metadata",
"chunk_index": i,
},
}
for i, c in enumerate(raw_chunks)
]
CONTEXT_PROMPT = """以下是一份文档的片段。请用 1-2 句话说明这个片段在整份文档中的位置和作用,
不要重复片段内容,不要编造片段中不存在的信息。只输出上下文说明。
文档标题:{doc_title}
章节路径:{section_path}
片段:
{chunk}
"""
def contextualize_chunk(chunk: dict, llm) -> dict:
"""生成 chunk 级上下文前缀,拼到原文前面。"""
context = llm.invoke(
CONTEXT_PROMPT.format(
doc_title=chunk"metadata".get("doc_title", ""),
section_path=" > ".join(chunk"metadata".get("section_path", \[\])),
chunk=chunk"text",
),
temperature=0,
)
关键:上下文前缀参与 embedding,但原文保持不变用于最终生成
chunk"embed_text" = f"{context}\n\n{chunk'text'}"
chunk"context_prefix" = context
return chunk
效果证据:Anthropic 的 Contextual Retrieval 报告显示,单纯前置上下文可以将 top-20 检索的失败率降低 35%;如果再叠加 contextual BM25,失败率降低幅度可以达到 49%。在我们的语料上,这一步单独把 Recall@5 从 58% 拉回到 69%,是单项收益最大的动作【此处需结合你的真实项目补充:你的语料规模、LLM 成本和实际提升】。
成本要注意:这一步需要对每个 chunk 调一次 LLM。2.6 万份文档切出约 18 万个 chunk,用低成本模型跑一遍,token 成本在几十美元量级,但时间成本是实打实的------串行跑了两天多,后来加了并发才把重建窗口压到可接受范围。如果文档高频更新,这个索引期开销需要认真算账。
3.2 混合检索 + RRF:向量不够,BM25 来补
纯向量检索的系统性盲点是精确词面信号:产品型号、错误码、罕见术语、业务别名,embedding 的语义泛化能力反而成了劣势------它会把"长得不像但意思接近"的捞回来,同时漏掉"字面完全匹配但语义 embedding 不够近"的文档。我们加一路 BM25,用 RRF(Reciprocal Rank Fusion) 融合两路排名,而不是简单加权分数。
python
from rank_bm25 import BM25Okapi
import numpy as np
def reciprocal_rank_fusion(
ranked_lists: listlist\[str], k: int = 60, top_n: int = 40
) -> liststr:
"""
RRF 融合多路排名。k 控制排名靠前文档的权重衰减速度。
ranked_lists 中每个元素是一路检索的 doc_id 有序列表。
"""
scores = {}
for ranked in ranked_lists:
for rank, doc_id in enumerate(ranked, start=1):
scoresdoc_id = scores.get(doc_id, 0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True):top_n
def hybrid_retrieve(query: str, vector_store, bm25_index, tokenizer) -> list:
dense_ids = d.id for d in vector_store.similarity_search(query, k=40)
sparse_ids = [
d.id for d in bm25_index.get_top_n(tokenizer(query), n=40)
]
fused_ids = reciprocal_rank_fusion(dense_ids, sparse_ids, top_n=40)
return fused_ids
选型数据:在一个公开的 production-grade RAG 基准上,dense-only 的 Recall@5 是 0.71,BM25-only 是 0.63,hybrid + RRF 到 0.79;如果继续叠加 cross-encoder 重排,Recall@5 到 0.84,MRR 从 0.58 提到 0.73。在另一个真实业务语料的对比中,hybrid 在 HR@5 上比 vector-only 高 +5.3pp(0.633 vs 0.589),而 vector-only 在 HR@12 上略优、p50 延迟快约 3.5 倍。结论不是"hybrid 永远更好",而是 hybrid 把 top-of-list 的质量拉上去了,这对后续重排和生成最有利。
3.3 条件触发重排:最贵的一步,只在需要时做
Cross-encoder 重排的收益很确定,但代价也很确定:一次额外的模型调用,典型延迟 200--500ms,如果每条 query 都做,P95 端到端延迟会直接绷不住。生产 RAG 的经验数据是:加 hybrid + reranker 后 Recall@5 通常能从 70% 上下提到 85--90%,这是 cost/benefit 最好的两个改动。但"最好"不等于"无脑全量"。
我们的做法是用融合后的 top-1 分数作为置信度信号:如果 RRF 融合后的第一名已经明显领先,说明两路检索"意见一致",重排大概率不会改变结果,跳过;如果 top-1 和 top-2 咬得很紧,或者 dense 和 sparse 的头部结果重叠度低,才触发重排。
python
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base") # 中文场景表现较好
def should_rerank(fused_scores: dict, dense_ids: list, sparse_ids: list) -> bool:
"""基于融合分数和两路一致性判断是否值得重排。"""
ranked = sorted(fused_scores.items(), key=lambda x: x1, reverse=True)
if len(ranked) < 2:
return False
top1, top2 = ranked01, ranked11
头部差距大,且两路 top-5 有明显重叠 → 不重排
overlap = len(set(dense_ids:5) & set(sparse_ids:5))
if top1 > top2 * 1.8 and overlap >= 2:
return False
return True
def rerank_if_needed(query: str, candidates: listdict) -> listdict:
if not should_rerank(candidates"fused_scores", candidates"dense", candidates"sparse"):
return candidates"docs":5
pairs = (query, d\["text") for d in candidates"docs"]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates"docs", scores), key=lambda x: x1, reverse=True)
return d for d, _ in ranked\[:5]
上线后的触发率大约 41%。那 59% 被跳过的 query 里,绝大多数是"两路检索高度一致、头部差距明显"的简单点查,重排对它们本来就没有增量。条件触发把重排的平均延迟贡献从 ~320ms 压到了 ~130ms,P95 端到端延迟只增加了 0.2s 左右【此处需结合你的真实项目补充:你的 reranker 模型、延迟基线和触发率】。
四、关键工程问题:成本、延迟与"修了 A 弄坏 B"
Contextual Retrieval 的索引期成本是硬门槛。 每个 chunk 一次 LLM 调用,文档更新时必须重新生成上下文前缀,否则新 chunk 和旧 chunk 的向量空间会不一致。我们的更新策略是增量 contextualize + 全量重建窗口:日常文档变更只对新 chunk 生成前缀,每月做一次全量重建确保一致性。如果文档每日高频更新,这一步的成本会迅速变成主要矛盾,需要认真评估是否值得。
混合检索引入了 BM25 索引的维护负担。 向量索引和 BM25 索引需要同步更新,任何一边的文档版本落后都会导致融合结果"打架"。我们加了一个索引一致性巡检:抽样比对两个索引里的 doc_id 集合,发现差集超过阈值就告警。这个巡检抓到过两次增量更新时只写了向量库、忘了写 BM25 的事故。
条件重排的阈值需要校准,不能拍脑袋。 我们一开始用"top1 > top2 * 1.5"作为跳过条件,结果触发率只有 18%,但有一部分被跳过的 query 在生成阶段被用户反馈"答偏了"。把阈值放到 1.8 之后,触发率到 41%,Recall@5 没有再下降,但"答偏"反馈减少了约六成。这个参数和语料分布强相关,必须用离线评估集校准,不能抄。
一个不容易注意到的回归:加了 Contextual Retrieval 之后,chunk 的 embedding 文本变长了,某些近似重复的上下文前缀会让本来不相似的 chunk 在向量空间里被拉近,产生新的假阳性。我们的应对是上下文前缀严格限制在 1--2 句,并且 prompt 里明确禁止重复 chunk 内容,只描述"这个片段在文档中的位置和作用"。
五、效果评估:分层看,不要只看一个总数
评估集:从真实日志分层抽样 320 条 query,标注 ground-truth chunk。对比四组配置:
配置 Recall@5 MRR@5 平均检索延迟 重排触发率
原始:固定分块 + 纯向量 58.1% 0.391 0.16s ---
- Contextual Retrieval 69.4% 0.482 0.18s ---
- 混合检索(RRF) 78.2% 0.561 0.29s ---
- 条件重排(最终) 84.1% 0.647 0.44s 40.6%
关键分层信号:
精确标识符类 query(错误码、型号、API 名称)从 61% 提到 91%,BM25 这一路贡献了绝大部分增量。纯向量方案在这类 query 上几乎是系统性失明。
长文档跨段推理类 query("这个限制条件适用于哪些场景")从 49% 提到 77%,Contextual Retrieval 是主力,混合检索和重排继续往上推。
简单点查类 query 从 82% 提到 86%,增幅最小,但没有任何下降------条件重排的"跳过"逻辑保护了这类 query 不被过度处理拖慢或引入噪声。
整体平均检索延迟从 0.16s 升到 0.44s,但端到端 P95 只从 2.1s 升到 2.3s,因为检索延迟在生成延迟面前仍然是小头。必须同时报告的代价:Contextual Retrieval 的索引期 LLM 调用和 BM25 索引维护是新增的运维复杂度,不是"查询期调个参"就能抹平的【此处需结合你的真实项目补充:你的索引规模和重建耗时】。
六、我踩过的坑
坑一:只修检索,不修分块,重排反而放大了错误。 最早我们想的是"先上 reranker 顶一顶",结果 reranker 把一篇"高相似度但低相关性"的文档排到了第一------因为 reranker 的训练目标也是相关性判断,如果候选池里的 chunk 本身上下文残缺,它也只能在一堆残缺项里挑一个"看起来最像"的。重排解决的是排序问题,解决不了候选池本身的质量问题。
坑二:Contextual Retrieval 的 prompt 写成了"摘要"。 第一版 prompt 让 LLM "总结这个片段",生成的前缀把 chunk 的细节压缩掉了,embedding 反而丢失了关键术语。改成"描述位置和作用、不复述内容"之后才对齐了目标。
坑三:BM25 分词没做领域适配。 早期直接用默认分词器,产品型号里的连字符和数字被切碎,BM25 这一路的召回质量很差,RRF 融合后反而拖累了 dense 的排名。加上自定义 tokenizer 保留 A-Z±\d+ 这类模式之后才正常。
坑四:监控只看端到端延迟,没看检索层。 有一次 Contextual Retrieval 的 LLM 调用超时增多,生成阶段仍然正常工作,但检索质量悄悄退化,直到用户反馈才被发现。后来在监控里加了 零结果率、Recall@5 抽样、两路索引 doc_id 差集 三个检索层指标,把"沉默的退化"变成了可观测信号。
七、团队协作与落地
落地节奏和前面 HyDE、GraphRAG 两篇是同一条工程纪律:离线对齐 → 灰度 → kill switch。
第一周在离线评估集上跑完四组对比,把分层结果发给业务方。关键动作是让业务方看"精确标识符类 query 从 61% 到 91%"这一行,而不是只给一个总 Recall。他们立刻理解了为什么"错误码搜不到"这个问题值得投入资源修。
第二周灰度 20% 流量,重点盯三个检索层信号:Recall@5 离线抽样、重排触发率(异常飙升说明融合分数分布漂移)、两路索引 doc_id 差集(BM25 和向量库不同步的早期信号)。灰度期发现重排触发率在下午高峰时段从 40% 涨到 55%,排查后确认是高并发下 RRF 的 k 参数在动态调整逻辑里被错误覆盖,修了之后恢复稳定。
第三周扩到 60%,同时把 Contextual Retrieval 的增量更新从手动触发改成文档变更事件驱动。第四周全量,保留 feature flag:关掉混合检索后回退到纯向量 + 条件重排,关掉重排后回退到纯向量,业务无感。
面试延伸
"RAG 召回不准 / 答非所问"是 AI 应用岗面试里出现频率最高的问题之一,因为它同时考察归因能力和工程判断。
Q1:用户说 RAG 答得不准,你从哪开始排查?
不要从模型开始。先做检索层归因:把 bad case 的 query 和实际检索回来的 top-k chunk 拿出来,人工读一遍------如果你自己读完也觉得"信息不够回答问题",问题就在分块或检索,不在生成。然后分层看指标:Recall@k 塌了 → 分块/召回问题;Recall 正常但答案错 → 重排或上下文整合问题;上下文里有正确答案但生成没用到 → 生成或 prompt 问题。Splunk 的故障分类里,生成阶段是"最不容易出错"的环节,先修检索,再碰 prompt。
Q2:Contextual Retrieval 和简单加 overlap 有什么区别?
Overlap 是物理上的相邻冗余------它让边界信息在相邻 chunk 里各出现一次,但 chunk 本身仍然不知道自己来自哪份文档、哪个章节。Contextual Retrieval 是语义上的身份注入------每个 chunk 在 embedding 之前被加上一段文档级上下文,它在向量空间里"带着位置被检索"。两者不互斥,我们同时用了:结构感知分块 + 10--20% overlap 做物理兜底,Contextual Retrieval 做语义兜底。Anthropic 的数据是单纯前置上下文降低 35% 失败率,叠加 contextual BM25 到 49%。
Q3:混合检索里 dense 和 sparse 怎么加权?RRF 的 k 怎么定?
我们不用加权分数融合,用 RRF 做排名融合,因为 dense 的余弦相似度和 BM25 的分数尺度不可比,加权需要额外校准且对语料分布敏感。RRF 只依赖排名,鲁棒性更好。k=60 是常用默认值,控制头部排名的权重衰减;如果想让头部差异更敏感,可以调小。但 RRF 不能代替重排------它解决的是"两路结果怎么合",不解决"合完之后头部是不是真的对"。
Q4:Cross-encoder 重排和用 LLM 重排怎么选?
LLM 重排在语义理解上可能更强,但 purpose-built reranker 在 cost/latency 上有数量级优势:有对比数据显示 rerank-2.5 比 SOTA LLM 重排便宜 60 倍、快 48 倍,NDCG@10 还高 15%。生产环境里 cross-encoder reranker 是默认选择,LLM 重排只适合离线评测或极低 QPS 的场景。如果 reranker 成为瓶颈,缩候选池(top-50 而不是 top-100)、用更小的模型、做量化加速,都是有效的杠杆。
Q5:这套方案最大的风险是什么?
索引期成本不可忽视。Contextual Retrieval 需要对每个 chunk 调 LLM,文档高频更新时重建开销会变成主要矛盾。第二个风险是多路索引的一致性------向量库和 BM25 索引不同步会产出"打架"的融合结果,必须有巡检。面试时如果能主动说出"这套方案的前提是文档更新频率可接受",比只讲技术点高一档。
最后
这篇和前两篇(HyDE、GraphRAG)放在一起看,其实是同一个工程逻辑的三次变奏:HyDE 修的是 query 侧的语义鸿沟,GraphRAG 修的是检索范式的结构性缺口,这篇修的是 chunk 侧的上下文丢失和检索侧的 modality 盲点。三者都不是"换模型"能解决的问题,都需要在链路里找到正确的切口,然后用条件和回退把代价控住。
如果你正在被 RAG 的"答非所问"困扰,我的建议是按这个顺序排查:先看分块,再看召回,最后碰 prompt。把 bad case 的 top-k chunk 人肉读一遍,大部分时候答案就已经写在那些被切碎的句子里了。