高级篇:进阶多跳 RAG:子问题拆解 + 循环检索 + 智能规划,搞定复杂链式问题

前言

单轮 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

核心新增四个能力:

  1. decompose_question:把原始问题拆成有序的子问题链
  2. 循环检索:按顺序逐个检索子问题,每轮合并文档
  3. plan_next_step:每轮检索后评估信息是否足够,决定继续还是生成
  4. 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 就能落地。

相关推荐
我的div丢了肿么办1 小时前
element-plus表格使用:filter-method过滤导致出现无数据
前端·javascript·vue.js
YIAN1 小时前
入门篇:LangGraph 入门实战:从零搭建最简单的 RAG 问答系统
javascript·langchain
YIAN2 小时前
前置篇:手把手用 Milvus + LangChain 搭建小说向量知识库,《天龙八部》EPUB 一键入库
javascript·langchain
YIAN2 小时前
进阶篇:RAG 优化第一步:给你的问答系统加上智能路由,实现双分支分流
javascript·langchain
Shockang2 小时前
LangChain 智能体治理实战:预算、权限与故障恢复
langchain
inksci2 小时前
发布Ai生成的html markdown 推荐
前端·javascript·html
是江迪呀2 小时前
向全世界售卖你的产品:做了一款教程引导插件
前端·javascript·后端
码艺-Alimjan4 小时前
维吾尔语 RTL 排版的隐形利器:`\u200F`
前端·javascript
Highcharts.js4 小时前
Highcharts 数据过滤实战指南:从基础实现到性能优化
javascript·性能优化·bi·highcharts·前端可视化·数据过滤·交互图表