前两步,我们已经让 RAG 学会判断"要不要检索",也能把复杂问题拆开做多轮检索。但还有一个很现实的问题:如果答案压根不在本地知识库里怎么办?这一篇继续改造 LangGraph:检索后先评估上下文是否足够,不够就生成 Web Query,自动进入网络搜索节点补充信息,再交给模型生成最终回答。
前两篇,我们已经让 RAG 做了两件事。
第一步:
这个问题
要不要查知识库?
第二步:
这个复杂问题
一次检索够不够?
于是流程已经从最开始的:
Question
↓
Retrieve
↓
Generate
慢慢变成了一个会判断、会循环的 Graph。
但这里还有一个绕不过去的问题:
如果答案根本不在本地知识库里呢?
这不是"多查几次"能解决的。
一、多检索几次,也搜不到不存在的东西
我们的本地知识库还是《天龙八部》。
现在用户问:
《天龙八部》中虚竹最后继承了哪些身份?
另外,第一部《天龙八部》电视剧是什么时候上映的?
第一个问题:
虚竹最后继承了哪些身份?
答案就在小说里。
Milvus 可以从本地知识库中检索相关片段。
但第二个问题:
第一部《天龙八部》电视剧是什么时候上映的?
就不一样了。
我们的知识库里存的是小说内容,并没有电视剧的上映信息。
这时候即使:
ini
Top K = 5
改成:
ini
Top K = 20
甚至重新检索很多次,也解决不了。
因为问题不是:
没搜准。
而是:
本地根本没有。
这两个问题要分开。
二、传统 RAG 的问题:检索完就直接回答
普通 RAG 往往是:
Question
↓
Retrieve
↓
Generate
也就是说:
markdown
检索到了什么
↓
直接塞给 LLM
↓
生成回答
这里其实少了一步:
这些资料真的够回答用户的问题吗?
比如刚才的问题,本地知识库可能已经找到了虚竹相关的小说片段。
但关于:
第一部《天龙八部》电视剧什么时候上映
本地完全没有信息。
如果这时候直接把检索结果交给模型回答,模型可能会依赖自己的参数记忆把后半部分补出来。
于是就容易出现:
diff
上下文里没有
+
模型自己补
=
幻觉
所以不能再让:
Retrieve → Generate
直接连在一起。
中间需要增加一个节点:
Evaluate
变成:
Retrieve
↓
Evaluate
↓
信息够吗?
这个节点才是这一篇真正的核心。
三、先让模型判断:现有信息够不够
先给 Graph 增加几个状态:
php
const GraphState = Annotation.Root({
question: Annotation,
retrievedDocs: Annotation,
localContext: Annotation,
webContext: Annotation,
evaluation: Annotation,
generation: Annotation,
});
这里最值得关注的是:
localContext
webContext
evaluation
分别代表:
localContext
→ 本地知识库找到的内容
webContext
→ 后面网络搜索补充的内容
evaluation
→ 模型对当前信息是否足够的判断
接下来还是先走本地检索。
四、本地检索节点只负责找资料
这一层其实没什么新东西。
还是从 Milvus 中检索相关文档:
ini
const retrieveLocalNode = async (state) => {
const retrievedDocs = await retrieveRelevantContent(
state.question,
state.k
);
const localContext = retrievedDocs
.map((doc) => doc.content)
.join("\n\n");
return {
retrievedDocs,
localContext,
};
};
流程就是:
css
Question
↓
Embedding
↓
Milvus
↓
Top K Documents
↓
localContext
但这里有一个职责划分值得注意:
Retrieve 只负责找资料,不负责判断这些资料够不够。
信息是否充分,交给下一个节点。
五、Evaluate 才是这一轮真正的决策节点
我们希望模型返回一个结构化结果:
css
const EvaluateSchema = z.object({
enough: z.boolean(),
missing: z.array(z.string()).max(6),
reason: z.string(),
web_query: z.string().optional(),
});
几个字段分别解决不同问题。
enough
当前上下文够不够回答:
arduino
true
→ 可以回答
false
→ 信息还不够
missing
如果不够,具体缺什么。
比如刚才的问题可能得到:
缺少第一部《天龙八部》电视剧上映时间的信息
reason
为什么认为当前上下文足够或不足。
web_query
如果本地信息不够:
下一步应该去网上搜什么?
于是 Evaluate 节点做的事情就很清楚了:
markdown
用户问题
+
本地检索结果
↓
LLM
↓
信息够不够?
六、为什么不直接拿用户原问题去搜索?
这里有一个很关键的细节。
用户原问题是:
《天龙八部》中虚竹最后继承了哪些身份?
另外,第一部《天龙八部》电视剧是什么时候上映的?
经过本地检索以后:
虚竹最后继承了哪些身份?
这部分已经有答案了。
真正缺的是:
第一部《天龙八部》电视剧是什么时候上映的?
如果直接把原问题整个扔进搜索引擎,搜索目标反而不够集中。
更合理的是:
markdown
先判断缺什么
↓
只针对缺失信息生成 Web Query
↓
再去搜索
比如生成:
第一部《天龙八部》电视剧上映时间
所以 web_query 并不是一个随手加进去的字段。
它解决的是:
已经从本地拿到的信息不用重复找,只搜索真正缺失的部分。
这个思路和上一篇的子问题拆解其实很像。
本质上都是在做:
让 Query 更适合下一次检索。
七、现在 Graph 第一次开始切换数据源
Evaluate 有了结果以后,就可以决定下一步去哪里。
逻辑很简单:
javascript
function afterEvaluateLocal(state) {
const parsed = JSON.parse(state.evaluation || "{}");
return parsed.enough === true
? "generate"
: "web_search";
}
如果信息已经足够:
Evaluate
↓
Generate
如果还不够:
sql
Evaluate
↓
Web Search
Graph 就变成:
sql
Local Retrieve
↓
Evaluate
↓
够了吗?
↙ ↘
够 不够
↓ ↓
Generate Web Search
这里其实发生了一个很重要的变化。
以前:
数据源是固定的。
现在:
数据源开始根据当前信息动态切换。
这已经非常接近 Agentic RAG 的核心了。
八、Web Search 节点到底做什么?
这里可以接任意 Web Search API。
具体使用哪一家搜索服务,并不是这一节的重点。
对于整个 Graph 来说,Web Search 节点只需要完成三件事:
markdown
1. 从 State 拿到 web_query
2. 调用网络搜索服务
3. 把搜索结果写回 webContext
核心逻辑可以压缩成:
ini
const webSearchNode = async (state) => {
const parsed = JSON.parse(state.evaluation || "{}");
const query =
parsed.web_query?.trim() ||
state.question;
const webContext = await webSearch(query);
return {
webContext,
};
};
注意:
sql
Web Search
本身并不负责回答用户。
它只是:
再找回来一批资料。
这一点和 Milvus 的 Retrieve 节点其实很像。
只是数据源不一样:
sql
Local Retrieve
→ 本地向量数据库
Web Search
→ 互联网
九、网络搜索结果本质上也只是 Context
网络搜索拿回来以后,可以整理成类似:
makefile
引用: 1
标题: ...
URL: ...
摘要: ...
引用: 2
标题: ...
URL: ...
摘要: ...
然后写进:
webContext
这时候 State 里就有了两部分上下文:
diff
localContext
+
webContext
最后生成回答的时候,把它们合起来:
sql
const context = [
state.localContext,
state.webContext,
]
.filter(Boolean)
.join("\n\n");
于是 LLM 最终看到的是:
diff
小说知识库中的相关内容
+
网络搜索补充的信息
+
用户问题
对于刚才的问题:
《天龙八部》中虚竹最后继承了哪些身份?
另外,第一部《天龙八部》电视剧是什么时候上映的?
最终答案的依据就来自两个地方:
虚竹的身份
→ 本地小说知识库
电视剧上映时间
→ 网络搜索
这就是多数据源 RAG 最直观的形态。
十、为什么 Web Search 完以后还要再 Evaluate?
这里是整个流程里很值得注意的一点。
我们没有直接写:
sql
Web Search
↓
Generate
而是:
sql
Web Search
↓
Evaluate
原因很简单:
搜过网络,不代表搜回来的信息一定够。
搜索结果也可能:
没有命中
结果跑偏
信息不完整
无法确认关键事实
所以更合理的职责划分应该是:
sql
Search
→ 负责找
Evaluate
→ 负责判断
整个过程变成:
本地检索
↓
评估
↓
不够
↓
网络搜索
↓
再评估
这时候整个检索流程才真正形成一个闭环。
十一、Agentic 不代表无限循环
不过这里还有一个很现实的问题。
如果写成:
sql
Evaluate
↓
Web Search
↓
Evaluate
↓
Web Search
↓
......
理论上可能一直循环。
这显然不是我们想要的。
所以实际系统一定要加边界。
当前这个简单 Demo 可以规定:
markdown
本地检索
↓
第一次 Evaluate
↓
不够
↓
Web Search
↓
第二次 Evaluate
↓
Generate
如果网络补充以后仍然无法确认答案,就明确告诉用户:
当前上下文仍不足以确认
而不是继续编。
这也是实现 Agent 时非常重要的一点:
自主决策不等于无限自主。
系统仍然需要明确的退出条件和执行边界。
十二、把这一篇的 Graph 串起来
现在完整流程大概是:
markdown
┌→ direct_answer → END
│
Question → Router
│
└→ local_retrieve
↓
evaluate
↓
信息够吗?
↙ ↘
够 不够
↓ ↓
generate web_search
↑ ↓
└──── evaluate
对应到 LangGraph:
php
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("local_retrieve", retrieveLocalNode)
.addNode("evaluate_local", evaluateNode)
.addNode("web_search", webSearchNode)
.addNode("generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", afterRoute, {
direct_answer: "direct_answer",
local_retrieve: "local_retrieve",
})
.addEdge("local_retrieve", "evaluate_local")
.addConditionalEdges(
"evaluate_local",
afterEvaluateLocal,
{
generate: "generate",
web_search: "web_search",
}
)
.addEdge("web_search", "evaluate_local")
.addEdge("direct_answer", END)
.addEdge("generate", END)
.compile();
单看代码,只是增加了:
sql
Evaluate
Web Search
两个节点。
但系统能力其实已经发生了很明显的变化。
十三、从"检索流程"走向"检索决策"
回头看最开始的 RAG:
Question
↓
Retrieve
↓
Generate
所有步骤都是固定的。
后来加入 Router:
要不要检索?
再加入多跳检索:
一次检索够不够?
现在又加入 Evaluate + Web Search:
本地信息够不够?
如果不够,
要不要换一个数据源继续找?
所以 Agentic RAG 真正重要的并不是:
用了多少个 Agent
也不是:
Graph 画得有多复杂
真正发生变化的是:
LLM 开始参与检索流程本身的决策。
十四、到这里,Agentic RAG 的主线已经基本完整
现在这套流程已经能够处理传统 RAG 中几个很典型的问题:
sql
简单问题
→ 不检索,直接回答
知识库问题
→ 查询 Milvus
复杂问题
→ 拆成多个子问题,多轮检索
信息不足
→ 判断当前缺什么
本地没有
→ 自动切换到 Web Search
串起来就是:
Question
↓
判断要不要查
↓
判断应该怎么查
↓
检索
↓
判断查到的够不够
↓
不够就继续查 / 换数据源
↓
Generate
这时候再看 Agentic RAG,就不应该简单理解成:
RAG 加几个 Agent。
更准确一点:
让 LLM 成为检索流程里的决策中枢,根据当前问题和已有证据,动态决定下一步应该做什么。
十五、但还有一种检索能力没有补上
现在我们已经有:
Milvus
→ 语义检索
也有:
sql
Web Search
→ 网络信息补充
但还有一类数据:
专业术语
精确实体
专有名词
这些内容有时候并不需要:
意思差不多
而是需要:
关键词准确命中
这时候纯向量检索并不一定合适。
所以接下来还需要补上一种能力:
关键词检索
这就要进入 Elasticsearch 了。
在继续做混合检索之前,先把 Elasticsearch 最核心的三个东西讲清楚:
IK 分词器
→ 词怎么切
倒排索引
→ 怎么快速找到文档
BM25
→ 找到以后怎么排序
这会是下一篇。