一.agentic RAG 是什么
将
LLM作为系统的决策大脑,让它自主决定如何检索、检索多少次、判断检索结果是否足够可靠,以及是否需要补充检索、优化查询或切换数据源,这种自我决策、自我反思、自我修正的自主检索闭环,就叫Agentic RAG。
传统大模型RAG流程搜索会存在一些问题:
- 简单常识问题也走向量检索,造成资源浪费
- 缺乏检索结果的评估与纠错机制,无法判断信息是否准确充足
- 无法处理需多步检索的链式推理问题
- 纯语义检索对专业术语、精确实体匹配不准
- 无联网补充能力,知识库缺失信息时易编造答案
他的解决方案就是Agentic RAG,他是一个自我决策、自我反思、自我修正的闭环检测系统。
基于LangGraph的图,利用闭环的决策循环和多 Agent 架构就可以实现:Agentic RAG。
二.用graph实现RAG
之前学习LECL的时候,用runnable相关的api实现过这个需求,现在用graph实现一遍。
我们需要知道是在graph里面,state放进去的是时候,你只需要告诉他一个Annotation.Root定义。然后就可以在各种node里面使用。比如retrieveNode,你可以把state里面的东西拿出来用,用完之后再返回出去就好了。
等graph.invoke()执行到的时候,他最终把处理完的state返回出来。
当然,大模型只是graph中generateNode的一个环节。
他的流程图是:
先执行
retrieveNode,去milvus里面根据余弦相似度,找对应的数据,找好以后放到state里面的document里面。再执行
generateNode调用大模型,根据document里面的数据,回答question的答案。
在generateNode里面,将state.documents里面的数据拿出来,拼接成一个字符串,他就是prompt里面小说片段的内容。
值得注意的是:所有的node,进入的参数是state,出去的参数也是state。state每经过一个node它里面的值就会变化一次,直到END最后返回出来。

js
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 BOOK_NAME = "天龙八部";
// ============ 图状态定义 ============
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
documents: Annotation,
generation: Annotation,
});
// ============ 模型与 Embedding ============
const model = new ChatOpenAI({
model: "qwen-plus", // ✅ 去除了重复的 model 字段
temperature: 0,
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, // ✅ 字段统一为 pageContent
id: doc.metadata?.id ?? "未知",
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.book_id}
章节:第 ${item.chapter_num} 章
内容:${item.content}`
)
.join("\n\n------------\n\n");
const prompt = `你是一个专业的《${BOOK_NAME}》小说助手。基于小说内容回答问题,用准确、详细的语言。
请按照以下《${BOOK_NAME}》小说片段内容回答问题。
小说片段内容:${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();
// ============ 公共打印函数(去重)============
function printDocuments(documents) {
if (documents.length === 0) {
console.log("未找到相关内容");
return false;
}
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 ? "..." : ""
}`
);
});
return true;
}
// ============ 主流程 ============
async function main() {
const question = "阿朱的结局是什么?";
let kArg = 5;
// 导出 mermaid(可选)
const drawable = await graph.getGraphAsync();
const mermaid = await drawable.drawMermaid({ withStyles: true });
console.log(mermaid);
// 连接 Milvus
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 },
},
});
// ✅ indexSearchParams 只设置一次
vectorStore.indexSearchParams = {
metric_type: "COSINE",
params: JSON.stringify({ ef: 64 }),
};
// 加载集合(loadCollection 非 Promise,去掉 await)
try {
vectorStore.client.loadCollection({ collection_name: COLLECTION_NAME });
console.log(`✓ 集合 ${COLLECTION_NAME} 已加载`);
} catch (error) {
if (!error.message.includes("already loaded")) {
throw error;
}
console.log(`✓ 集合 ${COLLECTION_NAME} 已处于加载状态`);
}
// 执行检索 + 生成
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 (!printDocuments(result.documents)) {
console.log("\n【AI 回答】");
console.log(`抱歉,我没有找到相关的《${BOOK_NAME}》内容。`);
return;
}
// 输出 AI 回答
if (!result.generation) {
console.log("\n【AI 回答】");
console.log("模型未返回内容。");
}
}
main();
三.添加条件--RAG
就是大模型是需要搜索 RAG 呢?还是直接给答案的。
比如我们公司里面的 milvue 里存储了很多律师文档,当我搜索:最新婚姻法案例的时候,就应该走公司的 milvue 找到匹配度最好的婚姻案件。如果我搜索:今天杭州的天气咋样?还需要走 RAG 吗?不需要,直接由大模型给我答案就好了。
上面的第一个案例就是所有的搜索都经过RAG的相似度搜索。
js
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,
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 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 {
question: state.question,
k: state.k,
strategy: route.strategy,
routeReason: route.reason,
};
};
const retrieveNode = async (state) => {
//milvus 检索出来的数据
const documents = await retrieveRelevantContent(state.question, state.k);
return {
question: state.question,
k: state.k,
strategy: state.strategy,
routeReason: state.routeReason,
documents,
};
};
const directAnswerNode = async (state) => {
//直接回答模式,不需要检索Milvus检
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) => {
// 大模型利用milvus检索出来的数据生成答案
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) {
console.log("✓ 集合 ebook_collection 已处于加载状态\n");
}
const result = await graph.invoke({
question,
k: Number.isFinite(k) ? k : 5,
strategy: "",
routeReason: "",
documents: [],
generation: "",
});
console.log(`\n最终策略: ${result}`);
}
main()
从graph 的流程图里面可以看出她有2个分支

routeQuestionNode 里面的大模型会根据这句话:

给出是simple,还是complex的回复。
routeQuestionNode 执行结束以后,将值保存到strategy里面存入state里面去。
在执行addConditionalEdges的时候从state里面拿出strategy,判断对应的节点。

四.多跳问答 -- RAG
在 Agent / RAG 领域将一个问题拆分成多个问题,然后一步步作答的过程,叫 Query Decomposition (查询分解),也叫 Multi-hop Question Answering(多跳问答)
把"一步回答不了"的问题,拆成一条推理链,逐跳检索,最后汇总。这个过程就叫多跳问答。
比如: 段誉遇到的第一个神仙姐姐画像,是谁的弟子?
拆分成:段誉遇到的第一个神仙姐姐的画像是谁?他是谁的弟子?
写一个简单的多跳案例
js
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 = new Map();
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);
}
}
return Array.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 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 DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string(),
});
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) {
throw new Error("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) {
throw new Error(`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");
}
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);
});

第一个是条件,第二个是循环 
条件走向的是不同的节点,循环是其中一个分支进入的是上一个节点。
五.网页搜索兜底
js
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) {
throw new Error("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) {
throw new Error(`搜索 API 请求失败(网络错误):${error.message}`);
}
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`搜索 API 请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);
}
let json;
try {
json = await response.json();
} catch (error) {
throw new Error(`搜索结果解析失败:${error.message}`);
}
if (json?.code !== 200 || !json?.data) {
throw new Error(`搜索 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 {
return JSON.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 {
return JSON.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()
这个案例里面去掉了多跳机制,加上一个按照条件语句进入网络搜索兜底的节点
