知识库从 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,把"向量生成"变成切片上的一个可重试的状态机,而不是一次性操作。
版本管理:为什么必须有
最开始我觉得版本管理是过度设计,直到遇到这件事:有用户投诉"知识库回答错了",我们查了半天发现,那份文档在投诉前一天被人改过,改的内容恰好是错的。文档后来又被改回来了,但如果我们没有版本记录,根本无法还原当时模型看到的内容。
现在的流程是:
- 文档更新时创建新版本(
version + 1),状态PROCESSING; - 新版本切片全部生成并向量化完成后,原子切换
kb_doc.current_ver; - 旧版本数据保留 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 token | 8.4 亿 | 1.1 亿 | 87% |
| 月 embedding 成本 | ¥16,800 | ¥2,200 | 87% |
| 平均单次更新耗时 | 12.4s | 2.1s | 83% |
| 向量库写 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 成本。但它的前提是切片策略要稳定,如果你们用的是语义切分这类边界浮动大的策略,这个收益会打折扣,需要先评估。