以《天龙八部》电子书为例,诊断一个 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(当前版本)
| 字段 | 类型 | 说明 |
|---|---|---|
id | VarChar(100) | 主键,格式:{bookId}_{chapterNum}_{chunkIndex} |
book_id | VarChar(100) | 书籍标识 |
book_name | VarChar(200) | 书名 |
chapter_num | Int32 | 章节序号 |
index | Int32 | 块在章节内的序号 |
content | VarChar(10000) | 文本内容 |
vector | FloatVector(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_id、chapter_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