海域重启礼包码汇总 海域重启最新可用兑换码分享
2026-07-21
2026-07-21 0
普通 RAG 的思路很直接:用户提出问题,系统从知识库检索相关片段,再让大模型根据这些片段生成回答。
这条链路解决了一个重要问题:模型不必只依赖训练时记住的知识,而是可以在回答前读取企业文档、产品手册、业务数据等外部资料,再根据资料组织答案。
但传统 RAG 的执行流程通常是固定的。这里的“固定”,不是说每次召回的文档都相同,而是说无论用户问什么、检索结果质量如何,程序都会按照上图展示的同一条路径执行。它不会在运行过程中重新判断,也不会主动改变检索策略。
但是,这种固定流程会遇到几个实际问题:
这些问题不能只靠增加 Top K 解决。Top K 只能多返回几条相似内容,无法改变固定的执行流程。真正需要改变的是:让系统先判断当前情况,再决定下一步。
这些判断涉及对问题和文本语义的理解,很难只靠固定的 if/else 完成,却正是大模型擅长的事情。因此,可以让大模型负责判断和规划,再由程序负责执行节点、限制次数并保证流程结束。
当大模型不再只负责生成答案,还参与决定是否检索、怎样检索和何时停止时,这种架构就叫 Agentic RAG。
普通 RAG 让模型读取资料;Agentic RAG 还让模型参与管理寻找资料的过程。
接下来,会通过四种逐步升级的 RAG 流程来理解这个过程的变化。
本文使用 LangGraph 组织 Agentic RAG 的执行流程,但不再展开讲解 LangGraph 的基础用法。
这里只需要知道:LangGraph 把一个复杂流程表示成一张图。
后面看到流程图时,可以把它简单理解为:数据保存在 State 中,依次经过多个 Node,再由 Edge 决定下一步走向哪里。
如果还不熟悉 LangGraph,可以先阅读前面的文章:LangGraph 多 Agent 入门:从流程图到旅行规划助手。
先从最容易理解的流程开始:每个问题都先检索,再生成答案。
先定义这条流程需要传递的数据:
复制代码const GraphState = Annotation.Root({
question: Annotation, // 用户问题
k: Annotation, // 检索数量
documents: Annotation, // 召回的文档
generation: Annotation,// 最终答案
});
检索:从知识库找到相关片段
根据用户的问题去向量数据库中查询相关数据。
复制代码/** 根据用户问题检索知识库,并把召回结果写入 Graph State。 */
const retrieveNode = async (state) => {
// 直接查询向量数据库,返回 [Document, score] 数组。
const results = await vectorStore.similaritySearchWithScore(
state.question,
state.k,
); // 只保留后续节点需要的字段。
const documents = results.map(([document, score]) => ({
score,
content: document.pageContent,
id: document.metadata.id,
chapter_num: document.metadata.chapter_num,
})); // 检索节点不负责回答,只把证据写入状态。
return { documents };
};
Document.pageContent 是正文,Document.metadata 保存片段 ID、章节等附加信息。
检索返回的每条数据可以整理成统一结构:
复制代码{
score: 0.86, // 相似度分数
content: "召回的小说原文", // 后续交给模型的正文
id: "1_000125", // 文档片段唯一标识
chapter_num: 12, // 章节信息
}
检索节点只负责“找证据”,不要在这里顺手让模型回答。把检索和生成分开后,我们才能单独检查:到底是没有召回正确资料,还是模型拿到正确资料后回答错了。
生成:让模型只依据检索片段回答
复制代码/** 把召回片段整理成上下文,再调用模型生成最终答案。 */
const generateNode = async (state) => {
// 没有任何证据时不继续生成,避免模型脱离资料自由发挥。
if (state.documents.length === 0) {
return { generation: "" };
} // 核心逻辑:给每个片段加上序号和章节,方便模型区分证据。
const context = state.documents
.map((doc, index) => `
[片段 ${index + 1}]
章节:第 ${doc.chapter_num} 章
内容:${doc.content}`)
.join("nn"); // 直接调用模型 API,不再封装额外函数。
const response = await model.invoke(`
请只根据下面的小说片段回答问题。${context}用户问题:${state.question}
`); // 只返回本节点更新的字段,LangGraph 会把它合并回 State。
return { generation: String(response.content) };
};
这里最重要的不是 prompt 写得多华丽,而是明确证据边界:模型只能依据检索片段作答;资料不足时必须说明无法确认。
用 LangGraph 串起检索与生成
复制代码const graph = new StateGraph(GraphState)
// 注册两个节点。
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode) // 定义固定执行顺序。
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.compile();
基础检索的优点是简单、可预测、容易调试。对于“用户一定是在查询知识库”的场景,它可能已经够用。
但如果系统同时接收普通问题和知识库问题,固定检索就显得浪费。下一步需要让流程先判断“这次到底要不要查”。
查询路由在基础检索前增加一个决策节点:
这里的 simple 和 complex 不是在判断语言难度,而是在判断是否需要外部知识:
simple:普通常识、定义、简单计算,不依赖特定资料。complex:需要小说情节、人物关系、原文细节或其他知识库证据。让路由结果可以被程序读取
如果让模型自由回答,它可能输出“这个问题比较复杂,我建议检索”。程序很难稳定解析这句话。因此先用 schema 约束模型输出:
复制代码const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation, // simple 或 complex
routeReason: Annotation, // 路由原因
documents: Annotation,
generation: Annotation,
});const RouteSchema = z.object({
// strategy 只能是 simple 或 complex,不能生成其他值。
strategy: z.enum(["simple", "complex"]), // reason 用于日志和调试,不直接控制流程。
reason: z.string(),
});
路由节点只判断,不检索:
复制代码/** 判断当前问题是否需要查询知识库,不在这里执行检索。 */
const routeQuestionNode = async (state) => {
const router = model.withStructuredOutput(RouteSchema); // 核心逻辑:让模型只返回 simple 或 complex,供条件边直接使用。
const route = await router.invoke(`
你是问答路由器,请判断用户问题是否需要查询小说知识库。- simple:不依赖小说原文即可回答。
- complex:需要具体情节、人物关系或原文证据。用户问题:${state.question}
`); return {
strategy: route.strategy,
routeReason: route.reason,
};
};
结构化输出解决的是“程序怎样稳定读取模型判断”,它并不保证模型的判断永远正确。因此 reason 很重要:调试时可以看到模型为什么把问题分到某条路径。
可以看看:
根据路由结果选择回答路径
两条分支的职责很明确。简单问题直接调用模型,复杂问题复用前面已经实现的检索和生成节点:
复制代码/** direct_answer 节点:处理不依赖知识库的普通问题。 */
const directAnswerNode = async (state) => {
const response = await model.invoke(`
请直接、简洁地回答下面的问题:
${state.question}
`); return { generation: String(response.content) };
};// retrieve 节点:complex 分支仍然使用“检索 -> 生成”的 RAG 节点(复用前面的函数)。
const ragGenerateNode = generateNode;
然后用条件边选择其中一条路径:
复制代码/** 把路由结果转换成下一条要执行的边。 */
function decideNext(state) {
// 条件函数只返回下一条边的名字。
// 核心逻辑:simple 直接回答,complex 进入知识库检索。
return state.strategy === "simple"
? "direct_answer"
: "retrieve";
}const graph = new StateGraph(GraphState)
// 添加节点
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("retrieve", retrieveNode)
.addNode("rag_generate", ragGenerateNode)
// 添加边
.addEdge(START, "route_question")
// 条件判断,看下一个节点走哪里
.addConditionalEdges("route_question", decideNext, {
direct_answer: "direct_answer",
retrieve: "retrieve",
}) .addEdge("retrieve", "rag_generate")
.addEdge("direct_answer", END)
.addEdge("rag_generate", END)
.compile();
查询路由是 Agentic RAG 的第一步:模型不再只负责生成文字,它开始影响程序接下来执行什么。
但复杂分支仍然只检索一次。如果问题依赖多个前后关联的事实,一次向量查询还是可能不够。
先来考虑这个问题:
复制代码云澈为什么会同时拥有云澈和萧澈两个名字,
他重生前后分别是什么身份?
它至少包含几个相互关联的信息点:
如果把整句话只做一次向量检索,Top K 结果可能全部集中在其中一个事实上。增加 Top K 只能“多取一些相似片段”,不能保证每个事实都被覆盖。
分步检索也就是常说的 Multi-hop RAG(多跳 RAG):先把复杂问题拆成有顺序的子问题,再逐条检索。
为多轮检索记录执行进度(定义 state)
复制代码const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation, // 预先拆好的有序子问题。
subQuestions: Annotation, // 下一轮应该检索哪一个子问题。
nextSubIdx: Annotation, // 多轮检索累计得到的文档。
documents: Annotation, // 已执行的检索次数与最大次数。
retrievalCount: Annotation,
maxRetrievals: Annotation, // 规划节点的决定:retrieve 或 generate。
plannedNext: Annotation, generation: Annotation,
});
nextSubIdx 是循环的游标。假设拆出三个子问题:
复制代码subQuestions = ["问题 A", "问题 B", "问题 C"];
nextSubIdx = 0;
第一轮检索 A,结束后把 nextSubIdx 改成 1;第二轮就会检索 B。
把原问题拆成可独立检索的子问题
复制代码const DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string(),
});/** 把复杂问题一次性拆成有先后顺序的独立子问题。 */
const decomposeQuestionNode = async (state) => {
const decomposer = model.withStructuredOutput(DecomposeSchema); const result = await decomposer.invoke(`
把用户问题拆成有序子问题,用于逐条向量检索。要求:
1. 每条都是可以独立检索的完整问句。
2. 不使用“他、她、此人”等依赖上文的代词。
3. 顺序符合事实依赖:先查前置事实,再查后续结论。
4. 输出 1~8 条,不要拆成零散关键词。用户问题:${state.question}
`); // 核心逻辑:清理空白结果,并从第一条子问题开始检索。
const subQuestions = result.sub_questions
.map((question) => question.trim())
.filter(Boolean); return {
subQuestions,
nextSubIdx: 0,
};
};
为什么不允许“他是谁”“此人后来怎样”这样的子问题?因为向量数据库只会看到当前查询,不知道上一条子问题的上下文。独立、明确的查询更容易召回正确片段。
可以看看效果,LLM 把原问题拆分成了五个问题:
那么接下来就是对每个子问题,进行检索,对检索出来的资料进行汇集咯。
每轮检索一个子问题并累计证据
复制代码/** 每次只检索一条子问题,并累计本轮找到的证据。 */
const retrieveNode = async (state) => {
// 核心逻辑:nextSubIdx 是循环游标,决定这一轮查询哪条子问题。
const index = state.nextSubIdx;
const query = state.subQuestions[index]; if (!query) {
throw new Error(`不存在下标为 ${index} 的子问题`);
} const results = await vectorStore.similaritySearchWithScore(
query,
state.k,
); const newDocuments = results.map(([document, score]) => ({
score,
content: document.pageContent,
id: document.metadata.id,
chapter_num: document.metadata.chapter_num,
})); // 多个子问题可能召回同一个片段,因此按 id 合并去重。
const documents = mergeUniqueById(
state.documents,
newDocuments,
); return {
documents,
retrievalCount: state.retrievalCount + 1,
nextSubIdx: index + 1,
};
};
去重不能只用正文字符串。更稳定的方法是使用入库时生成的唯一 id;如果同一片段被多次召回,可以保留相似度更高的那一次。
复制代码/** 合并多轮召回结果;相同文档只保留分数更高的一条。 */
function mergeUniqueById(existingDocuments, newDocuments) {
const documentMap = new Map(); for (const document of [...existingDocuments, ...newDocuments]) {
const previous = documentMap.get(document.id); // 核心逻辑:文档第一次出现,或本轮分数更高时才覆盖旧值。
if (!previous || document.score > previous.score) {
documentMap.set(document.id, document);
}
} return [...documentMap.values()]
.sort((a, b) => b.score - a.score);
}
每轮检索后判断是否继续
当然检索完成不一定要把剩余子问题全部查完。如果第一轮已经拿到了完整证据,可以提前生成答案。
复制代码const NextStepSchema = z.object({
nextAction: z.enum(["retrieve", "generate"]),
reason: z.string(),
});/** 根据当前证据和剩余子问题,决定继续检索还是开始回答。 */
const planNextStepNode = async (state) => {
const remaining =
state.subQuestions.length - state.nextSubIdx; // 只取少量摘要,避免规划 prompt 过长。
const documentSummary = state.documents
.slice(0, 6)
.map((document) => document.content.slice(0, 200))
.join("nn"); const planner = model.withStructuredOutput(NextStepSchema); const decision = await planner.invoke(`
请根据原问题、已检索文档和剩余子问题判断下一步。- 证据足以回答原问题:generate
- 仍缺关键事实且还有子问题:retrieve原问题:${state.question}
剩余子问题数量:${remaining}
已检索轮数:${state.retrievalCount}
最大检索轮数:${state.maxRetrievals}
已召回文档:${documentSummary}
`); let plannedNext = decision.nextAction; // 代码规则覆盖模型决定,保证流程一定能够结束。
if (remaining <= 0) plannedNext = "generate";
if (state.retrievalCount >= state.maxRetrievals) {
plannedNext = "generate";
} return { plannedNext };
};
这里体现了一个很重要的工程原则:
模型可以做判断,但程序必须保留确定性的安全边界。
如果完全听模型决定是否继续,它可能一直返回 retrieve。最大轮数和剩余子问题数量就是代码层面的终止保证。
用条件边形成检索循环
复制代码/** 简单问题直接回答,复杂问题先拆成子问题。 */
function afterRoute(state) {
return state.strategy === "simple"
? "direct_answer"
: "decompose_question";
}/** 把规划节点的决定映射到“继续检索”或“生成答案”。 */
function afterPlan(state) {
// 核心逻辑:返回 retrieve 时,条件边会让图重新进入检索节点。
return state.plannedNext === "retrieve"
? "retrieve"
: "generate";
}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();
当 plan_next_step 返回 retrieve 时,图重新回到检索节点,这就形成了 LangGraph 循环。
当前实现属于“预先拆解式多跳检索”:子问题在进入循环前一次性生成,循环中只决定继续还是停止。更高级的实现还可以根据上一轮证据临时改写下一条查询,但复杂度和失控风险也会增加。
向量数据库只能检索已经入库的内容。小说正文可以回答人物和情节,却不一定包含首发平台、最新改编消息或可点击来源链接。
例如:
复制代码《逆天邪神》中云澈拥有的第一部功法是什么?
另外,这部小说的作者和首发平台是什么?请给出来源链接。
这个问题同时需要两类资料:
联网补充对应这里实现的 Web Fallback。它不是一上来就联网,而是先使用本地资料;只有评估器判断本地内容不够时,才触发搜索。
分开保存本地资料和网络资料(定义 state)
复制代码const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation, // 本地向量数据库返回的原始文档。
retrievedDocs: Annotation, // 供模型阅读的本地文本上下文。
localContext: Annotation, // 网络搜索返回的标题、链接和摘要。
webContext: Annotation, // 评估器的判断结果。
evaluation: Annotation, generation: Annotation,
});
本地资料和网络资料必须分开保存。生成答案时可以把它们合并,但调试时需要知道每条事实来自哪里。
优先查询本地知识库
复制代码/** 优先从本地向量数据库检索,并整理出可读的文本上下文。 */
const retrieveLocalNode = async (state) => {
const results = await vectorStore.similaritySearchWithScore(
state.question,
state.k,
); const retrievedDocs = results.map(([document, score]) => ({
score,
content: document.pageContent,
id: document.metadata.id,
})); return {
retrievedDocs, // 将结构化文档整理成评估器和生成器可读的文本。
localContext: retrievedDocs
.map((document) => document.content)
.join("nn"),
};
};
这里采用的是直接使用完整问题检索一次(也可以采用分步检索)。但复合问题中的“作者、平台、链接”等词可能干扰小说情节的向量召回。实际项目可以再增加查询改写,把本地问题和联网问题分开。
判断本地资料是否足够
复制代码const EvaluateSchema = z.object({
// 当前资料是否足以完整回答用户问题。
enough: z.boolean(), // 明确列出还缺什么,而不是只返回“不够”。
missing: z.array(z.string()).max(6), reason: z.string(), // 本地不足时,给搜索引擎使用的完整查询句。
web_query: z.string().optional(),
});
评估节点同时服务于第一次本地评估和联网后的第二次评估:
复制代码/** 评估已有资料能否回答问题,并指出还缺少哪些信息。 */
const evaluateNode = async (state) => {
// 核心逻辑:同一个节点同时处理“本地检索后”和“联网补充后”两次评估。
const hasWebContext = Boolean(state.webContext?.trim());
const evaluator = model.withStructuredOutput(EvaluateSchema); const evaluation = await evaluator.invoke(`
判断当前上下文是否足以完整回答用户问题。用户问题:${state.question}本地知识库:
${state.localContext || "(空)"}${hasWebContext
? `网络搜索结果:n${state.webContext}`
: ""}如果资料不足,请列出 missing;第一次评估时还要生成 web_query。
`); return { evaluation };
};
与“检索到文档数量大于零”相比,让模型评估内容是否充分更接近真实需求。命中八条高度相似的片段,也可能全部在讲同一件事,依然无法覆盖完整问题。
根据缺失信息生成联网查询
这里采用的是 博查,地址是 open.bochaai.com/
进入控制台,注册 key 和购买资源包,有免费的 1000 次调用。
具体使用开发文档,可以看看官网哈,下面就直接列出代码了。
复制代码/** 使用评估器给出的查询词调用搜索 API,并保存可引用的结果。 */
const webSearchNode = async (state) => {
// 优先使用评估器生成的精准查询;没有时退回原问题。
const query =
state.evaluation.web_query?.trim() || state.question; // 直接调用搜索 API。
const response = await fetch("https://api.bochaai.com/v1/web-search", {
method: "POST",
headers: {
// 使用博查注册的key
Authorization: `Bearer ${process.env.BOCHA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
count: 8,
summary: true,
freshness: "noLimit",
}),
}); if (!response.ok) {
throw new Error(`网络搜索失败:${response.status}`);
} const result = await response.json();
const pages = result.data?.webPages?.value ?? []; // 核心逻辑:同时保存标题、URL 和摘要,方便最终答案标注来源。
const webContext = pages
.map((page, index) => `
引用:${index + 1}
标题:${page.name}
URL:${page.url}
摘要:${page.summary}`)
.join("nn"); return { webContext };
};
联网结果不应该只保留摘要。至少还要保留标题和 URL,最终回答才能提供可核对的来源。
如果是生产环境还需要考虑:
合并本地与网络资料生成答案
复制代码/** 合并本地和网络上下文,再让模型生成带来源的答案。 */
const generateNode = async (state) => {
// 核心逻辑:只合并实际存在的资料,避免 prompt 出现无意义空段落。
const context = [
state.localContext,
state.webContext,
]
.filter(Boolean)
.join("nn===== 网络补充 =====nn"); const response = await model.invoke(`
请优先依据给定上下文回答,不要编造。${context || "(没有可用上下文)"}用户问题:${state.question}要求:
1. 对网络信息保留引用编号和 URL。
2. 资料不足时明确说明无法确认,并指出缺失内容。
`); return { generation: String(response.content) };
};
这里的模型不是事实来源,而是资料整理者。事实来自本地知识库和搜索结果;模型负责把多种来源组织成连贯答案。
只在本地不足时触发搜索
复制代码/** 简单问题直接回答,其余问题先查本地知识库。 */
function afterRoute(state) {
return state.strategy === "simple"
? "direct_answer"
: "local_retrieve";
}/** 根据资料评估结果选择生成答案或联网搜索。 */
function afterEvaluation(state) {
// 已经搜索过一次,就进入生成,避免无限联网循环。
if (state.webContext?.trim()) {
return "generate";
} return state.evaluation.enough
? "generate"
: "web_search";
}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", afterEvaluation, {
generate: "generate",
web_search: "web_search",
})
.addEdge("web_search", "evaluate_local")
.addEdge("direct_answer", END)
.addEdge("generate", END)
.compile();
注意,联网后会再次进入评估节点,但只要 webContext 已经存在,当前条件函数就固定进入生成。第二次评估可以记录“资料仍然缺什么”,却不会再次触发搜索。
这是一个有意设置的边界:避免模型因为总觉得资料不足而无限搜索。更严格的实现可以把二次评估的 missing 也放进生成 prompt,让最终答案准确说明哪些内容仍未确认。
回顾上文,其实就是一直在改造同一条 RAG 链路:
随着流程一步步升级,大模型开始参与检索决策:判断要不要查、怎样拆解问题、当前资料够不够,以及是否需要继续检索或换一个资料来源。程序则负责保存状态、控制分支、限制次数,并保证流程能够停下来。
可以把 Agentic RAG 理解成一个会根据检索结果继续思考和调整的闭环:模型负责判断,程序负责控制;资料不够就继续找,方向不对就调整,满足条件后再生成答案。 LangGraph 的作用,就是把这个过程连接成一张可以执行、可以循环、也可以结束的图。