Agentic RAG(中):复杂问题如何拆成多跳检索闭环
单次向量检索适合"问题和答案在同一个片段里"的场景。真正让我发现它不够用的,是一类人物关系题:先根据描述确定 A 是谁,再查 A 的师承或亲属,最后才能回答原问题。
如果把整句复杂问题直接向量化,它同时包含多个实体和关系,召回结果可能只覆盖其中一部分。即便 top-k 里恰好有答案,模型也未必知道应该先确认哪个前置事实。
这类任务需要多跳检索(Multi-hop Retrieval):把问题拆成有顺序的子问题,逐轮检索并累积证据,再决定继续还是生成。重点不是某部小说的答案,而是这套可复用的控制流。
模型和 Milvus 需要按自己的环境配置,因此这里不提供未经固定评测集验证的召回率或准确率数据。
1. 多跳问题为什么不能只做一次 query rewrite
查询改写(Query Rewrite)通常生成几个意思相近的问法,然后并行检索。它提高覆盖面,但这些查询彼此独立。
多跳问题有依赖关系。例如:
- 某段描述中的人物是谁?
- 这个人物的师父是谁?
- 师父与另一个事件有什么关系?
第二步依赖第一步,第三步又依赖第二步。把三句同时发出去,后面的查询可能连实体名都不知道。
方案 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 模型去噪。这与多跳检索可以组合,但应该先分别理解,避免一次把图做得过于复杂。