跳到主要内容

24-Agentic RAG:基于 LangGraph 实现大模型自主决策的 RAG 闭环系统

前言

公司内部的 Agent 基本都要用到 RAG。

因为大模型能思考,但它不知道公司内部的文档,而我们需要它能基于内部文档来回答。

传统 RAG 是这样的:

查询的时候,把 query 用嵌入模型向量化,根据余弦相似度,匹配向量数据库中最相近的文档返回

但这个流程太固定,会有一些问题:

  • 所有问题都走检索,其实简单常识类问题不需要检索,浪费资源
  • 没有纠错和评估机制,无法判断检索内容是否准确、是否足够
  • 处理不了需要多步检索的复杂问题,比如先查 A、再查 B 才能得出结论
  • 专业术语、精确实体更适合关键词检索,纯语义检索容易匹配不准
  • 本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案

解决这些问题,显然要在 RAG 的固定流程中,引入大模型来思考。

  • 让模型根据问题类型选择检索策略,简单问题直接回答,复杂问题才走完整检索
  • 评估检索结果是否相关、是否足够,让模型判断是否需要重新检索或补充检索
  • 让模型自动拆解复杂问题,决定先查什么、后查什么,实现多步检索
  • 同时结合关键词检索与语义检索,由模型统一融合多路结果,提升专业场景准确率
  • 让模型判断本地知识库是否覆盖答案,覆盖不足时自动触发网络搜索补充信息

最终把原本 “死板的检索 - 生成” 流程,升级为可思考、可判断、可纠错的智能 RAG 架构。

这种由大模型自主决策怎么检索、检索的信息是否足够、是否要重新检索等的 RAG 流程就叫 Agentic RAG

这很适合用 LangGraph 的多 Agent 架构来做,每个 Agent 负责其中一块功能。

传统 RAG

我们先基于 LangGraph 实现传统 RAG

创建 src/naive-rag.mjs

import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { Milvus } from "@langchain/community/vectorstores/milvus";

const COLLECTION_NAME = "ebook_collection";
const TOP_K = 5;

const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
documents: Annotation,
generation: Annotation,
});

const model = new ChatOpenAI({
temperature: 0,
model: "qwen-plus",
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
apiKey: process.env.OPENAI_API_KEY,
});

const embeddings = new OpenAIEmbeddings({
model: "text-embedding-v3",
dimensions: 1024,
});

let vectorStore;

async function retrieveRelevantContent(question, k = TOP_K) {
try {
const docsWithScores = await vectorStore.similaritySearchWithScore(
question,
k,
);
return docsWithScores.map(([doc, score]) => ({
score,
content: doc.pageContent,
id: doc.metadata?.id ?? "unknown",
book_id: doc.metadata?.book_id ?? "未知",
chapter_num: doc.metadata?.chapter_num ?? "未知",
index: doc.metadata?.index ?? "未知",
}));
} catch (error) {
console.error("检索内容时出错:", error.message);
return [];
}
}

const retrieveNode = async (state) => {
const documents = await retrieveRelevantContent(state.question, state.k);
return {
question: state.question,
k: state.k,
documents,
};
};

const generateNode = async (state) => {
const context = state.documents
.map(
(item, i) =>
`[片段 ${i + 1}]
章节: 第 ${item.chapter_num}
内容: ${item.content}`,
)
.join("\n\n━━━━━\n\n");

const prompt = `你是一个专业的《天龙八部》小说助手。基于小说内容回答问题,用准确、详细的语言。

请根据以下《天龙八部》小说片段内容回答问题:
${context}

用户问题: ${state.question}

回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答
2. 可以综合多个片段的内容,提供完整的答案
3. 如果片段中没有相关信息,请如实告知用户
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答

AI 助手的回答:`;

process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream = await model.stream(prompt);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");

return {
question: state.question,
k: state.k,
documents: state.documents,
generation,
};
};

const graph = new StateGraph(GraphState)
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode)
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.compile();

async function main() {
const question = "阿朱的结局是什么?";
const kArg = 5;

// 导出为 Mermaid:可复制到 https://mermaid.live 或 Markdown 的 ```mermaid 代码块
const drawable = await graph.getGraphAsync();
const mermaid = drawable.drawMermaid({ withStyles: true });
console.log(mermaid);

console.log("连接到 Milvus...");
vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: COLLECTION_NAME,
url: "localhost:19530",
textField: "content",
primaryField: "id",
vectorField: "vector",
indexCreateOptions: {
metric_type: "COSINE",
index_type: "HNSW",
params: { M: 16, efConstruction: 200 },
search_params: { ef: 64 },
},
});
vectorStore.indexSearchParams = {
metric_type: "COSINE",
params: JSON.stringify({ ef: 64 }),
};
console.log("✓ 已连接\n");

