3 篇后再导入 4 篇,为什么知识库只剩 4 篇?——一次 Agentic RAG collection 增量发布故障复盘

5 阅读11分钟

30 秒判断:这不一定是文件丢了,更可能是“当前知识库”被一批新任务重新定义了。任务成功、文档登记、索引生成和活动快照发布是四种不同状态;只检查数据库里有没有文件,无法证明检索侧现在能看到几篇。

适合谁读:已经跑通过“上传文件 → 向量检索”,正在做批量导入、增量更新、版本管理或知识库运维的人;也适合正在设计同步、索引和发布链路的工程师。

系列位置:本文属于《学习问答助手Agent实践》的实现复盘部分。系列前 9 篇先完成了需求定义、架构设计与评测方法;进入实现后,第一篇《AI 写代码越快,为什么我反而越没有掌控感?》讨论控制模型,本篇接着复盘设计原则落到真实任务链后暴露的故障。前期入库设计篇讲的是原则上应该怎样构建。

代码库:ai-study-helper-v2

结构速览:§0 故障现场;§1 为什么文件没丢但知识库变小了;§2 四个状态不能混;§3 错误的发布模型;§4 哪些“快速修复”不该做;§5 正确的增量快照;§6 真正应该补的测试;§7 产品层面的启示;§8 协作复盘;§9 一页速查。

证据标记:✅ 实测=有真实界面复现;🧪 回归=已有自动化测试固定行为;🤔 推断=由当前机制推导出的结论。


0. 故障现场

一个很普通的用户操作序列:

  1. 先选择 3 个 HTML 文件;
  2. 后台任务完成,任务状态显示成功;
  3. 再选择另外 4 个 HTML 文件;
  4. 第二个任务也显示成功;
  5. 打开“当前知识库”,只看到 4 篇。

用户自然会问:

我先后入库了 3 篇和 4 篇,知识库到底应该有几篇?

按照“单用户、单知识库、增量更新”的产品语义,答案应该是:

3 + 4 = 7

实际却是:

第二批入库后,活动知识库里只剩第二批的 4 篇

更迷惑的是,前 3 篇并没有从本地文档登记库消失,旧快照也还在。它们只是没有进入“下一次检索实际读取的那份活动快照”。

✅ 实测:这个现象在真实桌面界面中复现,不是单纯的前端计数错误。


1. 为什么“文件没丢”,知识库却变小了

Agentic RAG 的入库不是一次文件复制,而是一条会跨多个状态层的流水线:

原始 HTML
  ↓
文档身份与版本
  ↓
章节树与 blocks
  ↓
dense / keyword 索引
  ↓
collection 快照
  ↓
活动指针
  ↓
检索真正能看到的内容

这条链路至少包含四种不同事实:

层回答的问题有数据是否等于用户能搜到
原始 artifact/文档登记系统保存过什么否
候选索引这批 blocks 是否可检索否
collection 快照一组固定版本的数据和索引是否完整否
活动快照指针当前检索正在使用哪份快照是

故障发生时:

  • 文档登记库里有旧文章;
  • 旧快照仍然保存着前 3 篇;
  • 新批次的 4 篇也完成了索引;
  • 但活动指针切到了一张“只包含本批 4 篇”的新快照。

所以,从“数据是否还在磁盘上”看,没有丢;从“用户下一次搜索能看到什么”看,确实丢了 3 篇。

🤔 推断:只要系统存在“活动快照”这一层,就不能用数据库里的文档总数回答“当前知识库有几篇”。


2. 四个状态不能混

很多 RAG 原型用一句话描述入库:

文件已经处理完成。

但“处理完成”至少有四种含义:

2.1 任务成功

表示某个后台任务完成了它负责的动作。

问题是,任务可能只负责当前批次。任务成功并不自动说明全局知识库状态正确。

2.2 文档已登记

表示系统已经分配稳定身份和版本。

这也不等于文章已经建立索引,更不等于它进入了当前可检索集合。

2.3 索引已生成

表示这批 blocks 已经生成向量和关键词候选索引。

