多跳 RAG 实战:把“检索一次”改造成“会规划的循环”
做 RAG 的朋友应该都有过这种体验:知识库里的内容明明都检索出来了,答案却还是不对,或者干脆漏掉了关键信息。一开始我以为是自己 embedding 模型选得不够好,后来才意识到,问题的根源不在“检索质量”,而在“检索次数”——我只给了系统一次检索机会,可很多现实问题,压根不是一次检索能回答的。这篇文章想聊的,就是怎么把这种“检索一次就收工”的简单 RAG,改造成一个“会规划、能循环”的多跳 RAG 系统。
多跳 RAG(Multi-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 条”这条管线。融合排序可以用 RRF(Reciprocal 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_question=question) # 规划器:第一步先做整体子问题拆解 sub_questions = PlanDecomposer().decompose(question) state.add_plan(sub_questions) retriever = HybridRetriever() reasoner = EvidenceReasoner() controller = StopController(confidence_threshold=0.7) for round_idx in range(max_rounds): # 1. 从“缺口”中生成这一轮的检索查询 queries = GapAnalyzer().generate_queries( original_question=question, known_facts=state.known_facts, sub_questions=state.pending_sub_questions, ) # 2. 混合检索,拿到候选文档 retrieved = retriever.hybrid_search(queries, top_k=5) state.add_retrieved(round_idx, retrieved) # 3. 推理器合并证据,输出已知事实 + 缺口 + 置信度 result = reasoner.analyze( question=question, known_facts=state.known_facts, new_evidence=retrieved, ) 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 用一个更大的窗口,这样关键词命中的上下文更完整。我实测过,混合检索比单用向量检索的 recall@5 平均能高 12 到 18 个百分点,在多跳场景里优势更明显。
# retriever.py 里的融合排序部分 from rank_bm25 import BM25Okapi import numpy as np class HybridRetriever: def __init__(self, vector_index, bm25_index, alpha=0.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(), key=lambda x: x[1], reverse=True) 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 的差别,也会更加清楚哪些环节值得投入更多精力去调优。等技术方案稳定了,再考虑引入更重型的图数据库、在线学习或者多塔模型,那些都是锦上添花的东西了。