try {
await vectorStore.client.loadCollection({
collection_name: COLLECTION_NAME,
});
console.log(`✓ 集合 ${COLLECTION_NAME} 已加载\n`);
} catch (error) {
if (!error.message.includes("already loaded")) {
throw error;
}
console.log(`✓ 集合 ${COLLECTION_NAME} 已处于加载状态\n`);
}

console.log("=".repeat(80));
console.log(`问题: ${question}`);
console.log("=".repeat(80));

const result = await graph.invoke({
question,
k: Number.isFinite(kArg) ? kArg : TOP_K,
documents: [],
generation: "",
});

console.log("\n【检索相关内容】");
if (result.documents.length === 0) {
console.log("未找到相关内容");
console.log("\n【AI 回答】");
console.log("抱歉,我没有找到相关的《天龙八部》内容。");
return;
} else {
result.documents.forEach((item, i) => {
console.log(`\n[片段 ${i + 1}] 相似度: ${item.score.toFixed(4)}`);
console.log(`书籍: ${item.book_id}`);
console.log(`章节: 第 ${item.chapter_num}`);
console.log(`片段索引: ${item.index}`);
console.log(
`内容: ${item.content.substring(0, 200)}${item.content.length > 200 ? "..." : ""}`,
);
});
}

if (!result.generation) {
console.log("\n【AI 回答】");
console.log("模型未返回内容。");
}
}

main();

RAG 是一个线性的流程,之前用 LCEL 的链写过,这次用 langgraph 来写:

检索节点就是把 query 向量化从 Milvus 里检索相关文档:

image-20260727220901226

生成节点是把检索的文档放到 prompt 里,调用大模型生成回答:

image-20260727220910900

然后我们一条条来解决上面的问题。

解决问题

让模型根据问题类型选择检索策略,简单问题直接回答,复杂问题才走完整检索

这个就是加一个节点来做判断,是直接回答,还是先检索向量数据库再回答

src/rag-query-router.mjs

import "dotenv/config";
import { z } from "zod";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { Milvus } from "@langchain/community/vectorstores/milvus";

const llm = new ChatOpenAI({
temperature: 0,
model: "qwen-plus",
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
apiKey: process.env.OPENAI_API_KEY,
});

const embeddings = new OpenAIEmbeddings({
model: "text-embedding-v3",
dimensions: 1024,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
apiKey: process.env.OPENAI_API_KEY,
});

const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});

const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
documents: Annotation,
generation: Annotation,
});

let vectorStore;

async function retrieveRelevantContent(question, k) {
try {
const docsWithScores = await vectorStore.similaritySearchWithScore(
question,
k,
);
return docsWithScores.map(([doc, score]) => ({
score,
content: doc.pageContent,
id: doc.metadata?.id ?? "unknown",
book_id: doc.metadata?.book_id ?? "未知",
chapter_num: doc.metadata?.chapter_num ?? "未知",
index: doc.metadata?.index ?? "未知",
}));
} catch (error) {
console.error("检索内容时出错:", error.message);
return [];
}
}

const routeQuestionNode = async (state) => {
console.log("---ROUTE_QUESTION---");
const router = llm.withStructuredOutput(RouteSchema);
const route = await router.invoke(`
你是问答路由器。请判断用户问题是否需要外部检索。

规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。

用户问题:${state.question}
`);

console.log(`路由策略: ${route.strategy} (${route.reason})`);
return {
question: state.question,
k: state.k,
strategy: route.strategy,
routeReason: route.reason,
};
};

const retrieveNode = async (state) => {
console.log("---RETRIEVE---");
const documents = await retrieveRelevantContent(state.question, state.k);
if (documents.length === 0) {
console.log("RETRIEVE结果: 未命中文档");
} else {
console.log(`RETRIEVE结果: 命中 ${documents.length}`);
documents.forEach((item, i) => {
const preview =
item.content.length > 120
? `${item.content.substring(0, 120)}...`
: item.content;
console.log(
`[R${i + 1}] score=${Number(item.score).toFixed(4)} chapter=${item.chapter_num} index=${item.index}`,
);
console.log(` ${preview}`);
});
}
return {
question: state.question,
k: state.k,
strategy: state.strategy,
routeReason: state.routeReason,
documents,
};
};

