最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
RAG 联网补救机制:本地知识库覆盖不足如何处理
时间:2026-09-17 12:10:01 编辑:袖梨 来源:一聚教程网
RAG 的检索结果看似相关,并不代表其中真的包含回答所需的信息。当用户的问题超出本地知识库边界时,系统要么拒绝回答,要么可能生成缺乏依据的内容。要改善这一点,需要在生成前评估资料是否充分,并在不足时引入联网搜索,形成可控的补充与再评估流程。
第一部分:先看这道题,暴露了 RAG 的"边界"
1.1 看看作者拿什么问题来测试
const question = `请回答《天龙八部》小说里面"雁门关事件"的主谋是谁, 并说明其儿子的最终解决;
另外请补充: 在《天龙八部》2013版电视剧中, 这段"雁门关事件"主要出现在哪几集?
请给出可核对的来源链接。
`;
这道题故意"刁难"——它其实包含两个完全不同性质的子问题:
| 子问题 | 答案在哪 | 本地向量库能答吗 |
|---|---|---|
| "雁门关事件"主谋是谁?他儿子的结局? | 小说原文里 | ✅ 能(只要书里有这两段) |
| 2013 版电视剧里这段在第几集? | 小说以外的世界(电视剧分集信息) | ❌ 绝对不能 |
第二个问题暴露了 RAG 的致命边界:
向量库里只存了《天龙八部》的小说原文。 小说原文里不可能有"2013 版电视剧第 X 集"这种信息——因为小说是小说,电视剧是电视剧。
不管你把检索做得多么精准、把 k 调得多大、把索引优化到极致,你都不可能从一本小说里检索出电视剧的分集信息。这就是所谓"巧妇难为无米之炊"。
更麻烦的是:如果你硬把这道题丢给纯 RAG,会出现两种情况之一——
- 模型说"资料中没有相关信息"(诚实但没用);
- 模型开始瞎编:"2013 版电视剧第 12 集",看起来很像真的,其实是幻觉。
这两种结果用户都不满意。 用户要的是能回答就回答,答不了就去想办法。
1.2 解法:给 RAG 加一条"联网兜底"的路
思路很直接:如果本地知识库不够,就去互联网上搜。
用户提问
↓
本地向量库检索
↓
【自我评估】现有资料够不够回答问题?
├── 够 → 直接生成回答
└── 不够 → 联网搜索 → 把网上内容也加进来 → 重新评估 → 生成回答
这就是本文件的核心:Web Fallback(联网兜底)。
用生活比喻:你去图书馆查资料(本地检索),翻完发现书里没这块内容(评估发现不足),于是掏出手机上网搜(联网兜底)——最后结合两部分信息给出答案。
1.3 完整的图长什么样
先看 rag-webfallback.mjs 的图结构:
┌── direct_answer ────────────────────────────┐
│ (simple:直接回答) │
START → route_question ┤ END
│ ┌── generate ←─────────┐ │
└── local_retrieve ─→ evaluate_local ──────────┘
↑ │
└─ web_search
用人话说就是:
route_question:先判断问题简单还是复杂;- 简单(simple)→
direct_answer→ 结束; - 复杂(complex)→
local_retrieve查本地知识库; - →
evaluate_local自我评估:"现在这些资料够不够回答?" - 够了 →
generate生成回答; - 不够 →
web_search联网搜索,然后回到evaluate_local再评估一次; - 第二次评估时因为已经有了联网内容,就走
generate→ 结束。
关键在于那个"环":evaluate_local → web_search → evaluate_local。这和上一篇多跳的循环很像,但目的不同——多跳是"一条条查子问题",这里是"查完本地不够就去查网上"。
第二部分:新增的核心概念
2.1 什么是"自我评估"(Self-Evaluation)?
这是本文件最重要的新思想。
传统 RAG 的假设是:检索回来的资料一定有用,直接拿去生成就行了。
但现实中,检索回来的资料可能:
- 完全跑题:问的是电视剧,检索回来一堆小说描写,相关度分数还很高(因为都涉及"雁门关"三个字);
- 只答了一半:回答了"主谋是谁",但没有"他儿子的结局";
- 根本不存在:知识库里确实没这块内容,但检索照样会返回"最像的 5 条"(向量检索永远会返回结果,哪怕全是垃圾)。
所以需要一个"质检环节":
在生成之前,先让模型看一眼资料,判断"这些够不够回答用户的问题?"
这就是 Self-Evaluation(自我评估),也有人叫它 Self-Reflection(自我反思)、Corrective RAG(CRAG,纠正式 RAG)。
它带来两个好处:
- 诚实:资料不够时能主动承认,而不是硬编;
- 可行动:知道"缺什么",就能有针对性地去补(联网搜索)。
2.2 什么是"兜底"(Fallback)?
Fallback 是软件工程里的常见模式:主方案失败时,自动降级到备选方案。
- 数据库主库挂了 → 切从库;
- CDN 节点不可用 → 回源站;
- 本地知识库不够 → 联网搜索。
为什么叫"兜底"而不是"补充"? 因为它的定位是最后一道防线——平时不用(省钱、快),只在主方案力不从心时才触发。
这个设计很省钱:如果每个问题都联网搜,成本高、速度慢。只有评估发现不足时才联网,绝大多数问题走本地就搞定了。
2.3 为什么"评估"要做两次?
看 evaluateNode 里的这段:
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
console.log(hasWeb ? "---EVALUATE_WEB_CONTEXT---" : "---EVALUATE_LOCAL_CONTEXT---");
同一个节点,会执行两次:
| 次数 | 时机 | 输入上下文 | 判断什么 |
|---|---|---|---|
| 第一次 | 本地检索之后 | 只有 localContext | 本地资料够不够?不够就联网 |
| 第二次 | 联网搜索之后 | localContext + webContext | 加上网上内容后,够不够了? |
为什么需要第二次? 因为联网之后,资料状况变了——可能:
- 网上内容补齐了缺口 → 可以生成了;
- 网上内容也没用 → 但不能再无限联网(否则死循环),所以策略是无论够不够都去生成(这个逻辑体现在
afterEvaluateLocal里,下文详述)。
这就是"带循环的自我评估"模式,也是 Agentic RAG 的典型特征。
2.4 新 API:Zod 的 z.boolean() 和 .optional()
上一篇讲过了 z.object()、z.enum()、z.array()、.min()、.max()。这里出现两个新东西:
const EvaluateSchema = z.object({
enough: z.boolean(), // ← 新:布尔值
missing: z.array(z.string()).max(6),
reason: z.string(),
web_query: z.string().optional(), // ← 新:可选字段
});
z.boolean():约束模型返回真正的布尔值 true / false。
为什么这很重要? 如果不用 schema 约束,模型可能返回 "是"、"够"、"yes"、1——这些值做 if 判断时行为完全不同("否" 是 truthy,会误判成"够")。用 z.boolean() 就强制它必须是 true/false,从源头消除歧义。
z.string().optional():这个字段可以不存在。
为什么需要"可选"? 看代码逻辑:
${
hasWeb
? ""
: "web_query: 若不够, 给出一个适合互联网搜索的中文查询语句(完整句, 不用代码: 为空也可)"
}
这是个很聪明的 prompt 技巧:
- 第一次评估(还没联网):需要模型给出
web_query(联网用什么搜索词); - 第二次评估(已经联网了):根本不需要这个字段了,所以 prompt 里根本不提它。
这就是"条件化 prompt(Conditional Prompting)" —— prompt 内容根据运行时状态动态组装。该问的时候问,不该问的时候不问,避免模型输出无用字段、节省 token、减少干扰。
Schema 用 .optional() 和这个技巧是配套的:因为字段可能不存在,schema 必须声明为可选,否则校验会失败。
第三部分:逐段拆解代码
3.1 状态定义(第 8~19 行)
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
// 召回
retrievedDocs: Annotation,
localContext: Annotation, // RAG 上下文
webContext: Annotation, // 网络搜索上下文
evaluation: Annotation, // 评估结果 { enough, missing, reason }
generation: Annotation, // 生成结果
});
字段从多跳版的 12 个精简到了 9 个。重点看这几个新增的:
| 字段 | 作用 |
|---|---|
localContext | 本地检索到的内容拼成的字符串(不是数组!) |
webContext | 联网搜索到的内容拼成的字符串 |
evaluation | 评估结果的 JSON 字符串 |
retrievedDocs | 本地检索的原始数组(带 score、id 等) |
这里有个重要的设计差异:上一篇(多跳)的 documents 是对象数组,而这里用 localContext / webContext 两个字符串。
为什么要这么改? 因为要传给模型的是文本。先在节点里把数组 join 成字符串,后面的生成节点就能直接拼接,逻辑更清晰。而 retrievedDocs 保留原始数组,是为了保留分数、元数据(调试、后续可能的 Rerank 用得上)。
"原始数据"和"给模型看的文本"分开存,是个好习惯。
3.2 路由节点(第 62~92 行)
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}`);
// 可选的, 不需要全部state 的设置
// 为后面的节点提供服务的
return {
strategy: route.strategy,
routeReason: route.reason,
retrievedDocs: [],
localContext: "",
webContext: "",
evaluation: "",
generation: "",
};
};
这是老面孔了——结构化输出做问题分类(simple / complex)。
新增的地方在返回值:它把所有后续要用的字段都初始化了:
retrievedDocs: [],
localContext: "",
webContext: "",
evaluation: "",
generation: "",
为什么要全部初始化? 这是防御性编程。因为后面有节点会读 state.webContext:
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
如果 webContext 是 undefined 而不是 "",String(undefined) 会变成字符串 "undefined"——它有 9 个字符,是 truthy! 于是 hasWeb 会错误地变成 true,程序会误以为"已经联网搜过了",从而永远跳过联网搜索。
这个坑非常隐蔽,值得单独记住:String(undefined) === "undefined" 是 truthy。所以用 String(x).trim() 做空值判断前,必须先确保 x 有合法初始值。
(代码里用 state.webContext && ... 先做了个短路判断来缓解,但初始化才是根本解法。)
3.3 本地检索节点(第 113~122 行)
const retrieveLocalNode = async (state) => {
console.log("---LOCAL_RETRIEVE---");
const retrieveDocs = await retrieveRelevantContent(state.question, state.k);
console.log(`本地检索命中:${retrieveDocs.length}条`);
const localContext = (retrieveDocs ?? []).map((d) => d.content).join("nn");
return {
retrieveDocs,
localContext,
};
};
简单清晰,两步:
- 检索:
retrieveRelevantContent(question, k)—— 用similaritySearchWithScore拿回带分数的片段数组; - 拼成文本:
.map(d => d.content).join("nn")—— 把每条的content抽出来,用两个换行连接成一个长字符串。
为什么用 nn 而不是 n? 因为双换行在文本里代表"段落分隔"。拼给模型看时,段落清晰,模型更容易区分"这是两段不同的资料",而不是看成连续的一整段话。
⚠️ 这里有个字段名不一致的问题:状态里声明的是
retrievedDocs(第 14 行),但节点返回的是retrieveDocs(少了d)。因为少了一个字母,
state.retrievedDocs永远不会被赋值。虽然当前代码没用到它(生成节点用的是localContext),所以没造成功能性 bug,但这属于典型的拼写型隐患——一旦以后有节点读retrievedDocs,就会拿到undefined。教训:这类 bug 靠肉眼很难发现,最好用 TypeScript 或统一常量名来避免。
3.4 评估节点(第 124~164 行)—— 全文最有价值的部分
① Schema
const EvaluateSchema = z.object({
enough: z.boolean(), // 是否足够生成 / 是否需要联网搜索
missing: z.array(z.string()).max(6), // 上下文缺的方面
reason: z.string(),
web_query: z.string().optional(), // 可选的联网搜索关键词
});
四个字段的职责:
enough:够不够(核心决策依据);missing:缺什么(这个字段很有价值,能帮你知道知识库的覆盖盲区);reason:为什么这么判断(可解释性);web_query:如果不够,联网该搜什么(把"检索词生成"交给模型)。
② 判断是第几次评估
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
console.log(hasWeb ? "---EVALUATE_WEB_CONTEXT---" : "---EVALUATE_LOCAL_CONTEXT---");
Boolean(x):把任意值转成真/假。空字符串""→false,非空 →true;String(x).trim():转成字符串并去掉首尾空白。为什么需要.trim()? 因为" "(纯空格)虽然不是空字符串,但实质上没内容。.trim()后变成"",Boolean("")就是false。这是严谨的空值判断。
这就是"同一个节点,两种行为"的实现方式——通过检查 webContext 有没有内容,判断这是第一次还是第二次评估。
③ 组装 prompt(条件化拼装)
const out = await evaluator.invoke(`
你是信息充分性评估器。判断当前上下文是否足以回答用户问题。
用户问题: ${state.question}
已检索上下文(来自本地知识库)
: ${state.localContext || "(空)"}
${hasWeb ? `联网搜素结果:n ${state.webContext || "(空)"}` : ""}
输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够, 列出缺失信息点(最多6条)
- reason: 简短原因
${
hasWeb
? ""
: "web_query: 若不够, 给出一个适合互联网搜索的中文查询语句(完整句, 不用代码: 为空也可)"
}
`);
这段 prompt 有三个细节值得学:
| 技巧 | 代码 | 为什么 |
|---|---|---|
| 空值占位 | ${state.localContext || "(空)"} | 如果上下文是空串,显示 (空) 而不是什么都不显示。明确告诉模型"这里确实没有内容",而不是让它猜 |
| 条件插入联网结果 | ${hasWeb ? 联网结果:n...` : ""}` | 没联网时不出现这一段,避免模型困惑 |
| 条件插入字段说明 | 最后那个 hasWeb ? "" : "web_query: ..." | 已经联网了就不需要再生成搜索词了 |
"明确告诉模型这里是空的"这一点特别重要。 如果 prompt 里是 已检索上下文: 后面什么都没有,模型可能误以为"没写就是没有要求";写成 (空) 就明确传达了"没有可用资料"这个事实,模型更容易做出正确判断。
④ 结果处理
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),
};
- 打印决策和理由:调试必备;
- 打印缺失点:这其实是知识库运营的金矿——如果某个"缺失信息点"反复出现,说明知识库该补这块内容了;
JSON.stringify(out)存进状态:把对象序列化成字符串。
这里有个可以商榷的设计:为什么要
JSON.stringify存,而不是直接存对象?LangGraph 的 state 本来就可以存任意 JS 对象。转成字符串后,路由函数又得
JSON.parse回去:const parsed = (() => { try { return JSON.parse(state.evaluation || "{}"); } catch { return {}; } })();这样一进一出,多了序列化开销,还引入了"解析失败"的可能(所以才要包
try/catch)。更简单的做法是直接存对象,路由函数里state.evaluation?.enough就完事了。什么情况下该存字符串? 只有当状态需要跨进程传递(比如持久化到数据库、通过 API 传输)时,才需要序列化。纯内存流转的图,没必要。
这个"过度序列化"是很好的面试讨论点——能看出你是否理解"数据结构该在原样使用时保持原样"。
3.5 联网搜索(第 217~292 行)—— 第三方 API 实战
这部分是全新的内容:调用外部搜索 API。
① 封装搜索函数
async function bochaWebSearch(query, count) {
const apiKey = process.env.BOCHA_API_KEY;
if (!apiKey) {
throw new Error("BOCHA_API_KEY 未配置 (环境变量BOCHA_API_KEY)");
}
const url = "https://api.bochaai.com/v1/web-search";
const body = {
query,
freshness: "noLimit",
summar: true, // 返回的内容, 做个总结
count: count ?? 10,
};
博查(Bocha) 是一个国内的 AI 搜索 API 服务,专为给大模型提供联网能力设计。
| 参数 | 含义 |
|---|---|
query | 搜索关键词 |
freshness: "noLimit" | 时间范围,不限时间 |
summar: true | 让接口返回摘要(而不是只有标题和链接) |
count: count ?? 10 | 返回条数,默认 10 |
两个细节:
if (!apiKey) throw new Error(...):启动前检查配置。缺 key 就直接报错,并在错误信息里写清楚是哪个环境变量——这样排查问题不用翻代码。这是很好的可维护性实践。count: count ?? 10:??空值兜底,没传就用 10。
小提醒:
summar这个参数名看着像是summary的笔误。接第三方 API 时,参数名必须严格按官方文档写——写错了通常不会报错(服务端会忽略未知参数),只是行为不符合预期(比如拿不到摘要)。所以调新 API 时,一定要对着文档核对参数名。
② 发请求
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(`BOCHA 搜索失败: ${error.message}`);
}
fetch 是 Node 18+ 内置的网络请求 API(不需要装 axios 之类的库)。
关键点:
method: "POST":搜索请求用 POST(body 里传查询参数);- **
Authorization:Bearer ${apiKey}`**:**Bearer Token 认证**,行业标准写法。格式是Bearer ` + 空格 + 密钥,不能漏掉空格; Content-Type: "application/json":告诉服务端"我发的是 JSON",不写的话服务端可能解析不了 body;body: JSON.stringify(body):把 JS 对象序列化成 JSON 字符串发送;try/catch包住 fetch:这层 catch 抓的是网络层错误(域名解析失败、连不上、超时),不是 HTTP 错误码(4xx/5xx 不会让 fetch 抛错)。
这个区分是高频面试点:fetch 只有在网络层失败时才 reject;服务器返回 404、500 时,fetch 正常 resolve,需要你自己检查 response.ok。
③ 检查 HTTP 状态码
// 先处理失败
// ok 200 语义化更好
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`搜索API 请求失败, 状态码:${response.status},
错误信息:${errorText}`);
}
response.ok:true表示状态码在 200~299 之间。用ok比手写status === 200更语义化(能正确处理 201、204 等成功码);response.text():把响应体读成文本(错误响应通常不是 JSON,直接text()更安全,不然会抛解析错误);.catch(() => ""):万一连读文本都失败,就退化成空字符串,保证不会因为读错误信息而抛出第二个异常,掩盖真正的错误。这个防御很细致。
④ 解析 JSON
let json;
try {
json = await response.json();
} catch (err) {
throw new Error(`搜索结果解析失败: ${err.message}`);
}
console.log(json, "/////////");
const webpages = json.data.webPages?.value ?? [];
response.json():把响应体解析成 JS 对象;- 单独的
try/catch:把"解析失败"和"请求失败"分开报错,这样错误信息能精确指向问题所在。分层处理异常是好习惯; json.data.webPages?.value ?? []:逐层安全取值。用?.防止中间某一层不存在时报错,用?? []保证最终一定是数组(后面要.map,undefined会崩)。
⑤ 格式化结果
if (webpages.length) return "未找到相关结果。";
return webpages
.map(
(page, idx) => `引用: ${idx + 1}
标题: ${page.name}
URL: ${page.url}
摘要: ${page.summary}
网站名称: ${page.siteName}
网站图标: ${page.siteIcon}
发布时间: ${page.dataLastCrawled}
`,
)
.join("nn");
}
把每条搜索结果格式化成带编号、标题、URL、摘要、来源的文本块,再拼接起来。
注意 URL 和站点名是故意保留的——因为 prompt 里要求模型"给出可核对的来源链接",有了 URL,模型才能引用出处。这就是 RAG 的可溯源能力。
? 这里有个严重 bug——判断条件写反了!
if (webpages.length) return "未找到相关结果。";这句话的意思是:"如果搜索结果条数大于 0(也就是搜到了东西),就返回『未找到相关结果』"。
完全反了! 正确写法应该是:
if (!webpages.length) return "未找到相关结果。"; // 没有结果时才说"未找到"后果:只要联网搜索成功返回了结果,函数就直接返回"未找到相关结果。",下面那段精心拼装的引用文本永远不会被执行。
这直接废掉了整个联网兜底功能——联网辛辛苦苦搜到的内容,全被这一行丢掉了,最终送给模型的
webContext永远是"未找到相关结果。"。这个 bug 的教训:
if (arr.length)和if (!arr.length)只差一个感叹号,语义完全相反,而且不会报错——代码照跑,只是结果是错的。这类"静默的逻辑反转"是最难排查的 bug 类型之一。 写条件判断时,建议把意图用注释写出来(// 没有结果时才返回提示),让 review 的人能对照检查。
⑥ 联网搜索节点
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 };
};
逐行看:
(() => { ... })():立即执行函数表达式(IIFE)。作用是把try/catch的结果赋值给变量。因为try/catch是语句不是表达式,没法直接写const x = try {...},所以用 IIFE 包一层;JSON.parse(state.evaluation || "{}"):把评估结果字符串解析回对象。|| "{}"保证空值也能解析(JSON.parse("")会抛错);catch { return {} }:解析失败就返回空对象兜底(注意这里catch不写参数,是 ES2019 的"可选 catch 绑定",因为用不到错误对象);(parsed.web_query ?? "").trim() || state.question:三层兜底,非常值得学:parsed.web_query ?? ""→ 字段不存在就用空串;.trim()→ 去掉首尾空白;|| state.question→ 如果最后是空字符串,就退化成用原始问题去搜。
web_query就搜了个空字符串。这是很成熟的防御式写法。bochaWebSearch(query, 8):实际搜索,取 8 条;return { webContext }:把结果写进状态,供评估节点和生成节点使用。
3.6 生成节点(第 166~198 行)
const generateNode = async (state) => {
console.log("---GENERATE---");
const context = [state.localContext, state.WebContext]
.filter(Boolean)
.join("nn"); // Boolean 函数
这里有一个致命的大小写 bug:
state.WebContext // ← 大写 W!
状态里定义的字段名是 webContext(小写 w),这里写成了 WebContext。JavaScript 的属性和变量名严格区分大小写,所以 state.WebContext 是 undefined。
后果:[state.localContext, undefined].filter(Boolean) → undefined 被过滤掉 → 最终 context 里只有本地内容,联网搜到的内容被完全丢弃了。
这直接废掉了联网兜底的核心价值——辛辛苦苦联网搜到的资料,最后一步没被用上。
这个 bug 和 3.5 节那个"判断反了"是双重打击:即使修好了搜索函数,这里还是会把联网结果丢掉。
process.stdout.write("n[AI 回答 (流式) ]n");
const stream = await llm.stream(`
你是一个严谨的中文问答助手。
优先依据上下文回答,不要编造
上下文(本地知识库 + 可选互联网补充)
${context || "(空)"}
用户问题: ${state.question}
回答要求:
1. 如果上下文足够, 给出清晰, 可核对的回答: 需要时引用: n / URL
"或者说明小说片段来支撑。"
2. 如果上下文仍不满足以确定关键事实, 明确说明"不确定/无法从上下文确认",
并说明缺失点。
3. 不要输出表情符号。
回答:
`);
let generation = "";
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");
};
prompt 设计的三个亮点:
| 要求 | 作用 |
|---|---|
| "优先依据上下文回答,不要编造" | 抑制幻觉的根本指令 |
| "如果上下文仍不足以确定关键事实,明确说明不确定" | 允许模型说"我不知道"——这非常重要。很多幻觉是因为模型觉得"必须给个答案" |
| "需要时引用 n / URL" | 要求可溯源,用户能核对 |
| "不要输出表情符号" | 输出格式约束(可能用于后续展示) |
特别强调第 2 条:给模型一条"退路"是抑制幻觉最有效的 prompt 技巧。如果你只说"要准确回答",模型会倾向于编一个看起来准确的答案;如果你明确说"不确定就说不确定",它才会诚实地承认。
流式输出部分和前几篇一样:llm.stream + for await + typeof 判断 + 累积 generation。
? 又一个问题:这个函数没有
return!函数在
process.stdout.write("n")之后就结束了,没有返回任何值。所以:
- 状态里的
generation永远不会被更新;main()里result.generation拿到的是初始值"";if (result.generation?.trim())永远为假 → 最后什么都不会打印。虽然流式输出已经把内容打印到控制台了(用户能看见),但图的状态里没有最终答案。如果你要把结果存数据库、返回给前端、做后续处理,就拿不到东西了。
正确写法:
return { generation };这是"节点必须返回状态增量"这个规则的又一次违反——和上一篇多跳里的问题一样。可见这个坑很容易犯。
3.7 两个路由函数(第 200~215 行)
① 第一个路由:简单问题直接答
const afterRoute = (state) =>
state.strategy === "simple" ? "direct_answer" : "local_retrieve";
读 strategy(值域是 "simple" / "complex",由 z.enum 限定),返回目标节点名。这里是对的——因为 strategy 的值域里确实有 "simple"。
② 第二个路由:评估后决定去生成还是联网
const 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";
};
逻辑拆成两步:
- 如果已经有
webContext(联网搜过了)→ 直接generate。这一步是"防死循环"的关键——保证联网最多只搜一次,不会无限循环; - 否则(还没联网)→ 解析评估结果,
enough === true就去生成,否则去联网。
注意 parsed.enough === true 这个写法:
return parsed.enough === true ? "generate" : "web_search";
这里用的是严格等于 true,不是 if (parsed.enough)。为什么?
因为如果解析失败(返回 {}),parsed.enough 是 undefined——用 if (undefined) 判断会走 else 分支(去联网),这恰好是安全的默认行为。用 === true 更显式、更严格:只有明确为 true 才去生成,任何其他情况(undefined、"true" 字符串、1)都会保守地选择联网。
这个写法在安全敏感的场景很重要——默认保守、默认多做一步验证,而不是乐观放行。
不过要注意一个潜在风险:这个"防死循环"依赖于 webContext 一定非空。假如联网搜索返回了空字符串(比如搜索函数有 bug、或者 API 返回了空结果),那么:
state.webContext是""→ 第一步判断为假;- 评估结果还是
enough: false→ 返回web_search; - 又去联网 → 又是空 → 无限循环!
更稳妥的做法是加一个计数器(就像多跳版里的 maxRetrievals):
webSearchCount: Annotation, // 状态里加个计数
// 路由函数里
if ((state.webSearchCount ?? 0) >= 1) return "generate";
"循环必须有次数上限"是 Agent 开发的铁律——永远不要完全依赖"某个条件一定成立"来终止循环。这和多跳版里 maxRetrievals 的设计思路完全一致。
3.8 组装图(第 294~314 行)
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("local_retrieve", retrieveLocalNode)
.addNode("evaluate_local", evaluateNode)
.addNode("generate", generateNode)
.addNode("web_search", webSearchNode)
.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();
核心就是这一行:
.addEdge("web_search", "evaluate_local") // 联网后回到评估节点
这条边让流程形成了"评估 → 联网 → 再评估"的环:
local_retrieve → evaluate_local → (web_search → evaluate_local) → generate
注意它和上一篇多跳的环的区别:
| 多跳的环 | 本文的环 | |
|---|---|---|
| 位置 | retrieve ↔ plan_next_step | evaluate_local ↔ web_search |
| 循环依据 | 还有子问题没查完 | 本地资料不够 |
| 循环次数 | 可能多轮(受 maxRetrievals 限制) | 最多 1 轮(webContext 非空就退出) |
| 目的 | 逐条补齐信息 | 补充外部信息 |
共同点:都是"循环 + 条件退出"结构,这正是 Agentic RAG 的典型形态。
3.9 运行入口(第 316~372 行)
① 可视化
const graphStructure = await graph.getGraph();
const mermaid = graphStructure.drawMermaid({ withState: true });
console.log("===== Mermaid流程图代码 =====");
console.log(mermaid);
和前两篇一样,生成 Mermaid 流程图代码,贴到支持 Mermaid 的编辑器里就能看到图。这是调试复杂流程最直观的手段——尤其本次有环,肉眼读代码很容易绕晕,看图一目了然。
② 连接 Milvus
vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: "ebook_collection",
url: "http://localhost:19530",
textFields: "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 }), // ← 注意这里
};
和上一篇(多跳)对比,这里有三处参数名不一致:
| 本文写法 | 上一篇(多跳)写法 | 说明 |
|---|---|---|
textFields | textField | 单复数不同 |
params | param | 单复数不同 |
这些是不一致的地方,很可能其中一个是错的。 LangChain 的 Milvus 集成里,这些参数名有明确的规范——接第三方库时,参数名必须按库的实际定义来写。
如果写错了会怎样? 常见后果是:
- 不报错,但配置没生效(比如索引参数被忽略,用了默认值)——最危险的情况;
- 或者报"未知参数"错误。
排查建议:遇到"配置看起来对但效果不对"时,先去查库的源码或官方文档核对参数名,而不是反复调数值。
loadCollection 部分:
try {
await vectorStore.client.loadCollection({ collection_name: "ebook_collection" });
console.log(`已加载集合`);
} catch (error) {
console.error(`集合已经处于加载状态`); // ← 这里退化了
}
⚠️ 注意这个
catch相比上一篇是"退化"了。上一篇(多跳)的写法是:
} catch (err) { if (!err.message.includes("already loaded")) { throw err; // ← 其他错误照抛 } console.log(`集合已处于加载状态`); }而这里无脑把所有错误都当成"已加载"——如果真的是"连不上 Milvus"、"集合不存在",也会被打印成"集合已经处于加载状态",真实故障被完全掩盖。
这是典型的"过度容错"反模式:为了让程序"看起来不报错",把错误全吞了。结果是排查问题时毫无线索。
好实践回顾:只忽略可预期且无害的那一种错误(
already loaded),其他错误必须暴露。
③ 执行
const result = await graph.invoke({
question,
k,
strategy: "",
routeReason: "",
retrieveDocs: [],
localContext: "",
webContext: "",
evaluation: "",
generation: "",
});
if (result.generation?.trim()) {
console.log(result.generation);
}
}
main();
注意两个问题:
- 初始状态里的字段名是
retrieveDocs(和retrieveLocalNode的返回一致),但状态定义里叫retrievedDocs——三处名字不统一,很混乱。 main()没有.catch()——前两篇都有main().catch((err) => console.error(err.message))。这里如果出错,会抛出未处理的 Promise rejection(新版本 Node 会直接让进程崩溃并打印一长串堆栈)。result.generation?.trim()永远为假(因为生成节点没return),所以这行判断等于死代码。
这三处合起来说明:这个文件是快速迭代的产物,思路和架构是对的,但细节打磨不足。
第四部分:四个文件的演进对比
| 对比项 | naive-rag | rag-query-router | rag-multihop | rag-webfallback |
|---|---|---|---|---|
| 图结构 | 直线 | 分叉 | 带环 | 带环 |
| 节点数 | 2 | 4 | 6 | 6 |
| 核心能力 | 跑通流程 | 按需检索 | 多跳拆解 | 联网兜底 |
| 决策点 | 无 | 1 | 3 | 2 |
| 循环目的 | 无 | 无 | 逐条查子问题 | 补外部信息 |
| 外部依赖 | Milvus | Milvus | Milvus | Milvus + 搜索 API |
| 解决什么问题 | 幻觉 | 成本 | 复杂问题 | 知识边界 |
| 关键新 API | StateGraph | structuredOutput | z.array | fetch / response.ok |
四个文件恰好覆盖了 RAG 的四个核心难题:
朴素版:怎么把流程跑起来? → 基础架构
路由版:每个问题都要检索吗? → 成本优化
多跳版:一个问题答不出来怎么办? → 拆解推理
联网版:知识库里根本没有怎么办? → 知识边界
演进脉络:从"能跑"到"跑得省"到"跑得对"到"跑得全"。
这也正是 Agentic RAG 的完整思想:模型不只是回答机器,而是会判断、会拆解、会求助、会承认不足的智能体。
第五部分:API 速查表
本篇新增的 API
| API | 作用 | 面试要点 |
|---|---|---|
z.boolean() | 约束布尔值 | 避免 "是"/"否" 这类歧义值做判断出错 |
z.string().optional() | 可选字段 | 配合条件化 prompt 使用 |
fetch(url, options) | Node 内置网络请求 | 只在网络层失败时 reject,4xx/5xx 不抛错 |
fetch 的 method/headers/body | 请求配置 | Content-Type 必写,body 要 JSON.stringify |
Authorization: Bearer xxx | Token 认证 | Bearer 后必须有空格 |
response.ok | 状态码 200~299 | 比 status === 200 更语义化 |
response.text() | 读成文本 | 错误响应用它更安全 |
response.json() | 解析 JSON | 单独 try/catch 精确报错 |
Boolean(x) | 转布尔 | - |
String(x).trim() | 去空白 | String(undefined) 是 "undefined"(truthy)! |
Array.prototype.filter(Boolean) | 过滤假值 | 一行去掉 undefined/""/null |
(() => {...})() IIFE | 立即执行函数 | 把 try/catch 语句的结果赋给变量 |
catch { ... } | 可选 catch 绑定 | ES2019,不用错误对象时可省略参数 |
前几篇的核心 API(复习)
| API | 作用 |
|---|---|
Annotation.Root({...}) | 声明共享状态 |
addNode / addEdge / addConditionalEdges | 节点 / 固定边 / 条件边 |
START / END / compile() / invoke() | 入口 / 出口 / 编译 / 执行 |
getGraph() / drawMermaid() | 流程图可视化 |
withStructuredOutput(schema) | 结构化输出 |
similaritySearchWithScore(q, k) | 带分数相似度检索 |
llm.stream(prompt) | 流式生成 |
Milvus.fromExistingCollection() + loadCollection | 连库 + 加载集合 |
z.object / z.enum / z.array / .min / .max | Schema 约束 |
第六部分:容易踩的坑(本篇重点)
坑 1:属性名大小写写错(最隐蔽)
const context = [state.localContext, state.WebContext] // ✗ 大写 W
JavaScript 严格区分大小写,state.WebContext 是 undefined。不会报错,只是联网内容静默丢失。
对策:
- 用 TypeScript(编译期就报错);
- 或者用
const { webContext } = state解构(写错名字会变成 undefined 变量,更容易发现); - 或者定义常量统一引用字段名。
这类 bug 的可怕之处:代码能跑、日志正常、不报错,只是结果不对。
坑 2:条件判断写反(静默的逻辑反转)
if (webpages.length) return "未找到相关结果。"; // ✗ 应该是 !webpages.length
if (arr.length) 和 if (!arr.length) 语义完全相反,且都不报错。
对策:把意图写成注释(// 没有结果时才提示),review 时对照检查。遇到"功能完全没效果"的情况,优先检查条件判断是不是反了。
坑 3:节点忘记 return
const generateNode = async (state) => {
// ... 流式输出
process.stdout.write("n");
}; // ✗ 没有 return,state.generation 永远是初始值
用户在控制台能看到输出,但状态里没有结果——导致后续无法持久化、无法返回给调用方。
对策:每个节点都要检查有没有 return。节点的职责是"返回状态增量",不做这个就等于白跑。
坑 4:循环没有次数上限
afterEvaluateLocal 靠"webContext 非空"来终止循环。如果联网返回空字符串(搜索函数有 bug、API 返回空),就会无限循环。
对策:加计数器(像多跳版的 maxRetrievals)。"循环必须有硬上限"是铁律——不要依赖"某个条件必然成立"。
坑 5:过度容错,把真实错误也吞了
} catch (error) {
console.error(`集合已经处于加载状态`); // ✗ 所有错误都当成"已加载"
}
"连不上数据库"也会被打印成"已加载",真实故障被掩盖。
对策:只忽略可预期、无害的错误(already loaded),其他必须 throw。容错要精准,不能无差别。
坑 6:字段名前后不一致
状态里叫 retrievedDocs,节点返回 retrieveDocs,invoke 初始值又是 retrieveDocs。三处不一致。
对策:用 TypeScript 定义 state 的类型,或用常量管理字段名。字段名是"契约",必须全局一致。
坑 7:把 String(undefined) 当成空值
String(undefined) // → "undefined"(9 个字符,truthy!)
Boolean(String(undefined).trim()) // → true(错!)
对策:
- 初始化:在路由节点里把所有字段初始化(本文件做对了);
- 判断前先检查
null/undefined:state.webContext != null && String(...).trim()。
坑 8:区分不了网络错误和 HTTP 错误
fetch 只在网络层失败时 reject。服务器返回 500,fetch 正常 resolve,必须自己检查 response.ok。
对策:两层检查——try/catch 抓网络错误 + if (!response.ok) 抓 HTTP 错误。(本文件这点做对了。)
坑 9:第三方 API 参数名写错
textFields vs textField、params vs param、summar vs summary——这些不一致可能是笔误。
对策:接第三方 API 时严格对照官方文档。参数名写错往往不报错、只是不生效。遇到"配置对但行为不对",第一件事就是核对参数名。
坑 10:过度序列化
把评估结果 JSON.stringify 存进状态,路由函数又 JSON.parse 回来,还得包 try/catch。
对策:纯内存流转的状态,直接存对象。只有需要跨进程传递或持久化时才序列化。
第七部分:动手实践建议
第一步:先体验"联网兜底"的完整流程
关键是要用对测试问题——作者选的那道题就很巧妙:既包含本地能答的(小说情节),又包含本地不可能答的(电视剧集数)。
const question = `请回答《天龙八部》小说里面"雁门关事件"的主谋是谁, 并说明其儿子的最终解决;
另外请补充: 在《天龙八部》2013版电视剧中, 这段"雁门关事件"主要出现在哪几集?
请给出可核对的来源链接。`;
跑起来后重点看日志顺序:
___ROUTE-QUESTION___ ← 路由(应该是 complex)
---LOCAL_RETRIEVE--- ← 本地检索
本地检索命中:8条
---EVALUATE_LOCAL_CONTEXT--- ← 第一次评估
评估: enough=false (...) ← 不够!
缺失信息点1: 2013版电视剧分集信息
---WEB_SEARCH--- ← 触发联网
联网查询: 天龙八部 2013版 雁门关事件 第几集
联网结果长度: xxx
---EVALUATE_WEB_CONTEXT--- ← 第二次评估
---GENERATE--- ← 生成
能亲眼看到"本地不够 → 联网 → 再评估 → 生成"这条链路,你就真的理解兜底机制了。
第二步:修好那几个 bug,对比效果
按这个顺序修,逐项验证:
- 修
if (webpages.length)→if (!webpages.length),观察webContext长度是否从"未找到相关结果。"变成真实内容; - 修
state.WebContext→state.webContext,观察最终回答里是否出现了联网信息; - 给
generateNode加return { generation },观察result.generation是否有内容; - 修
retrieveDocs/retrievedDocs命名,统一成retrievedDocs; - 修
catch里的无脑吞异常,只忽略already loaded。
修完前后对比,你会对"静默 bug 有多可怕"有深刻体会。
第三步:做实验,理解评估机制
-
只问本地能答的问题:
const question = "乔峰是哪个门派的帮主?";观察:
enough=true→ 直接生成,不联网。这验证了兜底的"按需触发"特性。 -
只问本地不可能答的问题:
const question = "2024年诺贝尔文学奖得主是谁?";观察:本地检索可能返回一堆无关的小说片段(向量检索永远会返回结果!),评估判定
enough=false→ 联网。 -
观察
missing字段的积累:多问几个不同问题,看看"缺失信息点"都集中在哪。这就是知识库的补充清单。 -
改评估 prompt:把评估器写得更严格("必须能直接回答才算够")或更宽松,观察联网触发频率的变化。
第四步:进阶优化方向
- 加联网次数上限:状态里加
webSearchCount,路由函数里判断上限——防止潜在死循环。 - 加搜索结果过滤:联网回来的内容可能与问题无关,加一层评估或过滤。
- 本地和联网结果分开标注:在 context 里明确标记"【本地】...【联网】...",让模型知道哪些是小说原文、哪些是网上信息,引用时更准确。
- 多路搜索并行:同时调多个搜索 API,取交集或并集,提升覆盖。
- 结果缓存:同样的
web_query缓存结果,避免重复请求(省钱、提速)。 - 加 Rerank:本地和联网结果合并后用 cross-encoder 精排,再进生成。