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

文章来源声明: 原文作者:东风破_; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686040694946922505; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

这篇把 RAG 从单次检索推进到多轮拆解与证据累积,并点破 LLM 拆题时提前填答案的隐蔽风险。适合正在做知识库问答、多跳检索与 Agent 工作流的开发者精读,避免把参数知识误当检索证据。

> 上一篇,我们让 LLM 决定“要不要检索”。但进入知识库之后还有第二个问题:一个复杂问题,真的能靠一次向量检索解决吗?这一篇继续改造 RAG:先拆问题,再逐个检索,用 State 累积证据,并让模型判断证据是否已经足够。最后再看一个很隐蔽的问题:LLM 拆问题时,可能已经偷偷替我们“回答”了问题。

上一篇,我们已经让 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”的意思。