const directAnswerNode = async (state) => {
console.log("---DIRECT_ANSWER---");
process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream = await llm.stream(`你是一个中文问答助手,请直接简洁回答问题。

问题:${state.question}
`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");
return {
question: state.question,
k: state.k,
strategy: state.strategy,
routeReason: state.routeReason,
documents: [],
generation,
};
};

const ragGenerateNode = async (state) => {
console.log("---RAG_GENERATE---");
const context = state.documents
.map(
(item, i) =>
`[片段 ${i + 1}]
章节: 第 ${item.chapter_num}
内容: ${item.content}`,
)
.join("\n\n━━━━━\n\n");
process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream =
await llm.stream(`你是一个专业的《天龙八部》小说助手。基于小说内容回答问题,用准确、详细的语言。

请根据以下《天龙八部》小说片段内容回答问题:
${context || "(未检索到相关内容)"}

用户问题: ${state.question}

回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答
2. 可以综合多个片段的内容,提供完整的答案
3. 如果片段中没有相关信息,请如实告知用户
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答

AI 助手的回答:`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");

return {
question: state.question,
k: state.k,
strategy: state.strategy,
routeReason: state.routeReason,
documents: state.documents,
generation,
};
};

function decideNext(state) {
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();

async function main() {
const question = "阿朱的结局是什么?";
const k = 5;

// 导出为 Mermaid:可复制到 https://mermaid.live 或 Markdown 的 ```mermaid 代码块
const drawable = await graph.getGraphAsync();
const mermaid = drawable.drawMermaid({ withStyles: true });
console.log(mermaid);

console.log("连接到 Milvus...");
vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: "ebook_collection",
url: "localhost:19530",
textField: "content",
primaryField: "id",
vectorField: "vector",
indexCreateOptions: {
metric_type: "COSINE",
index_type: "HNSW",
params: { M: 16, efConstruction: 200 },
search_params: { ef: 64 },
},
});
vectorStore.indexSearchParams = {
metric_type: "COSINE",
params: JSON.stringify({ ef: 64 }),
};
console.log("✓ 已连接\n");

try {
await vectorStore.client.loadCollection({
collection_name: "ebook_collection",
});
console.log("✓ 集合 ebook_collection 已加载\n");
} catch (error) {
if (!error.message.includes("already loaded")) {
throw error;
}
console.log("✓ 集合 ebook_collection 已处于加载状态\n");
}

console.log("=".repeat(80));
console.log(`问题: ${question}`);
console.log("=".repeat(80));

const result = await graph.invoke({
question,
k: Number.isFinite(k) ? k : 5,
strategy: "",
routeReason: "",
documents: [],
generation: "",
});

if (result.strategy === "complex") {
console.log("\n【检索相关内容】");
if (result.documents.length === 0) {
console.log("未找到相关内容");
} else {
result.documents.forEach((item, i) => {
console.log(`\n[片段 ${i + 1}] 相似度: ${item.score.toFixed(4)}`);
console.log(`书籍: ${item.book_id}`);
console.log(`章节: 第 ${item.chapter_num}`);
console.log(`片段索引: ${item.index}`);
console.log(
`内容: ${item.content.substring(0, 200)}${item.content.length > 200 ? "..." : ""}`,
);
});
}
}

console.log(`\n最终策略: ${result.strategy}`);
if (!result.generation?.trim()) {
console.log("模型未返回内容。");
}
}

main();

现在的 graph 如下:

我们加了一个对问题做路由的节点:

image-20260727221044752

根据问题返回不同的类型,然后用 conditional edge 转到不同节点来处理

这个路由节点用 withStructuredOutput 来控制结构化输出

image-20260727221057075

用大模型识别 query 是哪种类型,并给出原因

简单问题直接调大模型回答,复杂的问题先检索向量数据库再生成回答

这样就能识别出与小说相关的问题才走检索了。

处理不了需要多步检索的复杂问题,比如先查 A、再查 B 才能得出结论

比如这种:段誉遇到的第一个神仙姐姐画像,是谁的弟子?

直接把这个 query 向量化匹配显然不够准确

应该是先检索神仙姐姐画像是谁,有了结果再去检索她是谁的弟子。

所以我们要支持下子问题的拆分:

src/rag-multihop.mjs

import "dotenv/config";
import { z } from "zod";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { Milvus } from "@langchain/community/vectorstores/milvus";

const llm = new ChatOpenAI({
temperature: 0,
model: "qwen-plus",
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
apiKey: process.env.OPENAI_API_KEY,
});

const embeddings = new OpenAIEmbeddings({
model: "text-embedding-v3",
dimensions: 1024,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
apiKey: process.env.OPENAI_API_KEY,
});

/**
* complex:先拆解子问题序列,再按序检索
*/
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
/** 拆解得到的有序子问题,仅用于检索 */
subQuestions: Annotation,
/** 下一轮 retrieve 要用的下标(指向 subQuestions 中尚未检索的那一条) */
nextSubIdx: Annotation,
documents: Annotation,
currentQuery: Annotation,
retrievalCount: Annotation,
maxRetrievals: Annotation,
plannedNext: Annotation,
generation: Annotation,
});

let vectorStore;

async function retrieveRelevantContent(question, k) {
try {
const docsWithScores = await vectorStore.similaritySearchWithScore(
question,
k,
);
return docsWithScores.map(([doc, score]) => ({
score,
content: doc.pageContent,
id: doc.metadata?.id ?? "unknown",
book_id: doc.metadata?.book_id ?? "未知",
chapter_num: doc.metadata?.chapter_num ?? "未知",
index: doc.metadata?.index ?? "未知",
}));
} catch (error) {
console.error("检索内容时出错:", error.message);
return [];
}
}