候选索引仍然是“构建材料”,不是已经发布的活动知识库。

2.4 当前知识库已更新

只有活动快照指针切换成功,下一次检索才会读取新的文档集合。

因此,真正需要验证的不是:

任务有没有成功?

而是:

任务成功之后,活动快照中的文档集合是否符合用户预期?

这两句话看起来接近,测试价值完全不同。


3. 错误的发布模型:把“本批结果”当成“整个知识库”

最初的实现逻辑可以简化成:

新快照 = 本批成功的 docs + 本批成功的 rows

这个模型在第一次导入时完全正常:

第一批:0 + 3 = 3

第二次导入也不会报错:

第二批:0 + 4 = 4

它会非常安静地把“追加”变成“替换”。

更准确地说,代码没有主动删除第一批;它只是创建了一份只装第二批的新快照,然后让活动指针切过去。在快照模型里,这等价于告诉系统:

从这一刻开始,整个知识库只有这 4 篇。

为什么单批测试看不出来

如果每个测试都只做一次导入,那么:

  • 第一批发布 3 篇,正确;
  • 第二批发布 4 篇,单看本次也正确;
  • 索引、manifest、checksum 都对;
  • 活动指针也只指向一份内部完整的快照。

每个局部都“正确”,连续操作却违反产品语义。

这是很典型的状态迁移错误:局部操作的断言通过了,操作序列的系统不变量没有被测试。

从人机协作角度看,这里真正缺的也不只是一条测试。AI 可以把“本批结果生成快照”实现得局部正确,但原来没有被明确交付给它、也没有被负责人检查的,是更高一层的产品不变量:

同一个知识库连续导入两批不同文档,旧文档必须继续存在。

这正说明,切片开始时只写“支持批量入库”还不够。控制契约必须同时写清用户可观察的状态结果,例如“3 篇后再导入 4 篇,当前知识库必须是 7 篇”。


4. 哪些“快速修复”不该做

看到问题后,很容易想到几种快速方案。

方案 A:从文档登记库读取所有文档重新发布

这个方案看起来可以让文档数变成 7,但它绕过了当前活动快照。

如果用户先回滚到旧快照,再从登记库读取“所有最新文档”,系统会把回滚结果又悄悄覆盖掉。

错误不只在数量,而是在合并基线。

方案 B:每批都全量重新解析、重新 embedding

这可以保证结果统一,但旧文章的内容没有变化,却要重复抓取、切块、调用 embedding。成本高,速度慢,还会制造不必要的新版本和外部请求。

方案 C:索引行直接 append

向量表和关键词表追加新行看似简单,但同一篇文档更新时,旧版本 rows 也会残留。

最终可能同时搜到同一文档的两个版本。

方案 D:发现数量不对就切回上一个快照

回滚只能撤销一次发布,不能解决“下一次增量仍然会覆盖”的机制问题。

判断原则:修复必须基于“旧活动快照 + 本批变更”生成新状态,而不是从另一个事实源重新拼答案。


5. 正确的增量快照

最终采用的状态迁移是:

新活动集合 =
    旧活动快照中未被本批替代的文档
  + 本批成功文档

对应到索引:

新索引 rows =
    旧快照中保留文档的 rows
  + 本批成功文档的 rows

这套合并规则需要同时满足五条不变量。

不变量 1:不同来源继续追加

本批没有出现的旧 doc_id 原样保留。

旧:[A, B, C]
新批:[D, E, F, G]
结果:[A, B, C, D, E, F, G]

不变量 2:同一文档按版本替换

本批出现相同 doc_id 时,用它覆盖旧版本,而不是增加一篇:

旧:[A(v1), B, C]
新批:[A(v2)]
结果:[A(v2), B, C]

不变量 3:artifact 和索引必须一起替换

不能只换文档清单,不换 rows;也不能只换向量,不换关键词索引。

旧文档的 artifacts 和 rows 原样进入新快照,新版本则整组进入。

不变量 4:本批失败不能删除旧文档

旧:[A, B]
新批:[C 成功, D 失败]
结果:
  documents = [A, B, C]
  failed_documents = [D]

