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