/** 按 id 合并;同 id 保留更高 score */
function mergeUnique(existingDocs, newDocs) {
const map = newMap();
for (const d of [...existingDocs, ...newDocs]) {
const key = String(d.id);
const prev = map.get(key);
if (!prev || Number(d.score) > Number(prev.score)) {
map.set(key, d);
}
}
returnArray
.from(map.values())
.sort((a, b) => Number(b.score) - Number(a.score));
}

const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});

const DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string(),
});

const NextStepSchema = z.object({
nextAction: z.enum(["retrieve", "generate"]),
reason: z.string(),
});

const routeQuestionNode = async (state) => {
console.log("---ROUTE_QUESTION---");
const router = llm.withStructuredOutput(RouteSchema);
const route = await router.invoke(`
你是问答路由器。请判断用户问题是否需要外部检索。

规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。

用户问题:${state.question}
`);

console.log(`路由策略: ${route.strategy} (${route.reason})`);
return {
strategy: route.strategy,
routeReason: route.reason,
retrievalCount: 0,
maxRetrievals: state.maxRetrievals ?? 8,
documents: [],
subQuestions: [],
nextSubIdx: 0,
currentQuery: "",
};
};

const decomposeQuestionNode = async (state) => {
console.log("---DECOMPOSE_QUESTION---");
const decomposer = llm.withStructuredOutput(DecomposeSchema);
const out =
await decomposer.invoke(`你是《天龙八部》多跳问答的「子问题拆解器」。

用户原始问题:
${state.question}

任务:将问题拆成**有序**子问题列表 sub_questions,用于**依次向量检索**。要求:
1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答的也可只输出 1 条。
2. 每条子问题必须是**可独立检索**的完整中文问句,**禁止**使用「他/她/此人/上文」等指代;可写全人物名与事件名。
3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
4. **不要**把整句原题原样复制成唯一一条(除非确实无法拆分);不要拆成过碎的关键词列表。
5. 输出 1~8 条即可。

请输出 sub_questions 与简短 reason。`);

const subQuestions = out.sub_questions.map((s) => s.trim()).filter(Boolean);
if (subQuestions.length === 0) {
thrownewError("decompose_question: sub_questions 为空");
}

console.log(`拆解 ${subQuestions.length} 条子问题 (${out.reason})`);
subQuestions.forEach((q, i) => {
console.log(` [${i + 1}] ${q}`);
});

return {
subQuestions,
nextSubIdx: 0,
currentQuery: subQuestions[0],
};
};

const retrieveNode = async (state) => {
const subs = state.subQuestions ?? [];
const idx = state.nextSubIdx ?? 0;
const q = subs[idx]?.trim();
if (!q) {
thrownewError(
`retrieve: 子问题下标 ${idx} 无有效文本(共 ${subs.length} 条)`,
);
}

const round = state.retrievalCount + 1;
console.log(
`---RETRIEVE (第 ${round} 轮,子问题 ${idx + 1}/${subs.length})---`,
);
console.log(`查询: ${q}`);

const newDocs = await retrieveRelevantContent(q, state.k);
const merged = mergeUnique(state.documents ?? [], newDocs);

if (newDocs.length === 0) {
console.log("本轮未命中文档");
} else {
console.log(
`本轮命中 ${newDocs.length} 条,累计去重后 ${merged.length}`,
);
newDocs.forEach((item, i) => {
const preview =
item.content.length > 120
? `${item.content.substring(0, 120)}...`
: item.content;
console.log(
`[R${i + 1}] score=${Number(item.score).toFixed(4)} chapter=${item.chapter_num} index=${item.index}`,
);
console.log(` ${preview}`);
});
}

return {
documents: merged,
retrievalCount: round,
nextSubIdx: idx + 1,
currentQuery: q,
};
};

