从 RAG 到 Agentic RAG:第二步,把复杂问题拆开再检索

上一篇,我们让 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 已经开始形成循环

现在完整流程是:

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;

对应代码也很直接:

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"的意思。

相关推荐
dehuisun1 小时前
第07篇:RAG+Agent 部署架构、资源评估与私有化方案
人工智能
米小虾1 小时前
拆给 8 个子智能体,只拿回 2.3 倍信息:多智能体分解的产出守恒律
人工智能·agent
虹科网络安全1 小时前
Redis 安全公告:CVE-2026-81934 TLS 处理漏洞及修复建议
网络·人工智能·网络安全
IT·陈寒1 小时前
Redis 连接池泄漏害我加班到凌晨三点
人工智能·大模型·api·创业·变现·简历优化
西安栈上月明软件科技1 小时前
从业务黑话到本体图谱:OAG本体建模五步法(西安老系统AI化改造实战)
数据库·人工智能·架构
带鱼吃猫1 小时前
LangGraph入门:搭建智能快递配送系统AI工作流
人工智能·langchain
sjh7524229691 小时前
RNN 是个啥?一个“边读边记小本本”的神经网络
人工智能
SEO_juper1 小时前
Java 并发编程实战:从线程基础到高并发架构
运维·人工智能·爬虫·chatgpt·seo
dehuisun1 小时前
第 04 篇:主流向量数据库选型决策(Milvus/Qdrant/pgvector/ 金仓 /openGauss)
人工智能