Administrator
发布于 2025-06-28 / 22036 阅读
354

知识库场景下的海量文档存储设计

知识库从 5 万文档涨到 320 万,第一版设计撑不住了

我们公司的知识库最早是给客服用的,5 万篇 FAQ,存储设计得很随意:一张 kb_doc 表,切片直接以 JSON 数组的形式塞在 chunks 字段里,向量存在 Milvus 里,两边用 doc_id 关联。

这个设计在 5 万篇的时候完全没问题。到今年五月,文档量涨到 320 万篇(切片 4100 万条),问题全冒出来了:改一个文档的错别字要重写整条 JSON(平均 2.3MB);无法知道上次检索用的是哪个版本的切片;向量和切片数据经常对不上(Milvus 里删失败了但 MySQL 里已经删了)。

六月我们做了一次完整的存储重构。这篇记录新的建模方式和踩过的坑。

第一版设计的三个致命问题

复盘一下原来的表:

CREATE TABLE kb_doc (
    id       BIGINT PRIMARY KEY,
    title    VARCHAR(512),
    content  LONGTEXT,          -- 原文
    chunks   JSON,              -- 切片数组,平均 2.3MB
    status   TINYINT,           -- 0未处理 1已处理 2失败
    updated_at DATETIME,
    INDEX idx_status (status)
);

三个问题:

  • 切片内嵌在 JSON 里,无法单独操作。想更新第 7 个切片,得把整个 JSON 读出来、改、再写回去。binlog 里一条 2.3MB 的记录,主从同步压力巨大;
  • 没有版本概念。文档更新后直接覆盖,出问题时无法追溯"当时模型看到的是什么";
  • 向量存在外部系统,与切片的一致性没有保障。我们的处理流程是先删向量再写切片,中间失败就会留下孤儿数据。

新的建模:四层结构

重构后的核心思路是把"文档、版本、切片、向量"拆成四个独立的实体,各自管理各自的生命周期。

CREATE TABLE kb_doc (
    id           BIGINT PRIMARY KEY,
    space_id     BIGINT NOT NULL,        -- 知识空间(业务隔离)
    current_ver  INT NOT NULL DEFAULT 0, -- 当前生效版本
    doc_hash     CHAR(64),               -- 内容哈希,用于增量判断
    source_type  VARCHAR(32),            -- pdf / markdown / faq
    acl_tags     JSON,                   -- 文档级权限标签
    status       VARCHAR(16) NOT NULL,
    created_at   DATETIME NOT NULL,
    updated_at   DATETIME NOT NULL,
    INDEX idx_space_status (space_id, status)
) ENGINE=InnoDB;
CREATE TABLE kb_doc_version (
    id            BIGINT PRIMARY KEY,
    doc_id        BIGINT NOT NULL,
    version       INT NOT NULL,
    content_path  VARCHAR(512),          -- 原文存对象存储,不入库
    parse_result  JSON,                  -- 版面分析、元数据
    chunk_count   INT NOT NULL,
    status        VARCHAR(16) NOT NULL,  -- PROCESSING / READY / FAILED
    created_at    DATETIME NOT NULL,
    UNIQUE KEY uk_doc_ver (doc_id, version),
    INDEX idx_status (status)
);
CREATE TABLE kb_chunk (
    id           BIGINT PRIMARY KEY,
    doc_id       BIGINT NOT NULL,
    version      INT NOT NULL,
    seq          INT NOT NULL,           -- 切片在文档内的顺序
    content      TEXT NOT NULL,
    content_hash CHAR(64) NOT NULL,      -- 用于增量更新比对
    char_count   INT NOT NULL,
    acl_tags     JSON,                   -- 冗余自文档,避免检索时 JOIN
    vec_id       VARCHAR(64),            -- 向量库中的 ID
    vec_status   TINYINT DEFAULT 0,      -- 0待生成 1已生成 2失败
    created_at   DATETIME NOT NULL,
    UNIQUE KEY uk_doc_ver_seq (doc_id, version, seq),
    INDEX idx_doc (doc_id),
    INDEX idx_vec_status (vec_status)
);

几个关键设计:

  • 原文不入库,存对象存储,kb_doc_version 里只存路径。我们的原文平均 180KB,320 万篇就是 570GB,放数据库纯属给自己找麻烦;
  • ACL 标签冗余到切片。检索时要在向量库/切片层做权限过滤,如果每次 JOIN 文档表拿权限,4100 万条切片会拖垮查询。冗余之后检索只需要查一张表;
  • 切片带 vec_status,把"向量生成"变成切片上的一个可重试的状态机,而不是一次性操作。

版本管理:为什么必须有

最开始我觉得版本管理是过度设计,直到遇到这件事:有用户投诉"知识库回答错了",我们查了半天发现,那份文档在投诉前一天被人改过,改的内容恰好是错的。文档后来又被改回来了,但如果我们没有版本记录,根本无法还原当时模型看到的内容。

现在的流程是:

  1. 文档更新时创建新版本(version + 1),状态 PROCESSING
  2. 新版本切片全部生成并向量化完成后,原子切换 kb_doc.current_ver
  3. 旧版本数据保留 N 个版本(我们保留 5 个),更老的异步归档。

第 2 步的原子切换很关键,实现上就是一个带条件的 UPDATE:

UPDATE kb_doc
   SET current_ver = #{newVer}, doc_hash = #{newHash}
 WHERE id = #{docId}
   AND current_ver = #{oldVer}      -- 乐观锁,防止并发更新错乱

检索时只查当前版本:

SELECT c.* FROM kb_chunk c
  JOIN kb_doc d ON c.doc_id = d.id AND c.version = d.current_ver
 WHERE d.space_id = #{spaceId}
   AND c.id IN (#{chunkIds});        -- chunkIds 来自向量检索结果

这个 JOIN 看起来会慢,但因为 uk_doc_ver_seq 索引的存在,加上 chunkIds 通常只有几十个,实测 P99 在 8ms 以内。

增量更新:这才是省钱的地方

文档更新时,全量重建所有切片是最省事的做法,但成本很高——每个切片都要重新调 embedding 模型。我们统计了一下:一份 30 页的 PDF 大概切出 180 个切片,全量重建的 embedding 成本是 ¥0.021,耗时 12 秒。

而实际上,改一个错别字往往只影响其中一两个切片。所以我们在更新时做切片级的内容比对

public UpdatePlan diff(Long docId, int newVersion,
                       List<ChunkDraft> newChunks) {
    Map<String, KbChunk> oldByHash = chunkRepo.findByDocIdAndVersion(
                    docId, docVersionRepo.currentVer(docId))
            .stream()
            .collect(toMap(KbChunk::getContentHash, identity()));

    List<ChunkDraft> reused = new ArrayList<>();
    List<ChunkDraft> toCreate = new ArrayList<>();

    for (ChunkDraft draft : newChunks) {
        KbChunk existing = oldByHash.get(draft.hash());
        if (existing != null && existing.getVecStatus() == 1) {
            reused.add(draft.withExistingVector(existing.getVecId()));
        } else {
            toCreate.add(draft);
        }
    }
    return new UpdatePlan(reused, toCreate);
}

新版本只创建内容真正变化的切片,未变化的切片在新版本里复用旧版本的 vec_id(向量是一样的,没必要重算)。

实测效果(我们统计了一个月的更新任务,共 4.7 万次文档更新):

指标全量重建增量更新节省
月 embedding token8.4 亿1.1 亿87%
月 embedding 成本¥16,800¥2,20087%
平均单次更新耗时12.4s2.1s83%
向量库写 QPS峰值 3200峰值 410

这里有个前提:切片策略必须稳定。如果你的切片策略是滑动窗口 + 语义切分,改一个字可能导致后面所有切片的边界都变了,增量比对就完全失效。我们的策略是优先按标题层级切,超长段落再按固定长度切,边界相对稳定,实测平均复用率 78%。

向量与切片的一致性

这是我们踩坑最多的一块。两个系统之间无法用事务保证一致,只能靠状态机 + 补偿。

采用的状态流转:

切片创建(vec_status=0)
    ↓
生成向量 → 写入向量库成功 → vec_status=1 ✓
    ↓ 失败
vec_status=2 → 定时任务扫描重试(最多 5 次)
    ↓ 超过 5 次
告警 + 人工介入

关键设计是先写切片记录,再生成向量。这样即使向量生成失败,切片数据还在,可以重试。反过来(先写向量再写切片)会产生无法清理的孤儿向量。

还有个对账任务,每天凌晨跑一次,比对 MySQL 里的切片数和向量库里的向量数,差异超过阈值就告警:

@Scheduled(cron = "0 30 2 * * ?")
public void reconcile() {
    long chunkCount = chunkRepo.countReady();
    long vecCount = milvusClient.count(COLLECTION);
    long diff = Math.abs(chunkCount - vecCount);
    log.info("reconcile: chunk={}, vector={}, diff={}",
             chunkCount, vecCount, diff);
    if (diff > 1000) {
        alertService.send("向量库与切片数据差异过大: " + diff);
    }
}

这个任务上线第一个月抓出过 3 次问题,其中一次是 Milvus 批量写入时部分失败但返回成功,一次是我们的消费者在处理消息时重复消费导致多写了向量。

分区与清理

4100 万条切片,单表已经很大了。我们按 space_id 做了 HASH 分区,分成 32 个分区:

ALTER TABLE kb_chunk
  PARTITION BY HASH(space_id) PARTITIONS 32;

这样做的好处是不同业务空间的数据物理隔离,某个业务要清库时可以直接 truncate 对应分区,比 DELETE 快得多(我们清一个 200 万切片的空间,truncate 分区 0.3 秒,DELETE 要 4 分多钟)。

历史版本的清理用定时任务 + 分批删除,每批 1000 条,避免长事务:

@Scheduled(cron = "0 0 3 * * ?")
public void purgeOldVersions() {
    List<Long> ids = chunkRepo.findExpiredVersionIds(LocalDateTime.now()
            .minusDays(30), PageRequest.of(0, 1000));
    chunkRepo.deleteAllByIdInBatch(ids);
    // 对应的向量也异步删
    ids.forEach(vectorCleanQueue::offer);
}

当前的数据规模

项目数值
文档总数320 万
切片总数4100 万
kb_chunk 表大小186GB(含 32 个分区)
向量库(Milvus)4100 万 × 768 维,约 126GB 内存
日均文档更新1.6 万次
切片平均长度412 字符
检索 P99(含权限过滤)34ms

小结

知识库的存储设计,核心是把"文档、版本、切片、向量"四个概念的生命周期分开管理。它们的变化频率完全不同:文档元数据基本不变,内容偶尔变,切片随内容变,向量随切片变。混在一起就会互相拖累。

三个我觉得最值得做的点:切片独立成表(能单独操作,是增量更新的前提);版本化(可追溯,出问题能还原);向量生成做成可重试的状态机(跨系统一致性只能靠这个)。

增量更新是投入产出比最高的一项,我们省了 87% 的 embedding 成本。但它的前提是切片策略要稳定,如果你们用的是语义切分这类边界浮动大的策略,这个收益会打折扣,需要先评估。

参考