拆解 RAG 双流水线与检索排障要点
文章将 RAG 拆为离线解析、分块、嵌入与索引,以及在线查询改写、召回、重排和生成两条流水线。实践要点包括统一查询与文档的嵌入模型版本、换模型后重建嵌入,并用精确搜索评估近似索引的召回损失。
AI 深度解读
RAG 的做法很直接:先从自己的文档里找证据,再让语言模型根据证据回答。它适合帮助中心、内部知识库和代码文档,但接上向量搜索,并不代表答案就可靠。
这篇原文最有用的判断是:排查错误时,先检查文档有没有被正确解析、切分和检索出来,再考虑换模型。它把 RAG 拆成一条可以逐步验证的工程流水线,也解释了为什么增加上下文、调整提示词,有时救不了错误答案。
答案为什么常在生成之前就丢了
RAG 这个名称来自 Patrick Lewis 等人在 NeurIPS 2020 发表的论文。原始方案把预训练生成模型与 Wikipedia 的稠密向量索引结合,联合微调查询编码器和生成器,文档编码器及索引保持固定。两个变体分别为整段答案使用相同资料,或允许每个生成 token 使用不同资料。
今天常见的工程实现更简单:现成的 embedding 模型、搜索索引、通用聊天模型拼起来,通常没有联合训练。原文讲的是这种实现。
它包含两条共享索引的流水线。离线处理负责解析、清洗、切块、向量化和建索引;在线请求负责改写问题、召回、重排、组装提示词和生成答案。
前面漏掉的证据,后面没有机会凭空补回来。
PDF 双栏阅读顺序错乱、表格被压成数字串、扫描页没有经过 OCR,都会让输入先失真。HTML 的菜单、Cookie 提示和页脚,也可能被当成正文参与检索。
因此,解析时要保留标题、列表和表格行;每个片段还应携带文档 ID、标题、章节路径、URL、修改时间,以及租户或用户组等访问字段。重复内容需要清理,否则同一段话的多个副本可能占满搜索结果。文档内容哈希可以帮助识别变化,源文档删除时也要删除对应片段。
切块则是在精确匹配和上下文完整之间取舍。块太小,条件与结论可能被拆开;块太大,一个向量又要概括多个主题。原文没有提供通用最佳尺寸,示例中的 chunk(text, size=80, overlap=20) 只是按空白分词,每块 80 个词、重叠 20 个词,不能直接当成中文文档的生产配置。
检索要先找得全,再排得准
embedding 把文本映射为固定长度向量,让语义相近的文本距离更近。常用的余弦相似度范围是 -1 到 1;当向量长度归一化为 1 时,点积就等于余弦相似度。
查询和文档必须使用同一个模型及版本。不同模型产生的向量不能直接比较,换模型需要重算整个语料库。输入长度也要按模型自己的 token 规则检查:原文列出的 OpenAI text-embedding-3 模型上限是 8,192 tokens,其他模型可能更小;有些模型还要求查询和文档使用不同前缀或任务类型。
索引也有取舍。少量文档可以逐个计算相似度,进行精确的 flat search。大规模集合常用近似最近邻索引(ANN),例如 HNSW。它通过多层邻近图加快搜索,代价是可能漏掉真正最近的向量。M 控制节点链接数,efConstruction 控制建索引时的搜索范围,ef 控制查询时的搜索范围,速度、内存和召回率需要一起衡量。
作者建议用真实问题抽样,对比 ANN 与精确搜索,而不是只看查询延迟。
查询本身也需要处理。「那企业版呢?」离开对话上下文没有明确含义,需要先改写成独立问题;缩写可以展开,多事实问题可以拆成多个查询。Gao 等人在 2022 年提出的 HyDE,则先让模型写一份假想答案,再用它的向量检索。假想内容不直接作为事实,但每次查询会增加一次模型调用。
权限、租户、语言、产品和日期过滤应在搜索过程中执行。先取 top-k 再过滤,可能只剩少数结果,甚至一个不剩;提示词里的权限声明不能代替检索过滤。
语义搜索还容易漏掉错误码、SKU、名称和版本号,因此可以并行运行 BM25 关键词搜索,再融合结果。第一阶段用 bi-encoder 分别编码查询与文档,快速找出几十个候选;第二阶段用 cross-encoder 同时读取问题与片段,重新评分,只把少数高质量结果交给模型。
原文引用 Anthropic 的实验:召回 150 个候选,重排后保留 20 个,加入重排器后,top-20 检索失败率从 2.9% 降到 1.9%。这是特定测试中的结果,不能据此认定所有知识库都应该照搬这个数量。
提示词要能引用,程序还要能检查
检索完成后,每个片段应分配短 ID,让模型在具体主张后引用,例如 [S2]。程序随后检查引用 ID 是否确实出现在本次提示词中,再将其映射到源文档。
下面保留原文的查询侧示例,展示从精确搜索到调用模型的连接方式。它依赖前面已经构建的 index 和 embed():
def cosine(a, b):
return sum(x * y for x, y in zip(a, b)) # both are unit length
def retrieve(index, query, k=4):
q = embed(query) # same model as the documents
scored = [(cosine(q, vec), cid, text) for cid, text, vec in index]
return sorted(scored, reverse=True)[:k] # flat scan; ANN in production
def build_prompt(query, hits):
sources = '\n'.join(f'<source id="{cid}">\n{text}\n</source>'
for _, cid, text in hits)
return ('Answer using only the sources below. Cite the source id '
'after each claim. If they do not contain the answer, say so.'
f'\n\n{sources}\n\nQuestion: {query}')
def answer(index, query, call_model, min_score=0.2):
hits = [h for h in retrieve(index, query) if h[0] >= min_score]
if not hits: # tune min_score on your own data
return 'Not found in the sources.'
return call_model(build_prompt(query, hits)) # any chat model API
index = build_index({'refunds.md': 'Refunds are issued to the original card.'})
print(answer(index, 'How are refunds issued?', call_model=lambda p: p)) # echo
重点看 min_score=0.2 和最后一行:阈值需要用自己的数据校准,示例最后只是回显提示词,没有真正调用聊天模型,也没有实现重排、权限过滤和引用校验。原文的 embed(text, dim=256) 还是哈希词袋占位实现,且 Python hash() 只在同一进程内保持稳定,不能作为持久化语义索引使用。
提示词还需要明确:<source> 中的文本是资料,不能当成指令执行;资料没有答案时允许回答不知道。若下游程序消费结果,应把答案和引用 ID 放在独立的结构化字段里。
资料顺序也会影响效果。原文引用的「Lost in the Middle」研究发现,模型对长上下文开头和结尾的信息利用更好,中间的信息容易被忽略。强相关片段应优先放置,塞入更多资料不一定更可靠。
我的判断
这篇最值得采纳的是排查顺序:先标注真实问题的答案片段,再检查它是否进入 top-k,也就是 recall@k,最后才评估生成。这样能区分「没找到证据」和「找到却没用对」,避免所有故障都落到提示词上。
但「多数错误来自检索」「生成通常最可靠」是作者的工程判断,文中没有提供跨团队的统计或完整对照实验。Anthropic 的数字也不能替这两个判断作普遍证明。文章披露了 AI 辅助写作与来源核对,没有展示作者团队自己的完整实测报告。
另外,引用 ID 存在,只能证明模型引用了提供过的材料,不能证明材料支持对应结论。RAG 能降低部分幻觉,无法保证不误读、不混入模型记忆。
也不必一开始就上独立向量数据库。原文认为几千个片段可以尝试内存精确搜索;文档少且稳定时,直接放进长上下文可能更简单。规模、更新频率、访问权限和查询量,才决定检索架构是否值得增加复杂度。
能拿来做什么
- 给前端组件库或 Node 服务接知识库前,先选一组真实问题,标出正确资料,再逐步检查解析结果、切块边界和 recall@k,让失败位置可以定位。
- 面对错误码、API 名称和版本问题,可以尝试 BM25 与向量搜索融合,并比较结果,避免只凭语义相似度遗漏精确字符串。
- 在多租户内部助手中,把访问字段带入每个片段,并在检索时过滤;同时校验引用 ID,给用户展示可追溯的资料出处。
- 上线前加入文档无法回答的问题,验证系统是否会明确拒答;调整候选数、重排数量和阈值时,同时观察召回、延迟与 token 成本。