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

摘要

以《天龙八部》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_numcontent,你可以直接定位到原文的精确位置。

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生成回答。

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

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

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

相关推荐
赵广陆11 小时前
RAG进阶
pycharm·langchain
山间小僧12 小时前
「AI学习笔记」Loop Engineering 和 Graph Engineering
langchain·agent·ai编程
浮生望17 小时前
Milvus向量数据库实战:从零搭建AI日记助手的RAG完整链路
langchain
制造数据与AI践行者老蒋18 小时前
LangChain ReAct Agent 嵌套 JSON 报错?args_schema=None 解决 Field required
langchain·react agent·排坑笔记·json嵌套报错·pydantic校验·工具调用排坑
gb42152871 天前
python中unstructured库和langchain-unstructured库在解析pdf文件的时候的区别?
python·langchain·pdf
Tbisnic2 天前
LangChain的 六大核心组件与 RAG 知识库构建
人工智能·python·ai·langchain·rag·langgraph
gb42152872 天前
python中pypdf库和langchain-unstructured库在解析pdf文件的时候的区别?
python·langchain·pdf
badhope2 天前
用RAG做了个智能客服,上线第一天就被用户骂了——我的7天实战复盘
人工智能·langchain
Wang's Blog2 天前
AI Agent白手起家44: LangChain 文档切分实战 — 长度、文本架构与语义切片
人工智能·langchain