Agentic RAG(中):复杂问题如何拆成多跳检索闭环

Agentic RAG(中):复杂问题如何拆成多跳检索闭环

单次向量检索适合"问题和答案在同一个片段里"的场景。真正让我发现它不够用的,是一类人物关系题:先根据描述确定 A 是谁,再查 A 的师承或亲属,最后才能回答原问题。

如果把整句复杂问题直接向量化,它同时包含多个实体和关系,召回结果可能只覆盖其中一部分。即便 top-k 里恰好有答案,模型也未必知道应该先确认哪个前置事实。

这类任务需要多跳检索(Multi-hop Retrieval):把问题拆成有顺序的子问题,逐轮检索并累积证据,再决定继续还是生成。重点不是某部小说的答案,而是这套可复用的控制流。

模型和 Milvus 需要按自己的环境配置,因此这里不提供未经固定评测集验证的召回率或准确率数据。

1. 多跳问题为什么不能只做一次 query rewrite

查询改写(Query Rewrite)通常生成几个意思相近的问法,然后并行检索。它提高覆盖面,但这些查询彼此独立。

多跳问题有依赖关系。例如:

  1. 某段描述中的人物是谁?
  2. 这个人物的师父是谁?
  3. 师父与另一个事件有什么关系?

第二步依赖第一步,第三步又依赖第二步。把三句同时发出去,后面的查询可能连实体名都不知道。

方案 A 是生成多个平行改写,适合同一个事实的不同说法;方案 B 是生成有序子问题,适合关系链、因果链和时间链。两者解决的不是同一类问题。

2. 图状态必须显式记录"走到哪一步"

多跳流程不能只保存最终文档,还要知道当前子问题、下一下标和检索次数:

js 复制代码
const GraphState = Annotation.Root({
  question: Annotation,
  strategy: Annotation,
  subQuestions: Annotation,
  nextSubIndex: Annotation,
  currentQuery: Annotation,
  retrievalCount: Annotation,
  maxRetrievals: Annotation,
  plannedNext: Annotation,
  documents: Annotation,
  generation: Annotation,
});

nextSubIndex 是流程位置,retrievalCount 是资源消耗,maxRetrievals 是硬护栏。它们看起来有些重复,但含义不同:有时一次子问题可能重试,次数不一定等于下标。

把这些字段放进图状态后,循环就不再依赖函数局部变量。配合 Checkpointer,可以从某一轮恢复,而不是从头重新检索。

3. 子问题拆解必须强调"可独立检索"

拆解节点使用结构化输出,限制 1~8 个子问题:

js 复制代码
const DecomposeSchema = z.object({
  sub_questions: z.array(z.string()).min(1).max(8),
  reason: z.string(),
});

async function decomposeQuestionNode(state) {
  const decomposer = model.withStructuredOutput(DecomposeSchema);

  const result = await decomposer.invoke(`
把原问题拆成有序、可独立检索的中文子问题。

要求:
1. 顺序符合推理链,先前置事实,再后续结论;
2. 不使用"他、她、此人、上文"等指代;
3. 不要拆成零散关键词;
4. 输出 1~8 条。

原问题:
${state.question}
`);

  const subQuestions = result.sub_questions
    .map((question) => question.trim())
    .filter(Boolean);

  if (subQuestions.length === 0) {
    throw new Error("子问题列表为空");
  }

  return {
    subQuestions,
    nextSubIndex: 0,
    currentQuery: subQuestions[0],
  };
}

"禁止指代"非常重要。每条子问题会独立进入向量库,检索器没有上一句的自然语言上下文。如果查询是"他的师父是谁",Embedding 并不知道"他"指谁。

这里仍有一个限制:原实现一次性拆完所有子问题。如果后续实体只有在第一轮结果中才能确定,初始拆解器可能只能猜。更动态的做法是每一轮根据已有证据生成下一查询,但那会增加模型调用和循环风险。学习实现选择了一次拆解,逻辑更可控。

4. 每轮检索后先去重,再进入规划

多个子问题经常召回相同片段。如果直接拼接,重复文本会浪费上下文,还可能让模型误以为重复出现的证据更重要。

这里按文档 ID 去重,同一 ID 只保留更高分结果:

js 复制代码
function mergeUnique(existingDocs, newDocs) {
  const byId = new Map();

  for (const document of [...existingDocs, ...newDocs]) {
    const id = String(document.id);
    const previous = byId.get(id);

    if (!previous || Number(document.score) > Number(previous.score)) {
      byId.set(id, document);
    }
  }

  return Array.from(byId.values())
    .sort((a, b) => Number(b.score) - Number(a.score));
}

这段逻辑有一个需要实际验证的前提:score 越大是否越相关。不同向量库和封装可能返回相似度,也可能返回距离;距离通常越小越好。文章保留原实现思路,但上线前必须确认当前 Milvus/LangChain 适配器的分数语义。

检索节点按下标取当前问题,并推进状态:

