🏯 从零搭建《天龙八部》RAG 智能问答系统 --- 完整实战学习日志
阅读本文,你将收获:
- 一套完整的 RAG(检索增强生成)流程实践
- LangChain + Milvus + OpenAI 技术栈的落地经验
- EPUB 电子书加载、分块、向量化、检索的全链路代码解析
- 向量数据库设计与 IVP_FLAT 索引调优思路
📖 一、项目概览
这个项目做了什么?
我们把金庸经典武侠小说《天龙八部》(约 150 万字的 EPUB 电子书)变成了一本"可对话的书"------你可以像和一位资深金庸迷聊天一样,问它:
- "鸠摩智会什么武功?"
- "段誉的六脉神剑是怎么练成的?"
- "乔峰的身世秘密是什么?"
底层采用 RAG(Retrieval-Augmented Generation,检索增强生成) 架构:先检索小说中最相关的片段,再交给大模型基于这些片段生成准确回答,从根本上解决了大模型"幻觉"和知识截止日期的问题。
🧱 二、技术栈全景图
| 层级 | 技术选型 | 作用 |
|---|---|---|
| 文档加载 | @langchain/community EPubLoader |
解析 EPUB 格式,按章节拆分 |
| 文本分割 | @langchain/textsplitters RecursiveCharacterTextSplitter |
将长文本切分为语义连贯的块 |
| 向量化 | @langchain/openai OpenAIEmbeddings |
将文本块转为 1024 维向量 |
| 向量存储 | @zilliz/milvus2-sdk-node Milvus |
分布式向量数据库,存储 + 检索 |
| 大模型 | @langchain/openai ChatOpenAI |
基于检索到的上下文生成回答 |
scss
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ EPUB 电子书 │ → │ 文本分块器 │ → │ Embedding │ → │ Milvus 向量库 │
│ (150万字) │ │ (500字/块) │ │ (1024维) │ │ (IVF_FLAT) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────┬───────┘
│
┌───────▼───────┐
│ 相似度检索 │
│ (COSINE) │
└───────┬───────┘
│
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌───────▼───────┐
│ 用户提问 │ → │ 问题向量化 │ → │ 拼接 Prompt │ → │ LLM 生成回答 │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
🔧 三、项目结构
bash
tlbb/
├── .env # 环境变量(API Key、数据库地址等)
├── package.json # 依赖管理
├── 天龙八部.epub # 约 15MB 的小说源文件
├── readme.md # 项目说明
└── src/
├── main.mjs # 数据预处理:加载 → 分块 → 向量化 → 入库
├── query.mjs # 向量检索:输入问题 → 返回最相似的 Top-K 片段
└── rag.mjs # 完整 RAG 流程:检索 + LLM 生成回答
三个核心文件,职责分明:
| 文件 | 一句话职责 |
|---|---|
main.mjs |
把电子书"喂"进向量数据库 |
query.mjs |
从向量数据库中"找"相关内容 |
rag.mjs |
"检索 + 生成"完整对话流程 |
🗄️ 四、深入 main.mjs:数据预处理管道
这是整个系统的"地基"------把一本 150 万字的电子书变成向量数据库中可检索的向量。
4.1 向量数据库 Collection 设计
js
const COLLECTION_NAME = 'ebook'; // 集合名
const VECTOR_DIM = 1024; // 向量维度
const CHUNK_SIZE = 500; // 每个分块 500 字符
Milvus Collection 的表结构(Schema):
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
VarChar(100) | 主键,格式:{bookId}_{章节号}_{块序号} |
book_id |
VarChar(100) | 书籍 ID(支持多书扩展) |
book_name |
VarChar(200) | 书名,如"天龙八部" |
chapter_num |
Int32 | 章节编号,标记内容来源 |
index |
Int32 | 块在章节内的序号 |
content |
VarChar(10000) | 文本内容(最多 10000 字符) |
vector |
FloatVector(1024) | 文本的向量表示,核心检索字段 |
💡 设计亮点 :保留
chapter_num和index字段,检索时能精确定位到原文的章节位置,方便溯源。
4.2 核心函数解析
ensureCollection(bookId) --- 确保集合就绪
js
async function ensureCollection(bookId) {
// 1. 检查集合是否存在
const hasCollection = await client.hasCollection({
collection_name: COLLECTION_NAME,
});
// 2. 不存在 → 创建集合 + 创建索引
if (!hasCollection.value) {
await client.createCollection({ /* 定义字段结构 */ });
await client.createIndex({
field_name: 'vector',
index_type: IndexType.IVF_FLAT, // IVF 倒排索引
metric_type: MetricType.COSINE, // 余弦相似度
params: { nlist: 1024 }, // 聚类中心数
});
}
// 3. 加载集合到内存(已加载时捕获重复加载异常)
try {
await client.loadCollection({ collection_name: COLLECTION_NAME });
} catch (err) {
console.log('集合已处于加载状态,无需重复加载');
}
}
🎯 关键决策:nlist = 1024
IVF_FLAT使用 K-Means 聚类将向量空间划分为 1024 个簇。检索时先定位最近的簇,再在簇内精确搜索,大幅减少计算量。1024 是一个经验值,适合百万级数据量。
loadAndProcessEPubStreaming(bookId) --- 流式处理管道
js
async function loadAndProcessEPubStreaming(bookId) {
// Step 1: 加载 EPUB
const loader = new EPubLoader(EPUB_FILE, {
splitChapters: true, // 按章节生成多个 Document
});
const documents = await loader.load();
// → 得到 N 个 Document,每个代表一章
// Step 2: 创建文本分割器
const textSplitters = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每块 500 字符
chunkOverlap: 50, // 块间重叠 50 字符
});
// Step 3: 逐章处理(流式,边切边存)
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
const chapter = documents[chapterIndex];
const chunks = await textSplitters.splitText(chapter.pageContent);
await insertChunksBatch(chunks, bookId, chapterIndex + 1);
}
}
🧠 为什么用流式而不是一次性? 一本 150 万字的书会产生约 3000+ 个分块,每个块都要调 Embedding API 生成 1024 维向量。流式处理逐章切割、逐章入库,内存友好,失败时不会全盘重来。
4.3 关键参数的选择哲学
| 参数 | 取值 | 为什么 |
|---|---|---|
chunkSize |
500 | 小说语义密度适中,500 字足够覆盖一个完整情节片段 |
chunkOverlap |
50 | 10% 重叠率,保证上下文连贯,避免关键信息落在切割边界 |
VECTOR_DIM |
1024 | 匹配使用的 Embedding 模型输出维度 |
separator |
默认(\n\n) |
RecursiveCharacterTextSplitter 按 \n\n → \n → 。→ !→ ? 优先级递归切割 |
🔍 五、深入 query.mjs:纯向量检索
这是最"纯粹"的一步------给定问题文本,在向量数据库中找到语义最相似的 Top-K 个片段。
js
async function main() {
await client.loadCollection({ collection_name: COLLECTION_NAME });
const query = '段誉会什么武功';
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: 3, // Top-3
metric_type: MetricType.COSINE, // 余弦相似度
output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content'],
});
// 打印结果,带上相似度分数
searchResult.results.forEach((item, index) => {
console.log(`${index + 1}. [Score: ${item.score.toFixed(4)}]`);
console.log(` Content: ${item.content}`);
});
}
检索流程拆解:
css
用户问题 "段誉会什么武功"
│
▼
Embedding 模型 → 1024 维向量 [0.023, -0.451, ..., 0.187]
│
▼
Milvus COSINE 相似度检索 → Top-3 最相似文本块
│
▼
结果 1: Score 0.9234 --- "段誉学会了六脉神剑..."
结果 2: Score 0.8912 --- "段誉的凌波微步..."
结果 3: Score 0.8456 --- "北冥神功是段誉..."
💡 余弦相似度(COSINE) 衡量的是向量方向的接近程度,范围为 -1, 1。值越接近 1,语义越相似。它天然适合文本语义相似度计算,因为文本向量的方向 比长度更有意义。
🤖 六、深入 rag.mjs:检索 + 生成的完整闭环
query.mjs 只做了"找",rag.mjs 加上了"答"------这才是用户感知到的完整体验。
6.1 检索函数
js
async function retrieveRelevantContent(question, k = 3) {
const queryVector = await getEmbeddings(question);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k, // 默认返回 Top-3
output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content'],
});
return searchResult.results;
}
6.2 RAG 核心:Prompt 工程设计
js
async function answerEbookQuestion(question, k = 3) {
// 1. 检索相关片段
const retrievedContent = await retrieveRelevantContent(question, k);
// 2. 构建上下文
const context = retrievedContent.map((item, index) => `
片段${index + 1},章节${item.chapter_num},内容:
${item.content}
`).join('\n');
// 3. 组装 Prompt
const prompt = `
你是个专业的天龙八部小说助手。
基于小说回答问题,用准确详细的语言。
请根据以下小说片段内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细准确的回答
2. 如果没有,请说不知道
3. 可以综合多个片段的内容,提供完整的答案
4. 如果片段中没有相关信息,请如实告知用户
5. 回答要准确,符合小说的情节和人物设定
6. 可以引用原文内容来支持你的回答
AI回答:
`;
// 4. 调用 LLM
const response = await model.invoke(prompt);
return response.content;
}
6.3 Prompt 设计的五个关键约束
| 约束 | 目的 |
|---|---|
| "基于小说回答问题" | 限定知识来源,拒绝瞎编 |
| "如果没有,请说不知道" | 防止 LLM 用自己的预训练知识"脑补" |
| "综合多个片段" | 利用 Top-K 检索的冗余信息拼出完整答案 |
| "符合小说情节和人物设定" | 保持角色一致性 |
| "引用原文内容" | 增强可信度,读者可以溯源 |
🧠 这就是 RAG 的本质 :用检索到的外部知识约束大模型的生成,让它只在你提供的"参考资料"范围内作答。没有 RAG,大模型可能把段誉的武功说成"降龙十八掌";有了 RAG,它只会回答"六脉神剑、凌波微步、北冥神功"。
⚙️ 七、Embedding 配置详解
三个文件共用的 Embedding 配置:
js
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL, // 支持自定义代理地址
},
dimensions: VECTOR_DIM, // 指定输出维度
});
| 配置项 | 说明 |
|---|---|
apiKey |
OpenAI API 密钥(兼容任何 OpenAI 格式的服务) |
model |
Embedding 模型名称,如 text-embedding-3-small |
baseURL |
自定义 API 端点,支持中转代理或国产模型 |
dimensions |
显式指定向量维度,确保与 Milvus 的 Schema 一致 |
🗃️ 八、Milvus 向量数据库关键操作速查
| 操作 | 代码 | 说明 |
|---|---|---|
| 创建客户端 | new MilvusClient({ address, token, ssl: true }) |
Milvus Cloud 使用 TLS 连接 |
| 检查集合 | client.hasCollection({ collection_name }) |
返回 { value: boolean } |
| 创建集合 | client.createCollection({ fields: [...] }) |
定义主键、标量字段、向量字段 |
| 创建索引 | client.createIndex({ index_type: 'IVF_FLAT', metric_type: 'COSINE' }) |
必须对向量字段建索引才能检索 |
| 加载集合 | client.loadCollection({ collection_name }) |
将集合加载到内存,检索的前提 |
| 插入数据 | client.insert({ data: [...] }) |
批量插入,返回 insert_cnt |
| 向量检索 | client.search({ vector, limit, output_fields }) |
返回 { results: [{ score, ... }] } |
🚀 九、完整运行流程
Step 1:环境准备
env
# .env 文件
OPENAI_API_KEY=sk-xxxxxxxx
OPENAI_BASE_URL=https://api.openai.com/v1
EMBEDDINGS_MODEL_NAME=text-embedding-3-small
MODEL_NAME=gpt-4o
MILVUS_ADDRESS=https://your-instance.milvus.cloud:19530
MILVUS_TOKEN=your-milvus-api-token
Step 2:数据入库(首次运行)
bash
node src/main.mjs
输出示例:
erlang
================================================================================
开始加载集合:
================================================================================
连接数据库...
数据库连接成功
加载集合...
创建集合....
集合创建成功
创建索引
索引创建成功
集合加载成功
开始加载EPUB文件:./天龙八部.epub
加载完成,共50个章节
处理章节1: 50
章节1共12个数据切片
生成向量并插入中...
章节1共12条数据插入完成
处理章节2: 50
...
共插入3156条数据
Step 3:问答交互
bash
node src/rag.mjs
markdown
集合加载成功
鸠摩智是吐蕃国师,精通多种武功绝学,主要包括:
1. **火焰刀** --- 鸠摩智的成名绝技,以掌力发出炽热刀气...
2. **小无相功** --- 鸠摩智偷学自逍遥派...
3. **少林七十二绝技** --- 鸠摩智在天龙寺一战中展现了多种少林武功...
------ 以上内容综合自第10章、第14章、第18章
🎯 十、核心知识点总结
10.1 RAG 工作流的四个阶段
css
[文档加载] → [文本分割] → [向量化存储] → [检索生成]
EPUB Chunk Embedding Search + LLM
10.2 为什么需要文本分块?
- Embedding 模型有输入长度限制(通常是 8192 token)
- 语义聚焦:太长的文本包含混杂语义,检索精度下降
- 检索粒度:小块更精确地定位到答案所在的具体段落
10.3 为什么需要 chunkOverlap(重叠)?
假设一句话恰好被切在两块的边界:
块1:...段誉施展 ← 缺了下半句
块2:六脉神剑,击退了鸠摩智... ← 缺了上半句
有了 50 字符的重叠:
块1:...段誉施展六脉神剑,击退 ← 完整保留
块2:段誉施展六脉神剑,击退了鸠摩智... ← 完整保留
10.4 IVF_FLAT 索引原理
scss
┌─────────────────────────────────────────┐
│ IVF_FLAT 索引 │
│ │
│ 全量向量 │
│ │ │
│ ▼ K-Means 聚类 (nlist=1024) │
│ ┌──┴──┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │簇 0 │ │簇 1 │ │簇 2 │ ...│簇1023│ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ 检索时:定位最近的 N 个簇 → 簇内暴力搜索 │
│ 复杂度:O(nlist) + O(N/nlist) │
└─────────────────────────────────────────┘
10.5 为什么选 COSINE 而不是 L2(欧氏距离)?
- COSINE:只看方向,不看长度。「乔峰」和「乔峰乔峰」向量方向一致,余弦相似度接近 1
- L2:计算绝对距离。同一段话重复两遍,L2 距离会变大,即使语义完全相同
- 文本语义相似度 = 方向相似度,COSINE 是天然选择
🏗️ 十一、架构设计的可扩展性
当前设计已经为扩展留好了口子:
| 扩展方向 | 实现方式 |
|---|---|
| 多书支持 | book_id 字段已预留,存不同 ID + 检索时加过滤条件 |
| 更大规模 | 将 nlist 从 1024 提升到 4096 或更高 |
| 混合检索 | 结合关键词检索(BM25)+ 向量检索,弥补专有名词的向量盲区 |
| 流式回答 | ChatOpenAI 的 streaming: true 参数实现逐字输出 |
| 对话记忆 | 引入 LangChain 的 ConversationBufferMemory 支持多轮对话 |
💡 十二、踩坑笔记 & 最佳实践
坑 1:重复加载集合报错
js
// ❌ 直接加载,集合已加载时抛异常
await client.loadCollection({ collection_name: COLLECTION_NAME });
// ✅ 用 try-catch 包裹,优雅处理
try {
await client.loadCollection({ collection_name: COLLECTION_NAME });
} catch (err) {
console.log('集合已处于加载状态,无需重复加载');
}
坑 2:EPUB 解析为空
部分 EPUB 文件的分章方式特殊,splitChapters: true 可能失效。解决思路:先用 splitChapters: false 加载全文,再用正则按"第X章"手动切分。
坑 3:Embedding API 速率限制
3000+ 个分块连续调用 Embedding API 可能触发限流。解决方案:
- 在
insertChunksBatch中加await sleep(100)控制并发 - 或使用批量 Embedding API(
embeddings.embedDocuments(chunks))
最佳实践清单
csharp
✅ 函数单一职责:一个函数只做一件事
✅ 返回值可预测:insertChunksBatch 始终返回数字
✅ 错误处理分层:每个 async 函数有自己的 try-catch
✅ 配置集中管理:所有常量在文件顶部统一定义
✅ 流式处理思想:边加载边处理,不堆积数据
✅ 命名即文档:retrieveRelevantContent 函数名就是说明书
📝 十三、写在最后
这个项目虽然代码量不大(三个文件总共约 400 行),但它完整覆盖了 RAG 系统的全链路:
- 数据层:EPUB 解析 + 递归文本分割
- 向量层:OpenAI Embedding + Milvus IVF_FLAT 索引
- 检索层:余弦相似度 Top-K 检索
- 生成层:Prompt 工程 + LLM 调用
掌握了这套流程,你可以把任何文本知识库------技术文档、法律条款、医学指南、公司内部 Wiki------都变成可对话的智能助手。
🔗 相关资源: