摘要
以《天龙八部》EPUB拆解RAG全链路:EPubLoader章节加载、RecursiveCharacterTextSplitter分块、Milvus流式入库,实现语义问答。
一、一本百万字小说,如何让AI读懂它
上一篇文章我们用AI日记助手演示了RAG的基本流程:5篇日记、手动构造数据、一次插入。但真实场景中,数据源不是手动构造的,而是各种格式的文档------PDF、EPUB、CSV、Markdown。数据量也不是5条,而是百万字级别的小说。
这篇文章以金庸的《天龙八部》EPUB电子书为样本,完整走通一条工业级RAG管线:文档加载 → 文本分块 → 向量化 → 存储到Milvus → 语义检索 → LLM问答。你会看到,当数据量从5条日记变成一整本小说时,架构设计上需要做的所有调整。
项目依赖:
json
{
"@langchain/community": "^1.1.29", // EPubLoader
"@langchain/textsplitters": "^1.0.1", // RecursiveCharacterTextSplitter
"@langchain/openai": "^1.5.5", // Embeddings + ChatOpenAI
"@zilliz/milvus2-sdk-node": "^3.0.3", // Milvus 客户端
"epub2": "^3.0.2", // EPUB 解析底层库
"html-to-text": "^10.0.0" // HTML → 纯文本转换
}
三个文件对应三个阶段:main.mjs 负责加载和入库,query.mjs 负责语义检索,rag.mjs 负责完整的RAG问答。
二、文档加载:EPubLoader把一本电子书拆成章节
LangChain 的文档加载器覆盖了几乎所有常见格式:PDF、CSV、Markdown、JSON、Notion、Confluence,以及本文的主角------EPUB。
javascript
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub';
const loader = new EPubLoader('./天龙八部.epub', {
splitChapters: true,
});
const documents = await loader.load();
console.log(`加载完成,共${documents.length}个章节`);
EPubLoader 内部依赖 epub2 库解析EPUB格式,html-to-text 将章节内的HTML标签转换为纯文本。splitChapters: true 是关键配置------它让Loader按章节拆分,而不是把整本书作为一个大字符串返回。每个章节是一个独立的 Document 对象,包含 pageContent(章节正文)和 metadata(章节标题等信息)。
这一步的产出是 N 个 Document,每个对应《天龙八部》的一个章节。按章节拆分有两个好处:一是保留了天然的结构边界(章与章之间不会出现跨章拼接),二是为后续的流式处理提供了粒度------可以逐章分块、逐章向量化、逐章入库,而不是把整本书全部加载到内存中再处理。
三、文本分块:RecursiveCharacterTextSplitter 的切割策略
一个章节可能有几千字,直接整章向量化会导致两个问题:Embedding模型对超长文本的语义表达能力下降,且检索时返回的是整章内容,精度不够。所以需要分块。
javascript
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
RecursiveCharacterTextSplitter 的分块逻辑是"递归降级":先用最大的分隔符切割,如果切出来的块仍然超过 chunkSize,就用次一级的分隔符继续切,直到所有块都在限制范围内。默认分隔符优先级为:\n\n → \n → → ""(逐字符)。
chunkSize: 500 表示每个文本块最多500个字符。这个值不是越大越好------太小会导致语义碎片化,太大会降低检索精度。500是中文文本的常用经验值,大约对应200-300个中文字。
chunkOverlap: 50 是RAG中最容易被忽略但最重要的参数。它的含义是:相邻两个文本块之间重叠50个字符。为什么需要重叠?假设一句话正好被切在两块的边界上:
arduino
块1: "......段誉心中一凛,暗想这"
块2: "鸠摩智的火焰刀果然厉害......"
如果没有重叠,"段誉"和"鸠摩智"的关联就断开了。50个字符的重叠让相邻块之间保留了一段共同的上下文,避免关键信息在边界处丢失。
javascript
// 主循环:逐章处理
for (let chapterIndex = 0; chapterIndex < documentLen; chapterIndex++) {
const chapter = documents[chapterIndex];
const chunks = await textSplitter.splitText(chapter.pageContent);
console.log(`第 ${chapterIndex + 1} 章拆分为 ${chunks.length} 个片段`);
const insertedCount = await insertChunksBatch(chunks, bookID, chapterIndex + 1);
totalInserted += insertedCount;
}
逐章处理而非全量加载,是处理大文件的关键。如果《天龙八部》有50章、每章平均5000字,全量分块会产生约500个文本块,一次性向量化需要调用500次Embedding API,耗时且容易触发限流。逐章处理将API调用均匀分布,每一步的内存占用和API压力可控。
四、Milvus Schema:为电子书设计的字段结构
一个通用的电子书向量库,Schema设计需要考虑多本书的共存:
javascript
const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;
await client.createCollection({
collection_name: COLLECTION_NAME,
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: VECTOR_DIM }
]
});
七个字段的设计意图:
| 字段 | 类型 | 用途 |
|---|---|---|
id |
VarChar | 主键,格式 {bookId}_{chapterNum}_{chunkIndex},全局唯一 |
book_id |
VarChar | 区分不同书籍,支持多本书存入同一个Collection |
book_name |
VarChar | 书名,用于检索结果展示 |
chapter_num |
Int32 | 章节编号,定位原文位置 |
index |
Int32 | 块内序号,同一章节内多个块的顺序 |
content |
VarChar(10000) | 块的文本内容 |
vector |
FloatVector(1024) | 文本的向量表示 |
id 的命名规则 {bookId}_{chapterNum}_{chunkIndex} 是精心设计的------它不是随机生成的,而是编码了数据来源信息。当检索结果返回时,你一眼就能看出这条记录来自哪本书的哪一章的第几个片段。不需要额外查询,id本身就是元数据。
索引配置中有一个关键参数 nlist:
javascript
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: 1024 }
});
nlist 是K-Means聚类的簇数。IVF_FLAT的工作原理是:建索引时把所有向量聚成 nlist 个簇,查询时只搜索最近的 nprobe 个簇(默认值通常较小)。nlist 越大,每个簇内的向量越少,搜索精度越高但建索引越慢。对于一本小说几千个块的数据量,nlist: 1024 是合理的------每个簇平均只有几个向量,检索精度接近暴力搜索,但速度远快于O(n)遍历。
五、流式入库:批量向量化与逐章写入
insertChunksBatch 是入库的核心函数,它把一批文本块并行向量化后一次性插入Milvus:
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 getEmbeddings(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: COLLECTION_NAME,
data: insertData
});
return Number(insertResult.insert_cnt) || 0;
}
Promise.all + map 将同一章的所有块并行向量化,然后一次性批量插入。这是性能最优的方案------Embedding API调用是并行的,数据库写入是批量的。如果一章有10个块,并行调用比串行调用快约10倍(取决于API的并发限制)。
主流程中还有一个 ensureCollection 函数,包含了健壮的错误处理:
javascript
async function ensureCollection(bookID) {
const hasCollection = await client.hasCollection({ collection_name: COLLECTION_NAME });
if (!hasCollection.value) {
await client.createCollection({ ... });
await client.createIndex({ ... });
}
try {
await client.loadCollection({ collection_name: COLLECTION_NAME });
} catch(err) {
console.error('集合已经处于加载状态');
}
}
hasCollection 判断集合是否已存在,避免重复创建;loadCollection 的try-catch处理了"集合已在加载中"的边缘情况。这种"先检查再操作"的模式在数据库操作中非常实用------脚本可能被重复执行多次,但数据只会被创建一次。
六、语义检索:用自然语言查询小说内容
query.mjs 展示了检索阶段。用户输入"段誉会什么武功?",系统在Milvus中搜索语义最相似的文本块:
javascript
const query = '段誉会什么武功?';
const queryVector = await getEmbeddings(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: 3,
metric_type: MetricType.COSINE,
output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
});
返回的每条结果包含 score(余弦相似度,越接近1越相似)和 output_fields 中指定的字段。通过 chapter_num 和 content,你可以直接定位到原文的精确位置。
Milvus的搜索API极其简洁:不需要写SQL,不需要构建复杂的查询条件,传一个向量数组和limit,返回Top-K结果。对比传统数据库的全文检索(需要分词、建倒排索引、写LIKE或MATCH语句),向量搜索的语义理解能力是质的飞跃。
七、RAG问答:检索→上下文注入→LLM生成
rag.mjs 是完整的RAG问答管线,它将检索到的文本块注入Prompt,让LLM基于小说原文回答问题。
检索函数 retrieverdRelevantContent 封装了向量化和搜索逻辑,返回Top-K个最相似的文本块:
javascript
async function retrieverdRelevantContent(question, k = 3) {
const queryVector = await getEmbeddings(question);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
});
return searchResult.results;
}
问答函数 answerEbookQuestion 将检索结果拼接成Prompt上下文,调用LLM生成回答:
javascript
const context = retrieverdContent.map((item, i) => `
[片段${i + 1}]
章节:第${item.chapter_num}章
内容:${item.content}
`).join('\n\n----\n\n');
const prompt = `
你是一个专业的《天龙八部》小说助手。基于小说回答问题,用准确、详细的语言。
请根据以下小说片段内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细准确的回答。
2. 可以综合多个片段的内容,提供完整的答案。
3. 如果片段中没有相关信息,请如实告知用户。
4. 回答要准确,符合小说的情节和人物设定。
5. 可以引用原文内容来支持你的回答。
AI 助手的回答:
`;
这个Prompt的设计有几个关键点:
- 角色设定:"专业的《天龙八部》小说助手",限制了LLM的回答范围,避免它偏离小说内容自由发挥
- 上下文注入:检索到的原文片段被明确标注为"片段1"、"片段2",LLM知道这些是小说原文,而非对话历史
- 行为约束:五条要求覆盖了"有信息时""无信息时""多片段综合"三种场景,以及对准确性和可溯源性的要求
- 引用原文:第5条要求LLM可以引用原文,这在文学类问答中特别重要------用户希望看到"原文是这样写的",而不仅仅是AI的总结
主函数中查询"鸠摩智会什么武功?":
javascript
const result = await answerEbookQuestion('鸠摩智会什么武功?', 5);
console.log(result);
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生成回答。
工程上值得记住的三个要点:
- chunkOverlap不是可选项:没有重叠,关键信息会在分块边界处断裂
- id设计编码元数据 :
{bookId}_{chapterNum}_{chunkIndex}让每条记录自带定位信息 - 流式处理对抗大数据量:逐章加载、分块、向量化、入库,避免内存爆炸
当你的RAG项目从"5条日记"扩展到"50本书"时,这三个原则就是保证系统不崩塌的基石。