const planNextStepNode = async (state) => {
console.log("---PLAN_NEXT_STEP---");
const subs = state.subQuestions ?? [];
const nextIdx = state.nextSubIdx ?? 0;
const remaining = subs.length - nextIdx;

const subList = subs
.map(
(s, i) =>
`${i + 1}. ${s}${i < nextIdx ? " (已检索)" : i === nextIdx ? " (下一轮将检索,若选择继续)" : " (未检索)"}`,
)
.join("\n");

const docStr =
state.documents.length === 0
? "(尚无检索结果)"
: state.documents
.slice(0, 6)
.map(
(d, i) =>
`[${i + 1}] score=${Number(d.score).toFixed(4)}${d.chapter_num}章: ${d.content.slice(0, 200)}${d.content.length > 200 ? "..." : ""}`,
)
.join("\n\n");

const prompt = `你是多跳 RAG 规划器。检索查询已由前置步骤拆解为**有序子问题**;若需继续检索,下一轮将自动使用「下一条子问题」做向量检索,你**不要**自拟新的检索句。

用户原始问题:${state.question}

子问题序列:
${subList || "(无)"}

已检索轮数:${state.retrievalCount};剩余未检索子问题条数:${remaining}
最大检索轮数上限:${state.maxRetrievals}

已召回文档摘要:
${docStr}

请判断下一步:
1) 已有足够依据回答用户原始问题 → nextAction=generate
2) 仍缺关键事实、且仍存在未检索的子问题、且未超过轮数上限 → nextAction=retrieve

硬性规则:
- 若剩余未检索子问题条数为 0,必须 nextAction=generate。
- 若已检索轮数已达到或超过最大检索轮数,必须 nextAction=generate。`;

const model = llm.withStructuredOutput(NextStepSchema);
const { nextAction, reason } = await model.invoke(prompt);

let finalNext = nextAction;
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate";
if (remaining <= 0) finalNext = "generate";

console.log(
`[决策] plannedNext=${finalNext} (模型建议=${nextAction}) (${reason})`,
);

return {
plannedNext: finalNext,
};
};

function afterRoute(state) {
return state.strategy === "simple" ? "direct_answer" : "decompose_question";
}

function afterPlan(state) {
return state.plannedNext === "retrieve" ? "retrieve" : "generate";
}

const directAnswerNode = async (state) => {
console.log("---DIRECT_ANSWER---");
process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream = await llm.stream(`你是一个中文问答助手,请直接简洁回答问题。

问题:${state.question}
`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");
return { generation };
};

const generateNode = async (state) => {
console.log("---GENERATE---");
const context = state.documents
.map(
(item, i) =>
`[片段 ${i + 1}]
章节: 第 ${item.chapter_num}
内容: ${item.content}`,
)
.join("\n\n━━━━━\n\n");
process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream =
await llm.stream(`你是一个专业的《天龙八部》小说助手。基于小说内容回答问题,用准确、详细的语言。

请根据以下《天龙八部》小说片段内容回答问题:
${context || "(未检索到相关内容)"}

用户问题: ${state.question}

回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答
2. 可以综合多个片段的内容,提供完整的答案
3. 如果片段中没有相关信息,请如实告知用户
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答

AI 助手的回答:`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");
return { generation };
};

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();

async function main() {
const question =
"《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?";
const k = 5;

const drawable = await graph.getGraphAsync();
console.log(drawable.drawMermaid({ withStyles: true }));

console.log("连接到 Milvus...");
vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: "ebook_collection",
url: "localhost:19530",
textField: "content",
primaryField: "id",
vectorField: "vector",
indexCreateOptions: {
metric_type: "COSINE",
index_type: "HNSW",
params: { M: 16, efConstruction: 200 },
search_params: { ef: 64 },
},
});
vectorStore.indexSearchParams = {
metric_type: "COSINE",
params: JSON.stringify({ ef: 64 }),
};
console.log("✓ 已连接\n");

try {
await vectorStore.client.loadCollection({
collection_name: "ebook_collection",
});
console.log("✓ 集合 ebook_collection 已加载\n");
} catch (error) {
if (!error.message.includes("already loaded")) {
throw error;
}
console.log("✓ 集合 ebook_collection 已处于加载状态\n");
}

console.log("=".repeat(80));
console.log(`问题: ${question}`);
console.log("=".repeat(80));

const result = await graph.invoke({
question,
k: Number.isFinite(k) ? k : 5,
strategy: "",
routeReason: "",
subQuestions: [],
nextSubIdx: 0,
documents: [],
currentQuery: "",
retrievalCount: 0,
maxRetrievals: 8,
plannedNext: "",
generation: "",
});

if (result.strategy === "complex") {
if (result.subQuestions?.length) {
console.log("\n【子问题序列】");
result.subQuestions.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
}
console.log("\n【检索相关内容(累计)】");
if (result.documents.length === 0) {
console.log("未找到相关内容");
} else {
result.documents.forEach((item, i) => {
console.log(
`\n[片段 ${i + 1}] 相似度: ${Number(item.score).toFixed(4)}`,
);
console.log(`书籍: ${item.book_id}`);
console.log(`章节: 第 ${item.chapter_num}`);
console.log(`片段索引: ${item.index}`);
console.log(
`内容: ${item.content.substring(0, 200)}${item.content.length > 200 ? "..." : ""}`,
);
});
}
console.log(
`\n检索轮数: ${result.retrievalCount} / ${result.maxRetrievals}`,
);
}

console.log(`\n最终策略: ${result.strategy}`);
if (!result.generation?.trim()) {
console.log("模型未返回内容。");
}
}

main().catch((err) => {
console.error("运行失败:", err);
process.exit(1);
});

整体流程如图:

首先拆分成多个子问题:

image-20260727221218127

把原始问题拆成多个子问题的数组。

然后检索的时候根据 state 里的当前下标来检索对应问题的文档:

image-20260727221225634

这里因为会检索多轮,所以做了一下 id 的去重。

接下来判断是否检索完了,如果没有就继续检索:

image-20260727221237298

直到循环完,就检索完了所有子问题,接下来就生成回答就好了。

本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案

如果知识库中没有的内容,这时候 agent 就不知道怎么回答了。

这种情况我们可以调用网络搜索来兜底,把搜索结果放到 prompt 里来参考生成回答。

src/rag-webfallback.mjs

import "dotenv/config";
import { z } from "zod";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { Milvus } from "@langchain/community/vectorstores/milvus";

const llm = new ChatOpenAI({
temperature: 0,
model: "qwen-plus",
configuration: { baseURL: process.env.OPENAI_BASE_URL },
apiKey: process.env.OPENAI_API_KEY,
});

const embeddings = new OpenAIEmbeddings({
model: "text-embedding-v3",
dimensions: 1024,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
apiKey: process.env.OPENAI_API_KEY,
});

const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
retrievedDocs: Annotation,
localContext: Annotation,
webContext: Annotation,
evaluation: Annotation,
generation: Annotation,
});

let vectorStore;

async function retrieveRelevantContent(query, k) {
try {
const docsWithScores = await vectorStore.similaritySearchWithScore(
query,
k,
);
return docsWithScores.map(([doc, score]) => ({
score,
content: doc.pageContent,
id: doc.metadata?.id ?? "unknown",
book_id: doc.metadata?.book_id ?? "未知",
chapter_num: doc.metadata?.chapter_num ?? "未知",
index: doc.metadata?.index ?? "未知",
}));
} catch (error) {
console.error("检索内容时出错:", error.message);
return [];
}
}

const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});

const routeQuestionNode = async (state) => {
console.log("---ROUTE_QUESTION---");
const router = llm.withStructuredOutput(RouteSchema);
const route = await router.invoke(`
你是问答路由器。请判断用户问题是否需要外部检索。

规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。

用户问题:${state.question}
`);
console.log(`路由策略: ${route.strategy} (${route.reason})`);
return {
strategy: route.strategy,
routeReason: route.reason,
retrievedDocs: [],
localContext: "",
webContext: "",
evaluation: "",
generation: "",
};
};

const directAnswerNode = async (state) => {
console.log("---DIRECT_ANSWER---");
process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream = await llm.stream(`你是一个中文问答助手,请直接简洁回答问题。

问题:${state.question}
`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");
return { generation };
};

const retrieveLocalNode = async (state) => {
console.log("---LOCAL_RETRIEVE---");
const retrievedDocs = await retrieveRelevantContent(state.question, state.k);
console.log(`本地检索命中: ${retrievedDocs.length}`);
const localContext = (retrievedDocs ?? []).map((d) => d.content).join("\n\n");
return {
retrievedDocs,
localContext,
};
};

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 hasWeb = Boolean(state.webContext && String(state.webContext).trim());
console.log(
hasWeb ? "---EVALUATE_CONTEXT_WITH_WEB---" : "---EVALUATE_LOCAL_CONTEXT---",
);
const evaluator = llm.withStructuredOutput(EvaluateSchema);
const out =
await evaluator.invoke(`你是信息充分性评估器。判断当前上下文是否足以回答用户问题。

用户问题:${state.question}

已检索上下文(来自本地知识库):
${state.localContext || "(空)"}

${hasWeb ? `联网搜索结果:\n${state.webContext || "(空)"}\n` : ""}

输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够,列出缺失信息点(最多 6 条)
- reason: 简短原因
${hasWeb ? "" : "- web_query: 若不够,给出一个适合联网搜索的中文查询句(完整句,不用代词;为空也可)"}
`);

console.log(
`${hasWeb ? "二次评估" : "评估"}: enough=${out.enough} (${out.reason})`,
);
if (!out.enough && out.missing?.length) {
out.missing.forEach((m, i) => console.log(` 缺失${i + 1}: ${m}`));
}
return {
evaluation: JSON.stringify(out),
};
};

/**
* Call Bocha Web Search API
*/
async function bochaWebSearch(query, count) {
const apiKey = process.env.BOCHA_API_KEY;
if (!apiKey) {
thrownewError(
"Bocha Web Search 的 API Key 未配置(环境变量 BOCHA_API_KEY)。",
);
}
const url = "https://api.bochaai.com/v1/web-search";
const body = {
query,
freshness: "noLimit",
summary: true,
count: count ?? 10,
};

let response;
try {
response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
thrownewError(`搜索 API 请求失败(网络错误):${error.message}`);
}

if (!response.ok) {
const errorText = await response.text().catch(() => "");
thrownewError(
`搜索 API 请求失败,状态码: ${response.status}, 错误信息: ${errorText}`,
);
}

let json;
try {
json = await response.json();
} catch (error) {
thrownewError(`搜索结果解析失败:${error.message}`);
}

if (json?.code !== 200 || !json?.data) {
thrownewError(`搜索 API 返回失败:${json?.msg ?? "未知错误"}`);
}

const webpages = json.data.webPages?.value ?? [];
if (!webpages.length) {
return "未找到相关结果。";
}

return webpages
.map(
(page, idx) => `引用: ${idx + 1}
标题: ${page.name}
URL: ${page.url}
摘要: ${page.summary}
网站名称: ${page.siteName}
网站图标: ${page.siteIcon}
发布时间: ${page.dateLastCrawled}`,
)
.join("\n\n");
}