失败只表达“本批哪一个输入没有完成”,不能据此清空以前已经成功的内容。

不变量 5:合并基线是旧活动快照

不是文档登记库,也不是“磁盘上最新版本”,而是用户此刻正在使用的 activity snapshot。

否则回滚后再导入,会把用户明确选择回滚掉的内容重新带回来。

✅ 回归:当前实现用 3 篇后追加 4 篇的自动化测试固定了 7 篇结果,并同时校验 manifest 与索引 rows。


6. 真正应该补的测试

修复之后,最有价值的测试不是再测一遍单批发布,而是测试操作序列。

6.1 连续批次

任务 A:导入 3 篇
任务 B:导入另外 4 篇
断言:活动 manifest 7 篇
断言:索引 rows 覆盖同一个 7 篇集合

只检查 manifest 不够,因为可能出现“清单有 7 篇,索引仍只有 4 篇”。

6.2 版本替换

第一次:A(v1)
第二次:A(v2)
断言:文档数仍为 1
断言:活动版本为 v2
断言:索引只保留 v2 rows

6.3 partial 保留

旧库:[A, B]
新批:[C 成功, D 失败]
断言:活动库为 [A, B, C]
断言:failed_documents 只包含 D

6.4 回滚后的增量

v1:[A]
v2:[A, B]
回滚到 v1
再导入 C
断言:结果为 [A, C],不是 [A, B, C]

这条测试专门防止实现偷偷从“最新登记库”取数据。

6.5 数量、清单和索引三方对账

最终至少要同时满足:

manifest.documents
  == collection rows 里的 distinct doc_id
  == 用户界面显示的文章清单

🧪 回归:这些路径现在由连续批次、版本替换、partial 保留和回滚测试共同覆盖。


7. 产品层面的启示

这个故障不只是后端发布算法问题,它也暴露了产品状态不可见。

用户最初无法回答:

  • 我到底入库了几篇?
  • 都是哪几篇?
  • 上一次操作成功了吗?
  • 为什么任务显示成功,知识库数量却不对?

如果界面只显示一个“已完成”,用户只能猜测。

因此修完发布逻辑后,产品还需要同时提供两个事实源:

用户问题事实源
现在能搜到什么当前活动快照的文档清单
刚才做过什么持久化的入库任务记录

它们不能互相代替:

  • 任务成功不等于知识库一定变化;
  • 知识库有 7 篇也不代表最近一次任务成功;
  • 文档登记库有旧版本,不代表当前检索可见。

对用户最重要的不是 snapshot,而是“现在有什么”和“刚才发生了什么”。这也是负责人通过真实操作发现系统模型偏差的控制接口。


8. 协作复盘

这件事改变了我对分工的判断:

  • AI 很快实现并验证了局部快照机制,但“任务成功是否等于知识库正确”仍需要人来追问;
  • 人真正发现问题,不是靠逐行读代码,而是用连续操作恢复系统级心智模型;
  • 序列测试后来由 AI 实现,但应该更早由控制契约明确出来;
  • 当前知识库清单和操作历史不是装饰页面,而是让负责人和用户共同观察系统状态的控制接口。

因此在这个问题上,更合适的分工不是“AI 写、人审”,而是:

人定义系统不变量
AI 实现并写出验证
机器执行回归
人用真实操作确认产品语义

9. 一页速查

故障:先导入 3 篇,再导入 4 篇,活动知识库只剩 4 篇。

根因:新快照只合并了当前任务的 docs/rows,没有合并旧活动快照。

为什么没早发现:单批测试全部通过,没有测试连续操作后的系统不变量。

正确公式:

新活动集合 =
    旧活动快照中未被本批替代的文档
  + 本批成功文档

必须同时检查:

  • manifest 文档集合;
  • dense/keyword 索引 rows;
  • 当前界面文章清单;
  • previous snapshot 与回滚;
  • 同 doc_id 的版本替换。

最重要的一句话:

任务成功只是事件;活动快照才是用户此刻真正拥有的知识库状态。