把《天龙八部》装进向量数据库:EPUB加载、文本分块与RAG问答全链路实战

作者:浮生望日期:2026/8/10

摘要

以《天龙八部》EPUB拆解RAG全链路:EPubLoader章节加载、RecursiveCharacterTextSplitter分块、Milvus流式入库,实现语义问答。


一、一本百万字小说,如何让AI读懂它

上一篇文章我们用AI日记助手演示了RAG的基本流程:5篇日记、手动构造数据、一次插入。但真实场景中,数据源不是手动构造的,而是各种格式的文档——PDF、EPUB、CSV、Markdown。数据量也不是5条,而是百万字级别的小说。

这篇文章以金庸的《天龙八部》EPUB电子书为样本,完整走通一条工业级RAG管线:文档加载 → 文本分块 → 向量化 → 存储到Milvus → 语义检索 → LLM问答。你会看到,当数据量从5条日记变成一整本小说时,架构设计上需要做的所有调整。

项目依赖:

1{
2  "@langchain/community": "^1.1.29",   // EPubLoader
3  "@langchain/textsplitters": "^1.0.1", // RecursiveCharacterTextSplitter
4  "@langchain/openai": "^1.5.5",       // Embeddings + ChatOpenAI
5  "@zilliz/milvus2-sdk-node": "^3.0.3", // Milvus 客户端
6  "epub2": "^3.0.2",                   // EPUB 解析底层库
7  "html-to-text": "^10.0.0"            // HTML  纯文本转换
8}
9

三个文件对应三个阶段:main.mjs 负责加载和入库,query.mjs 负责语义检索,rag.mjs 负责完整的RAG问答。


二、文档加载:EPubLoader把一本电子书拆成章节

LangChain 的文档加载器覆盖了几乎所有常见格式:PDF、CSV、Markdown、JSON、Notion、Confluence,以及本文的主角——EPUB。

1import { EPubLoader } from '@langchain/community/document_loaders/fs/epub';
2
3const loader = new EPubLoader('./天龙八部.epub', {
4  splitChapters: true,
5});
6const documents = await loader.load();
7console.log(`加载完成,共${documents.length}个章节`);
8

EPubLoader 内部依赖 epub2 库解析EPUB格式,html-to-text 将章节内的HTML标签转换为纯文本。splitChapters: true 是关键配置——它让Loader按章节拆分,而不是把整本书作为一个大字符串返回。每个章节是一个独立的 Document 对象,包含 pageContent(章节正文)和 metadata(章节标题等信息)。

这一步的产出是 N 个 Document,每个对应《天龙八部》的一个章节。按章节拆分有两个好处:一是保留了天然的结构边界(章与章之间不会出现跨章拼接),二是为后续的流式处理提供了粒度——可以逐章分块、逐章向量化、逐章入库,而不是把整本书全部加载到内存中再处理。


三、文本分块:RecursiveCharacterTextSplitter 的切割策略

一个章节可能有几千字,直接整章向量化会导致两个问题:Embedding模型对超长文本的语义表达能力下降,且检索时返回的是整章内容,精度不够。所以需要分块。

1import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
2
3const textSplitter = new RecursiveCharacterTextSplitter({
4  chunkSize: 500,
5  chunkOverlap: 50,
6});
7

RecursiveCharacterTextSplitter 的分块逻辑是"递归降级":先用最大的分隔符切割,如果切出来的块仍然超过 chunkSize,就用次一级的分隔符继续切,直到所有块都在限制范围内。默认分隔符优先级为:\n\n\n ""(逐字符)。

chunkSize: 500 表示每个文本块最多500个字符。这个值不是越大越好——太小会导致语义碎片化,太大会降低检索精度。500是中文文本的常用经验值,大约对应200-300个中文字。

chunkOverlap: 50 是RAG中最容易被忽略但最重要的参数。它的含义是:相邻两个文本块之间重叠50个字符。为什么需要重叠?假设一句话正好被切在两块的边界上:

11: "……段誉心中一凛,暗想这"
22: "鸠摩智的火焰刀果然厉害……"
3

如果没有重叠,"段誉"和"鸠摩智"的关联就断开了。50个字符的重叠让相邻块之间保留了一段共同的上下文,避免关键信息在边界处丢失。

