同事在群里甩了一张截图
上周三下午,运营在群里 @ 我,配了张图:用户问"退款多久到账",机器人答"7 个工作日内"。而政策文档在 6 月 2 日就改成了"3 个工作日"。
后台里那份 PDF 更新时间是 6 月 2 日 15:47,状态"已解析、已入库"。文档是新的,索引里的内容是旧的。
这篇记录我们从这次投诉出发,花三周重做的知识库增量更新与版本管理。
先确认:索引里存的到底是什么
没急着改代码,先查事实。切片存在 kb_chunk 表里:
SELECT c.id, c.doc_id, left(c.content, 40) AS head,
c.updated_at, c.embed_version
FROM kb_chunk c
WHERE c.content LIKE '%个工作日%'
ORDER BY c.updated_at DESC
LIMIT 5;
结果很直观:
id | doc_id | head | updated_at | embed_version
--------+--------+-----------------------+---------------------+---------------
882143 | 1207 | 退款将在 3 个工作日... | 2026-06-02 15:52:11 | bge-m3-v2
882144 | 1207 | 3 个工作日内原路退回 | 2026-06-02 15:52:11 | bge-m3-v2
719033 | 1207 | 退款将在 7 个工作日... | 2026-05-21 09:14:03 | bge-m3-v2
719034 | 1207 | 7 个工作日内原路退回 | 2026-05-21 09:14:03 | bge-m3-v2
719035 | 1207 | 一般 7 个工作日到账 | 2026-05-21 09:14:03 | bge-m3-v2
同一份文档的两版切片同时躺在库里。新的两条是 6 月 2 日插进去的,旧的三条没删掉。检索时新旧混在一起,旧切片票数更多,答案就翻回了旧政策。
根因:三处设计缺陷叠在一起
把更新任务的代码扒了一遍,问题不止一个。
删除和插入不在一个事务里。老逻辑是:文档 MD5 变了就先 DELETE 旧切片、再批量 INSERT 新切片,两段各自提交。6 月 2 日那次跑到一半,embedding 服务限流抛异常,只插进 2 条就中断了,而删除早就提交了。
没有版本概念。表里只有"当前状态",没有"这是第几版"。想回滚只能重新上传、重新解析,一次 4.5 小时,所以实际上没人敢回滚。
没有变更影响评估。文档改了,谁也不知道会影响哪些问答、影响多少。
前两个是技术债,第三个是流程缺失,但它才是这次事故扩大的原因——6 月 2 日到 6 月 17 日,两周多没人发现。
第一步:块级增量,而不是文档级全量
原来的判断粒度是"文档"。文档里改了一个字,整篇 320 个切片全部重算 embedding。我们的知识库有 1,847 份文档、约 41 万切片,全量重算一次 12 万次 embedding 调用,¥310,4.5 小时。
改成块级:给每个切片算内容哈希,只处理哈希变了的块。关键是要有一个稳定的 chunk_id,否则每次切分顺序一变就被判定成"全变了"。
public record Chunk(String chunkId, String content, int seq) {
/** 用文档标识 + 段落路径 + 序号生成,内容变了 id 不变 */
static String stableId(String docKey, String sectionPath, int seq) {
String raw = docKey + "#" + sectionPath + "#" + seq;
return Hashing.sha256().hashString(raw, UTF_8).toString().substring(0, 32);
}
String contentHash() {
return Hashing.sha256()
.hashString(content.normalize(NFC), UTF_8)
.toString().substring(0, 32);
}
}
diff 逻辑:
public DiffResult diff(String docKey, List<Chunk> fresh) {
Map<String, String> oldHashes = chunkDao.loadHashMap(docKey);
List<Chunk> toEmbed = fresh.stream()
.filter(c -> !c.contentHash().equals(oldHashes.get(c.chunkId())))
.toList();
Set<String> freshIds = fresh.stream().map(Chunk::chunkId).collect(toSet());
List<String> toDelete = oldHashes.keySet().stream()
.filter(id -> !freshIds.contains(id)).toList();
return new DiffResult(toEmbed, toDelete);
}
写入时必须原子。DELETE 和 INSERT 放同一个事务,并且用逻辑删除 + 延迟物理清理:新块先插、标记 active,提交后再把旧块置为 inactive。这样即使中途失败,检索层看到的仍然是完整可用的旧数据。
@Transactional
public void apply(String docKey, DiffResult d, long version) {
chunkDao.insertNew(d.toEmbed(), version); // 新块 version = N
chunkDao.markInactive(d.toDelete(), version); // 旧块软删
chunkDao.upsertDocVersion(docKey, version);
}
检索侧只查 status = 'active',回滚就是把 version = N 的置 inactive、version = N-1 的置 active,一条 SQL,20 毫秒。
实测:那份 58 页的政策 PDF 改了 4 处,块级 diff 后只有 11 个块需要重算 embedding,耗时从 4 分 12 秒降到 8.6 秒。
第二步:版本快照
版本号用文档级单调递增,存一张快照表:
CREATE TABLE kb_document_version (
doc_key varchar(128) NOT NULL,
version bigint NOT NULL,
content_hash char(32) NOT NULL,
chunk_count int NOT NULL,
source_uri text NOT NULL,
operator varchar(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (doc_key, version)
);
回滚命令做成了 CLI,谁都能跑:
$ kbctl rollback --doc policy/refund-2026 --to-version 7
[15:22:03] 当前版本 v8(2026-06-02 15:52:11,chunk 322)
[15:22:03] 目标版本 v7(2026-05-21 09:14:03,chunk 318)
[15:22:03] 影响:chunk 新增 0 / 失效 11 / 恢复 7
[15:22:03] 已切换,耗时 23ms。检索缓存已失效 1,204 条
6 月 20 日真的用了一次:业务方发现新政策有个条款写错了,但文档已经推到线上一整天。回滚到 v7 用了不到 1 分钟,之前这种事要等 4.5 小时重跑。
第三步:变更影响评估
这是我认为最值钱的一步。文档更新后,自动跑一遍离线评测集,看答案变了多少。
我们维护了 640 条问答对作为回归集,每条标注了期望答案和来源文档。更新提交后异步触发:
public ImpactReport evaluate(String docKey, long fromVersion, long toVersion) {
List<QaCase> affected = qaCaseDao.findByDocKey(docKey); // 命中该文档的用例
List<CaseResult> after = affected.parallelStream()
.map(c -> runPipeline(c.question())) // 用新索引跑一遍
.toList();
List<CaseResult> before = resultDao.lastRun(docKey, fromVersion);
return ImpactReport.builder()
.total(affected.size())
.answerChanged(countChanged(before, after))
.scoreDrop(countScoreDrop(before, after))
.citationsLost(countCitationLost(before, after))
.build();
}
报告直接推到企业微信群。answerChanged 超过 15% 或者 scoreDrop 大于 3 条时,会阻断发布,等人工确认。
6 月 24 日拦下来一次:一份产品参数表更新,程序判定 38 条问答的答案变了,其中 5 条评分下降。人工一看,是新版表格里删掉了两列,导致召回的片段信息不全。文档作者重新补上再发,避免了线上事故。
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 单次文档更新耗时(58 页 PDF) | 4 分 12 秒 | 8.6 秒 |
| 全量重建耗时 | 4 小时 32 分 | 6 分 40 秒 |
| 全量重建 embedding 调用 | 124,800 次 | 4,310 次 |
| 全量重建成本 | ¥310 | ¥10.8 |
| 回滚耗时 | 不支持 | 23ms |
| 更新后到发现问题的时间 | 15 天(靠用户投诉) | 4 分钟(自动评估) |
还没解决的部分
块级 diff 依赖切分结果的稳定性,有两个场景还会退化成全量:
- 跨页表格。PDF 里一个表格跨了两页,解析器调整后分块位置整体位移,稳定 id 失效,一次更新命中 300+ 块。目前只能靠告警发现,然后手工确认;
- 图片型 PDF。走 OCR 的版本,每次 OCR 结果的换行和空格都不一样,内容哈希几乎必然变化。我们加了一层归一化(去空白、全角转半角),命中率从 100% 全变降到 34% 会变,但还是偏高。
另外,变更影响评估目前只覆盖有标注用例的文档,640 条用例只覆盖了 210 份高频文档。剩下 1,600 多份还是盲区,这个覆盖率我们打算 Q3 提到 60%,具体怎么补还在讨论。
小结
这次事故暴露的不是某个 bug,是我们把"文档"和"索引"当成了一回事。文档是一份文件,索引是它的一份有状态的派生数据,派生数据就该有版本、有原子写入、有回滚路径——这些是数据库领域几十年前就解决的问题,只是我们在做 RAG 的时候忘了。
如果只能做一件事,我会选变更影响评估。增量索引省的是钱和时间,影响评估省的是信誉。用户拿到一个错的退款承诺,比慢四分钟严重得多。