电子书RAG问答系统实战:Milvus+LangChain从零搭建
前言
大型语言模型爆火后,AI 幻觉成为绕不开的难题。如何让模型输出更可靠,尤其是基于私有数据作答?RAG(检索增强生成)技术是目前最务实的方案。本文从一个完整的电子书问答系统切入,串联 RAG 的核心流程——文档向量化、语义检索、智能问答——并揭示实战中的关键细节。
一、什么是RAG?
RAG的核心思想
传统大型语言模型有两大先天缺陷:一是训练数据存在时间截止点,无法获取新知识;二是仅认识公开语料,企业内部文档、专业书籍等私有知识完全不可知。
RAG 的策略非常直接:不让模型“凭记忆作答”,而是先到知识库中“查资料”,再将查到的材料连同问题一并交给模型生成答案。整体流程大致如下:
用户提问
↓
将问题转为向量(Embedding)
↓
在向量数据库中检索相似内容
↓
将检索结果 + 问题一起发给LLM
↓
LLM基于检索内容生成答案
本文案例场景
为了让你直观理解,我们选用《天龙八部》构建问答系统。例如用户问“段誉会什么武功?”,系统不会依赖模型臆测,而是从小说原文中检索相关段落,产出有据可查的答案。
二、技术栈选型
核心组件
这套系统主要依赖以下工具:
- 向量数据库:
@zilliz/milvus2-sdk-node——Milvus 官方 Node.js SDK - LangChain 生态:
@langchain/openai(Embedding 与 LLM 调用)、@langchain/community(EPUB 文档加载)、@langchain/textsplitters(文本分割)
为什么选择Milvus?
市面上向量数据库众多,Milvus 具备几个硬核优势:专为向量检索设计,十亿级规模依然稳定;支持 COSINE、L2 等多种相似度算法;索引类型丰富,从 IVF_FLAT 到 HNSW 任选;云原生架构,扩展便利。
三、系统架构设计
整体流程
系统分为两大模块:数据导入与实时查询。导入流程:电子书文件 → 文档加载(按章节拆分) → 文本分块(每块 500 字) → 向量化(Embedding) → 存入 Milvus 并建索引。查询流程:用户问题 → 向量化 → 语义检索 → LLM 生成答案。
┌─────────────────┐
│ 电子书文件 │
│ (EPUB格式) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 文档加载 │
│ 按章节拆分 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 文本分块 │
│ (500字/块) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 向量化 │
│ (Embedding) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 存入Milvus │
│ 建立索引 │
└─────────────────┘
四、核心代码实现
步骤1:创建向量集合
第一步:在 Milvus 中创建集合,定义数据 Schema 与向量索引。
async function ensureBookCollection(bookId) {
try {
const hasCollection = await client.hasCollection({
collection_name: COLLECTION_NAME,
});
if (!hasCollection.value) {
console.log(`${COLLECTION_NAME} 集合不存在,创建集合`);
// 定义数据 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: 100 },
{ 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: VECTION_DIM },
]
});
// 创建向量索引
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE, // 余弦相似度
params: { nlist: VECTION_DIM },
});
}
// 加载集合到内存
await client.loadCollection({ collection_name: COLLECTION_NAME });
} catch (err) {
console.error('集合创建失败', err.message);
throw err;
}
}
几个关键点:FloatVector 字段用于存储 1024 维向量;COSINE 度量非常适合文本语义相似度计算;IVF_FLAT 索引在速度与精度之间取得良好平衡。
步骤2:加载并处理 EPUB 文件
EPUB 文件加载后,先按章节拆分,再进一步切分成小块。为什么切块?后文会详细解释。
async function loadAndProcessEPubStreaming(bookId) {
try {
// 加载EPUB文件
const loader = new EPubLoader(EPUB_FILE, { splitChapters: true }); // 按章节拆分
const documents = await loader.load();
// 文本分割器配置
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每块500字
chunkOverlap: 50, // 块间重叠50字,保持上下文连贯
});
let totalInserted = 0;
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
const chapter = documents[chapterIndex];
console.log(`处理第 ${chapterIndex + 1}/${documents.length} 章`);
// 章节内容进一步切块
const chunks = await textSplitter.splitText(chapter.pageContent);
console.log(`拆分为 ${chunks.length} 个片段`);
if (chunks.length === 0) continue;
const insertedCount = await insertChunksBatch(chunks, bookId, chapterIndex + 1);
totalInserted += insertedCount;
}
console.log(`累计插入 ${totalInserted} 个片段`);
return totalInserted;
} catch (err) {
console.error('加载EPUB文件失败', err.message);
throw err;
}
}
分块的原因很简单:一是 Embedding 模型有长度限制(通常 8192 tokens),超过即截断;二是小块检索精度更高,能降低噪音干扰;三是块间重叠可避免关键信息恰好在断口丢失。
步骤3:批量向量化并插入
分块完成后,批量生成向量并写入数据库。这里使用并发调用,是性能优化的关键。
async function insertChunksBatch(chunks, bookId, chapterIndex) {
try {
if (chunks.length === 0) return 0;
// 并发生成Embedding(性能优化关键)
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbedding(chunk);
return {
id: `${bookId}_${chapterIndex}_${chunkIndex}`,
book_id: bookId,
book_name: BOOK_NAME,
chapter_num: chapterIndex,
index: chunkIndex,
content: chunk,
vector
};
})
);
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: insertData,
});
return Number(insertResult.insert_cnt) || 0;
} catch (err) {
console.error('插入数据失败', err.message);
throw err;
}
}
这里有两个亮点:Promise.all 并发调用 Embedding API 显著提升处理速度;批量插入减少网络往返次数,效率更高。
步骤4:语义检索实现
数据就绪后,用户提问时系统需将问题转为向量,再到数据库中找出最相似的几个片段。
async function retrieveRelevantContent(question, k=3) {
try {
// 将问题转为向量
const queryVector = await getEmbedding(question);
// 向量相似度检索
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k, // 返回Top K个最相似结果
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'book_id', 'chapter_num', 'index', 'book_name'],
});
return searchResult.results;
} catch (err) {
console.log('检索相关内容失败', err.message);
return [];
}
}
检索过程即:问题→Embedding 向量→与所有片段向量计算余弦相似度→返回最相似的 3 个片段。
步骤5:RAG问答核心逻辑
最后一步,将检索到的上下文与问题拼接,交给 LLM 生成答案。
async function answerEbookQuestion(question, k=3) {
try {
console.log('开始回答问题: ', question);
// 1. 检索相关内容
const retrievedContent = await retrieveRelevantContent(question, k);
if (retrievedContent.length === 0) {
return '抱歉,没有找到相关内容';
}
// 2. 构建上下文
const context = retrievedContent.map((item, i) =>
`[片段 ${i+1}] 章节:第${item.chapter_num}章 内容: ${item.content}`
).join('\n\n---\n\n');
// 3. 构建Prompt
const prompt = `你是一个专业的《天龙八部》小说助手,基于小说内容回答问题。
请根据以下小说片段内容回答问题:
${context}
用户问题: ${question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详情
2. 可以综合多个片段的内容,提供完整的答案
3. 如果片段中没有相关信息,请如实告知
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答
AI助手的回答:`;
// 4. 调用LLM生成答案
const response = await model.invoke(prompt);
console.log(response.content);
return response.content;
} catch (err) {
return '抱歉,处理您的问题时出现了错误';
}
}
Prompt 工程有几点讲究:为模型设定角色(“小说助手”);上下文携带章节信息以便定位;制定回答规范,尤其是“未找到则如实告知”以杜绝幻觉;要求可引用原文,增强回答可信度。
五、性能优化建议
1. Embedding并发控制
并发虽好,但需避免被 API 限流。建议分批处理:
// 避免API限流
const BATCH_SIZE = 10;
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(chunk => getEmbedding(chunk)));
}
2. 索引选择策略
数据量不同,索引方案应随之调整:
// 小规模数据(<100万): IVF_FLAT
index_type: IndexType.IVF_FLAT
// 大规模数据(>100万): HNSW
index_type: IndexType.HNSW
params: { M: 16, efConstruction: 200 }
3. 分块参数调优
分块大小需根据场景灵活调整:技术文档内容连贯性强,可用大块(1000 字,重叠 100 字);问答场景更注重精度,用小块(500 字,重叠 50 字)。
总结
至此,一套完整的 RAG 电子书问答系统搭建完毕。回顾核心流程:从 EPUB 文件加载、章节拆分、文本分块,到并发调用 Embedding 做向量化、批量插入 Milvus;查询时,问题转向量、余弦相似度检索、Top K 召回;最后配合精心设计的 Prompt,交由 LLM 产出有据可查的答案。
这套架构扩展性极强,稍加改动即可应用于企业知识库问答、法律文档检索、技术文档助手、客服智能问答等场景。掌握 RAG,你的 AI 应用才真正从“聊天玩具”进化为“生产力工具”。
