上一篇,我们已经让 RAG 学会了一件事:
这个问题
到底要不要查知识库?
于是流程从:
Question
↓
Retrieve
↓
Generate
变成了:
<span> ┌→ direct_answer
│
Question → Router
│
└→ retrieve → generate
</span>
这一步解决的是:
要不要查。
但只要开始处理稍微复杂一点的问题,很快就会碰到第二层麻烦:
查一次,够吗?
一、一次检索为什么会开始吃力?
还是拿《天龙八部》做例子:
《天龙八部》中“四大恶人”排行第二的是谁?
此人之子在身世揭晓前,
其生父在武林中的公开身份是什么?
这句话看起来只是一个问题。
但稍微拆一下就会发现,它里面其实藏着好几个信息点:
四大恶人第二是谁?
<span> ↓
他的儿子是谁?
↓
儿子的生父是谁?
↓
这个人的公开身份是什么?
</span>
而最普通的向量检索做的事情是什么?
整个 Question
↓
Embedding
↓
一个 Vector
↓
Milvus
↓
<span>Top</span> K
问题恰恰出在这里。
用户问的是一个复杂问题,但向量数据库最终收到的,仍然只是:
一个 Query Vector
它不会自动说:
这里其实有四件事。
我先查第一件,
再查第二件,
然后第三件……
它只会做一件事:
找出和整个 Query 在向量空间里最相似的 Top K 文档。
二、复杂问题会把多个检索意图揉在一起
可以把它想得简单一点。
一个单纯的问题:
四大恶人排行第二的是谁?
语义非常集中:
四大恶人
排行
第二
拿去做 Embedding,检索方向也比较明确。
但复杂问题里同时塞进:
四大恶人
儿子
生父
武林身份
这些信息最后都会进入同一个向量表示。
这时候 Milvus 找到的是:
和“整段问题”最相似的文档。
而不是:
分别给这几个信息点各找几条文档。
这两个任务不是一回事。
再加上:
<span>k</span> = <span>5</span><span>;</span>
最终只有 5 个位置。
如果某一部分语义特别强,很可能出现:
<span>Top</span> <span>5</span>
四大恶人相关 <span>3</span> 条
虚竹相关 <span>2</span> 条
玄慈公开身份 <span>0</span> 条
不是数据库里没有。
只是它没挤进这一次 Top K。
所以复杂问题的麻烦,不一定是“太难”。
很多时候只是:
一个 Query 里混进了太多检索意图。
既然如此,最自然的办法不是继续调 Top K。
而是:
先拆。
三、让 LLM 先把问题拆开
所以 Graph 里加入一个新的节点:
decompose_question
它不负责回答问题。
只负责一件事:
把复杂问题变成更适合检索的子问题。
代码里先约束它的输出:
const DecomposeSchema = z<span>.object</span>({
sub_questions: z.<span>array</span>(z.<span>string</span>()).<span>min</span>(<span>1</span>).<span>max</span>(<span>8</span>),
reason: z.<span>string</span>()
});
然后让 LLM 返回一组子问题。当前 Prompt 还特别要求:子问题要完整、能够独立检索,并尽量避免“他、她、此人”这样的指代。
例如可能得到:
Q1:四大恶人排行第二的是谁?
Q2:叶二娘的儿子是谁?
Q3:虚竹的生父是谁?
Q4:玄慈在武林中的公开身份是什么?
于是原来的:
一个复杂 Query
<span> ↓
一次向量检索
</span>
变成:
Q1 → Retrieve
Q2 → Retrieve
Q3 → Retrieve
Q4 → Retrieve
每一次 Embedding,只需要表达一个相对明确的检索意图。
这才是 decomposeQuestionNode 真正解决的问题。
不是让问题显得更“智能”。
而是:
把一次模糊的大搜索,拆成几次目标明确的小搜索。
四、拆完以后,怎么一条一条查?
这里开始体现 State 的价值。
这次新增了几个状态:
<span>subQuestions: Annotation,</span>
<span>nextSubIdx: Annotation,</span>
<span>currentQuery: Annotation,</span>
<span>retrievalCount: Annotation,</span>
<span>maxRetrievals: Annotation,</span>
<span>plannedNext: Annotation,</span>
<span>documents: Annotation,</span>
其中最关键的其实是:
nextSubIdx
第一次:
<span>nextSubIdx</span> = <span>0</span>
于是 Retrieve 查:
subQuestions<span>[0]</span>
查完以后:
<span>nextSubIdx: idx + 1</span>
下一轮就变成:
subQuestions<span>[1]</span>
所以看起来是“多轮检索”,真正推动它往前走的东西却很简单:
0 → 1 → 2 → 3
retrieveNode 每轮只是取出当前子问题,交给 Milvus,然后把下标往后移动一位。
五、检索多次以后,Documents 也得跟着变
以前只有一次 Retrieve:
Retrieve
↓
documents
↓
Generate
现在会查很多轮。
于是:
第一轮 documents
第二轮 documents
第三轮 documents
...
不能每次把之前的结果覆盖掉。
所以这里需要一个不断累积的:
证据池
代码里使用 mergeUnique() 合并:
const <span>merged</span> = mergeUnique(
state.documents ?? <span>[]</span>,
newDocs
)<span>;</span>
同一篇文档如果重复命中:
只留一份。
如果多次命中时分数不同:
保留 score 更高的结果。
这样 documents 的含义就发生了变化。
它不再是:
本轮查到了什么。
而是:
到目前为止,我们手里已经有哪些证据。
这个区别很重要。
因为下一步,模型看的正是这份累计证据。
六、查完一轮以后,不急着 Generate
如果只是:
Q1 → Retrieve
Q2 → Retrieve
Q3 → Retrieve
Q4 → Retrieve
<span> ↓
Generate
</span>
那其实还是一条写死的流程。
只是 Retrieve 多执行了几次。
所以这里又加了一个节点:
plan_next_step
它做的事情也很克制:
看看目前的证据,够不够回答原问题。
结构只有两个结果:
<span>const</span> NextStepSchema = z.<span>object</span>({
nextAction: z.<span>enum</span>([<span>"retrieve"</span>, <span>"generate"</span>]),
reason: z.<span>string</span>()
});
如果证据不够:
retrieve
如果已经足够:
generate
所以 Graph 变成:
retrieve
↓
plan_next_step
↓
够了吗?
↙ ↘
不够 够
↓ ↓
retrieve generate
这里有个细节值得分清。
planNextStepNode 虽然会看到当前的 documents,甚至能够从这些文档里判断:
现在已经知道叶二娘了
现在已经知道虚竹了
……
但它并没有把这些答案重新写进子问题。
它真正写回 State 的只有:
<span>return</span> {
plannedNext: finalNext
};
所以这个 Planner 的职责不是:
生成下一条 Query
而是:
证据够不够?
这一点先记住。
后面马上会用到。
七、到这里,Graph 已经开始形成循环
现在完整流程是:
graph TD;
__start__([<p>__start__</p>]):::first
route_question(route_question)
direct_answer(direct_answer)
decompose_question(decompose_question)
retrieve(retrieve)
plan_next_step(plan_next_step)
generate(generate)
__end__([<p>__end__</p>]):::last
__start__ --> route_question;
decompose_question --> retrieve;
direct_answer --> __end__;
generate --> __end__;
retrieve --> plan_next_step;
route_question -.-> direct_answer;
route_question -.-> decompose_question;
plan_next_step -.-> retrieve;
plan_next_step -.-> generate;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
对应代码也很直接:
<span>const</span> <span>graph</span> = <span>new</span> <span>StateGraph</span>(GraphState)
.<span>addNode</span>(<span>"route_question"</span>, routeQuestionNode)
.<span>addNode</span>(<span>"direct_answer"</span>, directAnswerNode)
.<span>addNode</span>(<span>"decompose_question"</span>, decomposeQuestionNode)
.<span>addNode</span>(<span>"retrieve"</span>, retrieveNode)
.<span>addNode</span>(<span>"plan_next_step"</span>, planNextStepNode)
.<span>addNode</span>(<span>"generate"</span>, generateNode)
.<span>addEdge</span>(START, <span>"route_question"</span>)
.<span>addConditionalEdges</span>(<span>"route_question"</span>, afterRoute, {
<span>direct_answer</span>: <span>"direct_answer"</span>,
<span>decompose_question</span>: <span>"decompose_question"</span>
})
.<span>addEdge</span>(<span>"decompose_question"</span>, <span>"retrieve"</span>)
.<span>addEdge</span>(<span>"retrieve"</span>, <span>"plan_next_step"</span>)
.<span>addConditionalEdges</span>(<span>"plan_next_step"</span>, afterPlan, {
<span>retrieve</span>: <span>"retrieve"</span>,
<span>generate</span>: <span>"generate"</span>
})
.<span>addEdge</span>(<span>"direct_answer"</span>, END)
.<span>addEdge</span>(<span>"generate"</span>, END)
.<span>compile</span>();
和上一篇相比,最大的变化已经很明显:
上一篇解决:
需不需要检索?
这一篇继续往前:
一次检索不够怎么办?
答案是:
拆问题
↓
分别检索
↓
累积证据
↓
判断够不够
↓
不够继续查
到这里,这套设计看起来已经很顺了。
但还有一个问题。
而且这个问题藏得比前面的都深。
八、等等,Q2 里的“叶二娘”从哪来的?
再看一次刚才的拆解结果:
Q1:四大恶人排行第二的是谁?
Q2:叶二娘的儿子是谁?
Q3:虚竹的生父是谁?
Q4:玄慈在武林中的公开身份是什么?
乍看没什么问题。
但仔细想一下:
在执行 Q1 检索之前,我们真的知道:
四大恶人第二 = 叶二娘
吗?
不知道。
可 decomposeQuestionNode 已经直接写出了:
叶二娘
再往后看:
虚竹
玄慈
也都是一样。
换句话说,真正发生的其实是:
原问题
↓
LLM 根据自己已有知识
↓
提前猜出了:
叶二娘 → 虚竹 → 玄慈
↓
再拿这些 Query 去知识库检索
这里就出现了一个很隐蔽的陷阱:
我们本来想让知识库提供答案链,结果 LLM 在拆问题的时候,已经提前把答案链补出来了。
当前 Prompt 甚至要求:
禁止使用「他 / 她 / 此人」
可写全人物名
为了生成一个“可以独立检索”的问题,LLM 不得不把:
此人
替换成:
叶二娘
问题就在这里。
如果模型猜对了,一切看起来都很漂亮。
如果它猜错了呢?
比如第一步就猜错:
四大恶人第二 = X
那么后面会一路变成:
查 X 的儿子
↓
查某个人的生父
↓
查另一个人的身份
检索器会非常认真地:
沿着一条错误的路继续搜索。
所以这版严格来说,还不是:
上一跳证据
↓
决定下一跳
而是:
LLM 提前规划完整路线
↓
知识库逐步验证
这两者差别很大。
九、真正的下一步:不要提前填答案,等检索后再回填
更稳妥的思路应该是:
Q1:
四大恶人第二是谁?
先查。
假设从文档中得到:
叶二娘
把它存进 State:
<span>lastAnswer:</span> <span>"叶二娘"</span>
然后再生成:
Q2:
叶二娘的儿子是谁?
再查:
虚竹
继续回填:
<span>lastAnswer:</span> <span>"虚竹"</span>
然后:
Q3:
虚竹的生父是谁?
整个过程变成:
Query <span>1</span>
↓
Retrieve
↓
Extract Answer
↓
lastAnswer
↓
Build <span>Next</span> Query
↓
Query <span>2</span>
也可以在拆问题时先保留占位符:
Q1:四大恶人排行第二的是谁?
Q2:{{answer_1}} 的儿子是谁?
Q3:{{answer_2}} 的生父是谁?
Q4:{{answer_3}} 在武林中的公开身份是什么?
等上一跳真的从知识库中拿到结果以后,再把:
{{answer_1}}
替换成:
叶二娘
这才叫真正意义上的:
上一跳结果,驱动下一跳检索。
那时候 Graph 也会再多一步:
retrieve
↓
extract_answer
↓
build_next_query
↓
retrieve
这才是更完整的 Multi-hop RAG。
写在最后
这一版 Demo 最开始只是想解决一个很实际的问题:
一个复杂 Query
为什么一次检索经常不够?
于是我们做了:
Decompose
↓
Retrieve
↓
Accumulate
↓
Plan
↓
<span>Loop</span>
已经比最开始的:
Retrieve → Generate
多走了很远。
但真正值得注意的,反而是最后暴露出来的问题:
LLM 可以帮我们拆问题,但一旦让它提前补全未知实体,它也可能把自己的参数知识偷偷带进检索链。
所以真正可靠的多跳检索,不应该是:
先猜完整条路
再去验证
而应该是:
走一步
拿到证据
再决定下一步
RAG 到这里,才开始真正有点“Agentic”的意思。
这篇把 RAG 从单次检索推进到多轮拆解与证据累积,并点破 LLM 拆题时提前填答案的隐蔽风险。适合正在做知识库问答、多跳检索与 Agent 工作流的开发者精读,避免把参数知识误当检索证据。