前面的 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);
对于 ./天龙八部.epub,BOOK_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 组成的数组。每个 Document 的 pageContent 保存一个章节的正文:
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_id、book_name、chapter_num、index |
记录片段来自哪本书、哪一章、哪个位置 |
| 检索内容 | content、vector |
保存原文并参与语义搜索 |
向量负责"找得像不像",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 系统。