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 智能问答完整链路(二)

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

相关推荐
廋到被风吹走1 小时前
【AI】本周AI领域三大重磅事件
人工智能
萌动的小火苗1 小时前
深度学习中的损失函数与优化算法基础知识
人工智能·深度学习·算法
A15362551 小时前
五金批发电商业财一体化 ERP 推荐:打通订单、库存、财务对账
大数据·运维·人工智能·零售
han_hanker1 小时前
SQL语法 , BETWEEN ... AND ...,比较运算符
前端·javascript·sql
把所有砖敲烂2 小时前
GLM 5.2 核心能力与效果实测全景
人工智能
神奇霸王龙2 小时前
Qwen3.7-Max屠榜:推理成本仅GPT-5.5的1/25
人工智能·python·gpt·ai·aigc·ai编程
StarkCoder2 小时前
AI 会做多、看少、不收尾:七种失效和拦住它们的办法
人工智能·架构
happyprince2 小时前
03_OpenCodeReview 深刻不忘观:硬约束 × 动态决策的设计哲学
人工智能
mONESY2 小时前
从零搭建 Milvus 向量知识库:Node.js 实现日记 RAG 检索全流程实战
javascript