尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

多跳RAG实战:从单次检索到规划循环的架构改造

多跳RAG实战:从单次检索到规划循环的架构改造 多跳 RAG 实战把“检索一次”改造成“会规划的循环”做 RAG 的朋友应该都有过这种体验知识库里的内容明明都检索出来了答案却还是不对或者干脆漏掉了关键信息。一开始我以为是自己 embedding 模型选得不够好后来才意识到问题的根源不在“检索质量”而在“检索次数”——我只给了系统一次检索机会可很多现实问题压根不是一次检索能回答的。这篇文章想聊的就是怎么把这种“检索一次就收工”的简单 RAG改造成一个“会规划、能循环”的多跳 RAG 系统。多跳 RAGMulti-hop RAG解决的核心问题是那种需要把多个信息片段串联推理的复杂问题。比如“A 公司收购的 B 团队他们的核心产品竞争对手是谁”你得先查到收购关系再查到 B 团队的产品再找竞品任何一步缺失答案都拼不出来。这篇内容会讲清楚多跳 RAG 的规划器、检索器、推理器和终止控制是怎么协同工作的也会给出一个能直接改来用的最小实现方案适合已经做过单跳 RAG、又想往深水区走一步的工程师。1. 先聊清楚单跳 RAG 在哪些场景下就是不够用1.1 一个“多实体、多关系”的问题为什么难住单跳检索单跳 RAG 的流程很简单用户提问你把问题向量化去向量库里找最相似的几个片段塞进 prompt让大模型生成答案。这个流程对“信息都在一个段落里”的问题是有效的——比如“某某产品的发布时间是哪年”一段文本里就有答案。但现实里的业务问题很少这么友好。我这里随便写几个例子“我们上一季度营收下降的主要原因是不是跟华南区大客户流失有关”“对比一下 A 方案的部署成本和 B 方案算上人力维护的话哪个更低”“去年那个被投诉最多的功能后来是哪个版本修复的修复方案是什么”这些问题有个共同特征它们都包含多个实体华南区、大客户、上季度营收并且实体之间存在关系原因、关联、时序。单跳检索最大的毛病就是它只能做“一次语义相似度匹配”。你拿整段问题去检索向量检索返回的片段可能聚集在“营收下降”附近也可能聚集在“大客户”附近但很难有哪个片段同时覆盖全部关系链条。结果就是LLM 手里拿着碎片却看不清拼图全貌只能硬编一个答案出来——这就是 RAG 幻觉的一个主要来源。我做过一个知识库问答的实测数据在 200 条“包含至少两个实体、需要两步以上推理”的问题集上单跳 RAG 的正确率只有 42%。同样的问题人工把关键环节补全后再喂给模型正确率能到 78%。这个差距说明的不是模型能力问题而是信息完整性问题。1.2 多跳不是“多检索几次”而是“先规划再循环”很多人对多跳 RAG 有个误解以为它就是查完一次再查一次反反复检索直到凑够 token。不是这样的。多跳的核心是系统得有“自知之明”——知道自己还缺什么然后针对缺口去查下一轮。我习惯把多跳 RAG 分成两个阶段理解规划阶段和执行阶段。规划阶段的任务是把用户的复杂问题拆解成若干个子问题或者生成一个有步骤的检索计划。执行阶段的任务是按照计划逐轮检索、逐轮收拢证据每一轮的输出会更新“已知信息”再据此决定下一轮查什么。这里有一个关键的设计理念变化检索的对象从“问题”变成了“缺口”。单跳 RAG 是拿问题去检索多跳 RAG 是拿“已知信息 未知缺口”去检索。这个“缺口”可能是某个实体的属性B 团队到底做的什么产品也可能是两个实体之间的关系A 公司是怎么收购 B 团队的。当系统把检索的锚点从完整问题换成具体缺口时召回精度会明显提升因为向量检索对“具体问题”的匹配度永远优于对“大而全问题”的匹配度。循环的意义在于每一步检索都会产生新的信息这些信息又构成下一步检索的输入。这就像解方程组你每消掉一个未知数剩下的未知数就更清晰。1.3 和 Agent / Tool Calling 的本质区别在哪里有人会问这跟现在的 Agent 或者 Tool Calling 有什么区别不都是让模型决定下一步做什么吗区别确实存在而且很重要。Tool Calling 类 Agent 通常有一个“工具清单”模型根据用户意图选工具、给参数、拿结果然后决定要不要再调工具。它更强调“动作选择”。多跳 RAG 的侧重点在“多步推理的证据链构建”动作空间是检索而不是调用任意工具。另一个实际差异在评估方式上。Agent 系统的成败关键在是否选了正确的工具多跳 RAG 的成败关键在证据链是否完整。所以多跳 RAG 里你会很关注“子问题覆盖率”——拆出的子问题是不是完整覆盖了原问题所需的所有事实这在做评测时是个非常实用的指标。2. 多跳 RAG 的整体方案从“查询”到“循环”的架构拆解2.1 核心四件套规划器、检索器、推理器、终止控制器我实现多跳 RAG 时把系统拆成了四个模块各管一段互相之间只交换结构化数据。这套拆分方式不依赖具体框架用 LangChain、LlamaIndex或者干脆自己写循环都能套进去。规划器Planner。它负责两件事在开始时把复杂问题拆成子问题也叫“查询分解”以及在每一轮回答“接下来要查什么”。第一版的规划器可以直接用大模型给它一段问题和已有的信息摘要让它输出下一步要检索的查询语句。更稳的方案是给规划器一个“子问题模板”比如“找出 X 和 Y 之间的关系”“找出 Z 的具体属性值”约束它的输出格式避免它问出天马行空的问题。检索器Retriever。这个模块在单跳 RAG 里已经有了但在多跳里要做的改造是“多路召回”。不同的子问题适合不同的检索方式实体属性用向量检索关系链用知识图谱关键词精确匹配用 BM25。你不需要引入特别复杂的技术但有条件的话建议至少做两路——向量检索加 BM25 混合召回。很多开源方案里的“三路混合检索”加上的第三路通常是知识图谱查询。如果暂时没有图谱也可以用 SQL 查询或者文档过滤规则来代替。推理器Reasoner。每轮检索完推理器负责“消化”新证据把新检索到的内容跟已有的中间结果合并判断哪些旧假设被验证了哪些被推翻了还缺什么信息。这一步听起来像是大模型做一个阅读理解但实测中直接让大模型输出“还缺什么”往往不够稳定更好的做法是引导它先输出“目前已确认的事实列表”再输出“待确认的缺口列表”。两个列表分开后面的检索环节才能有的放矢。终止控制器Termination Controller。多跳循环必须有个刹车。常用策略有三种一是最大轮数硬限制比如最多查 3 轮二是信息充分度判断——让推理器每次输出一个 0 到 1 的“回答置信度”低于阈值就继续检索三是固定条件判断——比如所有子问题都已经被标记为“已确认”则停止。2.2 任务拆解方式一次性规划 vs 逐步规划规划器的设计有两个方向实测下来各有适用场景。一次性规划One-Shot Planning是让大模型在开始检索前先输出完整的子问题列表。比如“分析 A 公司收购 B 团队后对市场格局的影响”模型直接拆出 5 个子问题B 团队的核心业务是什么、A 公司收购 B 团队的战略意图、收购后的整合动作有哪些、市场格局变化的关键指标是什么、竞争对手有哪些反应。然后系统拿着这 5 个子问题逐个去检索最后汇总。这种方式优点是流程清晰、容易并行加速、也好评估直接检查拆出的子问题有没有覆盖原问题。缺点是如果拆解阶段就有遗漏后面检索次数再多也补不回来。逐步规划Step-by-Step Planning则是查一步看一步。系统先针对初始问题做一次检索然后根据检索结果决定下一步查什么。这种方式更灵活不会因为初始拆解遗漏而彻底失败但缺点是延迟更高、循环更难控制容易“越查越偏”。我在实际项目里的取舍是第一版先用一次性规划把子问题拆解质量调稳再考虑改成逐步规划。原因很简单一次性规划的失败模式是确定的好排查、好评估逐步规划的失败模式是发散的你很难判断系统是“还没查完”还是“已经跑偏了”。2.3 必要的工程改造混合检索与外部状态存储多跳 RAG 的工程实现里基础设施改造往往比算法设计更花时间。我自己踩过不少坑这里挑两个重点说。第一个是混合检索的接入。多跳场景下子问题往往很短比如“B 团队的核心产品是什么”这种短查询用纯向量检索效果并不好因为 embedding 模型对短文本的语义表征不如长文本稳定。我的经验是所有子问题检索都走“向量召回前 20 条 BM25 召回前 20 条 融合排序取前 5 条”这条管线。融合排序可以用 RRFReciprocal Rank Fusion也可以用学习好的 rerank 模型二选一都能有明显提升。第二个是中间状态的管理。多跳循环里每一轮都会产生“子问题、检索结果、推理结论、置信度”这些中间数据。如果不做持久化调试的时候会非常痛苦——你完全不知道系统是在哪一步走岔的。我的做法是给每一轮生成一个唯一的 trace_id把所有中间数据写入一个状态表。这样不管是离线评测还是线上排查都能回放整个推理链条。3. 实操落地一个可运行的多跳 RAG 最小实现3.1 项目结构与数据准备多说无益直接进入能跑的代码环节。我下面这套实现没有依赖很重的框架只需要 OpenAI 兼容的模型接口、一个支持向量检索的数据库可以用 Chroma 或 FAISS以及一个 BM25 检索库比如 rank_bm25。完整项目结构大概是multi_hop_rag/ ├── planner.py # 子问题规划器 ├── retriever.py # 混合检索器 ├── reasoner.py # 证据推理器 ├── controller.py # 终止控制器 ├── state.py # 中间状态管理 └── main.py # 主循环入口数据准备阶段我建议先不要急着上企业知识库而是用一个半结构化的公开数据集把流程跑通。比如我测试时用的是公司内部产品的 FAQ 加上操作手册片段每一条数据都保留了来源 id 和段落编号。这个来源标记很重要因为多跳 RAG 在做证据回溯时要能精确指出来“这句话是从哪篇文档哪一个段落来的”。3.2 核心代码把循环和规划串起来主循环的伪代码逻辑如下实际运行起来就是一个 while 循环包着四步操作。from state import TraceState from planner import PlanDecomposer, GapAnalyzer from retriever import HybridRetriever from reasoner import EvidenceReasoner from controller import StopController def multi_hop_rag(question: str, max_rounds: int 3) - dict: # 初始化状态 state TraceState(original_questionquestion) # 规划器第一步先做整体子问题拆解 sub_questions PlanDecomposer().decompose(question) state.add_plan(sub_questions) retriever HybridRetriever() reasoner EvidenceReasoner() controller StopController(confidence_threshold0.7) for round_idx in range(max_rounds): # 1. 从“缺口”中生成这一轮的检索查询 queries GapAnalyzer().generate_queries( original_questionquestion, known_factsstate.known_facts, sub_questionsstate.pending_sub_questions, ) # 2. 混合检索拿到候选文档 retrieved retriever.hybrid_search(queries, top_k5) state.add_retrieved(round_idx, retrieved) # 3. 推理器合并证据输出已知事实 缺口 置信度 result reasoner.analyze( questionquestion, known_factsstate.known_facts, new_evidenceretrieved, ) state.update(result) # 4. 终止判断 if controller.should_stop(state): break return state.final_answer()这段代码看着不复杂但里面有几个地方我吃过亏必须展开讲讲。GapAnalyzer.generate_queries 是整条链路里最容易偷懒却最不能偷懒的一步。如果你直接把子问题原文当作检索 query那多跳就退化成了“检索多次的单跳”。我常用的做法是让大模型读取“已知事实列表”然后对每个待解决缺口生成一个“缺口感知查询”。举个例子“B 团队的核心产品是什么”在已有信息“A 公司收购了 B 团队”的前提下生成的查询应该偏向“B 团队 产品 技术方向”而不是泛泛的“B 团队业务”。HybridRetriever 要维护两个索引。向量索引和 BM25 索引的数据源是同一份文档但切片方式可能不一样。向量检索用 256 到 512 token 的段落切片BM25 用一个更大的窗口这样关键词命中的上下文更完整。我实测过混合检索比单用向量检索的 recall5 平均能高 12 到 18 个百分点在多跳场景里优势更明显。# retriever.py 里的融合排序部分 from rank_bm25 import BM25Okapi import numpy as np class HybridRetriever: def __init__(self, vector_index, bm25_index, alpha0.7): self.vector_index vector_index self.tokenized_docs bm25_index[tokenized_docs] self.bm25 BM25Okapi(self.tokenized_docs) self.alpha alpha def hybrid_search(self, query: str, top_k: int 5): # 一路向量召回 vec_scores self.vector_index.query(query, top_k * 4) # 一路BM25 召回 tokenized_query tokenize(query) bm25_scores self.bm25.get_scores(tokenized_query) top_bm25_ids np.argsort(bm25_scores)[-top_k * 4:][::-1] # 用 RRF 做融合排序对两路排名取倒数加权 rrf_scores {} for rank, doc_id in enumerate(vec_scores): rrf_scores[doc_id] rrf_scores.get(doc_id, 0) 1 / (60 rank) for rank, doc_id in enumerate(top_bm25_ids): rrf_scores[doc_id] rrf_scores.get(doc_id, 0) 1 / (60 rank) sorted_docs sorted(rrf_scores.items(), keylambda x: x[1], reverseTrue) return [doc_id for doc_id, _ in sorted_docs[:top_k]]RRF 里的常数 60 是经验值网上常见的是 60 或 61别去纠结这个数字它只是一个防止被一个高分排名压垮的平滑项。Reasoner 的分析输出格式一定要结构化。我见过很多人让大模型“自由发挥”总结本轮检索结果结果是每轮输出的 JSON 结构都不一样后面的代码要不停做兼容。我的做法是用 few-shot prompt锁定三种输出字段{ confirmed_facts: [已经确认的事实带来源文档ID], gaps: [仍然缺失的信息], confidence: 0.65, partial_answer: 当前基于已知事实可以给出的部分回答 }这里注意partial_answer字段是给最后总结用的每一轮都让模型尝试基于“目前掌握的信息”回答一遍原问题最后终止时就取最后一轮的回答。这个 trick 在工程上非常实用因为很多情况下即使循环提前被硬截断partial_answer 已经能给出相当不错的答案了。3.3 关键参数怎么定轮数上限、相似度阈值、拆解粒度这部分是纯实战经验不同数据集的参数可以参考但最好按自己的数据微调。轮数上限Max Rounds。我建议第一版先固定为 3。轮数太少1 到 2 轮基本体现不出多跳的优势轮数太多5 轮以上延迟和 token 成本成倍增长并且大概率在第 4 轮开始出现信息重复和上下文混乱。你可以打印出每一轮的实际收益——新增了多少条 confirmed_facts——如果第 3 轮新增的事实不足 1 条那说明 3 轮已经足够。置信度阈值Confidence Threshold。这个参数跟你的链路准确度相关。如果检索质量一般建议把阈值设低一点0.6 到 0.65让系统多跳几次去补证据如果检索质量不错阈值可以设到 0.75 以上减少无效循环。调参的方法是用 50 条典型问题做回归测试画一条“置信度-最终答案正确率”的曲线选拐点处的阈值。子问题拆解粒度。规划器拆解时最容易犯的错是“拆得太碎”。比如“A 公司收购 B 团队后有什么影响”会被拆成“A 公司是什么价格收购的”“A 公司为什么收购”“收购后股价涨了多少”“B 团队创始人去了哪里”这种问法子问题越多检索次数越多某一跳失败的风险就越大。我的经验是子问题数量控制在原问题里“关键实体数量 1”左右比较合适——核心实体有几个子问题就围绕这几个实体去展开多出来的那一个用来查实体间的关系。4. 实测过程中的常见问题与排查技巧4.1 循环发散、答非所问多跳循环最典型的失败模式就是“跑偏”。第一轮检索还围绕原问题第三轮已经在查跟原问题八竿子打不着的细节了。我踩过这个坑之后总结出一个排查方法不要只看最终答案要看每一轮的查询日志。如果发现第 2、3 轮的查询里已经出现了原问题里完全没有出现过的实体那大概率是 GapAnalyzer 在生成查询时“过度脑补”了。解决办法有两个。第一个是在 GapAnalyzer 的 prompt 里加一条硬性约束——“生成的查询只能基于原问题中出现的实体及其直接关联信息不得引入外部知识”。第二个是限制每一轮的查询数量默认只生成 1 到 2 个查询不要一口气生成 5 个查询太多会让后续推理和融合排序的负担倍增。4.2 检索质量没提升反而下降有些朋友从单跳改造到多跳之后发现检索结果比原来还差百思不得其解。我一开始也遇到过后来发现问题出在“切片方式”上。单跳 RAG 的切片通常比较短256 token 左右方便精确定位答案片段。但多跳 RAG 的中间查询往往是“缺口查询”短切片无法提供足够的上下文导致每一轮检索都只能返回一鳞半爪。我的调整方案是给多跳链路单独准备一份长切片索引切片长度设为 800 token 左右让每一跳返回的文档块都足够支撑下一轮的推理。短切片索引留给最后的“答案定位”环节用两套索引互补实测效果提升非常明显。另一个容易被忽略的点是 embedding 模型的输入长度上限。很多 embedding 模型对超过 512 token 的文本会直接截断如果切片太长端到端检索效果反而下降。你要是没有专门的长文本 embedding 模型长切片的向量化效果会打折扣。这种情况下建议“长切片配 BM25”也就是长切片只走关键词召回向量检索仍然用短切片两边做完混合融合。4.3 评估与可观测性怎么证明多跳真的有用最后一个问题怎么评估多跳 RAG 比单跳 RAG 好我用过的评估维度有三个分享给大家参考。第一个是最终答案正确率。这个最直接用一批“需要两步以上推理”的问题集对比两个系统在同一批问题上的表现。注意问题集里要确保覆盖不同类型的推理比如时间推理、因果推理、信息对比推理否则评测结果会偏向某一类。第二个是证据完备率。定义看一下答案引用的来源文档是否覆盖了“人工标注的关键证据文档集合”。这个指标在多跳场景里尤其有意义因为它能告诉你系统有没有找全证据——即使最终答案碰巧是对的证据不完备也说明推理链路有隐患。第三个是断裂点分析。跑完一轮测试后统计每个子问题在检索阶段有没有召回对应证据、在推理阶段有没有被正确引用。这些统计对应到代码里就是 TraceState 里的每轮证据覆盖情况。如果你发现大量失败都集中在同一个类型的子问题上比如“查实体间关系”的子问题召回率极低那就该考虑在这个环节引入知识图谱或者是更精确的查询改写而不是盲目调 embedding 参数。注意多跳 RAG 上线之前一定要做“单轮输出日志”的可视化排查。我见过太多团队拿着一个黑盒多跳系统上线出了问题完全没法定位。你宁可多花一周把中间状态记录和调试工具做好也不要直接跳进线上环境的泥潭里摸爬滚打。根据我个人经验多跳 RAG 的改造真正难的不是循环代码怎么写而是怎么让系统在每一跳都保持“清醒”——知道自己现在知道什么、缺什么、下一步该查什么。这个认知迭代的过程和你自己排查一个复杂问题时大脑里的运作方式是一样的。建议你先拿知识库里的真实问题跑起来看看 trace 日志你会很快感受到多跳 RAG 跟单跳 RAG 的差别也会更加清楚哪些环节值得投入更多精力去调优。等技术方案稳定了再考虑引入更重型的图数据库、在线学习或者多塔模型那些都是锦上添花的东西了。
返回列表