从 Demo 到生产:RAG 知识库的企业级架构方案
接上篇。在诊断出 5 个架构缺陷之后,这篇文章给出完整的改进方案、增量更新策略,以及知识去重的分层设计。
一、企业级 Schema 设计(改进方案)
回顾上篇提出的 5 个问题:主键用位置 ID、向量索引选型不当、缺失标量索引、缺失分区键、缺失版本字段。以下 Schema 一次性解决这些问题:
fields: [
// 主键 —— 内容哈希,天然去重
{ name: 'id', data_type: DataType.VarChar, max_length: 200, is_primary_key: true },
// 标量字段 —— 过滤和分组
{ name: 'book_id', data_type: DataType.VarChar, max_length: 100 },
{ name: 'book_name', data_type: DataType.VarChar, max_length: 200 },
{ name: 'chapter_num', data_type: DataType.Int32 },
{ name: 'chunk_index', data_type: DataType.Int32 },
// 内容
{ name: 'content', data_type: DataType.VarChar, max_length: 10000 },
{ name: 'content_hash', data_type: DataType.VarChar, max_length: 64 },
// 版本追踪
{ name: 'source_version', data_type: DataType.VarChar, max_length: 50 },
{ name: 'source_updated_at', data_type: DataType.VarChar, max_length: 50 },
{ name: 'ingested_at', data_type: DataType.VarChar, max_length: 50 },
// 向量
{ name: 'vector', data_type: DataType.FloatVector, dim: 1024 }
]
// 标量索引 —— 加速过滤
// 对 book_id 建倒排索引
// 对 chapter_num 建倒排索引
// 对 content_hash 建倒排索引
// 分区 —— 按 book_id 物理隔离
// 向量索引 —— 根据数据量选择
// < 100万: IVF_FLAT
// 100万~1000万: HNSW(推荐)
// > 1000万: IVF_PQ
每个新增字段和索引都在解决一个具体问题:
| 改动 | 解决的问题 |
|---|---|
| 哈希主键 | 内容没变的块自动跳过,支持增量更新 |
content_hash 字段 | 可按哈希查询,判断某段文本是否已入库 |
source_version / source_updated_at | 版本对比,知道哪些章节变了 |
ingested_at | 数据溯源,知道入库时间 |
| 标量索引 | 过滤不再全表扫描 |
| 分区 | 删除操作 O(1),物理隔离不同书籍 |
二、增量更新的三种模式
RAG 知识库在生产环境中的更新,不是重新跑一遍脚本那么简单。业界有三种成熟模式,按数据量和更新频率选择。
模式 A:蓝绿发布(全量重建,零停机)
旧集合 (collection_v1) → 在线提供服务
新集合 (collection_v2) → 后台全量建好
↓
一键切换 alias → collection_v2
↓
废弃 collection_v1
适用场景:全量数据重建、大版本升级、Schema 变更。
优点:零停机、可回滚(alias 切回去即可)、新旧数据完全隔离互不影响。
缺点:双倍存储成本,全量构建耗时长。
Milvus 原生支持 alias 切换:
// 切换 alias 指向新集合
await client.alterAlias({
collection_name: 'collection_v2',
alias: 'ebook_prod' // 应用层始终用这个 alias 查询
});
模式 B:增量 + 版本号(局部更新,细粒度控制)
1. 对比 source_updated_at → 识别变更的章节
2. 按 book_id + chapter_num 先删后插
3. 未变更的章节完全不动
适用场景:频繁局部更新、多文档知识库。这正是《天龙八部》项目的演进方向。
核心代码思路:
const updateChapter = async (bookId, chapterNum, newContent) => {
const partitionName = `book_${bookId}`;
// 1. 按分区 + 条件精确删除旧数据
await client.delete({
collection_name: COLLECTION_NAME,
partition_name: partitionName,
filter: `book_id == "${bookId}" && chapter_num == ${chapterNum}`
});
// 2. 重新切片 + 向量化 + 插入
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: CHUNK_SIZE,
chunkOverlap: 50
});
const chunks = await textSplitter.splitText(newContent);
const insertData = await Promise.all(
chunks.map(async (chunk, idx) => {
const contentHash = crypto.createHash('md5').update(chunk).digest('hex');
const vector = await getEmbedding(chunk);
return {
id: `${bookId}_${chapterNum}_${contentHash}`,
book_id: bookId,
chapter_num: chapterNum,
chunk_index: idx,
content: chunk,
content_hash: contentHash,
source_updated_at: new Date().toISOString(),
ingested_at: new Date().toISOString(),
vector
};
})
);
await client.insert({
collection_name: COLLECTION_NAME,
partition_name: partitionName,
data: insertData
});
};
优点:只处理变更部分,速度快,资源消耗小。
缺点:需要源数据有版本信息,逻辑比全量复杂。
模式 C:事件驱动(实时同步)
文档变更 → Webhook/MQ → 触发单文档处理 → upsert 入库
适用场景:飞书文档、Notion、Confluence 等协作型知识库。
优点:近实时同步,用户改完文档几秒内可检索。
缺点:需要上游系统支持事件推送,链路长、排查困难。
三、知识去重的四个层次
"这段知识在库里已经存在了吗?"——这个问题在不同粒度上有完全不同的答案:
| 层次 | 判断方式 | 适用场景 | 实现成本 |
|---|---|---|---|
| 文件级 | book_id + source_updated_at | 整本书是否变过 | 低,字段查询 |
| 章节级 | book_id + chapter_num + 内容哈希 | 哪个章节变了 | 中,需要对比逻辑 |
| 块级 | 内容哈希做主键,插入即去重 | 相同文本块自动跳过 | 低,Schema 层解决 |
| 语义级 | 向量相似度 > 0.95 | "换了个说法但意思一样"的重复 | 高,需要额外的语义去重流程 |
这四个层次并不是都要一次性实现,大部分场景做到第三层就足够了。第四层语义去重是一个独立的课题,值得单独写一篇文章展开。
前三层如何协同工作
入库时:
① 文件级: book_id=1 的 source_updated_at 和上次一样?
→ 一样:整本书跳过,什么都不做
→ 不一样:进入章节级判断
② 章节级: 第 5 章的 content_hash 变了?
→ 没变:跳过这章
→ 变了:先删旧数据,再逐块入库
③ 块级: 新切出来的每个 chunk,哈希主键已存在?
→ 存在:这条自动跳过(Milvus 主键冲突)
→ 不存在:正常插入
这样一来,真正需要重新向量化和插入的,只有内容确实变了的那些块。其他一切自动处理。
四、总结:从 Demo 到生产的检查清单
| # | 检查项 | Demo 做法 | 生产做法 |
|---|---|---|---|
| 1 | 主键设计 | 位置 ID | 内容哈希 ID |
| 2 | 向量索引 | IVF_FLAT | HNSW(10万+ 数据量时) |
| 3 | 标量索引 | 无 | 对过滤字段建倒排索引 |
| 4 | 分区键 | 无 | 按 book_id 分区 |
| 5 | 版本追踪 | 无 | 加 content_hash / updated_at / version |
| 6 | 增量更新 | 只支持全量 | 按章节先删后插 |
| 7 | 去重策略 | 粗糙(只查是否存在) | 分层去重(文件→章节→块→语义) |
| 8 | CLI 参数化 | 硬编码 bookId=1 | 支持 --chapter --book 参数 |
| 9 | 错误处理 | 一个 catch 到底 | 分级重试 + 死信队列 |
架构演进总览
Demo 阶段(当前):
EPUB → 全量切片 → 顺序插入 → 完成
特点: 跑通就行,不考虑更新
生产阶段(目标):
EPUB → 版本对比 → 增量识别 → 先删后插 → 分区管理
特点: 支持增量更新、精确去重、水平扩展、零停机
写在最后
从 Demo 到生产,差的不是代码量,而是对数据生命周期的理解:
- 建表时的设计决定了更新时的成本
- 主键的选择决定了系统能否感知变化
- 索引的选型决定了查询能撑多大体量
- 版本字段的存在是增量更新的前提
两篇文章合在一起,覆盖了 RAG 知识库从搭建到维护的完整链路。如果你也在做类似的项目,希望这个检查清单能帮你少踩一些坑。