一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

RAG 联网补救机制:本地知识库覆盖不足如何处理

时间:2026-09-17 12:10:01 编辑:袖梨 来源:一聚教程网

RAG 的检索结果看似相关,并不代表其中真的包含回答所需的信息。当用户的问题超出本地知识库边界时,系统要么拒绝回答,要么可能生成缺乏依据的内容。要改善这一点,需要在生成前评估资料是否充分,并在不足时引入联网搜索,形成可控的补充与再评估流程。

第一部分:先看这道题,暴露了 RAG 的"边界"

1.1 看看作者拿什么问题来测试

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

这道题故意"刁难"——它其实包含两个完全不同性质的子问题:

子问题答案在哪本地向量库能答吗
"雁门关事件"主谋是谁?他儿子的结局?小说原文里✅ 能(只要书里有这两段)
2013 版电视剧里这段在第几集?小说以外的世界(电视剧分集信息)绝对不能

第二个问题暴露了 RAG 的致命边界

向量库里只存了《天龙八部》的小说原文。 小说原文里不可能有"2013 版电视剧第 X 集"这种信息——因为小说是小说,电视剧是电视剧。

不管你把检索做得多么精准、把 k 调得多大、把索引优化到极致,你都不可能从一本小说里检索出电视剧的分集信息。这就是所谓"巧妇难为无米之炊"。

更麻烦的是:如果你硬把这道题丢给纯 RAG,会出现两种情况之一——

  1. 模型说"资料中没有相关信息"(诚实但没用);
  2. 模型开始瞎编:"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

用人话说就是:

  1. route_question:先判断问题简单还是复杂;
  2. 简单(simple)→ direct_answer → 结束;
  3. 复杂(complex)→ local_retrieve 查本地知识库;
  4. evaluate_local 自我评估:"现在这些资料够不够回答?"
  5. 够了generate 生成回答;
  6. 不够web_search 联网搜索,然后回到 evaluate_local 再评估一次
  7. 第二次评估时因为已经有了联网内容,就走 generate → 结束。

关键在于那个"环"evaluate_local → web_search → evaluate_local。这和上一篇多跳的循环很像,但目的不同——多跳是"一条条查子问题",这里是"查完本地不够就去查网上"。


第二部分:新增的核心概念

2.1 什么是"自我评估"(Self-Evaluation)?

这是本文件最重要的新思想。

传统 RAG 的假设是:检索回来的资料一定有用,直接拿去生成就行了。

但现实中,检索回来的资料可能:

  • 完全跑题:问的是电视剧,检索回来一堆小说描写,相关度分数还很高(因为都涉及"雁门关"三个字);
  • 只答了一半:回答了"主谋是谁",但没有"他儿子的结局";
  • 根本不存在:知识库里确实没这块内容,但检索照样会返回"最像的 5 条"(向量检索永远会返回结果,哪怕全是垃圾)。

所以需要一个"质检环节"

在生成之前,先让模型看一眼资料,判断"这些够不够回答用户的问题?"

这就是 Self-Evaluation(自我评估),也有人叫它 Self-Reflection(自我反思)Corrective RAG(CRAG,纠正式 RAG)

它带来两个好处

  1. 诚实:资料不够时能主动承认,而不是硬编;
  2. 可行动:知道"缺什么",就能有针对性地去补(联网搜索)。

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

如果 webContextundefined 而不是 ""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,
  };
};

简单清晰,两步:

  1. 检索retrieveRelevantContent(question, k) —— 用 similaritySearchWithScore 拿回带分数的片段数组;
  2. 拼成文本.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.oktrue 表示状态码在 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 ?? []逐层安全取值。用 ?. 防止中间某一层不存在时报错,用 ?? [] 保证最终一定是数组(后面要 .mapundefined 会崩)。

⑤ 格式化结果

  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三层兜底,非常值得学
    1. parsed.web_query ?? "" → 字段不存在就用空串;
    2. .trim() → 去掉首尾空白;
    3. || 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),这里写成了 WebContextJavaScript 的属性和变量名严格区分大小写,所以 state.WebContextundefined

后果[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";
};

逻辑拆成两步

  1. 如果已经有 webContext(联网搜过了)→ 直接 generate这一步是"防死循环"的关键——保证联网最多只搜一次,不会无限循环;
  2. 否则(还没联网)→ 解析评估结果,enough === true 就去生成,否则去联网。

注意 parsed.enough === true 这个写法

return parsed.enough === true ? "generate" : "web_search";

这里用的是严格等于 true,不是 if (parsed.enough)。为什么?

因为如果解析失败(返回 {}),parsed.enoughundefined——用 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_stepevaluate_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 }),          // ← 注意这里
};

和上一篇(多跳)对比,这里有三处参数名不一致

本文写法上一篇(多跳)写法说明
textFieldstextField单复数不同
paramsparam单复数不同

这些是不一致的地方,很可能其中一个是错的。 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();

注意两个问题

  1. 初始状态里的字段名是 retrieveDocs(和 retrieveLocalNode 的返回一致),但状态定义里叫 retrievedDocs——三处名字不统一,很混乱。
  2. main() 没有 .catch()——前两篇都有 main().catch((err) => console.error(err.message))。这里如果出错,会抛出未处理的 Promise rejection(新版本 Node 会直接让进程崩溃并打印一长串堆栈)。
  3. result.generation?.trim() 永远为假(因为生成节点没 return),所以这行判断等于死代码

