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

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

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

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

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

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

长文档 RAG 分成两个阶段

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

建库阶段

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

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

查询阶段

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

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

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

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

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

js 复制代码
const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;
const CHUNK_SIZE = 500;
const EPUB_FILE = './天龙八部.epub';

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

js 复制代码
import { parse } from 'path';

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

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

第二步:用 EPubLoader 按章节加载

LangChain Community 提供了 EPubLoader

js 复制代码
import {
  EPubLoader
} from '@langchain/community/document_loaders/fs/epub';

创建 Loader 时启用章节拆分:

js 复制代码
const loader = new EPubLoader(EPUB_FILE, {
  splitterChapters: true,
});

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

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

js 复制代码
const chapter = document[chapterIndex];
const chapterContent = chapter.pageContent;

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

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

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

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

项目使用 RecursiveCharacterTextSplitter 继续切分:

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

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

当前设置的含义是:

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

切片时按章节循环:

js 复制代码
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 包含:

js 复制代码
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 和余弦相似度:

js 复制代码
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:

js 复制代码
await client.loadCollection({
  collection_name: COLLECTION_NAME,
});

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

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

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

js 复制代码
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,
    };
  })
);

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

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

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

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

text 复制代码
bookId_chapterNum_chunkIndex

例如:

text 复制代码
1_12_3

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

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

js 复制代码
const insertResult = await client.insert({
  collection_name: COLLECTION_NAME,
  data: insertData,
});

return Number(insertResult.insert_cnt) || 0;

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

js 复制代码
totalInserted += insertedCount;

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

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

js 复制代码
const query = '段誉会什么武功';
const queryVector = await getEmbedding(query);

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

js 复制代码
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,每一项包含相似度分数与原始片段信息:

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

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

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

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

项目先封装检索函数:

js 复制代码
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;
}

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

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

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

最后构造小说问答 Prompt:

js 复制代码
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 知识库,需要解决的不只是模型调用,而是长文档如何加载、切分、定位和检索。

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

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

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

相关推荐
秦先生在广东1 小时前
Microsoft Agent Governance Toolkit:用确定性代码墙代替概率性提示词护栏
人工智能
幸福在路上wellbeing1 小时前
AI 智能体开发 · Day 2 详细学习手册
人工智能·学习
1名持续学习的码农1 小时前
Codex 任务总中断:ChatGPT Plus 用户该升级 Pro,还是先把需求写清?
人工智能·gpt·ai·ai编程·codex
QN1幻化引擎1 小时前
把 意识评测做成了一场"非侵入实验":不碰生产代码,分数反而更真了
人工智能·算法·架构
恋猫de小郭1 小时前
KotlinLLM 开源 ,一个可以在运行时自己生成永久 Kotlin 代码的 Agent 库
android·前端·人工智能
EDA365电子论坛1 小时前
AI 智能管控差分线间距规范,消除内外间距统一导致耦合失效问题
人工智能
武子康1 小时前
OpenAI Tibo:同样的 Rate Card,为什么更强的 Sol 反而更快耗尽 Codex 限额
人工智能·chatgpt·openai
北冥you鱼1 小时前
深入解析 DEX 项目池流动性设计原理:从恒定乘积到集中流动性
人工智能·区块链
微学AI1 小时前
一款童年游戏对超级智能体应用开发的启示 — 从《武林群侠传》看 Agent 架构设计的“江湖智慧“
人工智能·游戏·agent