摘要:普通 RAG 通常只有一条固定流水线:用户提问 → 向量检索 → 拼接上下文 → LLM 生成答案。它能工作,却不会判断。这篇从一个最基础的 RAG 出发,用 LangGraph 加入问题路由,让模型第一次真正参与系统控制流。
RAG 很容易做出 Demo。
准备一个向量数据库,把用户问题转成向量,检索几个相关片段,再塞给大模型:
Question
↓
Retrieve
↓
Generate
↓
Answer
代码甚至可以简单到只有两个节点:
sql
const graph = new StateGraph(GraphState)
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode)
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.compile();
这已经是一个完整的 RAG。
但问题也恰恰出在这里:
它太听话了。
无论用户问什么,它都老老实实执行:
检索 → 生成
它不会想:
这个问题真的需要查知识库吗?
这就是普通 RAG 和 Agentic RAG 之间一个非常重要的分界点。
一、Naive RAG 的问题不是不能检索,而是只会检索
假设我们做的是一个《天龙八部》知识库。
用户问:
阿朱的结局是什么?
这个问题显然适合走知识库。
因为它涉及小说中的具体情节:
问题
↓
Milvus
↓
找到阿朱相关章节
↓
LLM 根据原文回答
没有问题。
但如果用户问:
什么是向量数据库?
我们的 Naive RAG 还是会执行:
markdown
什么是向量数据库?
↓
搜索《天龙八部》
↓
返回几个莫名其妙的小说片段
↓
交给 LLM
这就很荒谬了。
真正的问题不在 Milvus,也不在 Embedding。
而在于:
我们的程序没有决策能力。
它只是一个固定的数据处理流水线。
二、真正的升级:让 LLM 参与控制流
所以 Agentic RAG 的第一步,并不是突然增加十几个 Agent。
也不是给模型塞一堆 Tool。
而是做一件很小、但非常关键的事情:
让模型判断下一步该怎么走。
原来的流程:
sql
START
↓
retrieve
↓
generate
↓
END
现在变成:
sql
┌→ direct_answer → END
│
START → router ──┤
│
└→ retrieve → rag_generate → END
程序第一次出现了真正意义上的分支。
模型不再只是最后负责回答问题。
它开始参与:
下一步执行哪个节点?
这才是这一阶段最值得理解的地方。
三、先定义 Graph State
我们的 Graph 需要维护几份数据:
php
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
documents: Annotation,
generation: Annotation,
});
可以把它理解成整个工作流共享的一块状态:
GraphState
question
用户问题
k
检索数量
strategy
路由结果
routeReason
为什么这么路由
documents
检索出来的文档
generation
最终回答
节点执行过程中,不需要自己把整个 State 从头维护一遍。
例如路由节点只负责产生:
kotlin
return {
strategy: route.strategy,
routeReason: route.reason,
};
LangGraph 会把这些字段更新回 Graph State。
所以理解 LangGraph 时,可以先记住一句话:
节点读取当前 State,返回自己想修改的部分。
这样看代码会清晰很多。
四、让模型只负责一件事:分类
接下来定义路由结果:
csharp
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});
我们不要让模型自由发挥:
我觉得这个问题可能需要检索......
而是要求它严格输出:
css
{
strategy: "complex",
reason: "涉及《天龙八部》具体人物情节"
}
这里使用:
ini
model.withStructuredOutput(RouteSchema);
得到一个受 Zod Schema 约束的模型。
ini
const router = model.withStructuredOutput(RouteSchema);
然后让它完成分类:
ini
const routeQuestionNode = async (state) => {
const router = model.withStructuredOutput(RouteSchema);
const route = await router.invoke(`
你是问答路由器,请判断用户问题是否需要外部检索。
规则:
simple:
常识问答、简短定义,
无需特定小说细节即可回答。
complex:
需要《天龙八部》具体情节、
人物关系、章节事实、
原文细节或证据支持。
用户问题:
${state.question}
`);
return {
strategy: route.strategy,
routeReason: route.reason,
};
};
这里的大模型已经不是"回答模型"了。
它承担的是另一种角色:
Router
输入:
阿朱的结局是什么?
可能得到:
css
{
strategy: "complex",
reason: "涉及小说具体人物结局,需要小说内容支持"
}
而输入:
什么是向量数据库?
可能得到:
css
{
strategy: "simple",
reason: "属于通用技术概念,无需查询小说知识库"
}
这时候模型还没有回答用户。
它只是完成了:
分类
五、真正决定程序怎么走的是 Edge
这是我认为 LangGraph 最值得理解的一点。
很多人第一次看 Agent Workflow,会把所有注意力放在 Node 上:
这个 Node 调了哪个模型?
这个 Node Prompt 怎么写?
但实际上:
Node 决定"做什么",Edge 决定"接下来做什么"。
定义一个条件函数:
ini
const decideNext = (state) => {
return state.strategy === "simple"
? "direct_answer"
: "retrieve";
};
然后交给:
scss
.addConditionalEdges()
php
.addConditionalEdges(
"route_question",
decideNext,
{
direct_answer: "directAnswer",
retrieve: "retrieve",
}
)
于是:
scss
routeQuestionNode
↓
strategy
↓
decideNext()
如果:
ini
strategy === "simple"
走:
directAnswer
如果:
ini
strategy === "complex"
走:
retrieve
这已经不是传统的:
scss
await fn1();
await fn2();
await fn3();
而是在描述:
一个状态驱动的执行图。
六、两条路线终于分开了
完整 Graph:
php
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("directAnswer", directAnswerNode)
.addNode("retrieve", retrieveNode)
.addNode("rag_generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges(
"route_question",
decideNext,
{
direct_answer: "directAnswer",
retrieve: "retrieve",
}
)
.addEdge("directAnswer", END)
.addEdge("retrieve", "rag_generate")
.addEdge("rag_generate", END)
.compile();
现在两个问题的执行轨迹已经完全不同。
普通问题
sql
什么是向量数据库?
START
↓
route_question
↓
simple
↓
directAnswer
↓
END
根本不会访问 Milvus。
小说知识问题
sql
阿朱的结局是什么?
START
↓
route_question
↓
complex
↓
retrieve
↓
Milvus
↓
rag_generate
↓
END
只有真正需要小说知识的时候才进行检索。
七、这已经算 Agentic RAG 了吗?
严格来说,它还非常初级。
但它已经出现了一个关键变化。
Naive RAG:
程序员决定流程
scss
retrieve();
generate();
现在:
LLM 的判断
↓
影响 Graph 的执行路径
也就是说:
LLM 开始参与系统决策,而不只是生成文本。
这正是 Agentic 系统非常核心的思想。
所以我更愿意把现在这个版本称为:
Agentic RAG 的第一步
而不是已经完成了一个多么智能的 Agent。
因为现在还有一个非常明显的问题没有解决。
八、检索出来的内容就一定靠谱吗?
现在我们的复杂问题会走:
question
↓
retrieve
↓
documents
↓
generate
但这里偷偷做了一个非常危险的假设:
向量数据库返回的 Top K 文档都是有用的。
实际上并不是。
向量搜索只能告诉我们:
这些文本在向量空间里比较相似。
它并不能保证:
这些内容真的能够回答用户的问题。
例如用户问:
阿朱为什么替父亲赴死?
向量库可能搜到:
阿朱和乔峰相处的片段
语义确实相关。
但未必包含:
为什么赴死
如果直接把这些内容交给模型生成答案,RAG 仍然可能产生幻觉。
所以现在的 Graph:
route
↓
retrieve
↓
generate
还缺一个非常关键的节点:
grade_documents
下一步,Graph 会继续进化成:
markdown
┌───────────────┐
│ ↓
retrieve → grade_documents → generate
│
└→ 不相关
但"不相关"之后怎么办?
重新搜索?
修改问题?
扩大 Top K?
还是换一种检索策略?
到了这里,RAG 才真正开始从一条固定流水线,变成一个:
会判断执行结果,并根据结果调整下一步动作的系统。
这也是下一篇要解决的问题。