深入解析(7):Query 流水线内部——Read → Select → Load → Assemble
把一个自然语言问题变成一条有据可查的 Wiki 答案的四个阶段。这条流水线为什么与普通 RAG 在根本上不同:它借鉴了什么,又在哪里分叉。
这是 Inside the System 系列的第七篇。前几篇走过模块化架构、模型选择、摄入延迟、矛盾检测、Schema 提取,以及驱动检索的蒙特卡洛 PPR 引擎。本篇打开查询侧的引擎盖,把一个自然语言问题变成一条 Wiki 锚定、带引用的答案的四阶段流水线。
如果你只想看面向用户的故事,请读 Announcement: v1.24 — Per-Task Models + Query Engine Refactor。本文假设你愿意在一个被重构过的模块里待上 15 分钟,看清楚设计选择背后的推理。
四阶段形态
整条流水线可以画成下面这张图——四个 phase 加一次 chat-LLM 调用,构成一次完整的查询:
graph LR
Q[用户问题] --> P1[Phase 1 读索引]
P1 --> P2[Phase 2 选种子]
P2 --> P3[Phase 3 加载页面]
P3 --> P4[Phase 4 组装上下文]
P4 --> G[Chat LLM]
G --> A[带引用的回答]
四阶段角色一句话:
- Phase 1 读索引:把
wiki/index.md加载进来——里面是每页的 path、title、aliases、summary,是个确定性的目录,不是嵌入。 - Phase 2 选种子:从用户的问题里找出”哪几页最可能是起点”。这是整条流水线最有意思的部分,下面单独讲。
- Phase 3 加载页面:把候选页的正文读出来,可能附带简短的摘要。
- Phase 4 组装上下文:把所有这些拼成 chat LLM 的系统提示,里面用
__WIKI_FOLDER__占位符代替真实文件夹(这背后有个小故事,后面说)。
四个 phase 之后还有一次 chat LLM 调用,它根据拼好的上下文生成最终答案,并把 [[wiki-link]] 形式的引用插进去。真正有意思的设计决策都集中在 Phase 2。
第一个分叉:这不是向量系统
在讲设计选择前,先说一个框定全局的决定。
教科书 RAG(检索增强生成)是这样工作的:把 query 嵌入成向量,到向量库里做相似度搜索,取 top-K 块,塞进 prompt,让模型回答。几乎所有”跟笔记聊天”的工具都做某种版本的这件事。
这条流水线完全不做这些。没有 query 嵌入,没有向量库,没有块相似度搜索。检索信号来自你的 Wiki 自身结构——摄入时插件建起来的 [[wiki-link]] 图。相关性用个性化 PageRank 在这张图上走出来,从你问题最直白点名的那些页面作为种子。(PPR 引擎有专门的文章:深入解析(6):蒙特卡洛个性化 PageRank。)
区分很要紧,因为图信号和嵌入信号犯的是不同的错。在两千页的库上,嵌入搜索会高高兴兴地把语义相似、但你从没链接过的页面顶上来——按相似度是真的,但在你自己的知识库里是结构上的陌生人。图搜索顶上来的是你自己链接行为已经标记为相连的页面——结构上相关,偶尔在语义上让人意外。选图,是对”个人 Wiki 里什么算相关”的一种立场:意思是”由你这个作者决定的那种相关”,而不是”通用嵌入模型认为的那种相关”。
这一个选择,决定了答案会带引用、会贴着你的笔记而不是贴着互联网,也决定了当你的 Wiki 真没东西时,流水线能老实告诉你。有了这个框定,下面是 Phase 2 里顺着它走的五个设计选择。
选择 1:Lex-然后-PPR,在中小规模胜过向量-然后-rerank
种子选择器的第一步,是能想到的最便宜的一步:把 query 分词,对 title + aliases 做子串扫描。零 LLM 调用,零向量,零额外 IO——索引已经在内存里了。如果你的 query 是”January 2026 meeting”,而你恰好有一页标题就是它,匹配是瞬间且无歧义的。
这些命中页作为种子,启动一次个性化 PageRank 游走,向外扩展到你的 Wiki 连到它们的页面。整个过程是确定性的:同一个问题,每次都得到同样的命中、同样的种子、同样的排序结果。
为什么从这么”朴素”的一步开始?因为在多数个人 Wiki 真正生活的规模——几百到两三千页——词法加图,在几乎所有要紧的维度上都压过嵌入再重排:
- 零嵌入成本:第一步零 LLM token、零向量存储 IO。
- 无冷启动:新页面摄入的瞬间 lex 就能用;嵌入需要预摄入步骤。
- 确定性:同一 query 命中同一批种子、同一组 top-K,重现和调试都容易。
- 可解释:能给用户展示”哪些 token 命中了哪些 title”。
代价在规模化时出现,值得说清楚而不是藏着。过了约 5,000 页,lex 开始返回太多弱命中(常见词撞上太多 title),那时真实向量存储才真有用。在 2,142 页的真实库上测过,lex-PPR 级联的 recall-at-5 与同库上强嵌入模型在采样误差范围内一致。在那个规模,加嵌入基本买不到东西,却要多一次摄入时的嵌入、一个要维护的向量库、每次查询的嵌入开销。所以没加。不是”嵌入不好”,是”在这个规模,嵌入还付不起自己的代价”。等它付得起时,会作为可选的 lex 前置阶段插进来,不替换级联。
选择 2:五个阶段,让 LLM 增强永不阻塞热路径
搭查询引擎有个诱人反模式:总是先调 LLM 做 query 理解,再做检索。感觉聪明,其实浪费——因为压倒多数的真实 query 简单到子串匹配已经搞定。为了重新推导你本来就有的东西,在每个 query 上都付一次 LLM 往返,是白白烧掉的延迟和成本。
所以 Phase 2 是瀑布式五个阶段,每阶段是前一阶段的兜底,昂贵的阶段只在便宜的阶段没接住时才触发。第一步是选择 1 的 lex 匹配,不涉及 LLM。只有当 lex 弱(命中数低于阈值、top score 低于阈值、或 token 不可靠,比如未分词的 CJK 文本)时,才升级到 LLM 辅助阶段。
具体说:
- “January 2026 meeting” 命中第一步,找到强匹配,从未为 query 理解调过 LLM,毫秒级。
- “what’s that concept about X” 第一步弱命中(X 不是字面 title),只有这时才升级到 LLM 关键词抽取,拿到关键词,扫描,找种子,跑 PPR。
五个阶段按顺序是:词法匹配;LLM 关键词抽取(仅 lex 弱时);用关键词对全部页面做本地子串扫描;从种子出发 PPR 扩展;都不中时进入兜底。原则就是”不必调 LLM 就不调”。约 90% 的 query 永远不需要升级,所以 90% 的 query 在个位数毫秒的检索开销内就到了 chat LLM。“不必调就不调”让热路径又快又便宜:昂贵阶段只在便宜阶段失败时才跑。
选择 3:本地子串扫描 > LLM 50 候选消歧
这是最微妙的选择,也是修了一个真实 bug 的选择。值得用一段单独讲。
老设计(v1.23.0):
LLM 种子选择器:把 query + 50 个候选(path、title、summary)发给 LLM → LLM 选最多 3 个
真实使用中暴露了一个 bug:当用户问的概念住在 vault 第 1,410 行时,LLM 根本看不到它——候选列表是从 pageRefs[0..50] 切片来的,按 wiki 内部顺序取前 50。相关页面根本没在输入里。
用户问了一个 Wiki 里有答案的问题,插件却答错了。
新设计(v1.24.1)——倒置了结构:
LLM 关键词生成:从 query 抽取 5-10 个概念名
本地子串扫描:用这些关键词 O(n) 扫全部 pageRefs(零 token)
为什么这样可行:
- LLM 抽概念,不做匹配。 LLM 不需要看到候选就知道用户在问什么。“量子纠缠的本质” → keywords
["量子纠缠", "quantum entanglement", "entanglement", "量子", "quantum"]。 - 本地扫描是穷举的。 vault 里每一页都查,不只是前 50。住在第 1,410 行的页面现在可达。
- 零 token 成本。 扫描在约 2,000 个 pageRefs 上 O(n),每个 pageRef 约 50 字符,毫秒级。
- 语言无关。 关键词 prompt 显式语言无关:LLM 自动检测 query 的主语言,输出该语言 + 英文的关键词(英文作为跨语言 Wiki 搜索的通用回退)。硬编码”中文 ↔ 英文”会破坏日语 / 韩语 / 法语为母语的用户。
那次 bug 之后,这个倒置结构成了设计原则:永远不要让 LLM 替你做穷举匹配——让它生成信号,让本地代码去匹配。
顺带说一下前面的 arm 标签——界面上你会看到三个:Lex+PPR、LLM+PPR、LLM+KB,各自把”答案的依据是哪条路径”摆到明面上。Lex+PPR 是词法匹配单独就够强,找页不需要模型;LLM+PPR 是词法弱、模型抽了关键词、本地扫描找到种子、PPR 再扩展;LLM+KB 是上面都没在你的 Wiki 里找到东西,引出了第四个选择。
选择 4:pureLLM 是一等状态
Stage 兜底路径——lex、关键词扫描、LLM 种子选择器都没找到 Wiki 相关页面——进入一个叫 pureLLM 的回答模式:
- 告诉 chat LLM:“未找到相关页面;从通用知识作答。”
- UI 显式展示 verify-vault 横幅,让用户看到这个答案没有 Wiki 背书。
- 答案不带引用(没有源页面可引)。
这是经过深思的选择。另一种做法——悄悄让 LLM 答、不标记缺少 Wiki 背书——会教用户”问 Wiki”等于”得到自信但无根据的答案”。这是 RAG 系统侵蚀用户信任的方式:通过不区分答案是有据还是编造。
pureLLM 状态也可观测:如果高比例 query 落到 pureLLM,这是个明确信号——你的 Wiki 没覆盖你老在问的话题(对未来的摄入决策也有用)。
这个诚实是有代价的:用户偶尔会看到”找不到”的回复,而 ChatGPT 会给一个看起来漂亮的答案。但这个代价值得付。一个会告诉你”我不知道”的工具,比一个总是自信满满的幻觉机器更值得放进日常工作流。
选择 5:Chat prompt 不包含完整 Wiki 索引
这是最反直觉的选择。标准 RAG 在规模化下的失效模式是”top-K chunks 加系统指令超出上下文窗口”。修法通常是更好的嵌入 + chunking。
这条流水线走了另一条路:chat prompt 里完全不放 Wiki 索引。取代的是一个紧凑的 pageSummaryHint:
- entities/Foo — Foo | aliases: Foo Corp / FOO
- concepts/Bar — Bar | aliases: bar theory
- sources/qux — Qux paper | aliases: qux-2026
只有 path + title + aliases,从 pageRefs 派生。Chat LLM 看到 Wiki 里有哪些页面(于是能说”我不知道 Baz,因为 Baz 不在你的 Wiki 里”),又不用看大段完整 summary 文本。
新设计之前,prompt 里塞着整个 wiki/index.md——2,137 页 vault 上是 70K token。这个改动对免费档用户尤其关键:他们的模型上下文窗口往往只有 8K–32K,少 70K 就意味着不再被截断、不再被幻觉。
这个选择的潜台词是:LLM 不需要看到所有候选才能回答——它需要的是已经被选好的相关页面 + 一个”还有什么可选”的简短列表。 检索和排序的工作由 lex + PPR 在本地完成,LLM 只负责最后一步的”理解 + 综合”。这个设计没有悄悄假设你买得起一个巨大的上下文窗口。
一行修复的 bug:__WIKI_FOLDER__ 占位符
v1.24.0 撞到的隐性 bug:chat LLM 被要求用用户当前的 settings.wikiFolder 渲染 [[wiki-link]] 路径。如果把真实文件夹烤进 prompt,用户的聊天历史又持久化了那个 prompt(和 LLM 用渲染过路径的响应),LLM 就会在后续 prompt 里复用那些路径——即便用户改了 wikiFolder。
这种 bug 的可怕之处:它在你改设置之前完全不可见。一改就把答案毁掉:LLM 用旧文件夹生成链接,Obsidian 找不到对应页面,引用全断。
修法:system prompt 里到处用字面占位符 __WIKI_FOLDER__。渲染时用当前文件夹替换占位符,仅用于显示。LLM 永远看不到字面文件夹,所以永远不会把某个特定值烤进行为。
一行修复,永远受益——这是把”配置文件状态”和”LLM 提示”严格隔离的小故事。
代价,说在前面
每个设计选择都有放弃,与其让你自己撞上,不如先讲清楚。
没用嵌入,意味着没有真正的语义匹配。一篇讲”猫科动物行为”的页面,不会因为 query 问”猫”就冒出来,除非页面带”猫”作别名——因为匹配是词法的,不是概念的。跨语言也掉进同一个坑:一个中文问题不会自动找到只有英文的页面,除非有显式跨语言别名(关键词抽取阶段能帮点忙,但不同于嵌入能做的自动桥接)。这个取舍被接受了,因为在个人 Wiki 上,你是作者,你建的别名和链接通常比通用模型的相似度概念更准。到了多人协作的大 Wiki、或同一个意思有五十种说法的领域,这笔账会反转,那时嵌入才是明显赢家。这是为个人 Wiki 场景刻意调过的取舍,不是没想清楚。
也没做块级检索。页面是原子的——长页面里只有一段相关,你拿到的仍是整页(长度封顶),不是分块系统能切出的那一片。这让图模型保持干净:节点是页面,链接在页面之间,PPR 走的是整页的图。引入子页块会为了一个在个人笔记尺度上通常很边缘的精度收益,把模型切碎。
这些不是包装成特性的假限制,是真的限制,级联确实偏向你显式命名和链接过的东西。对自己写、自己查知识库的人,这个偏向通常是对的。嵌入增强已排在路线图里,作为可选的 Stage 0——在 lex 之前跑的冷启动检索,用来补充它,从不替换它。
成本与延迟,经验值
2,000 页 vault 上的典型 query:
| 阶段 | 延迟(典型) | LLM tokens | 备注 |
|---|---|---|---|
| Phase 1 读索引 | 5–15 ms | 0 | 文件读 + 解析 |
| Stage 1 lex | 1–3 ms | 0 | 纯子串扫描 |
| Stage 1.5a LLM 关键词 | 200–800 ms | 50–150 | 仅在 lex 弱时 |
| Stage 1.5b 关键词扫描 | 1–5 ms | 0 | O(n) over pageRefs |
| Stage 3 PPR 级联 | 30–80 ms | 0 | 蒙特卡洛 |
| Phase 3 加载页面 | 50–200 ms | 0 | IO 绑定 |
| Phase 4 组装上下文 | 1–5 ms | 0 | 纯模板构建 |
| Phase 5 chat LLM | 1–4 s | 2–8K | 流式输出 |
“强 lex” query:到 chat LLM 之前总时 ≤300 ms。“弱 lex” query:LLM 关键词阶段加 200–800 ms,但只在必要时。“纯 LLM KB” 兜底:跳过 PPR,直接带着 pureLLM=true 标志进 chat。所有延迟人感知即瞬时,除了 chat LLM 调用——这无论如何都不可避免。
为什么这跟你有关
如果你读到这里,要点就是一个:这个插件的答案,在结构上对”它知什么不知什么是诚实的。”
arm 标签(Lex+PPR、LLM+PPR、LLM+KB)告诉你答案来自哪条路径。pureLLM 标志和 verify-vault 横幅告诉你”这段答案没有 Wiki 背书”。__WIKI_FOLDER__ 占位符防止改设置悄悄毁掉答案。
这些不是工程卫生,是让个人 Wiki 感觉像 Wiki、而不是穿着戏服的聊天机器人的设计选择。
插件的立场:你的 Wiki 结构是真相之源——什么链接到什么是你自己画出来的地图。LLM 是解释者,PPR 是导航者。检索不靠语义相似度,靠你 Wiki 自身的连接结构。嵌入是等它们自己付得起代价时的可选增强——但不是默认,因为在精心维护的个人 Wiki 上,它的边际收益不值得让所有 query 都付出代价。
下次你在 UI 里看到 “Lex+PPR” 标签,你知道了——这是快路径,零 token 跑完。看到 “LLM+KB” 标签和 verify-vault 横幅,你知道了——这是兜底路径,答案没根,谨慎使用。
这就是”图锚定的检索流水线”的全部含义。