RAG 实战:3 步将整本《天龙八部》存入向量数据库(一)
从零开始,用 LangChain + Milvus 将 168 章小说变成 AI 可检索的 3042 个向量片段。
前言
最近在学 RAG(检索增强生成),理论看了不少,但真正动手时才发现坑比想象中多。于是拿《天龙八部》电子书练手,把一整本小说拆成 3042 个向量片段存进了 Milvus,跑通了完整的数据入库流程。
这篇文章适合正在入门 RAG 的 Node.js 开发者。读完你会掌握:
- 如何用 LangChain 加载 EPUB 电子书并按章节拆分
RecursiveCharacterTextSplitter的分块原理和参数调优- Milvus 向量数据库的 Schema 设计和索引构建
- 批量 Embedding + 插入的工程实践
- 断点续传的设计思路(token 用完、网络中断都不怕)
文末附有完整可运行项目代码,clone 下来改个配置就能跑。
项目概览
整个项目的目标是:把《天龙八部》变成一本 AI 能"读懂"的书,然后可以向它提问。
本篇文章聚焦第一部分------数据入库管道:
markdown
天龙八部.epub
│
▼
EPubLoader(按章节加载,168章)
│
▼
RecursiveCharacterTextSplitter(每章再切成小片段)
│
▼
OpenAIEmbeddings(每个片段 → 1024维向量)
│
▼
Milvus 向量数据库(存储 + 建索引)
核心依赖:
| 包 | 作用 |
|---|---|
@langchain/community |
EPubLoader 文档加载器 |
@langchain/textsplitters |
文本切割器 |
@langchain/openai |
Embedding 模型调用(兼容 OpenAI API) |
@zilliz/milvus2-sdk-node |
Milvus 向量数据库客户端 |
第一步:加载 EPUB,拆出 168 章
EPubLoader:一行代码搞定章节拆分
传统思路是手动解析 EPUB(本质是个 ZIP 包),提取 HTML,清洗标签......很繁琐。
LangChain 的 EPubLoader 帮你全做了:
js
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub';
const loader = new EPubLoader('./天龙八部.epub', {
splitChapters: true, // 关键配置:按章节拆成多个 Document
});
const documents = await loader.load();
console.log(`加载完成, 共 ${documents.length} 个章节`);
// 输出:加载完成, 共 168 个章节
| 参数 | 作用 |
|---|---|
'./天龙八部.epub' |
EPUB 文件路径 |
splitChapters: true |
按章节拆分,每个章节变成一个独立的 Document 对象 |
每个 Document 的结构:
js
{
pageContent: "段誉兀自书空咄咄,心中自怨自叹...", // 章节正文
metadata: { chapter: 114, title: "..." } // 章节元信息
}
⚠️ 踩坑记录 1 :类名是
EPubLoader(P 大写),不是EpubLoader。新版@langchain/community改了命名,很多旧教程用的是小写 p,直接抄会报does not provide an export named 'EpubLoader'。
为什么每个章节只是一个 Document?
小说类 EPUB 的章节标题(如"第一回 青衫磊落险峰行")天然是语义边界。splitChapters: true 让 loader 按这些边界切分。但一章可能有几千字,直接做 embedding 效果不好------信息太杂,检索精度会下降。所以还需要第二步。
第二步:RecursiveCharacterTextSplitter 再切分
为什么需要再次切分?
Embedding 模型对输入长度有限制。更重要的是,太长的文本包含的信息太多元,用"段誉会什么武功"去检索一个 3000 字的章节,相似度会被大量无关内容稀释。
所以要把每个章节再切成小片段(chunk),让每个片段聚焦一个相对独立的语义单元。
RecursiveCharacterTextSplitter 的工作方式
它的核心思想是按优先级递归切割:
默认分隔符优先级:\n\n → \n → 空格 → 空字符串
先按双换行(段落)切,如果某段还是太长,再按单换行切,还不够就按空格......直到每个片段都在 chunkSize 以内。
js
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const textSplitter = new RecursiveCharacterTextSplitter({
separator: '\n', // 指定分隔符(小说按换行切比较自然)
chunkSize: 500, // 每块最多 500 字符
chunkOverlap: 50, // 相邻块重叠 50 字符,保持上下文连贯
});
| 参数 | 值 | 为什么这样设 |
|---|---|---|
separator |
'\n' |
小说以换行分句,按句切比默认的空格切更合理 |
chunkSize |
500 |
约等于一个完整情节片段,太长检索不准太短信息碎片化 |
chunkOverlap |
50 |
避免关键信息刚好卡在两块边界,导致检索不到 |
chunkOverlap 为什么重要?
假设第 499-501 个字恰好是"段誉使出凌波微步",没有 overlap 的话:
arduino
块A:...段誉使出凌波 ← 搜"凌波微步"匹配不到
块B:微步,身形飘忽... ← 搜"段誉使出"匹配不到
有了 50 字 overlap,这块关键信息至少完整存在于某一块中,不会因为"切在关键词中间"而丢失。
第三步:Embedding + Milvus 入库
这是最核心的一步,分四个环节。
3.1 Embedding 模型的初始化
js
import { OpenAIEmbeddings } from '@langchain/openai';
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.VITE_QWEN_API_KEY,
model: 'text-embedding-v3', // 千问 embedding 模型
configuration: {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
dimensions: 1024, // 输出 1024 维向量
});
用 @langchain/openai 的 OpenAIEmbeddings 而不是千问原生 SDK------因为千问的 embedding API 兼容 OpenAI 格式,一套代码能适配多个模型厂商。
⚠️ 踩坑记录 2 :
configuration.baseURL的值要和.env文件里的变量名对上。代码里写process.env.VITE_QWEN_BASE_URL但.env里叫VITE_QWEN_API_URL,拿了undefined就发请求到错误的地址,直接Request timed out。
3.2 设计 Milvus 集合 Schema
Milvus 的集合(Collection)相当于关系数据库的表,需要提前定义字段结构:
js
await client.createCollection({
collection_name: 'ebook2',
fields: [
{ name: 'id', data_type: DataType.VarChar,
max_length: 100, 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: 'index', data_type: DataType.Int32 },
{ name: 'content', data_type: DataType.VarChar,
max_length: 10000 },
{ name: 'vector', data_type: DataType.FloatVector,
dim: 1024 },
],
});
| 字段 | 类型 | 为什么需要 |
|---|---|---|
id |
VarChar | 主键,格式 01_114_28(书ID_章节_片段序号) |
book_id |
VarChar | 区分不同书籍,支持按书过滤搜索 |
book_name |
VarChar | 书名,方便展示 |
chapter_num |
Int32 | 第几章,支持按章节范围过滤 |
index |
Int32 | 章节内第几个片段 |
content |
VarChar | 切片原文------检索的目的就是拿这段文字给 LLM |
vector |
FloatVector(1024) | 文本的向量表示,核心检索字段 |
⚠️ 踩坑记录 3 :
content字段最容易被忽略。很多人只存向量不存原文------但 RAG 的 "R"(检索)就是为了拿原文给 LLM 看的,没存原文等于白检。
3.3 构建 IVF_FLAT 索引
js
await client.createIndex({
collection_name: 'ebook2',
field_name: 'vector', // 给向量字段建索引
index_type: 'IVF_FLAT', // 倒排文件索引
metric_type: 'COSINE', // 余弦相似度
params: { nlist: 1024 }, // K-Means 聚类中心数
});
| 参数 | 含义 | 选型依据 |
|---|---|---|
IVF_FLAT |
用 K-Means 把向量聚成 N 个簇,搜的时候只查最近的几个 | 数据量 < 10 万条的首选 |
COSINE |
余弦相似度 | 文本语义搜索的标准选择 |
nlist: 1024 |
聚成 1024 个簇 | 经验值 4 × √N,N 为总数据量 |
3.4 批量 Embedding + 插入
js
async function insertChunksBatch(chunks, bookId, chapterNum) {
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbedding(chunk); // 并行调 API
return {
id: `${bookId}_${chapterNum}_${chunkIndex}`,
book_id: bookId,
book_name: BOOK_NAME,
chapter_num: chapterNum,
index: chunkIndex,
content: chunk,
vector: vector,
};
})
);
const result = await client.insert({
collection_name: 'ebook2',
data: insertData,
});
return Number(result.insert_cnt) || 0;
}
关键设计:Promise.all 并行调用 embedding API。同一章内的 chunks 之间没有依赖关系,并行能大幅提速。如果一章有 60 个片段,串行要等 60 次 API 往返,并行只需等最慢的那一个。
进阶:断点续传
第一次跑 168 章,跑到第 114 章 token 用完了怎么办?重跑一遍又要重新调 embedding API------费钱费时。
解决办法:每次处理前,先查 Milvus 里哪些章节已经入库了:
js
async function getProcessedChapters(bookId) {
const result = await client.query({
collection_name: 'ebook2',
filter: `book_id == "${bookId}"`,
output_fields: ['chapter_num'],
limit: 100000,
});
const chapters = result.data.map(r => r.chapter_num);
return new Set(chapters);
}
然后在循环中跳过:
js
const processedChapters = await getProcessedChapters(bookId);
for (let i = 0; i < documents.length; i++) {
const chapterNum = i + 1;
if (processedChapters.has(chapterNum)) {
console.log(`第${chapterNum}章已处理,跳过`);
continue;
}
// ... 正常处理:切分 → embedding → 插入
}
这样无论中断多少次,重跑都从断点继续,已入库的章节不会被重复处理。Set 数据结构让 has() 查询是 O(1) 的,168 章毫秒级判断。
运行结果
yaml
加载完成, 共 168 个章节
处理第1/168章...拆分为1个片段...已插入1条数据
处理第2/168章...拆分为1个片段...已插入1条数据
处理第12/168章...拆分为54个片段...已插入54条数据
...
处理第168/168章...拆分为1个片段...已插入1条数据
总共插入 3042 条数据
168 章全部入库,3042 个向量片段,云端 Milvus 里已经有了整本《天龙八部》的"数字记忆"。
总结
这篇文章讲了数据入库的三个关键步骤 + 一个进阶技巧:
- EPubLoader ---
splitChapters: true一行代码按章节加载 EPUB - RecursiveCharacterTextSplitter --- 递归切割 +
chunkOverlap保证检索精度 - Milvus Schema + 索引 + 批量插入 --- 完整的向量入库链路
- 断点续传 ---
client.query()先查已入库章节,Set.has()秒级跳过
下一篇文章会讲第二部分:如何用向量搜索找到相关片段,再用 LLM 基于原文生成答案------让 AI 真正"读懂"《天龙八部》。
📦 文末附有完整可运行项目代码 👇
完整项目代码
Gitee 仓库 :gitee.com/dcx2758/ai_...
文件结构
bash
tlbb/
├── .env # 环境变量配置
├── 天龙八部.epub # 小说文件
├── src/
│ ├── main.mjs # 数据入库(本文内容)
│ ├── query.mjs # 向量搜索
│ └── rag.mjs # RAG 问答
└── package.json
快速启动
bash
# 1. Clone 仓库
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git
cd ai_doubao_dcx/ai/agent_in_action/tlbb
# 2. 安装依赖
pnpm install
# 3. 配置 .env(填入你的 API Key 和 Milvus 地址)
# VITE_QWEN_API_KEY=你的千问APIKey
# VITE_QWEN_EMBEDDING_MODEL=text-embedding-v3
# VITE_QWEN_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# MILVUS_ADDRESS=你的Milvus地址
# MILVUS_TOKEN=你的MilvusToken
# 4. 准备好 EPUB 文件放在项目根目录
# 5. 运行入库
node src/main.mjs
下一篇:让 AI 读懂《天龙八部》:向量搜索 + LLM 智能问答完整链路(二)
你觉得这种"实战踩坑"风格的教程怎么样?欢迎评论区交流 👏