从 Demo 到生产:Demo痛点的改进

0 阅读6分钟

从 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_FLATHNSW(10万+ 数据量时)
3标量索引对过滤字段建倒排索引
4分区键按 book_id 分区
5版本追踪加 content_hash / updated_at / version
6增量更新只支持全量按章节先删后插
7去重策略粗糙(只查是否存在)分层去重(文件→章节→块→语义)
8CLI 参数化硬编码 bookId=1支持 --chapter --book 参数
9错误处理一个 catch 到底分级重试 + 死信队列

架构演进总览

Demo 阶段(当前):
  EPUB → 全量切片 → 顺序插入 → 完成
  特点: 跑通就行,不考虑更新

生产阶段(目标):
  EPUB → 版本对比 → 增量识别 → 先删后插 → 分区管理
  特点: 支持增量更新、精确去重、水平扩展、零停机

写在最后

从 Demo 到生产,差的不是代码量,而是对数据生命周期的理解

  • 建表时的设计决定了更新时的成本
  • 主键的选择决定了系统能否感知变化
  • 索引的选型决定了查询能撑多大体量
  • 版本字段的存在是增量更新的前提

两篇文章合在一起,覆盖了 RAG 知识库从搭建到维护的完整链路。如果你也在做类似的项目,希望这个检查清单能帮你少踩一些坑。