从零搭建双体RAG:混合检索、向量召回与BM25融合实践

发布时间:2026/10/20 13:35:08
从零搭建双体RAG:混合检索、向量召回与BM25融合实践
写这个标题的时候我自己都想笑。“重生之我在双体 RAG”确实蹭了点网文梗但用来形容过去这一个月的心态倒是很贴切。我之前总觉得 RAG 不就是把文档丢给大模型让它在里面翻答案吗直到亲手把一个知识库问答项目从头搭了一遍才发现自己过去对 RAG 的理解有多粗糙。这里面的检索链路、切片策略、融合排序任何一个环节做得不到位最终答案都会肉眼可见地跑偏。这篇日志适合两类人看一是准备做知识库问答但还没想清楚技术方案的人二是在网上看了很多 RAG 概念、知道 Embedding 也知道 BM25但不知道怎么把它们正确地拼在一起的人。我会把我自己的设计思路、踩过的坑、最后的评估方法都摆出来尽量给出一套能直接抄作业的骨架。1. 项目概述为什么叫“双体 RAG”这一个月到底在搞什么1.1 从一次失败的问答说起最初遇到的问题是典型的“文档问答翻车现场”。我把一份几十页的产品手册切成文本块灌进向量库然后问“第二步配置失败时返回的错误码 304 是什么意思”结果系统完全答非所问。问题出在哪我的查询里有关键词“错误码 304”但语义上和手册里的“HTTP 304 Not Modified”是很接近的向量召回应该能找到才对。可实际情况是向量模型把“错误码 304”和“HTTP 304”映射到了比较接近的区域但没有高到能进 Top 5同时传统的词法检索又因为“错误码”和“HTTP”这些词根本对不上直接把这条文档块排到很后面。两头都差一点最终答案自然就废了。这就是我做“双体 RAG”的起点单靠向量做语义召回对精准词不敏感单靠关键词做字面匹配又解决不了同义改写。既然两条路都有各自的死穴那把两条路都走了再做结果融合才是成本最低、收益最稳的做法。1.2 “双体”到底指什么“双体”是我自己起的叫法通俗点说就是两路召回体协同工作一路是稠密向量召回负责语义相似一路是稀疏词法召回负责字面命中。这个组合在业内其实就是混合检索Hybrid Search但我觉得“双体”这个词更能提醒自己它的互补属性。向量召回的优势是能处理“同义不同词”“口语化提问”这类场景比如用户说“手机连不上网了”它能匹配到文档里的“网络连接失败”。弱势也很明显它对精确数字、版本号、报错码、专有名词经常钝感因为 Embedding 模型会把它们压缩成高维语义向量细微的字符差异可能被抹平。词法召回恰好相反它对“304”“AES-128”“init_worker”这种精确 token 非常敏感哪个字一模一样它就能给高分但用户换个说法比如把“无法启动”说成“起不来”它就彻底没辙了。所以两路召回不是二选一而是要合并着用。1.3 学习目标与 Demo 范围我给自己定的目标很实际在一个可控文档集上跑通一套“文档加载→切片→双路索引→融合召回→大模型生成→效果评估”的完整链路不追求上重排模型也不追求 GraphRAG 那些花活。Demo 的文档集控制在二十多篇技术文档涵盖操作手册、FAQ、配置说明、错误码表。答案类型包括“是什么”“怎么配置”“为什么报错”“给我一个操作步骤”这四类。这个范围足够训练我对每个环节的感觉又不至于被数据清洗的杂活拖垮。2. RAG 完整链路拆解一次知识库问答是怎么跑通的2.1 索引阶段文档清洗、切片与向量化很多人一上来就做 Embedding其实先要把文档处理干净。我踩过的第一个坑就是 PDF 转出来的文本包含大量页眉页脚、目录编号和断行这些内容如果进入索引检索时经常被当成正文召回答案里会混进莫名其妙的“第 3 页”。所以我的清洗流程是先用工具把 PDF 和 Word 转成 Markdown 或纯文本接着去掉页眉页脚和导航类文字再把表格转成 Markdown 表格或“字段: 值”的键值对文本最后保留代码块的原始缩进。这一步做完后续检索的准确率能提高一大截。切片是索引阶段的灵魂。我的经验是技术文档按“章节标题”切比按固定字数切更自然。一个章节内如果太长再按段落合并成 500800 字的块同时设置 100 字左右的 overlap。这样既保证了上下文完整又不会在一个块里塞太多主题导致检索时主题不聚焦。向量化环节我选了一个开源的中文 Embedding 模型维度不算高但足够处理技术文档里的中英混排。要提醒的是Embedding 模型必须和文档语言匹配拿通用英文模型硬跑中文文档召回效果会非常难看。切好的每个块生成一个向量存进向量库同时把同一批块做成词法索引等待查询。2.2 检索阶段稠密召回与稀疏召回的互补逻辑查询来了之后我先做一次轻量预处理如果 query 里有明显的关键字版本号、端口、函数名我会保留原样如果 query 比较口语化我会把它附加上几个可能的同义改写词但不过度改写因为改写本身可能引入噪声。然后就到了双体检索的核心向量路和词法路同时跑。向量路把 query 编码成向量在向量库里做余弦相似度检索取 Top 20词法路用 BM25 这类算法把 query 分词后在倒排索引里打分也取 Top 20。两条路各自返回一组候选块的 ID后面的融合排序决定谁最终进入上下文。这一步的关键是两条路的 Top K 不要设太小。如果各取 Top 5 就去融合那一路上榜的块数量太少融合的稳定性很差。我实际测试下来各取 Top 20 再融合取 Top 5效果最稳。2.3 融合排序为什么 RRF 比直接加权更稳拿到两路 Top 20 之后怎么合并最常见也是我一开始用的做法是给两路分数做加权求和。但你很快会发现稠密向量的余弦相似度基本分布在 0.60.9BM25 的分数可能从 0 到几十量纲完全不同。直接加权等于让 BM25 那一路主导向量那路基本是陪跑。RRFReciprocal Rank Fusion解决的就是这个问题。它不看原始得分只看排名位次对每个候选块把两路中的排名倒数值相加。比如某一块在向量路排第 3在词法路排第 10它的融合分就是 1/(603) 1/(6010)对所有候选算完后降序排列即可。RRF 的好处是鲁棒、无需调权重、不受两路分数量纲差异影响。这个方案做起来非常简单比我去调加权系数省太多时间了。2.4 生成阶段上下文组装与提示词约束融合排序选出 Top 5 个文本块后把它们按顺序拼成上下文再交给大模型生成答案。组装不是简简单单把文字拼在一起就行我给每个文本块加了一个引用编号比如“[1]”“[2]”同时要求模型在回答里标注答案依据来自哪个编号。提示词里的约束也很重要。我会明确写只能基于给定文本回答如果文本里找不到答案直接回答“根据已有资料无法回答”不要编造回答时引用文本中的原话或关键信息。加上这三条约束之后幻觉明显减少但无法根治后面我会单独讲怎么进一步控制。这一步还有个容易被忽略的点上下文总长度不要贪多。五个块每个 800 字总共四千字已经接近很多模型的上下文压力线。如果真的不够优先替换掉融合分最低的块而不是无限增加块数。3. 实操记录用最小实现跑通双体 RAG3.1 环境准备能用开源方案就用开源方案我做这个项目时没有用任何复杂的平台Python 3.10 加几个常见依赖就够了。向量库我用的是一个开源本地向量检索库BM25 我直接用了现成的开源实现大模型用的是本地部署的开源模型。整体跑下来效果不错也避免了把数据传到外部接口的安全顾虑。如果你没有本地 GPU也可以把 Embedding 和大模型拆开Embedding 用开源模型跑 CPU 也行大模型走内部服务或本机部署。步骤上差别不大关键是要保证 Embedding 模型和生成模型在领域上是匹配的至少要对中文技术文档有基本的理解能力。环境依赖上我建议把向量库、BM25、文档解析、Embedding 各自封装成独立模块这样后面替换任何一个组件都不会影响其他部分。3.2 文档切片参数怎么定切片参数看起来只是个数字实际上对效果影响极大。我整理了一个简单策略如果文档有清晰的标题层级按标题切段再按段落合并到 500800 字。如果文档没有标题比如 FAQ 列表按语义块切一个问答对尽量放进同一个块。overlap 设置在 50150 字之间。太小会截断上下文太大会导致重复信息反复被检索到。我试过把 chunk_size 直接拉到 2000结果检索到的块主题混乱一个问题里混着两三个无关话题问答效果直线下降。后来改成 600 字左右每块聚焦一个主题效果明显提升。切片时还要特别注意表格。表格如果被切成上下两半语义就断了。我的做法是先判断文本块里是否包含表格如果有表格就整个表格单独成块不再和其他段落合并。3.3 双路检索接口的代码骨架下面这段是我在日志里保留的核心检索代码骨架融合函数很短但整个系统的核心逻辑都在这里def reciprocal_rank_fusion(result_lists, k60): scores {} for docs in result_lists: for rank, doc_id in enumerate(docs): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) return sorted(scores.items(), keylambda x: x[1], reverseTrue) def hybrid_retrieve(query, vector_top_k20, bm25_top_k20, final_k5): dense_hits vector_index.search(query, top_kvector_top_k) sparse_hits bm25_index.search(query, top_kbm25_top_k) fused reciprocal_rank_fusion([dense_hits, sparse_hits]) return [doc_id for doc_id, _ in fused[:final_k]]向量检索模块只需要实现一个 search 接口先对 query 编码再做余弦相似度排序。BM25 模块同样实现一个 search 接口。这样 hybrid_retrieve 完全不需要关心内部细节后续要替换 Embedding 模型或向量库都只改对应模块。需要注意一个细节两个 search 返回的都是“按相关性降序排列的 doc_id 列表”顺序很重要。RRF 依赖排名位次如果你的接口返回的顺序是乱的融合结果就没有意义。3.4 生成模块与效果初测生成模块的逻辑也不复杂拿到 hybrid_retrieve 返回的 Top 5 块给每块编号拼成带编号的上下文塞进提示词模板再把用户 query 放在最后。我用的提示词模板大致如下你是一个知识库问答助手。请仅根据以下资料回答用户问题。 资料 [1] 第一个文本块 [2] 第二个文本块 [3] 第三个文本块 [4] 第四个文本块 [5] 第五个文本块 要求 1. 优先用 [编号] 标注答案依据。 2. 如果资料中找不到答案回复“根据已有资料无法回答”。 3. 不要编造资料中没有的信息。 用户问题query初测时我挑了十个不同形式的问题包括“怎么修改某个配置”“某个报错码是什么意思”“如何部署”等。结果发现“怎么修改”这类步骤型问题回答得很好因为文本块里有明确的操作序列“为什么报错”这类归因型问题偶尔会答到相近但不完全正确的块上这就暴露了检索层还需要优化的空间。4. 踩坑实录与排查技巧4.1 召回不准先怀疑切片而不是模型好几次检索结果相关但不对题我第一反应是 Embedding 模型不行换了好几个模型效果没明显改善。后来把召回的文本块打印出来看才发现问题出在切片上一个比较大的段落被切成两块后第一块是背景介绍第二块才是具体答案而向量相似度上背景介绍有时候反而分数更高因为它包含了更多和 query 重叠的词。排查切片问题有个笨办法把每个文本块的头部 50 个字打印出来看看有没有“讨论了前半段却没有后半段”的情况。如果发现大量块以“此外”“同时”“因此”开头说明你的切片点切在了句子的逻辑中间需要调整切片方向和 overlap。正确的做法是尽量让每个块有一个独立的语义起点比如从“如何配置”“错误码说明”这类小标题处开始切。如果文档结构不清晰也可以先用大块切一次再按段落二次切分。4.2 幻觉问题给大模型划好边界我在初版提示词里没写“找不到就回答不知道”这条约束结果模型开始自由发挥。比如我手头资料里根本没有某个功能它能一本正经地给出完整配置步骤看着很像那么回事其实全在编。这些幻觉信息一旦被用户拿去操作后果是很严重的。加上硬性约束后大部分幻觉被挡住了但还有一个漏洞上下文里确实有相关内容但大模型把两个不同章节的信息拼在一起生成一个看似合理但实际不存在的组合。要堵这个漏洞我做了两件事一是要求模型在回答末尾标注引用的块编号二是在评估阶段人工检查引用编号是否确实落在正确答案所在的块上。只要引用编号不对就说明这个回答不可信。4.3 融合失效分数没归一化会白做我一开始用的融合方式是简单加权把向量分数和 BM25 分数直接相加。跑出来的结果是召回列表几乎完全被 BM25 主导向量那路根本进不了前五。直觉上加权没问题但两路分数的分布差异太大了。后来改成 RRF效果立竿见影。RRF 的另一个好处是它对异常文档不那么敏感某一路给出一个极端高分也不会直接霸榜因为排名位次天然会稀释极端值的影响。如果你需要用加权方案请先对两路分数做 z-score 或 min-max 归一化再调权重。但我个人实测下来RRF 更省心而且少了归一化这一步代码也更少出错。4.4 快速评估20 道题的冒烟测试做 RAG 项目没有评估就不知道自己改的是对是错。我花了一晚上造了一个“金标测试集”从文档里挑出 20 个关键问题每个问题都标注了正确答案所在的文档块 ID。然后每次改完检索策略就跑一遍测试集记录两个指标recall5正确答案的块是否出现在 Top 5以及最终答案是否命中。这套冒烟测试的成本不高但收益极大。我靠它发现过切片切断了答案位置、BM25 分词策略太粗导致召回不到、融合 K 值太小导致正确答案落在 Top 5 之外等问题。很多你以为改的是模型其实一测就知道改的是策略。黄金标准测试集不需要很大也不需要对所有文档全覆盖。但问题必须覆盖检索层的典型场景包括精确词查询、同义改写查询、混合查询。只有测试集贴合真实使用场景测出来的指标才有意义。我再抛几个排查速查点现象大概率原因排查路径检索结果相关但不对题切片切断了关键信息打印召回块头部检查语义起始点专有名词/数字完全召回不到仅用了向量召回缺词法召回检查是否启用了双路检索融合结果基本被一路主导分数量纲未处理改用 RRF 或先做归一化答案编造提示词缺少硬边界加上“无法回答”约束和引用要求检索很准但答案太啰嗦上下文块太多减少 Top K保留融合分最高的块5. 学习日志收尾双体 RAG 能做什么不能做什么这一个月最大的体会是把 RAG 做稳定功夫在检索不在生成。很多人一开始就把大模型当成主角实际上检索链路里任何一个环节跑偏模型能力再强也救不回来。双体 RAG 给我的感觉就像是给模型配了两个互补的图书管理员一个靠语义找书一个靠书名精确找架两人交换意见后再把最靠谱的一摞书递过去。它不能做什么我也说清楚。双体 RAG 依然是“一次检索、一次生成”的简单结构处理不了需要多跳推理的复杂问题比如“对比第一版和第二版配置文件的差异并列出因为改动导致兼容性变化的配置项”。这类问题需要把多段内容做交叉对比已经不是单轮检索能满足的。数据更新也是个问题索引建好后文档变了要重建相关块的索引否则旧信息还会被召回。我自己下一步的计划是先把重排Rerank加进来在融合之后再做一轮精排看看能不能把 Top 5 的准确率再拉一拉。之后会考虑把表格和文本分别建索引避免结构化数据在切片时被破坏。这个方向应该够我再折腾一阵了。