前言
单轮 RAG 只能回答「一跳就能查到答案」的问题,但现实中很多问题是链式的:需要先知道 A,才能查到 B,再根据 B 推出 C。直接把原始问题向量化检索,往往因为语义偏移命中不了正确答案。
本文是系列最终篇,我们把 RAG 升级到多跳推理版:
- 自动把复杂问题拆成有序子问题
- 按顺序逐个子问题检索,每步积累上下文
- 智能规划器判断是否信息足够,可提前终止
- 文档自动去重,避免重复内容干扰生成
一、多跳 RAG 完整架构
最终版的工作流是一个带循环的状态机:
sql
START → route_question
├─ simple → direct_answer → END
└─ complex → decompose_question → retrieve → plan_next_step
├─ retrieve(继续循环)
└─ generate → END
核心新增四个能力:
- decompose_question:把原始问题拆成有序的子问题链
- 循环检索:按顺序逐个检索子问题,每轮合并文档
- plan_next_step:每轮检索后评估信息是否足够,决定继续还是生成
- mergeUnique:多轮检索的文档自动去重,按相似度排序
二、状态扩展
为了支持循环和多跳,状态新增了多个控制字段:
表格
| 字段 | 作用 |
|---|---|
| subQuestions | 拆解后的子问题数组 |
| nextSubIdx | 当前检索到第几个子问题(指针) |
| currentQuery | 当前正在检索的子问题 |
| retrievalCount | 已检索轮次 |
| maxRetrievals | 最大检索轮数,防止无限循环 |
| plannedNext | 下一步动作:retrieve /generate |
三、核心模块逐段解析
1. 子问题拆解节点
用 LLM 把复杂问题拆成有序子问题,要求每条都能独立检索,不能有指代。
javascript
const DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string()
});
const decomposeQuestionNode = async (state) => {
console.log("---DECOMPOSE_QUESTION---");
const decomposer = model.withStructuredOutput(DecomposeSchema);
const out = await decomposer.invoke(`
你是《天龙八部》多跳问答的【子问题拆解器】。
用户原始问题:${state.question}
任务:将问题拆成有序子问题列表 sub_questions,用于依次向量检索。
要求:
1. 链式推理、多层关系的问题,必须拆成多条;单跳即可答的也可只输出1条。
2. 每条子问题必须是可独立检索的完整中文问句,禁止使用「他/她/此人」等指代。
3. 顺序必须符合推理链:先搞清前置事实,再查后续结论。
4. 不要把整句原题原样复制成唯一一条;不要拆成过碎的关键词列表。
请输出 sub_questions 与简短 reason。
`);
const subQuestions = out.sub_questions.map(s => s.trim()).filter(Boolean);
console.log(`拆解${subQuestions.length}条子问题(${out.reason})`);
subQuestions.forEach((q, i) => console.log(`[${i+1}] ${q}`));
return {
subQuestions,
nextSubIdx: 0,
currentQuery: subQuestions[0]
}
}
2. 文档去重合并
多轮检索会命中重复文档,用 Map 按 id 去重,保留相似度更高的版本,最终按分数降序。
javascript
const mergeUnique = (existingDocs, newDocs) => {
const map = new Map();
for (const d of [...existingDocs, ...newDocs]) {
const key = String(d.id);
const prev = map.get(key);
if (!prev || Number(d.score) > Number(prev.score)) {
map.set(key, d);
}
}
return Array.from(map.values()).sort((a, b) => Number(b.score) - Number(a.score));
}
3. 循环检索节点
根据 nextSubIdx 指针取出当前子问题,执行检索,合并文档,指针后移。
ini
const retrieveNode = async (state) => {
const subs = state.subQuestions ?? [];
const idx = state.nextSubIdx ?? 0;
const q = subs[idx]?.trim();
const round = state.retrievalCount + 1;
console.log(`----第${round}轮,子问题 ${idx+1}/${subs.length}---`);
console.log(`---查询 ${q} ---`);
const newDocs = await retrieveRelevantContent(q, state.k);
const merged = mergeUnique(state.documents ?? [], newDocs);
console.log(`本轮命中${newDocs.length}条,累计去重后${merged.length}条`);
return {
documents: merged,
retrievalCount: round,
nextSubIdx: idx + 1,
currentQuery: q
}
}
4. 下一步规划节点
这是多跳 RAG 的「大脑」:综合已检索文档、剩余子问题、轮数上限,判断下一步动作。
ini
const NextStepSchema = z.object({
nextAction: z.enum(["retrieve", "generate"]),
reason: z.string()
});
const planNextStepNode = async (state) => {
console.log("---PLAN_NEXT_STEP---");
const subs = state.subQuestions ?? [];
const nextIdx = state.nextSubIdx ?? 0;
const remaining = subs.length - nextIdx;
// 拼接子问题状态和已检索文档摘要
const subList = subs.map((s, i) => `${i + 1}.${s}
${i < nextIdx ? "已检索": i === nextIdx ? "(下一轮将检索)" : "未检索"}`).join("\n");
const docStr = state.documents.length === 0
? "(尚无检索结果)"
: state.documents.slice(0, 6).map((d, i) =>
`[${i + 1}] score=${Number(d.score).toFixed(4)} 第${d.chapter_num}章:${d.content.slice(0, 200)}`
).join("\n\n");
const prompt = `你是多跳 RAG 规划器。
用户原始问题: ${state.question}
子问题序列:
${subList}
已检索轮次:${state.retrievalCount}; 剩余未检索子问题条数:${remaining}
最大检索轮数上限:${state.maxRetrievals}
已召回文档摘要:
${docStr}
请判断下一步:
1)已有足够依据回答原始问题 → nextAction=generate
2) 仍缺关键事实、且还有未检索子问题、且未超轮数上限 → nextAction=retrieve
硬性规则:
- 剩余子问题为0,必须 nextAction=generate
- 已达最大轮数,必须 nextAction=generate
`;
const planModel = model.withStructuredOutput(NextStepSchema);
const { nextAction, reason } = await planModel.invoke(prompt);
// 硬性规则兜底,防止模型判断失误
let finalNext = nextAction;
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate";
if (remaining <= 0) finalNext = "generate";
console.log(`[决策] plannedNext=${finalNext} (模型建议=${nextAction})(${reason})`);
return { plannedNext: finalNext };
}
5. 条件循环与完整图
规划节点通过条件边形成循环,满足条件就继续检索,否则退出循环去生成答案。
php
const afterPlan = (state) => state.plannedNext === "retrieve" ? "retrieve" : "generate";
const afterRoute = (state) => state.strategy === 'simple' ? "direct_answer" : "decompose_question";
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("decompose_question", decomposeQuestionNode)
.addNode("retrieve", retrieveNode)
.addNode("plan_next_step", planNextStepNode)
.addNode("generate", generateNode)
.addEdge(START, "route_question")
// 路由分支
.addConditionalEdges("route_question", afterRoute, {
direct_answer: "direct_answer",
decompose_question: "decompose_question"
})
.addEdge("decompose_question", "retrieve")
.addEdge("retrieve", "plan_next_step")
// 循环分支:规划 → 继续检索 或 生成
.addConditionalEdges("plan_next_step", afterPlan, {
retrieve: "retrieve",
generate: "generate"
})
.addEdge("direct_answer", END)
.addEdge("generate", END)
.compile();
四、多跳问题运行演示
问题:《天龙八部》中四大恶人排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?
运行流程:
scss
___ROUTE-QUESTION___
路由策略:complex 需要多步推理,涉及人物关系和身份细节
---DECOMPOSE_QUESTION---
拆解3条子问题(需要先确定第二恶人是谁,再找其子,最后查生父身份)
[1] 《天龙八部》中四大恶人排行第二的人物是谁?
[2] 四大恶人排行第二之人的儿子是谁?
[3] 该人物之子的生父在武林中的公开身份是什么?
----第1轮,子问题 1/3---
---查询 《天龙八部》中四大恶人排行第二的人物是谁? ---
本轮命中5条,累计去重后5条
---PLAN_NEXT_STEP---
[决策] plannedNext=retrieve (模型建议=retrieve)(已确定第二恶人是叶二娘,但还需其子与生父信息)
----第2轮,子问题 2/3---
---查询 四大恶人排行第二之人的儿子是谁? ---
本轮命中5条,累计去重后8条
---PLAN_NEXT_STEP---
[决策] plannedNext=retrieve (模型建议=retrieve)(已知其子是虚竹,还需生父公开身份)
----第3轮,子问题 3/3---
---查询 虚竹的生父在武林中的公开身份是什么? ---
本轮命中5条,累计去重后12条
---PLAN_NEXT_STEP---
[决策] plannedNext=generate (模型建议=generate)(所有关键信息已齐全)
[AI回答(流式)]
四大恶人排行第二的是「无恶不作」叶二娘。她的儿子是虚竹,在身世揭晓之前,虚竹的生父玄慈方丈在武林中的公开身份是少林寺方丈,同时也是当年雁门关惨案的带头大哥...
五、整套 RAG 演进总结
我们用四篇文章,从 0 到 1 完成了完整的演进路线:
表格
| 版本 | 核心能力 | 适用场景 |
|---|---|---|
| 向量入库版 | EPUB 切片 + Milvus 建库 | 知识库构建 |
| 基础单轮 RAG | 检索 → 生成 | 简单单跳问题 |
| 智能路由版 | 问题分类 + 双分支分流 | 混合问题场景,优化资源 |
| 多跳推理版 | 子问题拆解 + 循环检索 + 智能规划 | 复杂链式、多跳推理问题 |
这套架构可以直接复用到任何垂直领域知识库,只需要换一下数据集和 Prompt 就能落地。