百万字 EPUB 怎么做 RAG?用《天龙八部》跑通 Loader、Splitter、Milvus 与问答

0 阅读6分钟

前面的 AI 日记本只有几条短文本,每篇日记可以直接生成一个向量,再整体写入 Milvus。

把数据源换成一部长篇小说后,处理方式必须改变。一本 EPUB 包含多个章节和大量正文,如果把整本书转换成一个向量,用户询问“段誉会什么武功”时,系统只能知道问题和整本书是否相关,却无法定位真正包含答案的段落。

长文档 RAG 的关键不是直接调用大模型,而是先把原始电子书变成一组可检索、可定位的文本片段:

EPUB 电子书
→ 按章节加载
→ 把每章切成多个 Chunk
→ 为每个 Chunk 生成 Embedding
→ 保存向量、正文和章节信息
→ 根据问题检索 Top-K 片段
→ 把相关片段交给大模型回答

本文以《天龙八部》为数据源,使用 LangChain、Milvus 和 Qwen 跑通电子书知识库的建库、检索与 RAG 问答流程。

长文档 RAG 分成两个阶段

完整流程不是每次提问时重新读取整本小说,而是分为建库和查询两个阶段。

建库阶段

加载 EPUB
→ 按章节得到 Document
→ 每章继续切成多个 Chunk
→ Chunk 转换成 1024 维向量
→ 写入 Milvus

这个阶段提前执行,负责把原始电子书处理成可以被语义检索的知识库。

查询阶段

用户问题
→ 问题转换成向量
→ Milvus 检索相似片段
→ 拼接相关章节正文
→ Qwen 根据正文回答

查询阶段不再扫描 EPUB 文件,而是直接搜索 Milvus 中已经保存的向量和文本片段。

两个阶段使用同一个 Embedding Model 和相同的 1024 维向量空间,日后写入的正文向量才能与问题向量正确比较。

第一步:确定电子书和切片参数

项目先定义 Collection、向量维度、切片大小和 EPUB 路径:

const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;
const CHUNK_SIZE = 500;
const EPUB_FILE = './天龙八部.epub';

path.parse 用来从文件路径中提取书名:

import { parse } from 'path';

const { name: BOOK_NAME } = parse(EPUB_FILE);

对于 ./天龙八部.epubBOOK_NAME 的值是 天龙八部。写入 Milvus 时保存这个字段,后续同一个 Collection 存放多本书时,仍能知道每个片段来自哪里。

第二步:用 EPubLoader 按章节加载

LangChain Community 提供了 EPubLoader

import {
  EPubLoader
} from '@langchain/community/document_loaders/fs/epub';

创建 Loader 时启用章节拆分:

const loader = new EPubLoader(EPUB_FILE, {
  splitterChapters: true,
});

const document = await loader.load();
console.log(`加载完成, 共 ${document.length} 个章节`);

加载结果不是一个巨大的字符串,而是由多个 Document 组成的数组。每个 DocumentpageContent 保存一个章节的正文:

const chapter = document[chapterIndex];
const chapterContent = chapter.pageContent;

按章节加载解决的是第一层结构问题:系统先保留小说原有的章节边界,再在每个章节内部继续切片。

需要准确理解这里的执行方式:loader.load() 会先完成 EPUB 加载,后面的代码再逐章处理这些 Document。逐章处理避免一次性为全书所有 Chunk 同时生成向量,但它并不是从磁盘逐字节读取的文件流。

第三步:把每章切成可检索的 Chunk

一个章节仍然可能包含几千到上万字,直接生成一个章节向量仍然过于粗糙。

项目使用 RecursiveCharacterTextSplitter 继续切分:

import {
  RecursiveCharacterTextSplitter
} from '@langchain/textsplitters';

const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: CHUNK_SIZE,
  chunkOverlap: 50,
});

当前设置的含义是:

参数当前值作用
chunkSize500控制单个文本片段的目标大小
chunkOverlap50让相邻片段保留一部分重复上下文

切片时按章节循环:

for (
  let chapterIndex = 0;
  chapterIndex < document.length;
  chapterIndex++
) {
  const chapter = document[chapterIndex];
  const chapterContent = chapter.pageContent;
  const chunks = await textSplitter.splitText(chapterContent);

  if (chunks.length === 0) {
    continue;
  }

  await insertChunksBatch(
    chunks,
    bookId,
    chapterIndex + 1
  );
}

chunkOverlap: 50 的价值在于保留边界上下文。

假设一段武功描述刚好落在两个 Chunk 的交界处,完全不重叠可能把人物名称与招式说明分开。相邻片段重复少量文字后,两个 Chunk 都能保留更完整的语义线索。

Chunk 也不是越小越好。太小会失去完整情节,太大则会混入过多人物和事件。500 与 50 是这份小说 Demo 的起点,真实知识库还应根据文档结构、模型上下文和检索效果继续调整。

第四步:设计电子书 Collection

AI 日记本只需要保存日记 ID、日期、心情、标签和正文。电子书知识库还需要记录一本书中的章节与切片位置。

项目创建的 Schema 包含:

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标识一个确定的小说片段
来源信息book_idbook_namechapter_numindex记录片段来自哪本书、哪一章、哪个位置
检索内容contentvector保存原文并参与语义搜索

向量负责“找得像不像”,metadata 负责“找到后知道来自哪里”。如果只保存向量而不保存章节与正文,即使检索成功,也无法把证据交给模型或展示给用户。

第五步:为向量字段创建 IVF_FLAT 索引

项目使用 IVF_FLAT 和余弦相似度:

