从 Demo 到生产:你的 RAG 知识库离生产还有多远?

3 阅读6分钟

以《天龙八部》电子书为例,诊断一个 RAG 知识库在"跑通流程"之后的架构缺陷。


一、项目概述

技术栈:LangChain + Milvus + OpenAI Embedding + epub2

核心流程:

EPUB 电子书 → 章节拆分 → 文本切片 → 向量化 → Milvus 入库 → 相似性检索

这篇文章不讲怎么"跑通"——那个太简单了,网上教程满天飞。我要讲的是跑通之后的事:你的代码离生产环境还有多远?


二、当前架构复盘

2.1 整体结构

依赖层:    dotenv / langchain / milvus2-sdk-node
配置层:    COLLECTION_NAME, VECTOR_DIM(1024), CHUNK_SIZE(500)
服务层:    Embeddings 模型 → Milvus 客户端
业务层:    集合管理 → 数据入库 → EPUB 加载处理
入口层:    main() 编排流程

2.2 集合 Schema(当前版本)

字段类型说明
idVarChar(100)主键,格式:{bookId}_{chapterNum}_{chunkIndex}
book_idVarChar(100)书籍标识
book_nameVarChar(200)书名
chapter_numInt32章节序号
indexInt32块在章节内的序号
contentVarChar(10000)文本内容
vectorFloatVector(1024)文本向量

2.3 向量索引

  • 类型IVF_FLAT
  • 相似度COSINE
  • 参数nlist = 1024(K-Means 聚类簇数)

nlist 决定了把全库向量聚成多少个桶。搜的时候先找最近的几个桶,再在桶内逐一比对,避免暴力扫描全库。nlist ≈ √总向量数 × 4 ~ 16,对于 5 万级别的数据量,1024 是合理范围。

查询时还有一个配套参数 nprobe(默认 8),控制实际搜几个桶——搜得越多精度越高,速度越慢:

const searchResult = await client.search({
    collection_name: COLLECTION_NAME,
    vector: queryVector,
    limit: 3,                    // 返回 Top 3 条结果
    params: { nprobe: 16 },      // 搜最近的 16 个簇
    output_fields: ["id", "book_id", "chapter_num", "index", "content"]
});

2.4 核心流程代码

// 主流程 —— 四步走
const main = async () => {
    await client.connectPromise;          // ① 连接 Milvus
    await ensureCollection();             // ② 确保集合存在 + 加载
    // ③ 去重检查(按 book_id 查一条记录)
    const checkResult = await client.query({ filter: `book_id == "${bookId}"`, limit: 1 });
    if (checkResult.data.length > 0) return;
    await loadAndProProcessEpubStreaming(bookId); // ④ 全量导入
};

三、这个架构在 5 个维度上的缺陷

以下每一个问题,都是我在跑通 Demo 之后,用"如果这本书更新了一个章节,我该怎么办"这个现实问题逼问出来的。

3.1 主键设计:位置身份 vs 内容身份

当前做法

id: `${bookId}_${chapterNum}_${chunkIndex}`  // 例如 "1_5_3"

这是用"位置"来识别数据——"第 5 章第 3 块"。但当你修改了第 5 章的内容,中间插入了一段新文字,后面的所有块序号都会偏移。旧的 1_5_3 可能已经不代表原来的那段文本了,但系统无法感知。

正确的做法:用内容哈希做身份。

import crypto from 'crypto';

const contentHash = crypto.createHash('md5').update(chunk).digest('hex');
id: `${bookId}_${chapterNum}_${contentHash}`  // "1_5_a3f1b9c8d4e5..."

同样的内容永远算出同样的哈希。内容不变,ID 不变;内容变了,ID 自然跟着变。 这让系统天然具备"感知变化"的能力,是增量更新的基础。

场景位置 ID哈希 ID
章节内容没变,重新导入同名 ID 冲突报错哈希一样,自然跳过
章节中间加了一段话后续块 ID 全变,旧数据残留只有新增/变化的块产生新 ID
整章内容全改无法精确定位旧数据旧哈希全部消失,新哈希全新插入