这三处合起来说明:这个文件是快速迭代的产物,思路和架构是对的,但细节打磨不足。


第四部分:四个文件的演进对比

对比项naive-ragrag-query-routerrag-multihoprag-webfallback
图结构直线分叉带环带环
节点数2466
核心能力跑通流程按需检索多跳拆解联网兜底
决策点132
循环目的逐条查子问题补外部信息
外部依赖MilvusMilvusMilvusMilvus + 搜索 API
解决什么问题幻觉成本复杂问题知识边界
关键新 APIStateGraphstructuredOutputz.arrayfetch / response.ok

四个文件恰好覆盖了 RAG 的四个核心难题

朴素版:怎么把流程跑起来?        → 基础架构
路由版:每个问题都要检索吗?      → 成本优化
多跳版:一个问题答不出来怎么办?  → 拆解推理
联网版:知识库里根本没有怎么办?  → 知识边界

演进脉络:从"能跑"到"跑得省"到"跑得对"到"跑得全"。

这也正是 Agentic RAG 的完整思想:模型不只是回答机器,而是会判断、会拆解、会求助、会承认不足的智能体


第五部分:API 速查表

本篇新增的 API

API作用面试要点
z.boolean()约束布尔值避免 "是"/"否" 这类歧义值做判断出错
z.string().optional()可选字段配合条件化 prompt 使用
fetch(url, options)Node 内置网络请求只在网络层失败时 reject,4xx/5xx 不抛错
fetchmethod/headers/body请求配置Content-Type 必写,body 要 JSON.stringify
Authorization: Bearer xxxToken 认证Bearer 后必须有空格
response.ok状态码 200~299status === 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 / .maxSchema 约束

第六部分:容易踩的坑(本篇重点)

坑 1:属性名大小写写错(最隐蔽)

const context = [state.localContext, state.WebContext]   // ✗ 大写 W

JavaScript 严格区分大小写state.WebContextundefined不会报错,只是联网内容静默丢失

对策

  • 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,节点返回 retrieveDocsinvoke 初始值又是 retrieveDocs三处不一致

对策:用 TypeScript 定义 state 的类型,或用常量管理字段名。字段名是"契约",必须全局一致。

坑 7:把 String(undefined) 当成空值

String(undefined)          // → "undefined"(9 个字符,truthy!)
Boolean(String(undefined).trim())   // → true(错!)

对策

  • 初始化:在路由节点里把所有字段初始化(本文件做对了);
  • 判断前先检查 null/undefinedstate.webContext != null && String(...).trim()

坑 8:区分不了网络错误和 HTTP 错误

fetch 只在网络层失败时 reject。服务器返回 500,fetch 正常 resolve,必须自己检查 response.ok

对策两层检查——try/catch 抓网络错误 + if (!response.ok) 抓 HTTP 错误。(本文件这点做对了。)

坑 9:第三方 API 参数名写错

textFields vs textFieldparams vs paramsummar 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,对比效果

按这个顺序修,逐项验证

  1. if (webpages.length)if (!webpages.length),观察 webContext 长度是否从"未找到相关结果。"变成真实内容;
  2. state.WebContextstate.webContext,观察最终回答里是否出现了联网信息;
  3. generateNodereturn { generation },观察 result.generation 是否有内容;
  4. retrieveDocs / retrievedDocs 命名,统一成 retrievedDocs
  5. catch 里的无脑吞异常,只忽略 already loaded

修完前后对比,你会对"静默 bug 有多可怕"有深刻体会。

第三步:做实验,理解评估机制

  1. 只问本地能答的问题

    const question = "乔峰是哪个门派的帮主?";
    

    观察:enough=true直接生成,不联网这验证了兜底的"按需触发"特性。

  2. 只问本地不可能答的问题

    const question = "2024年诺贝尔文学奖得主是谁?";
    

    观察:本地检索可能返回一堆无关的小说片段(向量检索永远会返回结果!),评估判定 enough=false联网

  3. 观察 missing 字段的积累:多问几个不同问题,看看"缺失信息点"都集中在哪。这就是知识库的补充清单。

  4. 改评估 prompt:把评估器写得更严格("必须能直接回答才算够")或更宽松,观察联网触发频率的变化。

第四步:进阶优化方向

  1. 加联网次数上限:状态里加 webSearchCount,路由函数里判断上限——防止潜在死循环
  2. 加搜索结果过滤:联网回来的内容可能与问题无关,加一层评估或过滤。
  3. 本地和联网结果分开标注:在 context 里明确标记"【本地】...【联网】...",让模型知道哪些是小说原文、哪些是网上信息,引用时更准确
  4. 多路搜索并行:同时调多个搜索 API,取交集或并集,提升覆盖。
  5. 结果缓存:同样的 web_query 缓存结果,避免重复请求(省钱、提速)。
  6. 加 Rerank:本地和联网结果合并后用 cross-encoder 精排,再进生成。

热门栏目