RAG实战(一):EPUB 加载、文本切割与向量入库

摘要:把整本《天龙八部》喂给AI需要几步?本文用EPubLoader按章节加载epub电子书,RecursiveCharacterTextSplitter以500字为块加50字重叠切割,Embedding接口将每个片段转为1024维向量,最后批量写入Milvus并建IVF_FLAT索引。一台完整的RAG数据管道,从电子书文件到向量数据库。


目录

  1. 整体数据管道总览
  2. EPubLoader:按章节加载电子书
  3. RecursiveCharacterTextSplitter:把章节切成小块
  4. 批量向量化与入库
  5. 总结

一、整体数据管道总览

RAG 的第一步不是检索,是把原始文档变成可检索的向量。数据管道需要四个组件接力:Loader 从文件加载文档 → Splitter 切成小块 → Embedding 转为向量 → Milvus 存储并建索引。


二、EPubLoader:按章节加载电子书

EPUB 是一种常见的电子书格式,内部按章节组织内容。LangChain 的 EPubLoader 能直接解析 EPUB 并按章节输出 Document 对象:

javascript 复制代码
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} 个章节`);

splitChapters: true 是关键配置------EPUB 内部有章节标记,Loader 据此将整本书拆为独立的 Document 数组。每个 Document 的 pageContent 就是一整章的文本。如果不开这个选项,整本书会被读成一个巨大的 Document,后续切割会丢失章节边界信息。


三、RecursiveCharacterTextSplitter:把章节切成小块

一章可能有几千字,Embedding 接口一次性处理不了太长文本,LLM 的上下文窗口也放不下整章。需要把一个章节切成多个小块(chunk)

javascript 复制代码
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';

const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,      // 每块最多 500 个字符
    chunkOverlap: 50,    // 相邻两块重叠 50 个字符
});

两个核心参数

参数 作用
chunkSize 500 每块最大字数。太大超出 embedding 窗口,太小丢失上下文
chunkOverlap 50 相邻 chunk 之间的重叠字数。防止关键信息刚好卡在 chunk 边界被切断

重叠的意义:如果一刀切在第 250 字,"段誉施展凌波微步"中的"凌波微"在上一块,"步"在下一块------两块的向量都无法完整表达"凌波微步"这个武功名。50 字重叠意味着"凌波微步"至少会完整地出现在某一块中。

逐章切割

javascript 复制代码
let totalInserted = 0;
const documentLen = documents.length;

for (let chapterIndex = 0; chapterIndex < documentLen; chapterIndex++) {
    const chapter = documents[chapterIndex];
    const chapterContent = chapter.pageContent;

    const chunks = await textSplitter.splitText(chapterContent);
    console.log(`第 ${chapterIndex + 1} 章拆分为 ${chunks.length} 个片段`);

    if (chunks.length === 0) {
        console.log(`跳过空章节`);
        continue;
    }

    const insertedCount = await insertChunksBatch(
        chunks, bookId, chapterIndex + 1
    );
    totalInserted += insertedCount;
}

逐章处理有三个好处:保留章节号字段(后续检索时能看到结果来自哪一章)、避免一次性加载整本书撑爆内存、每个 chunk 的 chapter_num 字段让检索结果更结构化。


四、批量向量化与入库

Collection Schema 设计

javascript 复制代码
await client.createCollection({
    collection_name: 'ebook',
    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 },                      // 章节内第几个chunk
        { name: 'content',      data_type: DataType.VarChar, max_length: 10000 }, // 原文
        { name: 'vector',       data_type: DataType.FloatVector, dim: 1024 },     // 向量
    ]
});

和上一篇 AI 日记本相比,多了 book_idchapter_num 两个结构化字段------这让后续检索时不仅能拿到相关文本,还能精确知道结果来自哪本书的第几章。

批量插入

javascript 复制代码
async function insertChunksBatch(chunks, bookId, chapterNum) {
    if (chunks.length === 0) return 0;

    const insertData = await Promise.all(
        chunks.map(async (chunk, chunkIndex) => {
            const vector = await getEmbedding(chunk);
            return {
                id: `${bookId}_${chapterNum}_${chunkIndex}`,
                book_id: bookId,
                book_name: BOOK_NAME,
                chapter_num: chapterNum,
                index: chunkIndex,
                content: chunk,
                vector: vector
            };
        })
    );

    const insertResult = await client.insert({
        collection_name: 'ebook',
        data: insertData
    });
    return Number(insertResult.insert_cnt) || 0;
}

Promise.all 将同一章内所有 chunk 的 embedding 请求并行发出 。一章 20 个 chunk 就同时发 20 个请求,等全部拿到向量后再一次性 client.insert() 写入数据库------并行请求省时间,批量写入省网络往返。

建立索引

javascript 复制代码
await client.createIndex({
    collection_name: 'ebook',
    field_name: 'vector',
    index_type: IndexType.IVF_FLAT,
    metric_type: MetricType.COSINE,
    params: { nlist: 1024 }
});

nlist 是 K-Means 聚类的簇数------1024 个簇意味着查询时只需在与 query 最近的几个簇内计算相似度,而非全量比对。对于数十万条向量数据,索引是毫秒级响应的前提。


五、总结

  1. EPubLoader + splitChapters:按章节加载 EPUB,每个章节变成独立 Document。保留章节边界,让后续检索结果可以追溯到具体章节。
  2. RecursiveCharacterTextSplitterchunkSize: 500 控制每块字数,chunkOverlap: 50 防止关键信息被边界切断。逐章切割,空章节跳过。
  3. Collection 多字段设计 :除了 vectorcontent,加入 book_idchapter_numindex 让数据不仅有语义可搜,还有结构可查。
  4. Promise.all 并行 + 批量写入:同一章 chunk 并行请求 embedding,一次性 insert,性能和可靠性兼顾。
  5. IVF_FLAT + nlist:聚类索引让查询从 O(n) 全量比对降为 O(log n) 近簇搜索。

第一篇完成数据管道------从 epub 文件到向量数据库。下一篇,用自然语言搜天龙八部,看看"段誉会什么武功"能捞出哪些片段。


------ 一本带助手的智慧之书。

相关推荐
Revolution611 小时前
数组本身没有 map,为什么还能直接调用:原型链怎样查找属性
前端·javascript·面试
swipe1 小时前
10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新
前端·后端·全栈
cdcdhj1 小时前
vue3中的watchEffect()监听,什么时候监听,什么时候清理,什么时候停止
前端·javascript·vue.js
huabuyu1 小时前
几百 MB 的文件为什么等几分钟才能打开?文件分片下载与渐进预览原理
前端·javascript
虚惊一场1 小时前
麻将桌上的并发控制:扫码进房、乐观版本与零和结算
前端·javascript
巴勒个啦1 小时前
从需求到上线:记录一次完全由 AI 辅助完成的小产品全流程
java·前端
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 6 篇:状态管理 useState 与 useReducer
前端·vue.js·react.js
hunterandroid1 小时前
Room 并发写入与事务一致性:从数据竞争到可靠落地
前端
天才熊猫君1 小时前
自动给所有 catch 块补上错误上报:从原理到落地
前端·javascript