Agentic RAG(上):别让所有问题都走检索------查询路由与 Web 兜底
我最早写的 RAG(Retrieval-Augmented Generation,检索增强生成)流程很固定:用户提问,向量库检索,把文档塞进提示词,再让模型回答。这个流程能工作,但有一个明显浪费:无论用户问"1+1 等于几"还是问小说里的具体人物关系,都要走一遍 Embedding、向量检索和生成。
更麻烦的是,检索结果不够时,流程仍然会硬着头皮生成。模型并不知道本地知识库缺了什么,很容易把"没有证据"变成"听起来合理"。
于是我把线性 RAG 逐步改造成一个 Agentic RAG:先判断是否需要检索,再评估本地上下文是否充分,不够时只做一次网络搜索,最后基于可见上下文回答。
下面按这条演进路线拆解实现。外部 Milvus、模型和搜索 API 需要自行配置,文中同时修正几个开发过程中遇到的变量名和条件判断问题。
1. Naive RAG 的问题不在"简单",而在"无条件"
最初的 Naive RAG 只有两个节点:retrieve 完成检索,随后固定进入 generate,再结束流程。
它的优势是稳定、容易理解。只要知识库和问题都适合向量检索,这种线性流程反而比复杂 Agent 更好。
问题在于它没有选择权:
- 简单常识也产生一次检索成本;
- 本地文档为空时仍进入生成;
- 检索内容是否相关没有评估;
- 本地知识库之外的时效信息无法补充。
所以我没有直接删除 Naive RAG,而是把它保留为基线,再增加"路由"和"评估"两个决策点。
2. 用结构化路由区分 simple 与 complex
路由节点不应该返回一段自然语言让程序猜,而应该返回固定枚举。这里使用 Zod:
js
import { z } from "zod";
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});
async function routeQuestionNode(state) {
const router = llm.withStructuredOutput(RouteSchema);
const route = await router.invoke(`
你是问答路由器,请判断问题是否需要外部检索。
- simple:常识、简短定义,不依赖知识库具体事实
- complex:需要小说情节、人物关系、章节事实或证据
用户问题:${state.question}
`);
return {
strategy: route.strategy,
routeReason: route.reason,
};
}
图通过条件边选择下一步:
js
const afterRoute = (state) =>
state.strategy === "simple"
? "direct_answer"
: "local_retrieve";
graph.addConditionalEdges("route_question", afterRoute, {
direct_answer: "direct_answer",
local_retrieve: "local_retrieve",
});
方案 A 是关键词规则,例如包含书名就检索;方案 B 是让模型结构化分类。A 便宜且确定,适合规则清楚的业务;B 能处理更多自然语言变体,但会增加一次模型调用,也可能误判。
实际系统可以组合:先用确定规则处理明显场景,其余再交给模型路由。
3. 本地检索后,不要立刻生成
原项目用 Milvus 做向量检索,并保存内容、章节和分数:
js
async function retrieveRelevantContent(question, k = 5) {
const docsWithScores =
await vectorStore.similaritySearchWithScore(question, k);
return docsWithScores.map(([doc, score]) => ({
score,
content: doc.pageContent,
id: doc.metadata?.id ?? "unknown",
bookId: doc.metadata?.book_id ?? "unknown",
chapter: doc.metadata?.chapter_num ?? "unknown",
}));
}
async function retrieveLocalNode(state) {
const retrievedDocs =
await retrieveRelevantContent(state.question, state.k);
return {
retrievedDocs,
localContext: retrievedDocs
.map((document) => document.content)
.join("\n\n"),
};
}
我曾把状态字段写成 retrievedDocs,节点却返回 retrieveDocs,导致后续节点读取不到结果。这里统一使用 retrievedDocs。
检索完成后,我增加一个信息充分性评估节点:
js
const EvaluateSchema = z.object({
enough: z.boolean(),
missing: z.array(z.string()).max(6),
reason: z.string(),
webQuery: z.string().optional(),
});
async function evaluateNode(state) {
const evaluator = llm.withStructuredOutput(EvaluateSchema);
return {
evaluation: await evaluator.invoke(`
判断上下文是否足以回答问题。
问题:
${state.question}
本地上下文:
${state.localContext || "(空)"}
如果不足:
1. 列出缺失信息;
2. 给出一条适合网络搜索的中文查询。
`),
};
}
这里直接保存对象,不再"先 JSON.stringify,条件边再 JSON.parse"。图状态本来就能传对象,没有必要把类型信息丢掉再恢复。
4. Web fallback 只能兜底,不能变成无限循环
如果本地上下文不足,流程进入 Web fallback(网络兜底)。搜索节点优先使用评估器生成的查询:
js
async function webSearchNode(state) {
const query =
state.evaluation.webQuery?.trim() || state.question;
const webContext = await searchProvider.search({
query,
count: 8,
});
return {
webContext,
webSearched: true,
};
}
这个版本还踩过三个明确的问题:
- 搜索请求的
catch没有接收error,却读取error.message; - 原判断是"有网页结果时返回未找到",条件写反;
- 搜索后再次进入评估,如果没有护栏,可能在"评估不足---继续搜索"之间循环。
一个清晰的路由函数应该显式限制搜索次数:
js
function afterEvaluation(state) {
if (state.evaluation?.enough) {
return "generate";
}
if (!state.webSearched) {
return "web_search";
}
// 已经联网一次,即使仍不足也结束检索,
// 由生成节点明确说明缺失信息。
return "generate";
}
方案 A 是不断搜索直到评估器说够;方案 B 是限制为一次或固定次数。A 看似更"智能",实际可能产生不可控的延迟、费用和循环。没有可靠停止条件时,我选择 B。
5. 生成节点要允许回答"不知道"
本地知识库和网络结果都可能不够。最终提示词不能只说"请回答",还要允许拒绝编造:
js
async function generateNode(state) {
const context = [
state.localContext,
state.webContext,
].filter(Boolean).join("\n\n=== 网络补充 ===\n\n");
const response = await llm.invoke(`
你是严谨的中文问答助手。
规则:
1. 优先依据上下文回答;
2. 不要补写上下文中没有的事实;
3. 信息不足时明确说"无法从当前上下文确认";
4. 列出仍然缺失的信息。
上下文:
${context || "(空)"}
问题:
${state.question}
`);
return { generation: response.content };
}
"不知道"不是失败,而是检索系统的重要输出。如果业务强制每次给出确定答案,评估和兜底最终都会被生成节点抵消。
6. 完整图应该长什么样
整理后的流程如下:
js
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("local_retrieve", retrieveLocalNode)
.addNode("evaluate", 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")
.addConditionalEdges("evaluate", afterEvaluation, {
generate: "generate",
web_search: "web_search",
})
.addEdge("web_search", "evaluate")
.addEdge("direct_answer", END)
.addEdge("generate", END)
.compile();
这个图比 Naive RAG 多了两次模型决策,不一定更快、更便宜。它适合问题类型差异大、知识库可能不完整、错误答案代价较高的场景。如果所有请求都明确是内部文档查询,固定检索链可能更合适。
7. 还缺哪些生产级能力
当前学习实现已经表达了闭环思路,但距离生产还有几项:
- 给路由与评估保存可观察的理由和耗时;
- 对搜索 API 设置超时、错误分类和有限重试;
- 对网络内容做来源白名单、去重和提示注入防护;
- 给检索文档保留稳定引用,生成答案能追溯来源;
- 对
k、相似度阈值和搜索次数做配置,而不是散落在代码里; - 用固定评测集比较 Naive RAG 与 Agentic RAG,而不是凭主观感觉。
目前还没有固定评测集,因此本文不提供准确率或延迟数字。
结尾
从线性 RAG 升级到 Agentic RAG,我认为最关键的不是"多调用几次模型",而是增加了三个明确决策:
- 这个问题是否需要检索;
- 当前检索结果是否足够;
- 兜底已经尝试后,是否应该停止并承认信息不足。
可复用的取舍是:能用固定流程解决,就不要为了"Agentic"增加决策成本;需要动态路由时,让模型输出结构化枚举;任何循环都要有硬护栏;生成节点必须允许"不知道"。
下一篇继续处理另一个线性 RAG 的弱点:一个问题需要先查 A,再根据 A 的结果查 B 时,怎样做多跳问题分解和迭代检索。