
多跳 RAG 这些年喊得挺响但大部分团队实际落地的方案说白了就是把“检索一次”硬生生改成“检索好几次再拼在一起”。这不叫多跳这叫多次单跳。真正能解决复杂问题的多跳 RAG核心在于让系统自己决定“下一步该查什么”并且能把已经查到的信息当成推理的跳板一步步逼近最终答案。我最近把一个面向内部知识库的 RAG 问答服务从单检索流程改造成了带规划能力的循环结构整个改造过程踩了不少坑也沉淀了一些比较实用的设计思路这次完整分享一下。先说清楚一个关键认知多跳 RAG 不是简单地把问题拆成几个子问题然后并行检索那样做跳数再多也是平的。真正的多跳是状态驱动的循环——每一轮检索之后系统要根据新拿到的证据重新评估“信息缺口”再决定下一轮往哪个方向查。好比查一个跨部门项目纠纷先查合同条款发现要判断违约责任还得知道实际的交付记录于是下一轮去查交付日志查到日志之后又发现涉及验收标准再回头查附件协议。每一跳都建立在前一跳的证据之上这才是“规划式检索循环”的本质。这次实战项目里我选了 LangGraph 来做整个 Agent 状态的编排检索层用混合检索BM25 Embedding 向量召回模型走的是 Qwen 系接口。选 LangGraph 而不是直接手写while True循环核心原因在于多跳检索的状态节点太多了每一轮要有独立的查询改写、检索、证据提取、缺口判断、终止决策用原生代码写容易变成一坨分不清边界的面条逻辑而 LangGraph 的图结构能把每个环节的输入输出显式化也方便后面做中间过程的可视化追踪。1. 为什么单次检索撑不住复杂问题信息缺口才是关键指标在动手写循环之前得先把“为什么需要多跳”这个问题想透。很多团队拍脑袋决定上多跳结果发现改造完之后效果还不如原版就是因为没有真正理解单次检索失效的原因。我自己的分析框架是任何一条用户问题都可以拆成一个信息需求集合单次检索失败的本质是这一次检索根本无法同时覆盖完整的需求集合更致命的是系统自己不知道哪些需求没被满足。具体来说单跳 RAG 的失效场景有四种我列成了一张排查表方便对号入座失效场景典型问题句式单跳失败原因多跳解决的思路桥接型问题“A 公司的产品在 B 市场的合规风险有哪些”需要先定位 A 公司产品线再查 B 市场法规单次查不出跨越两个实体的信息第一跳查产品信息提取实体后第二跳定向查法规聚合型问题“对比 X 方案和 Y 方案在成本结构上的差异”两份文档可能相距很远单次检索只能命中其中一份拆成两个子查询分别召回最后合并对比递进型问题“这个故障是什么原因导致的怎么修复”需要先查故障现象定位原因再依据原因查解决方案第一跳定位原因实体第二跳基于原因查修复手册反查型问题“哪些客户受到了这次版本更新的影响”版本更新记录里没有直接列出客户名单第一跳查出变更模块第二、三跳查使用该模块的客户列表单跳检索像个闷头干活的新人不管问题多复杂搜一把就交差。多跳的本质是把“搜一把”变成“搜一把 → 看看还缺啥 → 再搜一把”直到信息缺口闭合到能生成可靠回答的程度。所以在整个系统里判断缺口是否闭合这一环直接决定了系统的上限后面会专门展开讲。2. 循环的核心不是搜索引擎而是状态机里的四个角色多跳 RAG 工程化的难点在于它不再是一个简单的“查询 → 检索 → 生成”管道而要变成一个自主行为的 Agent 系统。这里有一个非常容易跑偏的点别把智能全押在 Prompt 上要用状态图把行为边界圈出来。我的实践经验是把整个循环拆成四个角色每个角色负责一个不可再拆的动作再由状态机决定它们之间的流转关系。四个角色分别是规划器Planner负责分析当前已知信息与目标答案之间的差距拆解出下一步需要检索的子问题。这是多跳的核心也是唯一直接和大模型打交道的“大脑”角色。检索器Retriever接收规划器产出的子查询在混合索引里做召回返回 Top-K 候选段落。Retriever 不需要有智能不接收复杂的检索策略只无脑执行。证据提取器Extractor从原始文档块里抽取出与当前子查询相关的关键实体、事件和结论压缩成标准化的“证据片段”。这个角色负责把噪声去掉防止无关信息污染规划器的判断。缺口评估器Verifier拿着累计的证据列表判断是否足以回答原始问题。如果不足以回答必须明确指出缺的是哪一类信息如果足够了允许切换到生成阶段。用这套角色模型跑起来之后最直观的变化是系统的行为边界变得可预测了。规划器再怎么发挥也不可能产生“自己创造事实”的自由度因为它的输出永远只是检索请求不是答案。证据提取器再怎么压缩也不会丢掉核心实体因为提取规则和抽取 Prompt 都围绕“回答当前原始问题”来约束。整个循环被约束在“检索-评估-再检索”的框架里跑十次跟跑一百次行为逻辑是稳定一致的。这里有个细节值得单独说规划器和缺口评估器的 Prompt 一定要写成“基于已有证据”的格式。我在第一版实现里也犯过“让模型凭空想象下一步去哪查”的错误一上来就让它输出后续查询计划结果它根本不知道当前已经查到哪一步规划出来的查询方向经常和已有证据重复甚至矛盾。后来改成先把已收集的证据片段全部塞给规划器再让它基于这些证据明确“还缺什么”以及“下一步查什么”行为才变得靠谱。3. 用 LangGraph 搭起可打断、可续跑的循环骨架如果你的检索循环只是“反复调用同一个 retriever”那用for _ in range(max_steps)就够了别上 LangGraph杀鸡不用牛刀。但一旦涉到“每轮查询要改写”“需要根据中间结果决定下一步检索方向”“要做多轮证据累积”甚至要支持人工介入确认那状态图的优势就非常明显了。LangGraph 最核心的价值是把每一轮循环显式地建模成状态节点边缘控制逻辑清晰可见跑挂了可以回溯到任意中间状态。我用 LangGraph 搭的循环骨架大致是这样的from langgraph.graph import StateGraph, END from typing import TypedDict, Annotated, List import operator class AgentState(TypedDict): question: str # 原始问题 sub_queries: List[str] # 已规划的子查询历史 evidence_blocks: Annotated[List[dict], operator.add] # 累积证据片段 current_focus: str # 当前缺口描述 rounds: int # 当前跳数 enough: bool # 缺口是否闭合 final_context: List[str] # 最终送入生成器的上下文在这个状态定义里evidence_blocks用operator.add聚合操作符每跳产生的证据会自动追加到全局列表里不会被覆盖。rounds用来记录当前已经跑了几跳既是为了控制最大步数也是后续算费用和日志用的关键字段。current_focus是当前缺口评估器给出的“最缺什么信息”的描述规划器下一轮会围绕它来改写查询。图节点的流转我用了一个比较扁平的方案规划器 → 检索器 → 提取器 → 评估器 → 条件分支。条件分支只有一个判断“是否收束”。收束条件有两个一个是缺口评估器返回enoughTrue另一个是rounds达到设定的最大跳数我一般设 4 跳再往上收益急剧下降后面有数据。一旦走到 END就把evidence_blocks里所有证据段落和原始问题拼接成上下文丢给生成模型做最终作答。def route_after_verify(state: AgentState): if state[enough] or state[rounds] MAX_ROUNDS: return END return planner graph StateGraph(AgentState) graph.add_node(planner, planner_agent) graph.add_node(retriever, retriever_agent) graph.add_node(extractor, extractor_agent) graph.add_node(verifier, verifier_agent) graph.set_entry_point(planner) graph.add_edge(planner, retriever) graph.add_edge(retriever, extractor) graph.add_edge(extractor, verifier) graph.add_conditional_edges(verifier, route_after_verify, { planner: planner, END: END, })这套结构跑通之后最大的体验是“每次循环的产出是透明的”。查中间任何一跳都能看到当前拆了哪个子查询、召回了哪些文档块、提取出哪些证据、还缺什么信息。这对排查“为什么系统老是在边缘问题上打转”特别有用几乎可以直接对着状态记录定位原因。如果你的项目未来要支持复杂文档推理、需要审计中间结果我强烈建议直接上这个层级的工程化结构别用手写循环。4. 让规划器产生高质量子查询实体锚点与桥接词约束很多多跳 RAG 开源项目效果不好最后都死在同一个点上——规划器拆出来的子查询太差。典型的失败模式有三种重复查询已有证据覆盖过的内容、查询语句太泛导致召回全是噪声、拆出来的问题和原始问题脱节。这些问题本质上都是规划器缺少“信息约束”和“实体锚点”导致自由发挥过度。我的做法是给规划器加了两个硬性约束。第一实体锚点约束。规划器在输出下一步查询之前必须先列出“已确认实体”再从这些实体出发构造桥接查询。比如原始问题是“某制造企业的 MES 系统宕机可能导致哪些批次的订单交付延迟”第一跳查到了“该企业 MES 系统在 3 月 12 日发生存储介质故障”这个证据那已确认实体就是“MES 系统”“存储介质故障”“3 月 12 日”。下一步查询必须包含这些实体词而不是泛泛地问“订单交付延迟的原因有哪些”。在实现上我会在规划器的 Prompt 里加一段结构化的“实体锚点区”要求输出之前先填空已确认实体列表 - 实体1MES系统 - 关联信息3月12日存储介质故障 信息缺口MES系统故障影响哪些批次/订单 下一步检索查询必须包含以上实体且必须针对信息缺口这个约束看起来简单效果却非常明显。它本质上是在告诉模型——你的下一跳不能脱离已知事实只能在已知事实的邻域里扩展。第二桥接词约束。规划器给出的子查询不能是独立的、与原始问题无关的问题也就是说生成时要自问“这个查询的结果是否能贡献给原始问题的最终回答”我的 Prompt 里专门有一段“桥接自检”要求模型在输出查询前先自问如果查到的结果无法桥接回原始问题就不允许输出。这能过滤掉大量“看着相关、实际却在跑题”的检索轮次。给一个我调优后的规划器 Prompt 片段作为参考你是多跳检索规划器。你的任务是在每一跳中产出唯一一个下一步检索查询目标是填补信息缺口。 规则 1. 只能基于已有证据规划下一步禁止凭空想象。 2. 每一步必须引用至少一个已有证据中的实体作为查询锚点。 3. 输出格式为 JSON{rationale: 为什么查这个, query: 具体查询串, expected_answer_type: 期望补全的信息类型} 4. 如果无法产出有意义的下一跳查询将 query 置空。注意我要求输出rationale理由和expected_answer_type期望补全的信息类型这两个字段在调试阶段价值极高——你可以直接看到规划器心里是怎么想的而不是只看到一个干巴巴的查询。我在几十个小时的调试里几乎有四成的问题都是靠看 rationale 发现规划器逻辑偏颇才定位到的。5. 混合检索策略向量为主别丢掉精确匹配的拐杖规划器拆完了查询接下来是真正去文档库里找证据。在单跳 RAG 里你大可以选择一个 embedding 模型一条路走到底。但多跳场景里每一跳的子查询往往带有更明确的名词实体比如产品型号、合同编号、报错代码这些恰恰是纯向量检索的弱点——向量空间里相似不等于字面精确。同样的故障代码语义最接近的句子可能来自另一个模块而不包含这个代码本身的文档。所以我给检索层配的是“BM25 Embedding 向量”双通道混合召回再用 RRFReciprocal Rank Fusion做结果融合。具体实现不复杂关键是理解为什么要这么做BM25 负责字面精确匹配保证“代码、型号、编号”这类强标识实体不丢向量负责语义迁移保证“换了个说法”的同一意思也能被召回。from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer import numpy as np class HybridRetriever: def __init__(self, docs, embedder): self.docs docs self.embedder embedder self.doc_embeddings embedder.encode(docs, normalize_embeddingsTrue) tokenized_docs [doc.split() for doc in docs] self.bm25 BM25Okapi(tokenized_docs) def retrieve(self, query, top_k10): query_embedding self.embedder.encode([query], normalize_embeddingsTrue) vec_scores np.dot(self.doc_embeddings, query_embedding.T).flatten() tokenized_query query.split() bm25_scores np.array(self.bm25.get_scores(tokenized_query)) vec_ranks np.argsort(-vec_scores) bm25_ranks np.argsort(-bm25_scores) rrf_scores np.zeros(len(self.docs)) for rank, idx in enumerate(vec_ranks): rrf_scores[idx] 1 / (60 rank 1) for rank, idx in enumerate(bm25_ranks): rrf_scores[idx] 1 / (60 rank 1) top_indices np.argsort(-rrf_scores)[:top_k] return [self.docs[i] for i in top_indices], top_indices这套融合后的检索效果最直观的表现是多跳的错误率降了一个档次。举个实测例子一条子查询“R-5402 在高温老化测试中的阈值异常”纯向量召回结果里混入了很多“老化”“测试”“阈值”相关但型号完全无关的段落加入 BM25 之后文档块只要包含“R-5402”这个字符串排名就会被强行提高到召回前列。对于多跳链路来说这一步非常关键——第一跳召回错了后面每一跳都会在错误的方向上叠加错误最终的答案会以一种看起来很有逻辑的方式胡说八道。另外说一句索引粒度的问题。多跳 RAG 的文档块不建议切得太碎512 token 左右的块比较合适。切太碎会导致一个完整事件被拦腰截断提取器拿不到完整上下文还得靠多跳去补全被切掉的上下文白白增加跳数。切太粗又会让规划器一步检索到太多不相关的内容噪声污染严重评估器的判断也会被带偏。512 这个数是我在实测里试出来的甜点值。6. 关键技术一跳证据提取器怎么避免“信息雪崩”有了检索结果下一步不是一股脑全塞给评估器而是先做证据提取。这一步看起来是冗余环节实际作用非常大。多跳检索每一跳召回 8-10 个文档块跑上 4 跳累积的原始文档内容可能超过两万字。如果这些原始内容全部堆给大模型去判断“够不够”结果只有两个一是关键信息淹没在噪声里评估器很容易误判缺口状态二是 token 消耗急剧上升费用和延迟都变得不可控。证据提取器要做的事情很简单从文档块里抽出“和当前信息缺口直接相关的句子”并且统一转换成标准格式。这个标准格式我经过多轮迭代最终定为三元组加一段支撑原文证据记录 { entity: 核心实体, attribute: 属性/关系, value: 值/结论, source_snippet: 支撑原文片段, round: 1 }例如第一跳检索回来一份合同文档提取器产出的一条证据大概是{ entity: 供应商交货条款, attribute: 延迟违约金比例, value: 每延迟一天按合同总额的0.05%计算, source_snippet: 若乙方未按约定时间交货每延迟一日应向甲方支付合同总金额0.05%的违约金。, round: 1 }提取的 Prompt 我会加一个“抽取而非总结”的强约束宁可多抽几句原文也不要模型自己总结发挥。总结会丢失实体关系而原文片段可以回溯验证。多跳系统的每一跳证据都涉及此前的推理链如果证据本身就失真了后面的“推理”只是在错误的积木上盖楼。为什么要专门说“避免信息雪崩”因为我在不做提取器、直接堆原文的版本里真切体会过什么叫系统退化。第一跳结束信息量还在可控范围评估器判断“缺交付日志继续查”。第二跳把交付日志加进来之后评估器开始出现幻觉把“交付延迟”错判成“质量问题”第三跳直接偏到“供应商质量管理”方向上了整条检索链彻底跑偏。加了提取器之后评估器看到的是一份干净的结构化证据摘要而不是大量重复、矛盾的原始表达判断稳定性直线上升。7. 缺口评估器多跳循环的刹车闸与方向盘评估器是整条循环里灵魂的一环——它收到全部已有的证据后要做两件事一是判断证据是否足够回答原始问题二是在不够时明确指出最缺哪一类信息。前者是循环的闸后者是下一轮规划的方向盘。这个环节如果做不好整个系统要么在证据已经足够时继续傻跑浪费资源要么在证据明显不足时提前收束生成幻觉答案。我用的评估 Prompt 是这么设计的你是信息缺口评估器。原始问题{question} 已知证据如下 {evidence_blocks} 请判断当前证据是否足够回答原始问题。 判断标准 1. 如果所有关键实体、关系、时间节点已覆盖并且能形成一个完整推理链输出 {enough: true} 2. 如果缺少任何关键信息必须输出 false并列出精确的信息缺口类型例如 - 缺少具体数值/状态/实体关系/事件因果 - 某一实体在给定证据中只被提及但属性未说明 输出 JSON{enough: bool, gap_description: 最需要补全的信息, missing_entities: [缺失的实体列表]}注意这里我要求了missing_entities这个字段这是为规划器提供的精确“下一步方向”比“缺一段信息”这种模糊描述有价值得多。规划器拿到missing_entities之后可以直接围绕这些缺失实体构造桥接查询检索的方向率和命中率都会显著提高。这里有一个真实翻车案例值得分享一下。我最初版本的评估器跟规划器没有闭环——评估器判断了缺口但规划器可以不理会这个缺口自己另起炉灶规划。结果系统有时会在已经拿到“合同总额为 500 万”这条证据之后下一跳又去查“合同总额”相关的内容白白空转。后来我把“规划器的下一步查询必须引用评估器输出的 gap_description 和 missing_entities”写死到状态流转逻辑里如果规划器输出的子查询没有覆盖缺失实体直接重试或退出空转的问题才彻底解决。这种跨节点的状态传递约束是手写循环时非常容易忽略的也是状态机较之自由代码的核心优势之一。8. 循环什么时候停最大跳数只是保险语义收敛才是正解在多跳 RAG 里循环的终止条件直接决定了整个系统的效率。只靠“最大跳数”硬截断是下策因为它在问题稍微复杂时可能会在证据还不完整时就草草收场或者在较简单的问题上多跑了好几跳白费 token。比较好的做法是设计两个层级语义收敛判定作为主闸门最大跳数仅仅作为安全保险丝。语义收敛判定的思路是每一跳结束之后把“新增证据”和“已有证据”做一次对比计算信息增益。如果某次新增证据与已有证据高度重复信息增量低于阈值就判断“多跳下去没有新东西了”此时无论评估器觉得够不够都强制收束并把“信息增量不足”作为置信度信号交给最终生成阶段让它谨慎作答。简化实现思路大致是def semantic_converged(evidence_blocks, threshold0.85): if len(evidence_blocks) 2: return False # 将最后一个证据块的文本与之前所有块做相似度计算 last_text evidence_blocks[-1][source_snippet] pre_texts [b[source_snippet] for b in evidence_blocks[:-1]] max_sim max(compute_cosine_similarity(last_text, t) for t in pre_texts) return max_sim thresholdthreshold我一般取 0.85 左右具体情况取决于你的向量模型和文档风格跑个二十条标注样本就能调出来。语义收敛的价值在于系统能自动识别“这问题其实用一跳就够”的场景——第一跳证据已经很全面第二跳召回来的文档只是在重复第一跳的信息于是早早收手省掉无意义的后续检索。关于最大跳数我的经验值是4 跳。实测中超过 4 跳的问题绝大多数不是多跳检索能解决的——要么是知识库本身缺资料要么是问题超出了文档的覆盖面再多跳只会让模型逐渐遗忘原始问题到底是什么。很多时候第 3 跳、第 4 跳已经能拿到足够信息了第 5 跳开始就是在原地打转检索到的内容越来越偏、越来越远。9. 实测效果与典型失败案例为什么多跳在某些问题上反而更差光说理论和实现不够上一版和这一版的具体对比数据才是最诚实的汇报。我用内部标注的 100 条复杂问题做了评测拆成三组进行对比单跳 RAGbaseline、多跳 RAG无证据提取器、多跳 RAG完整版带规划、提取、评估闭环。打分标准是答案正确率 是否引用无关证据 是否完整覆盖问题所有子项满分为 5。方案答案正确率无关证据引用率平均跳数单条耗时单跳 RAG58%31%11.1s多跳 RAG无提取器63%44%3.27.8s多跳 RAG完整版81%17%2.66.2s看完这张表有三个点想特别强调。第一多跳不是银弹没有提取器和评估器约束的裸循环版本正确率只提升了 5 个点但无光证据引用率却攀升到了 44%——比单跳还差。这意味着它虽然多检索了几轮但把更多错误的信息喂给了生成器污染反而更严重。第二完整版靠证据提取和闭环评估把无关证据率压到 17%这个数字是答案质量最直接的支撑。第三完整版平均跳数是 2.6说明系统学会了在简单问题上提前收敛没有盲目跳到 4 跳上限。再看一个具体的失败案例。某个问题是“供应商在 3 月 20 日提出延期申请我们有没有权利按合同扣违约金”正确推理链路是查合同违约金条款 → 查延期申请是否被甲方批准 → 查批准后是否有补充协议。我的完整版在第二跳“延期申请是否被批准”上翻车了。原因在于评估器使用的是宽松标准它看到证据里有“延期申请已提交”就误以为“批准状态已知”没意识到“提交”和“批准”是两个不同的事实节点。这暴露了当前评估器的一个盲区它对动作完成态的理解还不够细容易把“过程”误判为“结果”。这也是下一步做细粒度事件状态识别的方向。借此也要提醒一句如果你的语料里没有充分的证据支撑多步推理别强行上多跳否则就是明知道查不到还让它硬编。这种情况下先做知识库本身的补全远比你优化检索链路有效得多。10. 更进一步的规划方向从固定状态机到动态任务分解现在跑通的多跳循环本质上还是“单条推理链”——无论中间怎么跳最后都是往一个最终答案上收敛。但真正复杂的真实场景往往是多线的一个问题可能包含两个互相独立的子问题需要分别检索最后再组装答案。这就要把单链循环升级成树状或图状的动态任务分解。我的下一步规划是在当前 LangGraph 状态机上增加一个“任务分解器”节点。规划器不再直接输出单条查询而是整体评估“这个问题是否包含多个独立子目标”如果包含就分解成子任务让每个子任务各自跑一套“规划-检索-提取-评估”循环最后进入汇总节点由生成模型在子任务证据的基础上合成面向原始问题的最终回答。这个方向我还在探索中但目前已经能明确感知到它跟多跳循环并不冲突而是互补的关系。多跳解决的是单条链路上的深度推理任务分解解决的是同一个问题内部的宽度扩展。两者结合起来才是比较完备的“会规划的检索循环”——既能往下钻也能往旁边铺。如果你也在做类似的多跳 RAG 落地我给的最核心建议只有一条先做闭环再加智能。先把“检索 → 提取 → 评估 → 再检索”这个硬循环跑通让每一轮的状态变更完全可控再逐步把 Prompt 调优、阈值调整、模型替换加进去。不要一开始就追求规划器的“聪明”聪明是建立在可控骨架之上的骨架不稳越聪明的模型跑起来越像脱缰的野狗。等整个循环变得稳定、可观测、可回溯了再往上面叠花活那时候你才会真正理解多跳 RAG 为什么值得折腾。