1// 主循环:逐章处理
2for (let chapterIndex = 0; chapterIndex < documentLen; chapterIndex++) {
3  const chapter = documents[chapterIndex];
4  const chunks = await textSplitter.splitText(chapter.pageContent);
5  console.log(` ${chapterIndex + 1} 章拆分为 ${chunks.length} 个片段`);
6  const insertedCount = await insertChunksBatch(chunks, bookID, chapterIndex + 1);
7  totalInserted += insertedCount;
8}
9

逐章处理而非全量加载,是处理大文件的关键。如果《天龙八部》有50章、每章平均5000字,全量分块会产生约500个文本块,一次性向量化需要调用500次Embedding API,耗时且容易触发限流。逐章处理将API调用均匀分布,每一步的内存占用和API压力可控。


四、Milvus Schema:为电子书设计的字段结构

一个通用的电子书向量库,Schema设计需要考虑多本书的共存:

1const COLLECTION_NAME = 'ebook';
2const VECTOR_DIM = 1024;
3
4await client.createCollection({
5  collection_name: COLLECTION_NAME,
6  fields: [
7    { name: 'id', data_type: DataType.VarChar, max_length: 100, is_primary_key: true },
8    { name: 'book_id', data_type: DataType.VarChar, max_length: 100 },
9    { name: 'book_name', data_type: DataType.VarChar, max_length: 200 },
10    { name: 'chapter_num', data_type: DataType.Int32 },
11    { name: 'index', data_type: DataType.Int32 },
12    { name: 'content', data_type: DataType.VarChar, max_length: 10000 },
13    { name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM }
14  ]
15});
16

七个字段的设计意图:

字段类型用途
idVarChar主键,格式 {bookId}_{chapterNum}_{chunkIndex},全局唯一
book_idVarChar区分不同书籍,支持多本书存入同一个Collection
book_nameVarChar书名,用于检索结果展示
chapter_numInt32章节编号,定位原文位置
indexInt32块内序号,同一章节内多个块的顺序
contentVarChar(10000)块的文本内容
vectorFloatVector(1024)文本的向量表示

id 的命名规则 {bookId}_{chapterNum}_{chunkIndex} 是精心设计的——它不是随机生成的,而是编码了数据来源信息。当检索结果返回时,你一眼就能看出这条记录来自哪本书的哪一章的第几个片段。不需要额外查询,id本身就是元数据。

索引配置中有一个关键参数 nlist

1await client.createIndex({
2  collection_name: COLLECTION_NAME,
3  field_name: 'vector',
4  index_type: IndexType.IVF_FLAT,
5  metric_type: MetricType.COSINE,
6  params: { nlist: 1024 }
7});
8

nlist 是K-Means聚类的簇数。IVF_FLAT的工作原理是:建索引时把所有向量聚成 nlist 个簇,查询时只搜索最近的 nprobe 个簇(默认值通常较小)。nlist 越大,每个簇内的向量越少,搜索精度越高但建索引越慢。对于一本小说几千个块的数据量,nlist: 1024 是合理的——每个簇平均只有几个向量,检索精度接近暴力搜索,但速度远快于O(n)遍历。


五、流式入库:批量向量化与逐章写入

insertChunksBatch 是入库的核心函数,它把一批文本块并行向量化后一次性插入Milvus:

1async function insertChunksBatch(chunks, bookId, chapterNum) {
2  if (chunks.length === 0) return 0;
3
4  const insertData = await Promise.all(
5    chunks.map(async (chunk, chunkIndex) => {
6      const vector = await getEmbeddings(chunk);
7      return {
8        id: `${bookId}_${chapterNum}_${chunkIndex}`,
9        book_id: bookId,
10        book_name: BOOK_NAME,
11        chapter_num: chapterNum,
12        index: chunkIndex,
13        content: chunk,
14        vector: vector
15      }
16    })
17  );
18
19  const insertResult = await client.insert({
20    collection_name: COLLECTION_NAME,
21    data: insertData
22  });
23
24  return Number(insertResult.insert_cnt) || 0;
25}
26

Promise.all + map 将同一章的所有块并行向量化,然后一次性批量插入。这是性能最优的方案——Embedding API调用是并行的,数据库写入是批量的。如果一章有10个块,并行调用比串行调用快约10倍(取决于API的并发限制)。

主流程中还有一个 ensureCollection 函数,包含了健壮的错误处理:

1async function ensureCollection(bookID) {
2  const hasCollection = await client.hasCollection({ collection_name: COLLECTION_NAME });
3  if (!hasCollection.value) {
4    await client.createCollection({ ... });
5    await client.createIndex({ ... });
6  }
7  try {
8    await client.loadCollection({ collection_name: COLLECTION_NAME });
9  } catch(err) {
10    console.error('集合已经处于加载状态');
11  }
12}
13

hasCollection 判断集合是否已存在,避免重复创建;loadCollection 的try-catch处理了"集合已在加载中"的边缘情况。这种"先检查再操作"的模式在数据库操作中非常实用——脚本可能被重复执行多次,但数据只会被创建一次。


六、语义检索:用自然语言查询小说内容

query.mjs 展示了检索阶段。用户输入"段誉会什么武功?",系统在Milvus中搜索语义最相似的文本块:

1const query = '段誉会什么武功?';
2const queryVector = await getEmbeddings(query);
3const searchResult = await client.search({
4  collection_name: COLLECTION_NAME,
5  vector: queryVector,
6  limit: 3,
7  metric_type: MetricType.COSINE,
8  output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
9});
10

返回的每条结果包含 score(余弦相似度,越接近1越相似)和 output_fields 中指定的字段。通过 chapter_numcontent,你可以直接定位到原文的精确位置。

Milvus的搜索API极其简洁:不需要写SQL,不需要构建复杂的查询条件,传一个向量数组和limit,返回Top-K结果。对比传统数据库的全文检索(需要分词、建倒排索引、写LIKE或MATCH语句),向量搜索的语义理解能力是质的飞跃。


七、RAG问答:检索→上下文注入→LLM生成

rag.mjs 是完整的RAG问答管线,它将检索到的文本块注入Prompt,让LLM基于小说原文回答问题。

检索函数 retrieverdRelevantContent 封装了向量化和搜索逻辑,返回Top-K个最相似的文本块:

1async function retrieverdRelevantContent(question, k = 3) {
2  const queryVector = await getEmbeddings(question);
3  const searchResult = await client.search({
4    collection_name: COLLECTION_NAME,
5    vector: queryVector,
6    limit: k,
7    metric_type: MetricType.COSINE,
8    output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
9  });
10  return searchResult.results;
11}
12

问答函数 answerEbookQuestion 将检索结果拼接成Prompt上下文,调用LLM生成回答:

1const context = retrieverdContent.map((item, i) => `
2  [片段${i + 1}]
3  章节:第${item.chapter_num}
4  内容:${item.content}
5`).join('\n\n----\n\n');
6
7const prompt = `
8你是一个专业的《天龙八部》小说助手。基于小说回答问题,用准确、详细的语言。
9请根据以下小说片段内容回答问题:
10
11${context}
12
13用户问题:${question}
14
15回答要求:
161. 如果片段中有相关信息,请结合小说内容给出详细准确的回答。
172. 可以综合多个片段的内容,提供完整的答案。
183. 如果片段中没有相关信息,请如实告知用户。
194. 回答要准确,符合小说的情节和人物设定。
205. 可以引用原文内容来支持你的回答。
21AI 助手的回答:
22`;
23

这个Prompt的设计有几个关键点:

  • 角色设定:"专业的《天龙八部》小说助手",限制了LLM的回答范围,避免它偏离小说内容自由发挥
  • 上下文注入:检索到的原文片段被明确标注为"片段1"、"片段2",LLM知道这些是小说原文,而非对话历史
  • 行为约束:五条要求覆盖了"有信息时""无信息时""多片段综合"三种场景,以及对准确性和可溯源性的要求
  • 引用原文:第5条要求LLM可以引用原文,这在文学类问答中特别重要——用户希望看到"原文是这样写的",而不仅仅是AI的总结

主函数中查询"鸠摩智会什么武功?":

1const result = await answerEbookQuestion('鸠摩智会什么武功?', 5);
2console.log(result);
3

k=5 表示返回5个最相似的文本块。对于"武功"这类可能分散在多个章节的信息,更大的k值能覆盖更多相关上下文,让LLM有更充分的素材来回答。


八、从日记到小说:RAG规模化的三个关键变化

对比上一篇文章的AI日记助手,本次《天龙八部》项目在规模上有了质的升级,也带来了三个关键的设计变化:

维度AI日记助手天龙八部RAG
数据来源手动构造5条数据EPubLoader加载电子书
数据量5条数千条(50章×N块)
处理方式一次性批量插入逐章流式处理
文本分块无(整篇日记为一个单位)RecursiveCharacterTextSplitter + overlap
Schema设计日记专用字段(mood, tags)通用电子书字段(book_id, chapter_num, index)
id设计简单字符串编码规则 {bookId}_{chapterNum}_{chunkIndex}
分块策略不适用chunkSize=500, chunkOverlap=50

规模化的核心问题是:当数据量大到无法一次性加载到内存时,如何处理? 答案就是流式处理——加载一章、分块一章、向量化一章、入库一章,循环往复。每一步的内存占用只与当前章节相关,而不是整本书。


九、总结

把《天龙八部》装进向量数据库,本质上做了一件事:将非结构化的文学作品,转化为可被LLM精确检索和引用的结构化知识

完整链路回顾:EPubLoader 按章节加载电子书 → RecursiveCharacterTextSplitter 以500字符为块、50字符重叠分块 → OpenAIEmbeddings 将每个块向量化为1024维向量 → MilvusClient.insert 批量写入Milvus → 用户提问"鸠摩智会什么武功?" → 向量化查询 → COSINE相似度搜索Top-K结果 → 拼接Prompt → ChatOpenAI生成回答。

工程上值得记住的三个要点:

  1. chunkOverlap不是可选项:没有重叠,关键信息会在分块边界处断裂
  2. id设计编码元数据{bookId}_{chapterNum}_{chunkIndex} 让每条记录自带定位信息
  3. 流式处理对抗大数据量:逐章加载、分块、向量化、入库,避免内存爆炸

当你的RAG项目从"5条日记"扩展到"50本书"时,这三个原则就是保证系统不崩塌的基石。


把《天龙八部》装进向量数据库:EPUB加载、文本分块与RAG问答全链路实战》 是转载文章,点击查看原文


相关推荐


前端框架vue3,vite 开发前端项目实践步骤指南
慧一居士2026/8/1

Vue 3 + Vite 前端项目实践指南 适用版本(截至 2026 年 7 月):Vue 3.5+ | Vite 6/7 | TypeScript 5.6+ | Node.js ^20.19.0 || >=22.12.0 目标:从零搭建一个可投入生产的工程化项目,覆盖「初始化 → 架构 → 规范 → 联调 → 构建部署」全流程。 全景路线图 环境准备 ──▶ 脚手架创建 ──▶ 目录规划 ──▶ Vite 配置 ──▶ 路由/状态/请求层


Java Jersey 实战指南:用 JAX-RS 注解写清晰的 REST API
唐青枫2026/7/24

