30 秒判断:编译出一堆页面不难——难的是这份产物能不能被改动。这篇拆的是 WeKnora 的知识编译:新文档进来会改什么、删掉一篇会剩下什么。一句话结论:能增、能删,但不幂等、而且会漂。支撑它的是三根柱子——正文素材是逐字原文块(让"删哪段"退化成文本判断)、页↔文档/块的引用落库、slug 身份账本(三根柱子分工不同:①②主要服务"可删",③服务"可改",见 §2)。还有一条反直觉的结论在 §6:这套编译产物目前不参与检索——页面没进向量/关键词索引,agent 靠
wiki_search正则找页,目录只服务人。适合谁读:正在选型或自建"预编译 + agent 检索"的人;读过本系列上一篇(LLM-Wiki 总论)的人;想知道"编译出的页面能不能被 agent 直接检索"的人;被 GraphRAG"删了文档等于没删"坑过、想知道有没有替代路线的人。
这篇文章回答三个问题:① 全量编出来是什么样、要多少钱(§3);② 新文档进来会改什么(§4);③ 删掉一篇会剩下什么(§5)——这是本系列第一次拿到删除的实测数据,也是全文重心。§1 先把这条七阶段流水线的"谁在跑/吃什么/吐什么"摆一遍,§2 讲"它凭什么能改能删",§6 讲代价与局限,§7 是选型判断。所有计量口径与实现细节(链接与关系的位置、批与并发、输出语言、体积锚点)都收在附录 A,正文只在需要时指过去。
证据标记:📄 源码(
Tencent/WeKnoratagv0.8.2,commit3e8b0bf,本地克隆逐行核过)· ✅ 实测(本机五组语料、六个库的真实跑批)· 📎 原文(提示词/注释原话)· 🤔 推断(会明确标出)。
0. 结论先行:能增、能删,但不幂等
一句话:WeKnora 的 wiki 用同一条七阶段流水线承担三种输入——全量(空库投第一批)、增量(往库里再投)、删除(从库里删掉一篇文档)。它们的差别不是三套实现,而是同一条流水线在不同输入下走进不同的分支;分支细节从 §1 那张表开始,逐节展开。
实测效果(细节见 §3–§5):
| 变更 | 能不能 | 代价 |
|---|---|---|
| 全量 | 能,成本可预期 | 源文 5.7k token → 编译共 239,912 token = 源文的 42 倍(全部阶段合计;其中写页 43 次/197,696 = 82%) |
| 增量 | 能,而且是真局部 | 源文 2.7k token → 编译共 98,773 token = 源文的 36 倍(全部阶段合计;其中写页 21 次/74,294 = 75%)→ +22 页、0 合并,多出一个一级目录 |
| 删除 | 能,页面级很干净 | 删一篇 15,257 token 的文档 → 只花 24,388 token(源文的 1.6 倍) 、47 页消失、5 个多源页被剥掉来源;但留下 19 条悬空链接,而且删了再投不是复原(47 个 slug 只回来 27 个) |
⇒ 一句话记住:能增、能删是真的,"可逆、可复现"是假的。 §4/§5 给证据,§6 列出七条不牢的地方。
上一篇《把知识库编译成 Wiki,再把检索权交给 LLM》把 LLM-Wiki 的验证拆成三篇——§6.1 知识编译 / §6.2 错误处理 / §6.3 检索;这一篇就是 §6.1 的答案,它列的三件"要验的事"分别落在:
| 总论 §6.1 要验的 | 本文在哪答 |
|---|---|
| ① 全量怎么编(视角选择/抽取/合并) | §3(产出与成本);抽取与合并的机制在 §1/§2 |
| ② 增量怎么处理(是否真局部/全局结构谁维护/新旧混存) | §4(+ §4.3"最后一次为准"那张三层硬度表) |
| ③ 删除怎么处理(悬空链接/孤儿页/答案是否还引用) | §5(+ §5.2 的两个发现、§5.3"不可复原") |
没答上的三处:视角选择只答了一半——这套实现没有 schema,"视角"的等价物是抽取粒度参数,而它只控抽候选那一步;视角变更后再追加没做(没有 schema,也没改粒度做过对照);问答侧的两问("同一批问题各问一次,答案会不会自相矛盾""答案里是否还在引用被删内容")要跑问答,留给下一篇。检索侧本文只留一个钩子:编译产物目前不参与检索(附录 A.5)。
实现细节(链接与关系的位置、批与并发、输出语言、体积锚点)都在附录 A;去抖窗口在 §4.4;正文只在需要时引它。
1. 先看流水线:七个阶段各自吃什么、吐什么
后面会反复用到这七个阶段,先列一遍:① 抽候选 → ② 去重与身份对齐 → ③ 引用匹配 → ④ summary 页 → ⑤ 目录规划 → ⑥ 写页 → ⑦ 链接注入与索引收尾。
三个术语先说明白:slug = 页面在库里的唯一标识(形如
concept/rag);块(chunk) = 切分后的最小证据单位(默认目标 512 个字符/块;"字符"按 Unicode 码点计,不含字节/token 语义,≈ 100–130 个英文 token。它是软预算——单个拆不动的长段/代码块/表格会整块超过它,真正的硬顶是absoluteMaxSize=7500:A5 的 70 块 p50=431、最长 827,那一块就是一个 827 字符的长段);多源页 = 被两篇以上文档共同支撑的同一页。
三种模式共享的是这条流水线的后半段(⑥ 写页 + ⑦ 收尾):全量与增量从 ① 走到 ⑦;删除不经过 ①–⑤,直接以"反查 + 撤回"进入 ⑥。所以要看清"哪个行为变了",就得先知道每一步谁在跑、吃什么、吐什么:
| 阶段 | 谁在跑 | 输入 | 输出 |
|---|---|---|---|
① 抽候选 WikiCandidateSlugPrompt | 每篇文档各一次(Map) | 整篇文档(超 32,768 字符就截断)+ 这篇文档上一版产出过的 slug 清单 | "值得单独建页"的实体/概念列表(只抽点,不抽边,理由见附录 A.1) |
② 去重与身份对齐 WikiDeduplicationPrompt | 每篇文档,紧随 ① | 候选slug条目 + 库里已有的页(按标题召回前 5) | 归一后的身份:命中已有页就复用那个 slug |
③ 引用匹配 WikiChunkCitationPrompt | 每篇文档(与 ④ 并行) | ② 产出的slug条目 + 这篇文档的全部块(按预算分批) | slug → [chunk_id];顺带能补出 ① 漏掉的 slug |
④ summary 页 WikiSummaryPrompt | 每篇文档(与 ③ 并行) | 截断到 32,768 字符的文档 +② 产出的slug条目列表 | 一页 summary/<文档 id>(每篇文档固定一页) |
⑤ 目录规划 WikiTaxonomyPlanPrompt | 批级一次(≤5 篇),reduce 之前 | 整批的全部新条目 + 已有目录池 | slug → 目录路径(逐字复用已有目录) |
⑥ 写页 WikiPageModify*Prompt | Reduce:按 slug 并发 | 批级 slug 列表——每个 slug 的 additions/retracts + 支撑它的原文块 +(改写时)这页的旧正文 | 页面正文(新建,或整页重写)——整条链路最主要的写内容的环节 |
⑦ 链接注入 + 索引收尾 linkifyContent / WikiIndexIntro*Prompt | finalize(跨批 20 秒去抖) | 本批受影响的页 + 全库页面标题 | 正文里的 [[slug|文字]]、死链清理、索引页引言 |
读这张表要抓住三件事,后面每一节都在用:
- ①–④ 是"每篇文档各一份" :三篇文档进来就是三份并行的 Map,互不知道对方在看什么。所以"这一页要不要重写"不可能在这一步定下来——一篇文档只看得到自己。
- ⑤–⑦ 是"批级一份" :Reduce 手里是整批的 slug 列表;同一批里多篇文档命中同一个 slug,会被合成一条链、喂给一次写页调用——**"多源页"的一个来源就是这样的。
- "批"不是文件粒度:它是"一次 ingest 触发认领的文档集合,默认最多 5 篇"(细节见附录 A.2)。
⇒ 记住这张表再看后面。三种变更的入口是:全量=空库投第一批(§3);增量=往库里再投——又分A 新文档入库(主场景)与 B 同一份文档再入库两种情形(§4);删除=从库里删掉一篇文档(§5)。它们的差别落在 ①–⑤,共同落点是 ⑥–⑦——§3 是"库是空的,②⑤⑦ 几乎空转";§4 是"② 命中已有页、⑥ 拿到的是批级 slug 列表";§5 只用到 ⑥ 的 retract 分支和 ⑦ 的收尾。
2. 它凭什么能改、也能删:三根柱子,而不是"模型聪明"
先看反面。GraphRAG 的删除是"被忽略"的:删掉一个源文档,图、社区、社区报告等输出与删除前逐字节相同(退出码 0),只有全量重编译一条路;它的增量也是追加式——社区不重划、度数不重算,所以同一份语料追加两轮后根节点数 9 → +6 → 15,结构分代。(见本系列《GraphRAG 增量索引实测》)
原因不难找:GraphRAG 的产物是 LLM 转述过的社区报告。"这一段是哪个源贡献的"在报告里没有落点,于是删除无法定位、增量无法局部化。
WeKnora 能改能删,靠的是三根柱子(它们分工不同——哪根服务"可改"、哪根服务"可删",见本节末那张表):
① 正文素材是逐字原文块 —— 让"删哪段"退化成文本判断。
引用匹配阶段的产出从来不是 slug → 某句/某段区间,而是 slug → [块 ID];写页时把这些块的完整原文拼进提示词(📎 原话: "The <new_information> block above is assembled from VERBATIM source chunks already cited as directly supporting this page." ),并且要求 LLM 不要重写(📎 WikiPageModifySystemPrompt,prompts_wiki.go:331):
- You are a COMPILER, not a creative writer. Stay close to the verbatim source wording. You may lightly reorder, deduplicate, and join related sentences, but must not rephrase for style, expand short statements, or invent transitions.
(大意:你是编译者,不是创作者——贴着原文措辞走;允许轻度重排、去重、把相关句子接起来,但不许为了文风改写、不许把短句扩写、不许自造过渡。)
于是"删掉某篇文档"的操作可以写成一句文本级判据(📎 同一条链路的 WikiPageModifyUserPrompt 第 2 条,prompts_wiki.go:387):
- REMOVE facts/claims that were ONLY sourced from the
<deleted_documents>and are NOT present in any<remaining_source_documents>or<new_information>.(大意:删掉"只有
<deleted_documents>撑着、在剩余来源与本次新信息里都找不到"的事实/论断。)
如果页面是语义化改写的,页↔源只剩"意思相近",这句判据就无从可靠执行——这就是"忠实性"除了防幻觉之外的第二个、也是更硬的价值。
② 页↔文档、页↔块的引用是落库的。
wiki_pages.source_refs(文档级,格式 <knowledge_id>|<doc_title>)与 wiki_pages.chunk_refs(块级 UUID 数组)都是真列——删除时靠 source_refs 反查"哪些页受这篇影响",靠 chunk_refs 剥块引用。⚠️ 但"维护"只做到一半:重解析不会清理旧引用(chunk_refs 只并集、不删旧,见 §6 第 4 条)。
③ slug 是身份账本 —— 让"新抽到的"落回原来那一页。
写页是"按键覆盖":reduce 拿到的是 slug → updates,改哪一页完全由这个键决定。所以新文档里再出现"RAG"时,如果身份没归一到已有的 concept/rag,就会长出第二个 slug、建第二页(孪生页),老页则永远留着旧内容——增量退化成"堆页","改"就不存在了。身份归一靠三手:Pass 0 先把"这篇文档上次产出过的 slug"喂给模型(§1 表①)+ 去重层按标题召回 + LLM 判同(跨文档命中就复用库里的 slug)+ 确定性精确身份(归一化标题相同直接复用,连"这次判 concept、下次判 entity"都兜)。
反过来看更清楚:重解析时如果模型换了 slug,这篇文档的旧 slug 就不在"这次抽到的"集合里 ⇒ 老页被判 retractStale ⇒ 只有它撑着的单源页直接删(wiki_ingest.go:2124),同时新 slug 建一张新页。净效果是"删一页 + 建一页",链接和 version 链都断。
⇒ 三根柱子不是"每根都撑两件事",分工是这样:
| 柱子 | 主要服务 | 机制 |
|---|---|---|
| ① 逐字原文块 | 可删 | 把判据降成文本级("只有被删文档撑着"才动手) |
| ② 引用落库 | 可删(定位哪些页受影响)+ 可改(剥掉哪个块) | source_refs / chunk_refs 反查 |
| ③ 身份账本 | 可改 | 让"新抽到的"落回同一页;身份一漂,重解析就会误删(上一条) |
一个现成的失效口子:去重召回只看标题、不认别名,所以"中文标题 + 英文别名"这种同义在结构上就会长出孪生页(见 §6 第 2 条)。
📄 三根柱子都在源码里可核;🤔 但请注意:这三根柱子只保证"能定位到页",不保证"改得对" ——§6 会看到它在块级和目录级的失效方式。
⇒ 三根柱子备齐了。接下来三节,就是把它们放进三种库状态里跑一遍:全量(§3)→ 增量(§4)→ 删除(§5)。
3. 全量:起点长什么样(以及钱花在哪)
这一节先给"起点":一篇文档从进库到变成一批页面,产出多大、钱花在哪。口径提醒:本节表里的"字符数"都是源文的(Source 有多长与部署语言无关);真正受输出语言影响的是页面的字符数(本部署把英文语料编译成了中文页面),见附录 A.3。
两次全量实测(同一个 WeKnora,同一个 standard 粒度) :
| A5 · Anthropic《How we contain Claude across products》 | P6+P7 · Hamel 的 evals 两篇 | |
|---|---|---|
| 语料 | 28,956 字符(≈ 4,500 词;5,701 token) | 103,328 字符(≈ 1.6 万词;20,431 token;272 块) |
| 产物 | 45 页(concept 25/entity 18/summary 1/index 1) | 86 页(concept 49/entity 34/summary 2/index 1) |
| 成本 | 239,912 token = 源文的 42 倍(51 次调用,含全部阶段;写页 43 次) | 508,422 token = 源文的 25 倍(102 次调用,含全部阶段;写页 83 次) |
| 耗时 | ingest 49.3 s + finalize 2.1 s | ingest 57.2 s + finalize 2.2 s |
| 链接 | 251 条;lint 0/8/12 | 362 条;lint 0/17/6 |
表里三处口径先说明白(后文反复用):
- 成本行的"N 次调用"= 一次 ingest 批次的全部 LLM 调用之和(抽候选/去重/引用/summary/写页/目录/索引引言/文档摘要),不是只算写页那一步。"源文 token"的口径(含 Pass 0 的对账与 32,768 字符截断那条坑)一并收进附录 B。
- 语料行的 token 数用 tiktoken
o200k_base计;"N 倍"= 编译消耗 ÷ 语料 token。这个口径可以对账:A5 的 5,701 + 约 1.5k 的固定提示词开销 ≈ Pass 0 实测 prompt 7,179,P6/P7 两篇也各自对得上(详见附录 B)。 - 链接行的"251 条"=
/wiki/stats的total_links(页面链接图的边数);"lint 0/8/12"=结构体检 lint 的三类计数,按broken_link(链到已不存在的页)、orphan_page(没有任何入链)、missing_cross_ref(正文提到别的页、却没建链)的顺序写——lint 一共查 6 类,这里只报与链接/可达性相关的这三类。两边broken_link都是 0,这就是 §0/§5 里"悬空"这个词所指;这三类后文会反复用到。
成本结构高度集中:写页(reduce)一项吃掉 79–82% 的 token(A5:43 次/197,696;P6+P7:83 次/403,406)——因为每个条目一次 LLM 调用。其余按量级排:引用匹配 7–11% 、抽候选 ~4% 、summary ~4% 、目录规划 1.4%、索引引言 0.1%(另有文档摘要 ≈1%,它不属于 wiki 流程)。
两列的倍数差得不小(42× vs 25×),但写到每一页的成本几乎一样(A5:197,696 ÷ 43 ≈ 4.6k token/页;P6+P7:403,406 ÷ 83 ≈ 4.9k/页)——真正拉开差距的是每一千个源文 token 能建出多少页(A5 7.5 页 vs P6+P7 4.1 页:长文里几乎每段都能服务好几个主题,FAQ 里大量段落撑不出新页)。
还有一个容易被忽略的细节:去重在空库上不花钱——没有相似页候选就直接返回;A5 首跑、以及 P6+P7 同批进空库,去重调用都是 0 次(后者的页面要等 reduce 才落库,map 阶段看库还是空的)。
⇒ 小结:全量的钱几乎全花在写页上——每个条目一次调用,没有省法。真正的问题从第二篇文档开始:新文档递进来会怎样(§4)、删掉一篇又会怎样(§5)。
4. 增量:两种情形,先分清
"增量"其实是两件事,机制和成本都不一样;日常用得多的是第一种:
| 触发 | 机制一句话 | 本文的实测 | |
|---|---|---|---|
| 情形 A:新文档入库(主场景) | 往库里再投一篇文档 | 抽候选 → 去重:命中已有页就并进去,没命中就新建一页 | §4.2(A4:+22 页、0 合并) |
| 情形 B:同一份文档再入库(重解析/重传) | 文档改了重传,或同一份原样重投 | 这篇文档上次产出过的每个 slug 整页重写;这次没再抽到的按"这篇不再提了"处理 | §4.3(45 → 54 页)/§5.3(重投 P7) |
两种情形共用同一条机制——写页的输入是批级 slug 列表(§4.1)。下面按"先机制、后情形 A、再情形 B"的顺序讲。
4.1 两种情形共用的机制:写页的输入是"批级 slug 列表"
先把"谁决定写页"说清楚,因为这里最容易想当然(📄 ProcessWikiIngest):
- Map 阶段每篇文档各跑一遍,产出的是"这篇文档抽到的每个 slug 一条 addition"——无条件的,没有任何"内容没变就跳过"的判断。另外,如果这篇文档此前产出过页(=情形 B),它还会顺手做一次集合差、补上撤回(§4.3)——新文档(情形 A)没有这一半;
- 这些 updates 汇总成批级的一张表:
slugUpdates: slug → [update...](同一批里多篇文档命中同一个 slug,就在同一个 key 上合并;撤回也是这条链上的 update,只是类型不同); - Reduce 遍历这张表、按 slug 并发处理:有 addition 的走写页(新建,或整页重写),只有撤回的走剥离/删除。⇒ 撤回只有两个来源:① 情形 B 的文档级集合差(§4.3);② 整篇文档被删(§5.1)——新文档入库(情形 A)这条路上不会出现撤回。
所以有三件事要分开,别混成一件:
| 问题 | 由谁决定 |
|---|---|
| 要不要写这一页 | 这个 slug 在不在本批的 slug 列表里(有 addition 就写) |
| 写成什么样 | 编辑器看到的东西:这页的旧正文 + additions 带来的素材块 + 撤回指令 |
| 要不要发"撤回" | 文档级的集合差——这篇文档上次产出过的页 vs 这次抽到的(只有情形 B 才非空,见 §4.3) |
三个通用后果:
- 更新粒度是"页",不是"段" :无论情形 A 的并入还是情形 B 的重写,reduce 都是"拿旧正文 + 新素材 → 吐出一整页"(version +1),没有"只改一段"的路径——§4.4 里"改写必然更贵"、version 号、以及删除留下的链接断口,都源于这一条。
- 写页次数只跟"命中几个 slug"有关,跟"改了多少字"无关:成本模型是"每条 slug 一次",不是 diff 大小(于是"投喂方式"本身成了成本变量,见 §4.4)。
- 批是局部变量:
slugUpdates只活在这一批里——同一个 slug 落在两个批次里就会被写两次(第二次是整页改写)⇒ "怎么投喂"本身就是成本变量(§4.4)。
4.2 情形 A(主场景):新文档入库 = 一堆新页 + 少量并入
先看最干净的一支:往已经装了两篇 evals 文章的库里,投一篇主题相邻但不同的文档(Anthropic《Effective harnesses for long-running agents》)——
| 项 | 结果 |
|---|---|
| 语料 | 13,855 字符 ≈ 2,714 token(A4) |
| 页数 | 95 → 117(+22:21 个内容页 + 1 个 summary) |
| 合并 | 0 个已有内容页被并入(唯一被改的是索引页) |
| 成本 | 29 次调用 / 98,773 token = 源文的 36 倍(写页 21 次 74,294 占 75%) |
| 耗时 | ingest 22.5 s |
| 去重 | 跑了 1 次(1,480 token),但一个都没合上 |
| 目录 | 新增 1 个一级目录(项目与产品),另有 1 页落在根目录 |
| lint | missing_cross_ref 25 → 48(新页带来的"标题出现但未建链") |
🤔 这份结果是"增量"最理想的形态:新文档=一堆新页 +(有新主题时)一张新目录,已有内容几乎不动。有一点要说清:长出新的一级目录不是缺陷——这一批的目录规划只看"本批的新条目 + 已有目录池",新主题本来就该有新位置(把这个行为去掉,只会让新内容硬塞进不合适的目录)。真正的风险在 "新条目该归到哪个目录"只靠提示词要求"逐字复用已有标签",程序侧没有账本:同一个主题在不同批次里可能被分到两个目录,旧目录也可能被换掉(漂移的实测见 §6 第 6 条——那次是同一份文档重投,错误分析 从 评估方法/错误分析 换到了 评估方法/自动化评估)。
如果新文档和库里的主题重叠呢?那就走到"并入"这一支——后投的文档命中已有页,去重步把它的 slug 归一过去,reduce 就把两边的素材写进同一页。我们的探针实验(往 P6+P7 库里补两篇小文)拿到的是这个形状:
concept/rag(标题"检索增强生成",别名RAG/Retrieval-Augmented Generation)的source_refs1 → 2 → 3,正文 1,849 → 2,564 → 2,607 字符;- 新增的 7 个概念页全部被塞进已有目录,一级目录一个都没新增;
- 同批两篇文档(P6+P7)最终产生 4 个"多源页" :
concept/synthetic-data-generation(P6 13 块 + P7 15 块)、entity/hamel-husain(4+9)、entity/shreya-shankar(2+6)、entity/langsmith(2+2)。
⇒ 顺带把两种"多源"的来路分清:同批进——两篇文档的更新落在同一个 slug,reduce 只写一次页、两份素材一起喂 ⇒ 这一页出生即多源(§3 那批 P6+P7 的 4 页就是);分两批进——第一篇先把页建出来(单源),第二篇命中已有页再并进去 ⇒ 单源页"长成"多源页,代价是这页整页重写一次。两条路终点一样(一页多项来源),成本与痕迹不同。
多源页读起来什么样? 那页最典型的 synthetic-data-generation 读得出"两篇被编进了一页"(P6 的 Rechat/Zillow 实例 + P7 的"维度/元组/两步生成"),编辑器还自己写了桥接句;但也抓到了真实痕迹——entity/langsmith 上同一件事被"摘要句 + 原文块"讲了两遍(因为每篇素材都以 Pass 0 的一句话描述打头,而编辑器只被允许"轻度重排/去重",没有被要求合并这两者)。
4.3 情形 B:同一份文档再入库 = 每个旧 slug 整页重写
情形 B 才会用到"文档级的集合差":同一篇文档这次新抽到的 slug vs. 上次产出过的页("上次产出过的页"= 库里 source_refs 含这篇文档的页,getExistingPageSlugsForKnowledge → ListSlugsBySourceRef):
| 情形(同一篇文档:这次新抽到的 slug vs. 上次产出过的页) | 代码动作 | 结果 |
|---|---|---|
| 两边都有 | 额外补一个 retract(带上这篇文档上一版的摘要,当"旧版本长什么样"的信号)+ 正常 addition | 编辑器一次拿到"删旧"和"加新" ⇒ 整页重写 |
| 上次有、这次没有 | retractStale | 把这篇文档从该页 source_refs 剥掉;只靠它撑着的单源页直接删 |
| 上次没有、这次有 | 只有 addition | 新建页 |
(术语:retract = 撤回,让编辑器把"只有这篇来源撑着"的内容从页上摘掉;addition = 新增,把这篇文档抽到的东西并进去;retractStale 是"这一页这篇文档已经不再提了"的那种撤回。summary/ 页是例外——它每次都整页覆盖,不走撤回。)
⇒ 结论:重解析必然重写。 Map 对每个抽到的 slug 都发 addition,所以重解析同一份文档(哪怕一字未改)也会把这些页整页重写一遍(同一个库重解析一次,页数 45 → 54)—— "重编译不幂等"就是从这来的;而且重写比新建贵(prompt 里要多带整页旧正文,见 §4.4)。同一份文档重投的完整实测见 §5.3。
⇒ 重解析时,系统怎么知道"页上哪句话是这篇文档的旧版本写的"?答案是:它并不真的知道。 它手里只有这篇文档上一版的 summary 页内容(SummaryContentByKnowledgeID,注释原文 "the surviving summary page's content" ),把它当作"旧版本长什么样"的参考交给编辑器。所以"以最新一次为准"这句话,在三个层面上的硬度完全不同:
| 层面 | 重解析后是什么状态 | 谁说了算 |
|---|---|---|
| 页面集合 | 上次抽到、这次没抽到的 slug 走 retractStale:剥掉这篇文档的来源,只靠它撑着的单源页直接删;这次仍抽到的照常写 | 程序——硬 |
| 页面正文 | 编辑器拿到"这篇文档的旧 summary + 这一页的旧正文 + 新素材",被要求"删旧 + 并新";可"哪句话属于旧版本"它只能靠 summary 猜 ⇒ 实测剥离很保守,可能新旧并存(§5.2 那次删除走的是同一段 reduce 撤回逻辑) | 模型——软(是目标,不是保证) |
| 证据引用 | chunk_refs 只并集、不删(§6 第 4 条):旧块和新块都留在页上,同一页挂着两代证据 | 只增不减 |
⇒ 所以确切的说法是:页面的"在不在"以最后一次的抽取结果为准(硬);页面的"写成什么"以最后一次为目标、但不保证(软);页面上挂的证据则是累加(旧的不去、新的照来)。
为什么正文这一层是"软"的?提示词把职责写得很直白——更新现有页、删掉只在被撤来源里出现的、并入新素材、保留仍然有效的(📎 WikiPageModifySystemPrompt / WikiPageModifyUserPrompt,prompts_wiki.go:321/:387–394):
You are a wiki editor tasked with updating an existing wiki page. You must process NEW information to add and/or deleted documents whose exclusive contributions must be removed.
- REMOVE facts/claims that were ONLY sourced from the
<deleted_documents>and are NOT present in any<remaining_source_documents>or<new_information>.- ADD and MERGE the facts from
<new_information>into the page. You are a COMPILER, not a writer: …- Preserve existing information that is still valid and still about {{.PageTitle}}.
⇒ 两点要说准:① 这是 "旧正文 + 新素材"的增量编辑,不是"把这一页的全部来源素材重新拿来从零写"——<new_information> 里只装这一批新引用的块原文(其余来源只在有撤回时以 <remaining_source_documents> 出现,而且给的是那些文档的 summary,当"这句话还有没有人撑着"的判据,不是它们的块);② 判断是增量的、输出仍是整页:模型要吐出一整页新正文(version +1)。
顺带回答一个更深的取舍:为什么不干脆"按全部素材重写这一页"? (🤔 推断;官方文档与注释没有正面解释这一点,只能从代码形态反推。)
先看这个替代方案好在哪——它把现行设计里最难的两件事直接消掉:
- 删除/替换变成确定操作:不必再判断"这句话是不是只来自被删的那篇、还有没有别的来源撑着"——重建时干脆不把被删文档的块送进去,剩下的素材里没有它,内容自然消失。
- 自愈:纠错、去重(多轮增量留下的"摘要句 + 原文块"重复)、把正文与证据重新对齐(
chunk_refs可以重算,而不是两代并集),都不必再发明新机制。 - 整套 retract 补丁可以取消:
retract/retractStale/reparse 的"撤回 + 新增",本质上都是在给"不重读素材"打补丁。
反过来看现行设计:它的代价不是"偶尔删不干净",而是 "删得保守"属于必然——编辑器要删一句话,得先回答两个问题:① 这句是不是来自被撤来源?② 它还有没有别的来源撑着?可它手里的东西只有:页面正文(没有句级归属——正文里没有"这句来自哪篇文档/哪个块"的锚点,chunk_refs 只到页级)、被删文档的 summary、以及其他来源的 summary(不是它们的块)。两个问题都没有证据可依,最稳妥的选择就是不删——我们实测正是如此(多源页只掉 −45~−276 字符,而且主要是链接降级)。同一个信息缺口还有两个下游:素材层 chunk_refs 只并集(正文与证据脱节)、写错了没有自愈路径(只能等下一轮增量再碰到它)。
📄 能证实的只有两点:官方文档把 Reduce 写成"按 slug 增量更新或合并页面";注释里的动机集中在规模(4w-document 那一整套改造)与失败隔离——写页失败时"现有页保持不变"(wiki_ingest_batch.go:2101–2110),素材取不到时也留了"退回 Details/(summary not available)"的兜底。
🤔 成本不是主要理由:一次增量写的输入是"旧正文 + 本批新块",按素材重写是"全部来源的块",差额上限就是 (1 − out/in) × 旧素材——同语言约 10%、跨语言约 65%,是常数因子、不是量级;而正文本身也随页面增长,枢纽页两边都大。(唯一能把差距拉开的极端情形是"页面被输出预算封顶、素材还在继续涨",那是封顶的副产品。)
⇒ 真要走"按剩余素材重算"这条路,缺的前置条件只有两条:素材随时可得且完整、有一个显式的"重建这一页"入口(人工或 lint 触发)——而不是让每次 ingest 都默默全量重算。
⇒ 所以我们的读法是:这条链路把"正确性"押在"每轮增量都不出错"上,而不是"随时能从素材重算"上。 这不是对错之分,但它决定了后面所有代价的形状(§6 第 1、3 条都是它的下游)。
4.4 投喂方式就是成本变量(怎么批量导入更省)
写页占整条链路 token 的 79–82% ,而同一个条目每被"另一批"碰到一次,就要整页重写一次(§1 那张表里的"⑤–⑦ 是批级一份"+ §4.1 的"批是局部变量":slugUpdates 只活在本批,跨批会被再写一次;跨批并发由 per-slug 锁串行,但不合并)。于是"怎么把这些文档喂进去"本身就是一个成本变量:
为什么"再写一次"必然更贵?这个不用实测——从提示词的结构就能推断(📎 WikiPageModifyUserPrompt):写页的输入是"系统规则 + 页面元信息 + 新素材块",而改写的输入在同一套东西之外还要塞进这一页的旧正文(<existing_page_content>,prompts_wiki.go:358–360)。在"同一页、同一批新素材"的前提下,输入只会更多,而输出两边都是整页 ⇒ 成本单调更高,不存在"测出来反而更便宜"的情况。
⇒ 省在哪因此很清楚:同一个 slug,同批只写一次;分成两批就多写一次——而且是更贵的那一次。
而"批次"不是用户显式选的,是时间去抖决定的:文档入库后要等 30 秒(wikiIngestDelay,注释原话 "Debounces rapid uploads" )才触发批次任务,窗口内的上传会攒成一批。我们的两批实验正好落在两边:P6+P7 连着上传 ⇒ 同一批(那 4 个"出生即多源"的页就是这么来的);A4 隔了 1.5 小时才投 ⇒ 自成一批。
⇒ 实操顺序(前面一层最有效、也最可控):
- 连续 / 批量上传,让会共享条目的文档落进同一个 30 秒窗口;
- 一个窗口里要一次塞进 >5 篇同域文档时,把
IngestBatchSize(默认 5)调大,别让它被切批; - 顺带把
IngestMapParallel(默认 10)调到 ≥ 批大小,否则 Map 阶段会排队。
⚠️ 但省的量取决于"批内 slug 的重叠率":P6+P7 只共享 4/87 个 slug ⇒ 就算分成两批,也只多 4 次写页(占那批 83 次写页的 ≈5%),几乎不省;真正值得为它调参的是"同一条产品线的多篇公告/新闻"这种高度重叠的语料。
⇒ 小结:增量的改动是局部的、可预期的,但结构会随时间累积——多源页是陆续"长"出来的,一级目录也是陆续"加"出来的。
5. 删除:删掉一个源之后,会发生什么
这一节是这个系列第一次拿到删除的实测数据。
5.1 代码里的删除链
先钉一个词: "知识条目"(knowledge)= 知识库里的一条记录——它可能是一篇上传的文件,也可能是"手工知识"那样直接录入的一段文本;我们的实验里就是投进去的那几篇文章(A5/A4/P6/P7)。下文按场景也称它"文档"。
删一个知识条目 → 写 tombstone → 清 pending ingest → 按 source_refs 反查现有页 → 入队 WikiRetract 操作 → 在 reduce 里逐 slug 处理:
- 多源页 → 走 retract 分支:把被删文档的摘要当"旧版本长什么样"的信号交给编辑器,让它在页面正文里按"一条事实 / 论断"判断(不是按块) ——哪些内容只来自被删文档(在剩余来源与本次新增里都找不到支持)就删掉。提示词原话是 "REMOVE facts/claims that were ONLY sourced from the
<deleted_documents>and are NOT present in any<remaining_source_documents>or<new_information>" 。⚠️ 这里的 "facts/claims" 指的是页面正文里的句子,不是块——块是"支持材料",判据是"这句话还有没有别的来源撑着"。这一点直接解释了 §5.2 那个"删得极保守"的现象。 - 单源页 → 直接删(没有别的来源支撑);
- 落库时
removeChunkRefs剥掉该文档的块引用; - finalize 里清死链、修剪空目录、重写索引页引言。
5.2 实测:删掉 78,647 字符那篇(P7)
| 项 | 结果 |
|---|---|
| 语料 | 78,647 字符 ≈ 15,257 token(P7) |
| 页数 | 117 → 70(删掉 47 页:46 个内容页 + 1 个 summary) |
| 成本 | 只花 24,388 token = 源文的 1.6 倍(6 次调用:5 个多源页各改一次 + 索引引言;47 个单源页是直接删库里那一行,不调 LLM) |
| 被改写的页 | 6 页:5 个多源页被剥掉 P7 的 source_ref,+ 索引页引言重写(version 全部 +1) |
| 多源页正文 | 几乎不动:synthetic-data-generation 3,395 → 3,350 字符(−45)/rag −92/shreya-shankar −276/hamel-husain −153/langsmith −57 |
| 那 −45 字符是什么 | 逐行对账后是链接降级(`[[concept/error-analysis |
| lint | broken_link 0 → 19;orphan_page 23 → 8;missing_cross_ref 48 → 34;stale_ref 全程 0(来源引用剥干净了,脏的是页间链接) |
| 残留文件行 | 被"删除"的页其实是软删除(deleted_at 有值、status 仍是 published,行还在库里) |
两个发现比数字更有意思:
① "只删独有贡献"这条判据在实践中非常保守——而且这是结构决定的,不是模型不够聪明。 要删一句话,编辑器得回答两个问题:① 这句是不是来自被删的那篇?② 它还有没有别的来源撑着? 而它手里的证据只有:页面正文(没有句级归属——正文里没有"这句来自哪篇文档/哪个块"的锚点,chunk_refs 只到页级,见 §4.3 那张三层硬度表)、被删文档的 summary(<deleted_documents> 里装的是它的摘要,不是块原文)、以及其他来源的 summary(<remaining_source_documents> 同样只有摘要)。拿摘要去匹配正文里的句子,只能模糊猜;两个问题都答不准,最稳的选择就是不删——实测正是如此:那 5 个多源页各自有一半左右的块来自 P7(synthetic-data-generation 是 15/28),正文却几乎原样保留。⇒ 删文档时,真正干净的是"整页消失"(单源页),不是"页内剥离"。
② 删除会留下悬空链接,而且没人自动收拾。 19 条 broken_link 全部指向被删掉的页(concept/trace 等)。原因是清理逻辑只扫"本批受影响的页" ——也就是"引用了被删文档的页";而这些悬空链接挂在别的页上(它们链接到被删页,但不引用 P7),不在扫描范围内。而且 AutoFix 只在 API 被调用时执行,删除后不会自动跑。
5.3 再投一次会得到什么("复原测试"的答案是:不会复原)
| 项 | 结果 |
|---|---|
| 语料 | 78,647 字符 ≈ 15,257 token(同一篇 P7 再投一次) |
| 页数 | 70 → 114(删除前是 117 ⇒ 少 3 页) |
| 新建 / 更新 | 新建 44 页;7 个页被更新(多源页重新挂上 ref、正文补回);63 页未变(上一代留下的页与新页并存) |
| slug 重合度 | 被删的 47 个 slug 只有 27 个回来;20 个没回来(entity/arize/entity/braintrust/entity/hex/entity/julius/entity/teresa-torres/concept/transition-failure-matrix …) |
| 新增 | 16 个原来没有的 slug(entity/bertscore/entity/rouge/concept/likert-scale/concept/criteria-drift …) |
| 成本 | 361,915 token = 源文的 24 倍(62 次调用,含全部阶段;其中写页 49 次/290,337) |
| lint | broken_link 19 → 0(因为 concept/trace 这些目标页又存在了——悬空是被"填上"的,不是被清理的) |
| 目录 | 29 个目录里 11 个为空(4 个一级父目录 + 7 个二级空目录);concept/error-analysis 从 评估方法/错误分析 漂到 评估方法/自动化评估,留下一个空目录 |
⇒ 小结:删除在页面级很干净,页内剥离很保守,而且不可逆——删得掉页面,但还原不了原来那棵树。
⇒ 同一份文档、同一个库、同一个模型,重投出来的不是同一棵树。
这不是缺陷,而是这类系统的固有属性:抽取、命名、归目录每一步都是 LLM 的判断——人把同一批资料重新整理一遍,也不会得到逐字相同的第二份索引。(编译链路的 LLM 调用统一 temperature = 0.3、不设 seed,而且是写死的——WikiConfig 里没有温度这一项。"可复现"从一开始就不在这套设计的目标里;顺带一个数:同一篇文档、同配置跑两次,是 33 页 ↔ 41 页。)要紧的不是"能不能复原",而是偏差有多大、能不能收口:这次是 47 个被删的 slug 回来 27 个、另新增 16 个、页数比原来少 3 页,还多出 7 个空目录 ⇒ 收口的成本落在人工(合并重复页、清理空目录),而不是系统内部。
6. 七个不牢的地方
按"会不会静默地咬人"排:越往前,越是"你没主动犯错也会中、而且不容易发现";越往后,越是"看得见、能人工收拾"。
-
页内剥离极保守(✅ 实测):多源页在源被删后正文几乎不动(−45 字符起)——而且这是结构决定的:判据要问"哪句话只来自被删的那篇",可这个信息根本不在编辑器手里(详见 §4.3/§5.2)。"被删文档的独有断言是否还留在页面上"这条,我们没有验到"清干净" 。
-
去重召回只比标题(📄 源码 + ✅ SQL 实证):候选召回走
WHERE lower(title) % :q(pg_trgm,阈值 0.3),页面的别名从来不参与召回(链接注入同理,见附录 A.1)——所以"中文标题 + 英文别名"这种同义(提示词里明确允许合并的类型)永远进不了候选,只能长出孪生页。实测:某页别名里一字不差写着Retrieval-Augmented Generation,而 live 谓词查它 0 行。 -
删除之后没有"重放"这条路(✅ 实测):重投不是复原(47 个被删 slug 回来 27 个、另新增 16 个、页数少 3)。这是 LLM 编译的固有属性——人的重做同样不幂等——但要把它当成选型前提:要"可复原"就得自己留页面快照(
wiki_page_revisions只保存每次覆盖前的版本,不负责重建被删页)。 -
块级引用只增不减("块级增量"没兑现) (📄 源码 + ✅ 实测):页面落库时是
page.ChunkRefs = mergeChunkRefs(旧 refs, 本批 additions)——只并集、不删旧的(wiki_ingest_batch.go:2129–2134),只有删除文档那条路才调removeChunkRefs。所以重解析同一篇文档后,页面会同时挂着两代块引用:重解析会生成一批新 chunk(新 UUID),旧的仍留在 refs 里(实测:A5 那篇重解析后,库里 142 个块=两代各 71,52 个页面全部双代、合计 452 条引用跨两代)。⇒ 两个后果要分开看:①chunk_refs不是"当前正文的证据清单",而是历次写入的并集(含已经被替换掉的块);② 就算它完全准确,它也只是一份页级的块清单——正文里没有任何锚点把"这句话"指回"那个块",所以"哪句话来自哪块"在这套数据模型里不成立(这也是 §5.2"删不干净"的上游,见 §4.3 那张三层硬度表)。 -
目录没有统一的抽象坐标(✅ 实测 + 📄 源码):
- "混轴"是这么来的:规划每次只看到"本批(≤5 篇)的新条目 + 已有目录池",提示词要的是"这一批的条目落在一棵自洽的树上"——批内有规则(同类同深度、优先一个宽顶层),跨批没有任何约束(没有全局重规划、没有层级/轴校验、没有账本)⇒ 第一批的视野决定顶层形状,后续批次装不下新内容时,就按那一刻这一批的抽象水准另造顶层。我们自己的库就是实例:
组织与人物(按"实体类型")+评估工具/评估方法/评估概念(按"领域子类")+ 后来 A4 加的项目与产品(按"项目/产品")——同一层混着三个轴。 - ⇒ 结论分两段:在 WeKnora 现状下,混轴损害的是人的导航(索引页/浏览器);一旦按 LLM-Wiki 的思路把目录当枚举/导航轴交给 agent(按目录列、按目录跳),它立刻从"不好看"升级成"检索能力缺口"——这就是"目录不是检索通道"的完整含义。说这条之前得先知道 agent 现在到底怎么召回——两套工具、三条边界(含"页面没进索引"与"别名不参与正则"),已整段挪到附录 A.5。
- "混轴"是这么来的:规划每次只看到"本批(≤5 篇)的新条目 + 已有目录池",提示词要的是"这一批的条目落在一棵自洽的树上"——批内有规则(同类同深度、优先一个宽顶层),跨批没有任何约束(没有全局重规划、没有层级/轴校验、没有账本)⇒ 第一批的视野决定顶层形状,后续批次装不下新内容时,就按那一刻这一批的抽象水准另造顶层。我们自己的库就是实例:
-
目录会漂移,而且三处本来可以修(✅ 实测 + 📄 源码):
- 固有:目录分配是 LLM 的判断(温度 0.3、无 seed),同一份文档重投、同一概念换目录很正常(
错误分析:评估方法/错误分析→评估方法/自动化评估)——人重新整理一遍也会这样,所以这更像选型前提("结构会漂"),不是缺陷。 - 可修①:没有"slug → 目录"的历史账本。 已经存在的页其实不会被改(套用规划时跳过
FolderID != ""的页,注释写明"人工移动是权威的");漂移出现在页面被删后重建那条路上(§5.3)——新页要重新规划,而规划不知道它以前住哪。存一份"slug → folder"的归属,重建时优先复用即可。 - 可修②:空目录残留。
PruneEmptyFolderChains只在 retract 涉及的目录链上跑,重投/漂移留下的空目录不会被顺手清掉(实测 29 个目录里 11 个为空,含 7 个二级)。 - 可修③:嵌入粗筛本身偏脆(目录数 > 60 才启用):对每个条目硬取余弦 top-3、没有分数下限,真正的"家目录"排第 4 就静默消失;无嵌入模型时的降级路径是
capFolders(pool, 150),与"一级目录全留"不自洽(可能把一级锚点截掉)。⚠️ 这条是读码发现的潜在风险——我们两个库的目录数都 ≤ 30,粗筛没有启用,所以上面那次实测漂移不是它造成的。
- 固有:目录分配是 LLM 的判断(温度 0.3、无 seed),同一份文档重投、同一概念换目录很正常(
-
删除留悬空链接(✅ 实测 0 → 19):清理只覆盖"引用被删文档的页",不覆盖"链接到被删页的页";
AutoFix需要手动调。🤔 修法不长:删除时用被删页的in_links反查,把这些页一并纳入清理范围。
再加一条规模记账(🤔 推断 + 📄 常数):上面那些"能不能用"的判断,最后都落在几个写死的常数上——它们把设计者心里的库规模画了出来:
| 常数 | 值 | 环节与作用 |
|---|---|---|
| 目录池上限 | 400 | ⑤ 目录规划:从该库的 wiki_folders 里拉"已有目录"当候选池,一次最多 400 条(按 path 字符串序,而非一级优先) |
| 全量喂给规划器 | ≤ 60 个目录 | ⑤ 目录规划:目录 ≤60 时整池喂(完美复用、零嵌入成本);一超过就切成"每个条目取余弦 top-3 近邻"的近似召回 |
| 提示词里的可复用目录 | 150 | ⑤ 目录规划:渲染进提示词的已有目录上限,超过就 capFolders 截断 |
| 一次规划装多少条目 | 60 | ⑤ 目录规划:本批条目分批规划,前一批分配的目录会 feed-forward 给后一批——这是跨批"收敛到一棵树"的唯一机制 |
| 单篇文档输入 | 32,768 字符 | ① 抽候选 / ④ summary(两步共用同一份截断文本):超出的部分不会产生新条目 |
| 交叉链接回填 | 不回填旧批次 | ⑦ 链接注入:新页只链"该页已有 out-links + 本批新页";官方注释:4 万文档规模下 "trade off some long-tail recall" ,交给 lint AutoFix |
⇒ 这些常数全是"几十/几百"量级,连起来就是设计者假设的 "健康库"画像:几十到几百个目录、几千页以内。超过这个区间它不会报错,而是逐级退化:精确复用 → 近似召回(>60 个目录)→ 提示词截断(>150)→ 长尾不建链(交给 lint 与人工)。
⇒ 七条串起来就是一句话:它给你的是"一份大致正确、能跟着语料改的中间产物",不是"可逆、精确、结构稳定"的知识底座。七条正好分成三组——可逆(第 3 条)、精确(第 1/2/4 条:删不干净、身份可能裂、证据只增)、结构稳定(第 5/6/7 条:目录混轴、会漂、脏链接要人清);再加上面的规模记账,就是这份承诺的适用区间(几十~几百个目录、几千页以内,越界逐级退化)。
7. 什么时候可以信它
可以放心用的场景:
- 语料扁平(一批同域文档,不是层层嵌套的组织资料);
- 你想要的是一份人可读、可增量维护的中间产物——人能按目录和链接翻(页面 + 链接 + 目录),agent 能读页面(找页靠
wiki_search正则、或直接给 slug;读到页之后可以顺着返回里的<relationships>与页内链接继续跳——但链接不参与召回,见附录 A.5 与 A.1); - 库的规模落在 "几十~几百个目录、几千页以内" ,并且你能接受目录超过 60 个之后复用走近似召回(§6 规模记账);
- 你能接受结构会漂,并愿意周期性跑 lint + 人工整理目录(悬空链接、孤儿页都由 lint 报出来);
- 删除的语义是"来源撤回"(这篇不算数了),而且不要求"页面上的相关句子一定被抹掉" ——页内剥离实测很保守(§5.2);
- 要批量导入一批同域文档:把它们在一个 30 秒窗口内连续投喂(必要时调大
IngestBatchSize)——同一页只写一次而不是反复重写;写页占成本 79–82%,这一条比任何参数都直接(见 §4.4)。
不要依赖它的场景:
- 超大语料(几千页以上):目录、身份、链接三件事的一致性全靠 LLM + 提示词;越过规模区间不会报错,而是逐级退化(精确复用 → 近似召回 → 提示词截断 → 长尾不建链,交给 lint 与人工);
- 要求删除可逆/可审计:删 + 重投不能复原(§6 第 3 条);"可审计"也打折——
chunk_refs只增不减,而且没有句级锚点,"哪句话来自哪块"不可归因(§6 第 1、4 条); - 要求结构稳定的导航:同一份文档两次编译,目录与页面集合都会变(§6 第 5、6 条);
- 要求"同一事物在库里唯一" :去重召回只比标题、不认别名,中英对译/缩写这类同义可能裂成两页(§6 第 2 条);
- 要求 agent 按目录/主题枚举与遍历(LLM-Wiki 那种用法):现在没有这条通道——目录不进检索,页面也没进向量/关键词索引,agent 只能正则找页、或按 slug 直读(读到页之后可以顺页内链接跳,但那只是"读下去",不是一条召回通道——见附录 A.5);要这条路得自己补。
和 GraphRAG 的分工(🤔):GraphRAG 把全局结构交给算法(Leiden 社区),代价是重——全量重编译、删除基本被忽略,好处是"语料越大越压得动";WeKnora 把结构交给 LLM、把变更做在局部,好处是"能增能删",代价是结构会漂、规模上不去、目录只服务人。选哪边,取决于你更怕"重"还是更怕"漂"。
附录 A:口径与陷阱(正文需要的细节都在这)
正文刻意把这些挪出来,免得挡住主线;但只要打算复现或对账,就绕不开它们。
A.1 附:为什么"只抽点、不抽边"不是遗漏
这条管道里没有任何关系抽取:抽候选只输出实体/概念,引用匹配只输出 slug → 块;页与页之间只有 [[slug|文字]] 链接,而且是确定性字符串注入产生的,不带类型。
这不是偷懒,是载体选择:wiki 的"图"本来就指页面之间的链接图(InLinks/OutLinks,GET /graph 可查);带类型的关系抽取在另一条管线(Neo4j 图谱,默认关)。想表达"是什么关系",只有两条路:reify 成一个页面(专题页、对比页),或者写进正文句子(与 GraphRAG 的分工见 §7)。
但要注意:链接是"可读、不被检索用"的。 ① 检索侧完全不碰它——页面没进向量/关键词索引,search_knowledge 召回的全是原始文档块(附录 A.5);② agent 找页靠 wiki_search(对 title/slug/summary/content 做正则;别名不在其中)(⚠️ 且只在 smart-reasoning 模式下注册,见附录 A.5),链接不参与召回排序;③ 链接唯一实际用途在 wiki_read_page 的返回里——它会带一段 <relationships>(links_to/linked_from + 邻居摘要,上限 20 条),所以"读一页 → 看邻居 → 再读一页"这条手工跳转可行;④ 目录(category_path)同样不在以上任何一环。
⇒ 代价:没有"按关系枚举/按谓词查"的能力——跨源连接降级成"正文里提到过才算连接",而且只在人读页面或那份邻居列表里可见;链接图还很稀疏且单向生长(P6+P7:86 页、注入覆盖 77 页,仍有 17 个 orphan_page)。顺带一个容易想当然的地方:链接注入的候选只认页面标题,别名不参与(去重召回也一样,见 §6 第 2 条)。
A.2 批、并发与去抖窗口
"批"这个词 §1 已经钉过(=一次 ingest 认领的文档集合、默认 ≤5 篇);这里只补它的并发实况:
- Map 的并发度其实由批大小决定:抽候选/去重/引用/summary 都是"每篇各跑一遍"——errgroup 上限虽然写的是 10,但一批最多 5 篇 ⇒ 那 10 永远够不着(每批各自新建 errgroup,多批叠加也不会让它生效)。真正的在飞峰值来自另一层:每篇文档内部"摘要 ∥ 引用匹配"两路并行(
wg.Add(2))⇒ 5 篇 × 2 ≈ 10 个调用。而 Reduce 的那个 10 倒是常用(按 slug 并发,一批能产出几十上百个 slug)。 - "去抖窗口"(30 秒 ingest 去抖、20 秒 finalize 去抖)不在这一节,见 §4.4。
A.3 输出语言:一个会改变所有字符数的部署级参数
先说一个会改变一切数字的部署级参数:输出语言。 wiki 页面的语言由 middleware/language.go 三级决定——WEKNORA_LANGUAGE 环境变量 > 请求的 Accept-Language 头 > 硬编码 zh-CN;而编译链路(batch/finalize)读的是 worker 的 ctx,所以改上传请求的头也改不动它(我们实测:带 Accept-Language: en-US 上传,页面照样是中文)。本部署没设环境变量 ⇒ 所有英文语料都被编译成中文页面,页面语言与源文语言是两件事。这一点直接决定了下面那个体积比该怎么读(想改,只能在部署级设 WEKNORA_LANGUAGE)。
⇒ 所以读正文里任何一个"字符数 / 体积比"时,先把这层记住:源是英文、页面是中文——它是 A.4 那个三乘数里的第一个乘数。
A.4 产物体积比不是常数(以及"编辑器几乎不改写"的实测锚点)
产物 ÷ 源 不是固定的"压缩率",它 ≈ 引用倍数 × 编辑器 out/in × 语言密度 三个因子相乘:同一块被多少页引用、编辑器吐出的正文比素材短多少、以及源语言与页面语言是否相同。三个锚点:中文源 → 中文页是 2.03(out/in 0.90,原文 12-gram 覆盖 79% );英文源 → 中文页只有 0.71–0.98(out/in 0.34–0.38,字符数被中文密度压掉);而 A5 那种单篇长文反而膨胀到 154% ——引用倍数 3.14(vs P6+P7 的 1.97)把语言缩水盖了过去。
⇒ 两个能直接用的结论:① 别把"体积比"当压缩率,它随语料结构与语言变;② "页面素材=逐字原文块"在内容层面成立(同语言下编辑器几乎不删不改:out/in 0.90、12-gram 覆盖 79%)——§2 柱子① 讲的三个"忠实性好处"就建立在这上面。
A.5 agent 侧现在到底怎么召回(两套工具、三条边界)
原本挂在 §6 第 5 条里;因为那一节越写越长,把它整段挪到这里。
两条路:① 知识侧是 search_knowledge(hybrid/semantic/keyword),搜的是原始文档块;② wiki 侧是 wiki_search + wiki_read_page——wiki_search(query) 把 query 当大小写不敏感的正则,在 title/slug/summary/content 上匹配(⚠️ 工具描述里还写着 alias,但 repo.Search 的 WHERE 里并没有它——描述与实现不一致),返回 slug + 摘要(默认每库 10 条、最多 50),然后 wiki_read_page(slug) 读整页、顺着页内链接继续跳。⚠️ 这套 wiki 工具只在 smart-reasoning 模式下注册——默认的 quick-answer 被代码注释直接称为 RAG-only("those can't ever retrieve a wiki page",custom_agent.go:807–810)。第一条边界:全程不碰目录。 没有"列出某个目录"的工具,wiki_read_page 附带的索引概览也只是按页面类型分组的前 20 条(代码注释: "any deeper exploration should go through wiki_search" ,agent/tools/wiki_tools.go:21–23);category_path 只出现在接口/UI 层(handler/wiki_page.go/types/wiki_page.go),两条召回路都不用它。
第二条边界:页面没进向量/关键词索引。 ChunkTypeWikiPage 的注释("用于将 wiki 页面接入现有检索管线")和 PluginWikiBoost(重排后给 wiki 页块 ×1.3)都说明"意图存在",但这一版找不到"把页面写成 chunk"的创建路径(全仓只有删除 wp-<pageID> 一处),我们日志里 wp- 也零出现 ⇒ search_knowledge 永远召回不到页面,页面只能被正则命中或直接读。第三条边界:别名不参与 agent 侧的正则——UI 侧的 repo.List 认别名(tsvector + aliases ILIKE),agent 侧不认(见 §6 第 2 条)。
附录 B:材料、口径与复现
被测对象:Tencent/WeKnora v0.8.2(commit 3e8b0bf),标准版 Docker Compose,对话模型 DeepSeek、向量模型 qwen3.7-text-embedding,Wiki 粒度 standard。
五组语料:A5(Anthropic · containment,28,956 字符)/P6+P7(Hamel 的 evals 两篇,103,328 字符)/A4(Anthropic · harness,13,855 字符)/两篇自写探针(各 ~840 字符)/一本人写中文书的第 9 章(24,933 字符,用于"同语言"对照)。
六个库:A5 单篇库、A5 · focused 对照库、P6+P7 · 多源页读感(本文 §4/§5 的主战场)、两个语言对照库(同一篇英文分别按 zh-CN/en-US 上传)、一个中文源库(同语言对照),另有若干一次性探针库。
复现路径(脚本在本仓库 experiments/weknora/):prep_multisource.py(语料 → Markdown)→ weknora_multisource.py(建库/投喂/等待/落盘)→ probe_incr_del.py inc|del(增量与删除对账)→ probe_multisource_chunks.py(块级溯源)→ analyze_multisource.py(形状与读感粗指标)→ probe_alias_recall.py(别名召回)。原始产物在 data/tmp/weknora-multisource/(含 incr-a4.json/del-p7.json/incr-p7.json)。
口径声明:本文所有 token 数取自容器日志的逐次 usage(purpose= 标签),页面与 lint 数取自 API 与数据库双向核对;"页"含 summary 页与索引页(正文页会另标)。删除实验的"被删页"以 API 视角(非归档)为准——数据库里是软删除。全文的"源文 token"统一按 tiktoken o200k_base 数投喂的那份 Markdown(A5 5,701/A4 2,714/P6 5,174/P7 15,257),"N 倍"= 编译消耗 ÷ 源文 token;这个口径可与日志对账——Pass 0 实测 prompt ≈ 源文 token + 1.3–1.5k 固定提示词开销(A5 5,701→7,179、A4 2,714→4,085、P6 5,174→6,508、P7 截断后 6,436→7,836),说明 o200k 与模型自己的分词器只差几个百分点。注意输入上限是 32,768 字符(maxContentForWiki,在 mapOneDocument 开头对 content 截断一次,抽候选与 summary 两步共用)——所以 78,647 字符的 P7 在那两步只看前 3.3 万字符,只有引用匹配用的是全部块、覆盖全文。耗时(ingest/finalize)分别取自容器日志 wiki ingest stats 与 wiki finalize 两行的 elapsed= 字段——同一批的两段耗时,不含排队与轮询。
同系列:《把知识库编译成 Wiki,再把检索权交给 LLM》·《GraphRAG 增量索引实测:删掉文档它连跑都不跑》·《GraphRAG 索引实战:产出什么、哪里会静默出错》·《GraphRAG 论文里那张"省 97% token"的账》。