Administrator
发布于 2026-06-17 / 745 阅读
10

知识库增量更新与版本管理方案

同事在群里甩了一张截图

上周三下午,运营在群里 @ 我,配了张图:用户问"退款多久到账",机器人答"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);
}

写入时必须原子。DELETEINSERT 放同一个事务,并且用逻辑删除 + 延迟物理清理:新块先插、标记 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 的时候忘了。

如果只能做一件事,我会选变更影响评估。增量索引省的是钱和时间,影响评估省的是信誉。用户拿到一个错的退款承诺,比慢四分钟严重得多。

参考