3.2 向量索引:Demo 默认值不能直接上生产

当前做法IVF_FLAT, nlist=1024

IVF_FLAT 是入门级索引,适合 <100 万条数据。数据量上去后,聚类精度下降,召回率跟着掉。

不同索引类型的适用场景:

数据量           推荐索引      内存占用     查询速度     建索引速度
< 100          IVF_FLAT      ★★          ★★★         
100 ~ 1000   HNSW          ★★★★       ★★★★★       慢(内存换速度)
1000 ~ 1亿     IVF_PQ                   ★★★         
> 1亿            DiskANN                  ★★★★        

生产环境推荐 HNSW。你的向量维度是 1024,HNSW 的常用配置是:

{
    index_type: 'HNSW',
    metric_type: 'COSINE',
    params: {
        M: 16,              // 每个节点的最大连接数(越大精度越高,内存越多)
        efConstruction: 200 // 构建时的搜索宽度(越大精度越高,建得越慢)
    }
}

3.3 标量索引:查询有两步,你只优化了一步

一次完整的 RAG 检索实际上是两步

步骤①: 过滤出目标范围    ← WHERE book_id = '1'
步骤②: 向量相似性搜索    ← ORDER BY cosine_similarity

你对向量字段建了索引(加速步骤②),但 book_idchapter_num 这些用于过滤的标量字段没有索引

没有标量索引时,过滤走的是全表暴力扫描:

查询 "book_id == '1' AND 向量相似度 top10"

无标量索引: 扫全表 50 万条 → 逐条检查 book_id → 找到 10 万条 → 向量比对 → 返回
            耗时: ~3 秒

有标量索引: 倒排索引直接定位 book_id=1 的 10 万条 → 向量比对 → 返回
            耗时: ~0.3 秒

修复方式——对过滤字段建倒排索引:

await client.createIndex({
    collection_name: COLLECTION_NAME,
    field_name: 'book_id',
    index_type: 'INVERTED',  // 倒排索引
});

3.4 分区键:删除一本书不该扫全表

当前做法:按条件 book_id == "1" 删除,需要逐条扫描匹配

分区键的价值:把数据按某列物理隔离。同一个集合内,每本书独占一个分区。删除一本书 = 释放一个分区 = O(1) 操作

无分区: 删除 book_id=1   →  全表扫描  →  找一条删一条  →  慢
有分区: 删除 partition_1  →  直接释放  →  毫秒级完成   →  快

3.5 版本字段:你永远不知道数据是什么时候进的库

当前的 Schema 缺少两个关键的时间维度:

  • 源文档的版本:这数据来自书的第几次修订?
  • 入库的时间:这条数据是什么时候写入的?

这两个字段决定了增量更新的可行性:

场景: 你修改了第 5 章,要更新知识库

无版本字段:
  "这书之前导过吗?导的哪个版本?不知道,全删了重新来吧"
  → 全量重建,2 小时

有版本字段:
  "第 5 章 source_updated_at 变了,其他 49 章没变"
  → 只更新第 5 章,2 分钟

建议增加的字段:

字段用途
content_hash内容指纹,判断"这段改没改"
source_version源文档版本号
source_updated_at源文档最后修改时间
ingested_at入库时间

写在最后

这 5 个缺陷有一个共同特点:它们都是"建表时"的决策,但后果要到"更新时"才显现

  • 主键选了位置 ID → 更新时无法感知变化
  • 向量索引选了 IVF_FLAT → 数据量上来后召回率下降
  • 没建标量索引 → 每次过滤都在全表扫描
  • 没用分区键 → 删除操作跟数据量成正比
  • 没加版本字段 → 无法判断哪些数据已过期

每一个决策都是在"现在方便"和"以后方便"之间做权衡。Demo 阶段可以走捷径,但理解捷径背后的代价,才是从 Demo 走向生产的关键。


下一篇预告:基于这 5 个缺陷,给出企业级的 Schema 改进方案、增量更新的三种模式,以及知识去重的四个层次。

项目地址:tlbb 技术栈:LangChain + Milvus + OpenAI Embedding + Node.js