const webSearchNode = async (state) => {
console.log("---WEB_SEARCH---");
const parsed = (() => {
try {
returnJSON.parse(state.evaluation || "{}");
} catch {
return {};
}
})();
const query = (parsed.web_query ?? "").trim() || state.question;
console.log(`联网查询: ${query}`);
const webContext = await bochaWebSearch(query, 8);
console.log(`联网结果长度: ${webContext.length}`);
return { webContext };
};

const generateNode = async (state) => {
console.log("---GENERATE---");
const context = [state.localContext, state.webContext]
.filter(Boolean)
.join("\n\n===== 联网补充 =====\n\n");
process.stdout.write("\n【AI 回答(流式)】\n");
let generation = "";
const stream =
await llm.stream(`你是一个严谨的中文问答助手。优先依据上下文作答,不要编造。

上下文(本地知识库 + 可选联网补充):
${context || "(空)"}

用户问题:${state.question}

回答要求:
1. 如果上下文足够,给出清晰、可核对的回答;需要时引用“引用: n / URL”或小说片段来支撑。
2. 如果上下文仍不足以确定关键事实,明确说明“不确定/无法从上下文确认”,并说明缺失点。
3. 不要输出表情符号。

回答:`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");
return { generation };
};

function afterRoute(state) {
return state.strategy === "simple" ? "direct_answer" : "local_retrieve";
}

function afterEvaluateLocal(state) {
if (state.webContext && String(state.webContext).trim()) {
return "generate";
}
const parsed = (() => {
try {
returnJSON.parse(state.evaluation || "{}");
} catch {
return {};
}
})();
return parsed.enough === true ? "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", afterEvaluateLocal, {
generate: "generate",
web_search: "web_search",
})
.addEdge("web_search", "evaluate_local")
.addEdge("direct_answer", END)
.addEdge("generate", END)
.compile();

async function main() {
const question =
"请回答《天龙八部》小说里“雁门关事件”的主谋是谁,并说明其儿子的最终结局;另外请补充:在《天龙八部》2013 版电视剧中,这段“雁门关事件”主要出现在哪几集?请给出可核对的来源链接。";
const k = 8;

const drawable = await graph.getGraphAsync();
console.log(drawable.drawMermaid({ withStyles: true }));

console.log("连接到 Milvus...");
vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: "ebook_collection",
url: "localhost:19530",
textField: "content",
primaryField: "id",
vectorField: "vector",
indexCreateOptions: {
metric_type: "COSINE",
index_type: "HNSW",
params: { M: 16, efConstruction: 200 },
search_params: { ef: 64 },
},
});
vectorStore.indexSearchParams = {
metric_type: "COSINE",
params: JSON.stringify({ ef: 64 }),
};
console.log("✓ 已连接\n");

try {
await vectorStore.client.loadCollection({
collection_name: "ebook_collection",
});
console.log("✓ 集合 ebook_collection 已加载\n");
} catch (error) {
if (!error.message.includes("already loaded")) throw error;
console.log("✓ 集合 ebook_collection 已处于加载状态\n");
}

console.log("=".repeat(80));
console.log(`问题: ${question}`);
console.log("=".repeat(80));

const result = await graph.invoke({
question,
k,
strategy: "",
routeReason: "",
retrievedDocs: [],
localContext: "",
webContext: "",
evaluation: "",
generation: "",
});

console.log(`\n最终策略: ${result.strategy}`);
if (!result.generation?.trim()) {
console.log("模型未返回内容。");
}
}

main();

现在的流程如下:

检索完向量数据库,会评估一下信息是否足够:

image-20260727221327377

根据问题和检索的文档判断信息是否足够

不够的话生成一个 web search 用的 query,走网络搜索节点:

image-20260727221335531

取出 state 里的网络搜索 query,调用博查来搜索。

其他问题

继续来看 RAG 其他问题:

  • 没有纠错和评估机制,无法判断检索内容是否准确、是否足够
  • 专业术语、精确实体更适合关键词检索,纯语义检索容易匹配不准

评估阶段我们现在已经加了。

而关键词检索需要用到 ElasticSearch 全文检索数据库,后面再讲。

至此,我们基于 LangGraph 的多 Agent 架构实现了自主决策的 Agentic RAG 流程。