js 复制代码
async function retrieveNode(state) {
  const index = state.nextSubIndex ?? 0;
  const currentQuery = state.subQuestions[index]?.trim();

  if (!currentQuery) {
    throw new Error(`子问题下标 ${index} 无有效文本`);
  }

  const newDocs = await retrieveRelevantContent(
    currentQuery,
    state.k,
  );

  return {
    documents: mergeUnique(state.documents ?? [], newDocs),
    retrievalCount: state.retrievalCount + 1,
    nextSubIndex: index + 1,
    currentQuery,
  };
}

5. 模型可以建议继续,但硬规则拥有最终决定权

检索一轮后,规划节点查看子问题进度和已有文档,输出 retrieve 或 generate:

js 复制代码
const NextSchema = z.object({
  nextAction: z.enum(["retrieve", "generate"]),
  reason: z.string(),
});

async function planNextStepNode(state) {
  const remaining =
    state.subQuestions.length - state.nextSubIndex;

  const planner = model.withStructuredOutput(NextSchema);
  const suggestion = await planner.invoke(
    buildPlannerPrompt(state, remaining),
  );

  let finalNext = suggestion.nextAction;

  // 硬规则覆盖模型建议
  if (remaining <= 0) finalNext = "generate";
  if (state.retrievalCount >= state.maxRetrievals) {
    finalNext = "generate";
  }

  return { plannedNext: finalNext };
}

这是我认为原实现最值得保留的设计:模型有判断权,但没有无限资源。

如果完全相信模型,它可能在已有足够证据时继续搜索,也可能忽略次数上限。硬规则必须在程序里再次覆盖,而不能只写在提示词里。

方案 A 是每个子问题都检索完再生成,行为确定但可能浪费;方案 B 是模型判断何时停止,节省潜在检索但增加一次规划调用。这里选择 B,同时用"没有剩余问题"和"达到上限"兜底。

6. 循环边把流程串起来

完整图的核心是 retrieve → plan → retrieve/generate:

js 复制代码
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", (state) => state.plannedNext, {
    retrieve: "retrieve",
    generate: "generate",
  })
  .addEdge("direct_answer", END)
  .addEdge("generate", END)
  .compile();

生成节点把去重后的文档拼成带编号的上下文,并要求信息不足时明确说明。这里只是"基于累积证据回答",并没有真正验证每一步推理是否成立。更严格的实现还需要保存"哪个子问题召回了哪些文档",否则最终只剩一个扁平文档列表,证据链不够清楚。

7. 多跳检索最容易踩的四个坑

子问题拆得太碎

"人物名""师父""结局"这类关键词不是完整问题,向量检索语义不足。每条查询要能独立理解。

指代没有消解

"他后来怎样"离开原问题后没有意义。拆解阶段要补全实体;如果实体尚未知,就需要动态规划而不是一次性拆分。

去重依据不稳定

按文档 ID 最可靠;没有稳定 ID 时才考虑内容哈希。只按文本完全相等去重,会漏掉轻微差异的重复片段。

没有硬停止条件

提示词中的"最多检索 5 次"不是约束。代码必须检查计数,并为异常、空结果和模型错误提供出口。

结尾

多跳 RAG 的核心不是"多查几次",而是维护一条可观察的检索计划:

  • 把复杂问题拆成有序、可独立检索的子问题;
  • 每轮保存当前下标、检索次数和累积证据;
  • 按稳定 ID 去重,并确认分数方向;
  • 让模型建议继续或生成,但由代码执行次数上限;
  • 最终答案要能回溯子问题与证据,而不是只拿一堆扁平文本。

下一步先为"子问题---召回文档---最终结论"保存可追溯映射并建立固定评测题,再进入另一种提升检索质量的路线:同一个查询同时走 Elasticsearch 关键词检索和 Milvus 语义检索,再通过 Rerank 模型去噪。这与多跳检索可以组合,但应该先分别理解,避免一次把图做得过于复杂。

相关推荐
小盆女神节奶粉1 小时前
LangChain 中间件
langchain·agent
杨超越luckly3 小时前
一线加新一线占61.7%,506家店,西西弗书店的“贵地生存法则”
数据分析·agent·可视化·西西弗书店·生意经
半糖程序员4 小时前
从零构建 Agent(11):压缩过长的上下文
typescript·agent
析数塔4 小时前
rea 逆向工具体验:把 Electron 应用、Native 模块、.NET 程序都交给 Agent 追问
开源·agent
北京地铁1号线4 小时前
为大模型创建一个简单的skill:在12306网站查询车次(仅作查询,不提供购票功能,仅作学习使用)
自然语言处理·大模型·agent·技能·skill
架构师那点事儿4 小时前
Agent Skill: 视频/PPT 内容提取 Skill —— 从 0 到 1 诞生记 + 使用指南
llm·agent·ai编程
sarasuki4 小时前
MCP 为什么是一个协议而不是一个框架呢?
设计模式·agent·mcp
全栈Agent 小李5 小时前
【无标题】
前端·后端·agent·ai编程·全栈·cursor·mcp
bazingaedward5 小时前
别让 AI Agent 碰到你的 API Key:给 Claude Code 做一个"只给名字、不给值"的密钥层
agent·claude