写在前面:前两篇我们搭了 RAG、打了两个补丁(分诊、拆题)。这篇补最后也是最关键的一块------让 RAG 知道自己"不知道"。 朴素 RAG 最要命的地方在于:它搜到什么就用什么。哪怕书里压根没有相关内容,它也会硬着头皮编一个答案。readme 把这个问题直白地写了出来------"本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案。" 今天这个 demo 干的就两件事:先评估"手头的资料够不够",不够就出门联网买料,买回来再评估一遍。以下所有代码均来自课堂真实文件。
一、先看场景:一道故意设计的问题
rag-webfallback.mjs 的测试问题,是整篇的题眼:
javascript
const question = `请回答《天龙八部》小说里"雁门关事件"的主谋是谁,并说明其儿子的最终结局;
另外请补充:在《天龙八部》2013版电视剧中,这段"雁门关事件"主要出现在哪几集?
请给出可核对的来源链接。
`
这个问题被精心设计成了"一半本地能答、一半本地答不了"。
拆开看:
| 子问题 | 本地知识库(《天龙八部》EPUB)能答吗 |
|---|---|
| 雁门关事件的主谋是谁 | ✅ 能------小说里有明确情节 |
| 其儿子的最终结局 | ✅ 能------小说里写了 |
| 2013 版电视剧中出现在哪几集 | ❌ 不能------小说原文里没有电视剧信息 |
| 给出可核对的来源链接 | ❌ 不能------本地文本给不了链接 |
而且这些信息在小说里的推理路径也是清晰的:
雁门关事件的主谋 → 慕容博(散布假消息,挑起雁门关伏击)
慕容博的儿子 → 慕容复
慕容复的结局 → 精神失常(在坟头做皇帝梦)
前两问,向量检索能搞定。后两问,本地库里根本没有------这就逼出了"联网兜底"的流程。
好的测试用例不是"随便问一句",而是"精确地卡在能力边界上"。 这个问题正好卡在"本地能答"和"本地答不了"的分界线上。
二、状态设计:把"料"分成三堆
javascript
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,
})
跟上篇的多跳版本比,这里的状态设计有个明显变化------上下文被拆成了两堆:
| 字段 | 装什么 |
|---|---|
localContext |
本地知识库检索到的内容 |
webContext |
联网搜索回来的内容 |
为什么要分开存,而不是合并成一个 context?
三个理由:
- 来源可追溯------生成答案时可以标注"这段来自书里,那段来自网络"
- 流程可控 ------
webContext有值就意味着"已经联网过了",可以据此避免重复联网 - 评估更精确------评估节点能分别知道"本地料够不够"和"网络料补上没"
那个 evaluation 字段的注释也点明了结构:
javascript
evaluation: Annotation, // { enough, missing, reason }
它存的是一个 JSON 字符串(后面会看到为什么)。
三、备料检查:那个救命的评估节点
这是全篇的核心。EvaluateSchema:
javascript
const EvaluateSchema = z.object({
enough: z.boolean(), // 是否足够生成
missing: z.array(z.string()).max(6), // 上下文缺的方面
reason: z.string(),
web_query: z.string().optional() // 可选的 web 搜索的关键词
})
四个字段,各有使命:
| 字段 | 类型 | 作用 |
|---|---|---|
enough |
boolean | 总开关------够不够回答 |
missing |
string\[\](最多6条) | 缺料清单 |
reason |
string | 判断理由 |
web_query |
string(可选) | 给联网搜索用的查询词 |
这个 Schema 的设计非常讲究------它不只是"判断够不够",还顺手把"下一步该干什么"也定好了。
逐个看:
enough 是 boolean(不是字符串)------布尔值让代码判断可以直接用,不用做字符串比较。
missing 用 .max(6) 限长------防止模型长篇大论地罗列缺失点。 缺失点列太多也没意义,抓重点就行。
web_query 标了 .optional()------因为不是每次评估都需要它。 如果料够了,就不需要联网,这个字段也就没意义。
节点实现
javascript
const evaluateNode = async (state) => {
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
console.log(hasWeb ? "___EVALUATE_CONTEXT_WITH_WEB___" : "___EVALUATE_WEB_CONTEXT___");
// llm 大脑,规划,分析,分步骤
const evaluator = llm.withStructuredOutput(EvaluateSchema);
const out = await evaluator.invoke(`
你是信息充分评估专器。判断当前上下文是否足以回答用户问题。
用户问题: ${state.question}
已检索上下文(来自本地知识库):
${state.localContext || " (空) "}
${hasWeb ? `联网搜索结果:\n ${state.webContext || " (空) "}` : ""}
输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够,列出缺失信息点(最多6条)
- reason: 简短原因
${hasWeb ? "" :
"- web_query: 若不够,给出一个适合互联网搜索的中文查询句(完整句,不用代码:为空也可)"}
`);
console.log(`${hasWeb ? "二次评估": "评估"} :
enough=${out.enough}`)
if (!out.enough && out.missing?.length) {
out.missing.forEach((m, i) => console.log(`缺失 ${i+1}: ${m}`))
}
return {
evaluation: JSON.stringify(out),
}
}
这个节点会被调用两次------第一次评估本地料,第二次评估"本地 + 网络"的合并料。 一个函数,两个身份。
实现上做了几处巧妙的区分:
第一,hasWeb 判断
javascript
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
三层处理:state.webContext 存在?→ 转字符串 → 去掉空白后还有内容吗?
String(...).trim() 这一步是必要的------防止一个全是空格的字符串被误判成"有内容"。
第二,日志名字随状态变化
javascript
console.log(hasWeb ? "___EVALUATE_CONTEXT_WITH_WEB___" : "___EVALUATE_WEB_CONTEXT___");
一个日志名,隐含了"这是第几次评估"。调试时一眼能看出来。
第三,prompt 是动态拼装的
javascript
${hasWeb ? `联网搜索结果:\n ${state.webContext || " (空) "}` : ""}
没联网时,这段内容压根不出现。 而不是"联网结果:(空)"------少一段噪音就少一分干扰。
第四,web_query 的说明只在第一次出现
javascript
${hasWeb ? "" :
"- web_query: 若不够,给出一个适合互联网搜索的中文查询句(完整句,不用代码:为空也可)"}
第二次评估(已经联网过了)就不再要求 web_query ------因为不需要再搜了。这个细节说明设计者很清楚流程走到哪一步。
第五,web_query 的措辞要求
arduino
"给出一个适合互联网搜索的中文查询句(完整句,不用代码:为空也可)"
"完整句,不用代码" ------这个约束很实在。因为本地知识库是《天龙八部》,模型可能习惯性地给出小说式的查询词。但在互联网上搜"2013版电视剧 雁门关 集数",需要的是一句自然的中文查询。
一个值得注意的存储细节
javascript
return {
evaluation: JSON.stringify(out),
}
把结构化对象转成 JSON 字符串再存。
为什么要这样?因为 LangGraph 的状态字段是共享的 ------Annotation 里声明的是通用类型,存字符串最安全、最不容易出岔子。取用的时候再 JSON.parse 回来:
javascript
const parsed = (() => {
try {
return JSON.parse(state.evaluation || "{}");
} catch {
return {};
}
})();
这段"立即执行函数"(IIFE)配合 try/catch,是一个很稳的防御式写法:
state.evaluation || "{}"------ 空值兜底try/catch------ 解析失败兜底- 返回
{}------ 保证后面访问.enough不会崩
因为 evaluation 是字符串,任何解析失误都会导致 JSON.parse 抛异常。 这两层兜底,是把"可能崩的地方"都焊死了。
四、路由与本地检索
路由复用:还是那个分诊台
javascript
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: "",
}
}
RouteSchema 和上篇一模一样------分诊台被完整复用了。 这是模块化设计的好处:一个可靠的判断节点,可以在不同流程里搬运。
注意这段注释:
javascript
// 可选的,不需要全部state的设置
// 为后面的节点提供服务的
这个节点把后面要用的字段全部初始化了一遍 (retrievedDocs、localContext、webContext、evaluation、generation)。
这是个好习惯------在流程入口把状态"归零",后面的节点就不用到处写"如果为空就......"的兜底判断。 相当于每次启动前先把桌面擦干净。
而这个测试问题(雁门关 + 电视剧集数)会被判为 complex------因为它明确要求"具体情节、原文细节或证据支持"。
本地检索:只取内容
javascript
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("\n\n");
return {
retrievedDocs: retrieveDocs,
localContext,
}
}
这个节点返回两个东西:
| 返回值 | 内容 |
|---|---|
retrievedDocs |
完整的文档对象(含分数、章节等元数据) |
localContext |
纯文本拼接的上下文 |
为什么要两个都存?
localContext直接喂给 prompt------模板字符串里需要的就是一段纯文本retrievedDocs保留结构化数据------评估节点和调试输出可能需要看分数、章节号
一份数据,两种形态。 朴素 RAG 里只有 documents,这里拆成了两个字段------流程变复杂后,数据形态也要跟着分化。
注意检索用的是原始问题 (state.question),而不是拆解后的子问题------这个 demo 没有引入多跳拆解,它专注于"评估 + 兜底"这一件事。 每个 demo 只演一个能力,这是很清醒的做法。
五、出门采购:联网搜索节点
评估说"料不够",该出门买料了。
决策逻辑:先看有没有买过
javascript
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";
}
三行判断,逻辑很严密:
ini
已经联网过了?(webContext 有内容)
→ 是 → 直接 generate(防止无限联网!)
→ 否 → 看评估结果够不够
→ enough === true → generate
→ 否则 → web_search
第一行的判断是防死循环的关键。
想一下:web_search 之后会回到 evaluate_local 做二次评估。如果二次评估又说"不够",会怎么样?→ 又去联网 → 又评估 → 死循环。
所以这里设了一道硬闸:只要 webContext 已经有内容,无论如何都去 generate。
"最多联网一次" ------这不是能力限制,是安全边界。
parsed.enough === true 用 === 严格比较而不是 ==------因为 enough 必须真的是布尔 true,字符串 "true" 不算数。
博查搜索:一个完整的 API 封装
javascript
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 = process.env.BOCHA_API_URL;
const body = {
query,
freshness:"noLimit",
summary: true, // 返回的内容,做个总结
count: count ?? 10
}
用的是博查(Bocha)搜索 API------国内一个面向 AI 应用的搜索服务。配置全部走环境变量,这是标准做法(前面 FastAPI 那篇讲过)。
请求参数:
| 参数 | 值 | 含义 |
|---|---|---|
query |
查询句 | 来自评估节点的 web_query |
freshness |
"noLimit" |
不限制时间范围 |
summary |
true |
返回内容做摘要(注释说明了) |
count |
传进来的数量 | 要几条结果 |
summary: true 这个参数很关键------搜索引擎直接返回摘要,省掉了自己抓网页正文的麻烦。
这一点体现了"选对工具"的价值:直接调一个"为 AI 设计的搜索 API",比自己写爬虫去抓网页、解析 HTML、提取正文要省太多事。
然后是请求发送:
javascript
let response;
try {
response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch {
throw new Error(`搜索API 请求失败(网络错误):${error.message}`);
}
标准的 fetch POST 请求------Bearer Token 认证、JSON 请求体。
错误处理分了两层,这是个很专业的写法:
javascript
// 先处理失败
// ok 200 语义化更好
if (!response.ok) {
// 二进制 json() text()
// 出错,打印出错信息
const errorText = await response.text().catch(() => "");
throw new Error(`搜索API 请求失败,状态码:${response.status}
错误信息:${errorText}`);
}
let json;
try {
json = await response.json();
} catch {
throw new Error(`搜索结果解析失败:${error.message}`);
}
| 层次 | 检查什么 | 怎么报错 |
|---|---|---|
| HTTP 层 | response.ok(状态码 2xx) |
带状态码 + 错误文本 |
| 解析层 | response.json() 能不能解析 |
单独报"解析失败" |
为什么要把这两层分开? 因为它们是完全不同的故障:
- HTTP 层失败 = 请求没成功(Key 错了、欠费了、服务挂了)→ 去查账号
- 解析层失败 = 请求成功了但返回格式不对(接口变了)→ 去查接口文档
如果混在一起报"搜索失败了",你根本不知道该往哪个方向查。
注释里那句"ok 200 语义化更好"------说的是 response.ok 这个属性比自己写 response.status === 200 更直观,而且它能覆盖所有 2xx 状态码。
.catch(() => "") 也是个细节------读错误信息本身也可能失败,再套一层兜底防止报错时报错。
结果解析与格式化
javascript
console.log(json, "///////////");
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")
}
结果格式化成带出处的结构------每条结果包含序号、标题、URL、摘要、网站名、发布时间。
这个格式化直接影响最终答案的质量。
还记得测试问题的最后一句吗?------"请给出可核对的来源链接 "。为什么这里要把 URL 和网站名都带上?因为生成节点要靠这些信息给出引用。
如果只返回摘要文本,模型没法给出链接。格式化的字段,决定了最终答案能不能满足需求。
这是一个"下游需求倒推上游格式"的经典例子------不是"搜索返回什么就存什么",而是"最终答案需要什么,就格式化出什么"。
搜索节点:拿到采购清单
javascript
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
}
}
查询词的选取逻辑值得细看:
javascript
const query = (parsed.web_query ?? "").trim() || state.question;
三步:
parsed.web_query ?? ""------ 取评估节点给的查询词,没有就空字符串.trim()------ 去空格|| state.question------ 如果还是空的,就用原始问题兜底
这是一个"有则用模型优化过的,无则退回原始问题"的降级策略。 评估节点可能给了更好的查询词(它知道缺什么),也可能没给(模型没填),那就直接用原问题。
bochaWebSearch(query, 8) 传了 8------要 8 条结果。 因为网络信息质量参差不齐,多取几条再让模型自己筛选。
注释里还有一句很有工程意识的话:
javascript
// 封装
// 方便切换其他服务
这就是"封装成独立函数"的意义 ------如果哪天想从博查换成必应、Google,只要改 bochaWebSearch 这一个函数,图的结构完全不用动。
六、生成节点:把两堆料合起来
javascript
const generateNode = async (state) => {
// 增强prompt
// localContext
// webContext
console.log('___GENERATE___');
const context = [state.localContext, state.webContext]
.filter(Boolean)// Boolean 函数
.join("\n\n==联网补充==\n\n");
两行代码,完成合并:
| 步骤 | 作用 |
|---|---|
.filter(Boolean) |
过滤掉空值(本地没检索到 / 没联网) |
.join("\n\n==联网补充==\n\n") |
用显式分隔符拼接 |
.filter(Boolean) 是个很聪明的写法------Boolean("") 是 false,Boolean("内容") 是 true。用它来过滤空字符串,比写 .filter(s => s && s.length > 0) 简洁得多。
分隔符 ==联网补充== 是给模型看的信号,告诉它"从这里开始是网络来的信息"。这样模型回答时能区分两类来源。
然后是 prompt:
javascript
const stream = await llm.stream(`你是一个严谨的中文回答助手,
优先依据上下文回答,不要编造。
上下文(本地知识库 + 可选联网补充):
${context || "(空)"}
用户问题:${state.question}
回答要求:
1.如果上下文足够,给出清晰,可核对的回答:需要时引用:n / URL
"或小说片段来支撑。"
2. 如果上下文仍不满足已确定关键事实,明确说明"不确定/无法从上下文确认",
并说明缺失点
3. 不要输出表情符号。
回答:
`);
三条要求,对应前两篇的教训:
| 要求 | 解决什么 |
|---|---|
| 1. 要求可核对,引用 URL 或原文 | 提供出处,让用户能验证 |
| 2. 不确定就明说,并说明缺失点 | 防幻觉的最后一道防线 |
| 3. 不要输出表情符号 | 严谨场景的输出规范 |
第 2 条是整篇的收口。
前面做了那么多------评估够不够、缺什么、联网补充------最终还是要落到"如果还是不够,就老实说不够"。这才是"可纠错"的完整含义:不是保证永远答对,而是保证不会假装答对。
prompt 开头那句"优先依据上下文回答,不要编造"也值得注意------"优先"两个字用得好,它意味着"上下文里有就用,没有才靠模型自身知识",而不是"只能依据上下文"。
七、完整的图:一个会自我检查的闭环
javascript
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();
流程图:
sql
┌→ direct_answer → END
START → route_question
└→ local_retrieve → evaluate_local ─┐
↑ │
│ ┌───┴───┐
│ ↓ ↓
└─ web_search generate → END
(联网后回来再评估)
注意 web_search → evaluate_local 这条边------它形成的是一个"评估循环"。
完整走一遍:
arduino
1. route_question → 判定 complex
2. local_retrieve → 从《天龙八部》里检索(能查到慕容博、慕容复)
3. evaluate_local → 评估:enough = false
missing: ["2013版电视剧具体集数", "可核对的来源链接"]
web_query: "天龙八部 2013版电视剧 雁门关事件 第几集"
4. web_search → 带着 web_query 出门联网
5. evaluate_local → 二次评估(现在有 webContext 了)
6. generate → 合并本地 + 网络,生成最终回答
这个闭环的精妙之处在第 5 步 ------联网回来不是直接生成,而是再评估一次。
为什么?因为联网搜回来的东西可能:
- 也没查到(那评估会再判定不够,但因为有防重复机制,直接进 generate 并如实说"无法确认")
- 查到了但质量差(评估能看到内容,可以指出问题)
- 完全够了(判定 enough,正常生成)
"检查 → 补料 → 再检查" ------这是任何认真做事的流程都该有的样子。
日志会把这些步骤都打出来:
ini
___ROUTE-QUESTION___
路由策略:complex 需要具体情节和证据
___LOCAL_RETRIEVE___
本地检索命中 8 条
___EVALUATE_WEB_CONTEXT___
评估 : enough=false
缺失 1: 2013版电视剧具体集数
缺失 2: 可核对的来源链接
___WEB_SEARCH___
联网搜索: 天龙八部 2013版电视剧 雁门关事件 第几集
联网搜索结果长度: 1234
___EVALUATE_CONTEXT_WITH_WEB___
二次评估 : enough=true
___GENERATE___
这一串日志,就是整个 Agent 的"思考过程"。 节点名前后的 ___ 下划线让它们在输出流里一眼可辨------又一个调试友好设计。
八、五个硬伤,全部收工
回到第一篇那张表,现在全部打完了:
| # | 硬伤 | 补丁 | 状态 |
|---|---|---|---|
| 1 | 所有问题都检索 | 路由分流(simple/complex) | ✅ |
| 2 | 没有评估机制 | 评估节点(enough/missing/web_query) | ✅ |
| 3 | 处理不了多跳 | 问题拆解(子问题 + 顺序检索) | ✅ |
| 4 | 语义匹配不准 | 关键词检索(ES) | ⬜ 下一篇 |
| 5 | 不会联网兜底 | 网络搜索 + 二次评估闭环 | ✅ |
readme 对这套架构的总结只有一句话,但每个字都有分量:
"基于 LangGraph 实现闭环的 Agentic RAG,也就是 Agent 自主决策要不要检索,用什么检索(web search + milvus + es),信息够不够,效果怎么样,要不要重新搜。"
四个决策,全部由 Agent 自己做:
| 决策 | 对应节点 |
|---|---|
| 要不要检索 | route_question |
| 信息够不够 | evaluate_local |
| 要不要补充 | afterEvaluateLocal(web_search or generate) |
| 补充后够了吗 | 二次评估 |
这就是"闭环"和"线性"的区别------线性流程是"检索 → 生成 → 结束",闭环是"检索 → 评估 → 补充 → 再评估 → 生成"。
readme 还有一句提醒,很重要:
"具体 Agentic RAG 要根据业务场景设计,理解这个闭环思路就可以。"
意思是:别照抄这个图。
不同业务的闭环长得不一样:
- 客服系统可能不需要联网,但需要"查订单状态"
- 代码助手可能不需要多跳,但需要"读文件"
- 合规场景可能不需要多路召回,但必须"每条结论都要出处"
变的是节点,不变的是那个思想------让 Agent 自己判断"我手上的信息够不够",不够就想办法补。
九、这一篇的三个收获
收获一:评估是"防幻觉"最有效的手段。
prompt 里写一百句"不要编造",不如加一个节点问模型"你手上的资料够回答吗"。前者是嘱咐,后者是流程。 嘱咐靠自觉,流程靠机制。
收获二:好的 Schema 设计是"判断 + 行动"二合一。
EvaluateSchema 不只是判断"够不够",还给出了"缺什么"和"下一步搜什么"。一个结构化输出同时完成了评估、诊断、规划三件事。
收获三:错误处理要分层,防循环要设闸。
bochaWebSearch 里把 HTTP 失败和解析失败分开报错------故障定位的前提是故障分类。 而 afterEvaluateLocal 里那句"已经联网过就直接生成"------任何允许循环的设计,都必须配一个终止条件。
PS:这篇写下来,"Agentic"这个词的含义清晰了很多------它不是"用 AI 做更多事",而是"让 AI 知道自己的边界"。知道自己不知道,才会去查;知道自己查过了,才会停。下一篇我们补最后一个硬伤:为什么"高血糖"和"低血糖"在向量数据库里几乎一样,以及 ES 怎么救场。