简介 Jersey 是 Jakarta RESTful Web Services 规范的一种实现。 老名字常叫 JAX-RS,新包名是: jakarta.ws.rs Jersey 自己的核心包名通常是: org.glassfish.jersey 简单理解: Jakarta REST / JAX-RS 是规范 Jersey 是实现 Spring Boot 提供 Jersey 自动配置和 starter Jersey 的开发方式是用注解把 Java 类声明成 HTTP 资源: @Path("/


数据结构之双链表
无忧.芙桃2026/7/16

本篇目标: 1. 学会关于双链表的相关操作 2. 了解链表和顺序表的区别和优点 一、双链表接口实现 1. 双向链表的结构 • 单链表的结点中保存了指向后继结点的地址,所以单链表中找当前结点的后继结点很容易,但要获取当前结点的前驱结点就很麻烦,就只能从头开始往后遍历获取,时间复杂度为 O(n);所以单链表中只有当前结点的指针 pos 时(没有头指针),想要在 pos 之前插入结点和删除 pos 位置结点都是无法实现的。 • 双向链表相比单链表最大的特征是每个结点中多了一个前驱指针,


Android 面试系列:Kotlin 协程的 delay 到底发生在哪个线程?
潜龙勿用之化骨龙2026/7/8

在开发中,我们经常写出这样的代码: mainScope.launch { log("start") delay(1000) log("end") } 这段代码看起来非常简单,但它隐藏了一个非常经典的问题: delay 这 1 秒到底发生在哪个线程? 主线程前后都在执行,那中间谁在“等时间”? 结论 delay 从来不占用线程等待,它是一次“挂起 + 时间注册 + 调度恢复 + 状态机推进”的过程。 中间没有任何业务线程在 sleep,也没有线程在阻塞计时。


别再只会用 cron:Linux systemd Timer 定时任务实战详解
唐青枫2026/6/30

简介 Linux 上提到定时任务,最先想到的通常是 cron。 cron 足够简单,也足够稳定,但任务一旦涉及日志、启动依赖、超时控制、错过后补跑、运行用户和资源限制,单独一行 crontab 很快就会变得难以维护。 systemd Timer 提供了另一套方案: .timer 负责决定什么时候执行 .service 负责决定执行什么、以什么方式执行 例如,每天凌晨备份一次应用数据,可以拆成两个单元: myapp-backup.timer | | 到达触发时间


火山 DTS 正式支持 MySQL 同步到 Milvus , 解决业务库到向量库最后一公里
火山引擎Agent社区2026/6/21

这两年,大模型、智能问答越来越多地落到实际业务里。很多企业在推进过程中慢慢发现,影响 AI 应用落地效率的,除了模型本身能力之外,数据链路是否能顺畅跑通,也同样非常关键。 目前,企业大部分的业务数据库依然在关系型数据库中,而AI应用对支撑语义检索、相似召回的向量数据库有着更强的依赖。怎么把结构化业务数据稳定、持续地同步到向量数据库,正在成为不少企业建设 AI 数据底座时绕不开的问题。 现在,火山引擎 DTS 正式支持 MySQL 同步到 Milvus,帮助企业快速打通从业务数据库到向量数据库的数


计算机网络基础:在 P2P 对等方中搜索对象
梁辰兴2026/6/13

📌目录 ⚖️ 在P2P对等方中搜索对象:去中心化网络的信息发现机制🎯 一、P2P搜索问题概述:去中心化带来的挑战(一)搜索问题的本质(二)搜索算法设计目标(三)搜索算法的分类体系 📦 二、无结构P2P网络中的搜索机制(一)泛洪查询机制(二)随机漫步搜索(三)迭代加深搜索(四)Gossip协议搜索(五)向量时钟与语义搜索 🌐 三、分布式哈希表:结构化搜索的突破(一)DHT的基本原理(二)Chord算法详解(三)CAN算法详解(四)Kademlia算法详解(五)Pastry与T


真正值钱的 AI 小工具,可能只是帮人少打一遍字
深海恶霸Grace2026/6/6

我有个朋友是做财务兼采购的。 他最近有个特别烦的工作: 整理各种报价单信息。 有时候是供应商发来的截图。 有时候是一张图片。 有时候干脆就是一段文字描述。 最后这些东西都要被他重新整理进 Excel。 项目名称、规格、数量、单位、单价、总价。 听起来不难。 但真正做起来,很折磨。 因为这不是一道复杂题。 这是重复劳动。 你得盯着图片看一眼,再切到 Excel 里打一条。 再回来看一眼,再打一条。 遇到数字多一点、截图糊一点、格式乱一点的时候,眼睛真的会看花。 最烦的是,录完之后还不能放心。 因为


Sqoop 安装完整教程(基于 WSL2 + Ubuntu 24.04)
穆金秋2026/5/30

本教程详细介绍了在WSL2+Ubuntu24.04环境下安装配置Sqoop1.4.7的完整流程: 环境准备 Java8+、Hadoop3.3.6、MySQL8.0.45已安装验证命令:java -version/hadoop version/mysql --version 安装步骤 下载Sqoop1.4.7并解压到/usr/local配置环境变量(SQOOP_HOME和PATH)安装MySQL JDBC驱动到Sqoop/lib目录解决依赖问题(commons-lang等jar包)


Gogs: 打造属于你自己的轻量级 Git 服务
修己xj2026/5/8

在软件开发的世界里,Git 已经成为版本控制的事实标准。GitHub、GitLab 等平台提供了强大的托管服务,但有时候,我们需要一个完全属于自己的私有 Git 仓库——可能是为了代码安全,可能是为了定制化需求,可能是为了集成到现有服务中,也可能只是想在自己的服务器上搭建一个个人代码库。开源gitlab有点重,最近我在GitHub上发现了一个轻量级项目Gogs。 什么是 Gogs? Gogs 是一个用 Go 语言编写的自助 Git 托管服务。它的目标是以最简单、最轻松的方式搭建一个简单、稳定且

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读