await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE,
  params: {
    nlist: 1024,
  },
});

IVF_FLAT 会先把向量划分到不同的聚类中。查询时先缩小候选范围,再比较候选向量,从而避免每次都扫描 Collection 中的全部数据。

nlist 表示聚类数量。它会影响索引构建、候选范围和查询效果,不应该脱离数据规模直接照搬。这里的 1024 是当前 Demo 的配置,真实项目需要通过召回率和查询延迟测试确定。

创建完成后加载 Collection:

await client.loadCollection({
  collection_name: COLLECTION_NAME,
});

ensureCollection 会先调用 hasCollection。Collection 不存在时完成 Schema 和索引创建,已经存在时直接进入加载流程,避免重复建库。

第六步:使用 Promise.all 生成章节向量

每个章节被拆成多个 Chunk 后,项目通过 Promise.all 为这一章的片段生成向量:

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,
    };
  })
);

这里保留了两层处理节奏:

章节之间:for 循环顺序处理
章节内部:Promise.all 并发生成多个 Chunk 的向量

这样既不会一次性对全书所有片段发起请求,也不必让同一章节中的 Chunk 完全串行执行。

每个片段的主键由三部分组成:

bookId_chapterNum_chunkIndex

例如:

1_12_3

表示第 1 本书、第 12 章、第 4 个 Chunk。确定的 ID 让数据片段与原始位置形成稳定映射。

向量生成完成后,整章一次写入 Milvus:

const insertResult = await client.insert({
  collection_name: COLLECTION_NAME,
  data: insertData,
});

return Number(insertResult.insert_cnt) || 0;

insert_cnt 统一转换为数字,调用方就可以持续累计已经写入的片段数量:

totalInserted += insertedCount;

第七步:用自然语言检索小说片段

知识库建立后,query.mjs 不需要再读取 EPUB,只需要把问题转换成向量:

const query = '段誉会什么武功';
const queryVector = await getEmbedding(query);

随后从 ebook Collection 中检索 Top-3:

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',
  ],
});

Milvus 返回 searchResult.results,每一项包含相似度分数与原始片段信息:

searchResult.results.forEach((item, index) => {
  console.log(`${index + 1}. ${item.score}`);
  console.log(item.chapter_num);
  console.log(item.content);
});

语义检索不要求问题和原文使用完全相同的关键词。只要人物、武功和相关情节在向量空间中接近,对应片段就有机会进入 Top-K。

第八步:把检索结果变成 RAG 上下文

单独的向量搜索只会返回相关原文。要让系统直接回答问题,还需要把这些片段整理进 Prompt。

项目先封装检索函数:

async function retrieveRelevantChunks(question, k = 3) {
  const queryVector = await getEmbedding(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;
}

然后把命中的章节和正文拼成上下文:

const content = retrievedContent
  .map((item, index) => `
    [片段${index + 1}]
    章节${item.chapter_num}
    内容:${item.content}
  `)
  .join('\n\n---\n\n');

上下文不仅包含正文,还保留章节编号。大模型能够知道证据来自哪一章,输出也更容易追溯。

最后构造小说问答 Prompt:

const prompt = `
你是一个专业的《天龙八部》小说助手。
基于小说回答问题,用准确、详细的语言。

小说片段:
${content}

用户问题:${question}

如果片段中没有相关信息,请如实告知用户。
回答要符合小说的情节和人物设定。
`;

const response = await model.invoke(prompt);

这一步中,大模型不是依靠训练时可能见过的小说内容自由回答,而是优先根据当前检索到的原文组织答案。

Loader、Splitter、Milvus 和 Qwen 分别负责什么

整个项目接入了多个组件,但职责边界非常清楚:

组件负责的工作不负责什么
EPubLoader读取 EPUB,按章节生成 Document不生成向量
RecursiveCharacterTextSplitter把章节切成适合检索的 Chunk不判断答案
Embedding Model把正文和问题映射成向量不保存原文
Milvus保存数据并检索相似片段不生成自然语言回答
Qwen根据问题和相关片段组织答案不负责遍历整本 EPUB

一套 RAG 的效果取决于整条链路。Loader 丢失正文、Chunk 切得不合理或 Milvus 没有召回关键片段,最后的大模型都无法凭空补回可靠证据。

从小说 Demo 到通用电子书知识库

当前代码已经完成单本电子书的核心流程。继续扩展时,可以利用现有字段支持更多能力:

  • 使用 book_id 区分多本电子书;
  • 在向量检索前按 book_id 过滤范围;
  • 在回答中展示章节号和引用片段;
  • 为重复导入设计清理、更新或幂等写入流程;
  • 根据模型接口限制,为大量 Chunk 增加并发控制;
  • 记录检索分数,评估不同 chunkSize、Overlap 和 Top-K 的效果。

这些扩展不会改变核心架构,仍然是“先处理文档并建库,再检索证据并回答”。

总结

把一部长篇 EPUB 做成 RAG 知识库,需要解决的不只是模型调用,而是长文档如何加载、切分、定位和检索。

这次完整流程可以概括为:

EPubLoader 按章节加载
→ RecursiveCharacterTextSplitter 切片
→ Promise.all 生成章节内 Chunk 向量
→ Milvus 保存正文、章节 metadata 和 vector
→ 问题向量检索 COSINE Top-K
→ 相关片段组成 Context
→ Qwen 根据小说原文回答

短文本 Demo 证明向量检索能够工作,长篇电子书实战才真正把 Loader、Splitter、Embedding、向量数据库和生成模型连成了一套可复用的 RAG 系统。