什么是 Agentic RAG 呢?

将 LLM 作为系统的决策大脑,让它自主决定如何检索、检索多少次、判断检索结果是否足够可靠,以及是否需要补充检索、优化查询或切换数据源,这种自我决策、自我反思、自我修正的自主检索闭环,就叫 Agentic RAG。

当然,具体要根据业务场景来设计实际方案。

总结

传统的 RAG 流程很固定,用户问题向量化 → 相似度检索 → prompt 拼接 → 生成回答

但它有一系列的问题:

  • 简单常识问题也走向量检索,造成资源浪费
  • 缺乏检索结果的评估与纠错机制,无法判断信息是否准确充足
  • 无法处理需多步检索的链式推理问题
  • 纯语义检索对专业术语、精确实体匹配不准
  • 无联网补充能力,知识库缺失信息时易编造答案

解决方案就是 Agentic RAG。

Agentic RAG 是由大模型作为决策中枢,自主控制检索方式、评估检索效果、判断是否需要补充检索或发起网络搜索,形成自主思考与迭代优化的闭环检索系统。

我们基于 LangGraph 的图,实现了这个闭环的决策循环,用多 Agent 架构实现了 Agentic RAG。

比如加入了意图识别路由、多跳检索的循环、效果评估和网络搜索(ElasticSearch 的关键词检索后面再学)

当然,具体的 Agentic RAG 还是要根据业务场景来设计,不是完全照搬,比如我们公司项目就是简化版相对固定的检索流程。

主要是理解什么是 Agentic RAG,如何基于 LangGraph 实现这个决策循环,然后针对传统 RAG 的不同的问题怎么解决就可以了

传统RAg对比Agentic RAG

Agentic RAG 是什么?

Agentic RAG 是在传统 RAG 的基础上,引入 Agent(智能体) 能力,让 LLM 不只是回答问题,还负责规划、决策和调用工具

一句话概括:

传统 RAG:固定流程。

Agentic RAG:动态流程,由 LLM 自主决策。

一、传统 RAG

传统 RAG 流程固定:

用户问题

Embedding

向量检索(TopK)

返回相关文档

LLM 生成答案

特点:

  • 检索一次
  • TopK 固定
  • 不会重新检索
  • 检索结束后直接生成答案

例如:

Question

Search

LLM

Answer

如果第一次检索的信息不足,流程依然结束,不会主动再次搜索。

二、Agentic RAG

Agentic RAG 的核心区别是:

LLM 可以自主决定下一步该做什么。

流程如下:

用户问题


Agent(LLM)

是否需要检索?


Retriever

返回文档


Agent(LLM)

信息足够?
┌───┴────┐
│ │
否 是
│ │
继续检索 输出答案

Agent 可以自主决定:

  • 是否检索
  • 检索几次
  • 如何修改 Query
  • 是否调用其他工具
  • 什么时候结束

三、示例

用户:

createAgent 底层是如何工作的?

Agent:

search_docs("createAgent")


发现缺少 StateGraph


search_docs("StateGraph")


发现缺少 ToolNode


search_docs("ToolNode")


综合多个文档生成答案

整个过程中,是否继续检索由 LLM 自己决定,而不是程序提前写死。

四、Retriever 只是 Agent 的一个工具

在 Agentic RAG 中,Retriever 只是众多工具之一。

Agent 还可以调用:

  • Retriever(知识库)
  • Web Search
  • 数据库
  • API
  • 浏览器
  • 文件系统
  • Python
  • MCP Tool

例如:

分析一家公司的投资价值:

① 查询公司资料
② 查询财报
③ 查询新闻
④ 查询竞争对手
⑤ 综合分析

整个执行过程由 Agent 自主规划。

五、与 LangGraph 的关系

LangGraph 很适合实现 Agentic RAG,例如:

START

Agent(LLM)

是否调用 Tool?
├── 否 → END
└── 是

Retriever

返回文档

Agent(LLM)

再次判断是否继续调用 Tool

这与你之前设计的循环一致:

START

Agent

Tool Call?
├── No → END
└── Yes

Tools

Agent

Tool Call?

本质上就是 Agent 根据当前结果不断决定下一步。

六、两者区别

对比项传统 RAGAgentic RAG
工作流固定动态
检索次数一次多次,可循环
Query用户输入直接检索Agent 可重写、拆分
是否自主决策
Tool通常只有 RetrieverRetriever + Web + API + DB 等
适用场景FAQ、知识库复杂分析、多步骤任务

总结

传统 RAG:

Question

Search

LLM

Answer

Agentic RAG:

Question

Agent

Decision

Search / API / DB ...

Agent

Information Enough?
┌──┴──┐
│ │
No Yes
│ │
继续 Answer

一句话总结:

传统 RAG 是固定的"检索 → 回答";Agentic RAG 则是由 Agent 自主规划整个获取信息的过程,RAG 检索只是它可使用的一种工具。