RAG 实战:3 步将整本《天龙八部》存入向量数据库(一)

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/openaiOpenAIEmbeddings 而不是千问原生 SDK------因为千问的 embedding API 兼容 OpenAI 格式,一套代码能适配多个模型厂商。

⚠️ 踩坑记录 2configuration.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) 文本的向量表示,核心检索字段

⚠️ 踩坑记录 3content 字段最容易被忽略。很多人只存向量不存原文------但 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 里已经有了整本《天龙八部》的"数字记忆"。

总结

这篇文章讲了数据入库的三个关键步骤 + 一个进阶技巧:

  1. EPubLoader --- splitChapters: true 一行代码按章节加载 EPUB
  2. RecursiveCharacterTextSplitter --- 递归切割 + chunkOverlap 保证检索精度
  3. Milvus Schema + 索引 + 批量插入 --- 完整的向量入库链路
  4. 断点续传 --- 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 智能问答完整链路(二)

你觉得这种"实战踩坑"风格的教程怎么样?欢迎评论区交流 👏

相关推荐
MindUp4 小时前
告别排版焦虑:从 PPT 模板到 AI 生成工具的个人使用体验与效率对比
人工智能·powerpoint
WIN赢4 小时前
【抽象思想-从复杂中抽离简单、收敛的口子】
java·前端·javascript
martindelophy4 小时前
Codex Chrome 插件 + Timeline Studio:构建可编辑的 AI 视频剪辑 Agent 工作流
前端·人工智能·chrome
AIkk864 小时前
大文件怎么压缩变小方便传输?本地压缩+云端方案对比测评
人工智能
薛定e的猫咪4 小时前
(ICLR2026)MORL‑FB:从无奖励强化学习视角重新审视多目标强化学习
人工智能·深度学习·机器学习
行业研究员4 小时前
腾讯云ADP:智能体平台封神榜
人工智能·microsoft·腾讯云·智能体·智能体平台·腾讯云adp
问天_观心4 小时前
零基础在windows环境下的WSL使用llamafactory(一)
人工智能·windows·python·神经网络·语言模型·github·模型蒸馏
陈童学哦5 小时前
DeepSeek Harness(Cordis):打破Agent框架黑盒,一切皆插件
人工智能
小睿科技5 小时前
建筑AI睿兔大脑 | AI把工程成本测算从经验活变成算清楚的技术活
人工智能
正经教主5 小时前
AI提示词工程(高阶)第19课:提示词评估与A/B测试
人工智能