一、这篇讲什么
最近完整跑通了一个 RAG(检索增强生成)项目:把《天龙八部》电子书切成小段,转成向量存进 Milvus 数据库,然后用自然语言提问,AI 能从书里找到相关内容并回答。
这篇文章把整个过程拆解开来讲,覆盖 Loader → Splitter → Embedding → Milvus → RAG 检索 五个核心环节,适合刚接触 RAG 的朋友跟着理解。
二、整体架构:RAG 到底是什么
RAG = Retrieval-Augmented Generation,三个词拆开:
Retrieval(检索) → 从知识库里搜出相关内容
Augmented(增强) → 把搜到的内容拼接到提示词里
Generation(生成) → 让大模型基于这些内容回答问题
整个过程分两个阶段:
【入库阶段】
电子书 → Loader 加载 → Splitter 切片 → Embedding 转向量 → 存入 Milvus
【查询阶段】
用户问题 → Embedding 转向量 → Milvus 相似度搜索 → 召回相关片段 → 拼接 Prompt → 大模型回答
三、Loader:从各种来源加载文档
3.1 什么是 Loader
Loader(加载器)负责把不同格式的文件读进来,统一的输出是 LangChain 的 Document 对象。不管原始文件是 EPUB、PDF、CSV 还是网页,Loader 都给你转成统一格式。
3.2 我们用到的:EPubLoader
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub';
const loader = new EPubLoader('./天龙八部.epub', {
splitChapters: true, // 按章节拆分,一个章节 = 一个 Document
});
const documents = await loader.load();
// 返回:[Document1(第1章), Document2(第2章), ..., Document168(第168章)]
splitChapters: true 的作用:
| 值 | 结果 |
|---|---|
true | 每个章节一个 Document,168 章 = 168 个 Document |
false | 整本书一个 Document |
EPUB 内部本身就是按章节存储的(每个 .xhtml 文件一个章节),Loader 能识别这种结构。
3.3 常见的其他 Loader
LangChain 提供了大量现成的 Loader,开箱即用:
| Loader | 来源 |
|---|---|
PDFLoader | PDF 文件 |
CSVLoader | CSV 表格 |
TextLoader | 纯文本 |
DirectoryLoader | 整个目录 |
NotionLoader | Notion 页面 |
WebBaseLoader | 网页内容 |
// 举例:如果换成 PDF
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
const loader = new PDFLoader('./book.pdf');
不管是哪种 Loader,最终都输出 Document[],下游处理逻辑完全不用变。
四、Splitter:把长文本切成小段
4.1 为什么要切
- 大模型有上下文长度限制,一整章直接塞进去可能超限
- 向量搜索讲求粒度匹配——搜"段誉学六脉神剑"应该只返回相关段落,而不是整章
- 切片越小,检索越精准;切片太大,噪音也大
4.2 我们用的:RecursiveCharacterTextSplitter
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每段最多 500 个字符
chunkOverlap: 50, // 相邻段落重叠 50 个字符
separator: '\n', // 优先在换行处切割
});
4.3 三个关键参数
chunkSize(块大小)
这个值决定了检索的精准度。500 字是一个经验值,大约半页的内容量。太小会丢失上下文,太大会让搜索结果不够精确。
chunkOverlap(重叠量)
段1: [0 ========= 500]
段2: [450 ========= 950] ← 和段1 重叠 50 字
段3: [900 ========= 1400]
为什么要有重叠?因为关键信息可能恰好跨在两段的边界上。比如"段誉学会了六脉神剑","六脉神剑"在前一段末尾,"段誉学会了"在后一段开头,没有 overlap 的话两个片段都搜不到完整信息。
separator(分隔符)
Splitter 会按优先级寻找切割点:\n\n → \n → 。 → , → 逐字符。这样能尽量在自然的断句处切割,而不是把一个句子拦腰截断。
4.4 两层切割的设计
我们的项目用了两层切割:
EPUB 原文件
↓ EPubLoader(第一层:按章节)
[第1章, 第2章, ..., 第168章]
↓ TextSplitter(第二层:按字数)
第1章 → ["段誉被无量剑派...", "钟灵笑道...", ...] 共 15 段
第2章 → [...]
...
先按章节分大块,再按字数分小块,既保留了章节的天然结构,又控制了每段的长度。
五、Embedding:把文字变成数字
5.1 什么是 Embedding
大模型看不懂文字,只能算数字。Embedding 就是把一段文字映射成一个固定维度的数字数组(向量)。
"段誉学会了六脉神剑"
↓ Embedding 模型
[0.023, -0.145, 0.678, ..., 0.034] ← 1024 个浮点数
语义相近的文字,它们的向量在空间中距离也近。这就是向量检索的数学基础。
5.2 初始化模型
import { OpenAIEmbeddings } from '@langchain/openai';
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY, // API 密钥
model: process.env.EMBEDDING_MODEL_NAME, // 模型名,如 text-embedding-v3
configuration: {
baseURL: process.env.OPENAI_BASE_URL, // 自定义 API 地址
},
dimensions: 1024, // 输出向量维度
});
5.3 两个核心方法
// 单条文字 → 向量(适合搜索时转换用户问题)
const vector = await embeddings.embedQuery('段誉会什么武功?');
// 返回:[0.023, -0.145, ...] 1024 维
// 批量文字 → 向量(适合入库时一次处理多个文档)
const vectors = await embeddings.embedDocuments(['段落1', '段落2', '段落3']);
// 返回:[[...], [...], [...]] 三个 1024 维向量
5.4 向量维度 1024 是什么意思
text-embedding-v3 模型把每段文字压缩成 1024 个数字。维度越高,能表达的信息越丰富,但计算和存储成本也越高。1024 是一个平衡点——百万字级别的文本完全够用。
六、Milvus:向量数据库
6.1 为什么需要专门的向量数据库
普通数据库用 WHERE name = 'xxx' 做精确匹配。向量数据库做的是相似度搜索:给你一个向量,找出最接近的 K 个。
类比:你在图书馆找书,传统方式是查书名编号,向量搜索是"帮我找跟这本内容最像的 5 本书"。
6.2 我们用的 Zilliz Cloud(托管版 Milvus)
import { MilvusClient, DataType, MetricType, IndexType } from '@zilliz/milvus2-sdk-node';
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS, // Zilliz Cloud 地址
token: process.env.MILVUS_TOKEN, // API Token
});
6.3 创建 Collection(建表)
Collection 类似于关系数据库里的"表"。建表时需要定义每个字段的类型:
await client.createCollection({
collection_name: 'ebook2',
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: 1024 },
],
});
| 字段 | 类型 | 含义 |
|---|---|---|
id | VarChar | 主键,格式 书号_章号_段号,如 01_3_7 |
book_id | VarChar | 书的编号 |
book_name | VarChar | 书名 |
chapter_num | Int32 | 第几章 |
index | Int32 | 该章节的第几个切片 |
content | VarChar | 原始文本内容 |
vector | FloatVector(1024) | 文本对应的向量 |
6.4 建索引:IVF_FLAT + COSINE
await client.createIndex({
collection_name: 'ebook2',
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: 1024 },
});
索引类型 IVF_FLAT:
IVF = 倒排索引,先把向量空间切成很多"簇"
FLAT = 在命中的簇里暴力搜索,保证精度
相似度算法 COSINE(余弦相似度):
衡量两个向量方向的接近程度,值域 [-1, 1],越接近 1 越相似。适合文本语义匹配。
nlist: 1024:
把全部向量空间分成 1024 个簇。搜索时只搜最近的几个簇,不用扫全量数据。
经验公式:nlist ≈ 4 × √数据条数。我们有 3000+ 条,1024 略大但能跑。
6.5 确保集合存在——幂等设计
async function ensureCollection(bookId) {
const hasCollection = await client.hasCollection({ collection_name: COLLECTION_NAME });
if (!hasCollection.value) {
// 不存在 → 创建
await client.createCollection({ ... });
await client.createIndex({ ... });
}
// 无论新建还是已存在,都要加载
await client.loadCollection({ collection_name: COLLECTION_NAME });
}
这个模式叫幂等设计——不管执行多少次,结果都一样。第一次建表,之后跳过。程序可以放心反复跑。
6.6 插入数据:并发 + 批量
async function insertChunksBatch(chunks, bookId, chapterNum) {
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbedding(chunk); // 并发生成向量
return {
id: `${bookId}_${chapterNum}_${chunkIndex}`, // 精华:id 编码信息
book_id: bookId,
book_name: BOOK_NAME,
chapter_num: chapterNum,
index: chunkIndex,
content: chunk,
vector: vector,
};
})
);
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: insertData, // 一次批量插入
});
return Number(insertResult.insert_cnt) || 0; // 兜底:失败返回 0
}
三个要点:
Promise.all并发:10 个 chunk 同时调 embedding API,不等排队- id 设计
书号_章号_段号:唯一、可读、可追溯。想删第 3 章所有数据?id LIKE "01_3_%"即可 || 0兜底:网络抖动导致insert_cnt异常时返回 0,上层totalInserted += 0不受影响,程序不会崩
6.7 断点续传
async function getProcessedChapters(bookId) {
const result = await client.query({
collection_name: COLLECTION_NAME,
filter: `book_id == "${bookId}"`,
output_fields: ['chapter_num'],
limit: 100000,
});
const chapters = result.data.map(r => r.chapter_num);
return new Set(chapters); // Set 去重
}
程序中断了重新跑,先查哪些章节已经入库,跳过不重复处理。168 章的书,断点续传能省大量时间。
七、RAG 检索与生成
7.1 相似度搜索
const queryVector = await getEmbedding('段誉会什么武功?');
const searchResult = await client.search({
collection_name: 'ebook2',
data: queryVector, // 问题的向量
anns_field: 'vector', // 搜哪个向量字段
limit: 5, // 返回 Top 5
metric_type: MetricType.COSINE, // 余弦相似度
output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content'],
});
返回结果按 score 降序排列,越接近 1 越相关。
7.2 三段式:检索 → 拼 Prompt → 大模型回答
async function answerEbookQuestion(question, k = 3) {
// ① 检索:从向量库搜出最相关的 k 个片段
const retrievedContent = await retrieveRelvantContent(question, k);
if (retrievedContent.length === 0) {
return '很遗憾,没有相关内容';
}
// ② 拼接:把多个片段组装成上下文
const context = retrievedContent.map((item, i) => `
[片段${i + 1}]
章节${item.chapter_num}
内容:${item.content}
`).join('\n\n-----\n\n');
// ③ 生成:给大模型发 prompt
const prompt = `你是一个专业的《天龙八部》小说助手。
基于小说回答问题,用准确的、详细的语言。
请根据以下小说片段内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细准确的回答
2. 如果没有,请说不知道
3. 可以综合多个片段的内容,提供完整的答案
4. 回答要准确,符合小说的情节和人物设定
`;
const response = await model.invoke(prompt);
return response.content;
}
整个流程一图概括:
用户问:"鸠摩智会什么武功?"
↓
Embedding 转向量
↓
Milvus 搜 Top 5 最相关的文本片段
↓
把 5 个片段拼接成 prompt 的上下文
↓
ChatOpenAI 大模型阅读上下文 + 回答问题
↓
"鸠摩智是吐蕃国师,精通火焰刀、小无相功..."
7.3 关键参数:top_k
k 决定每次检索返回几个最相似的片段。值太大会引入噪音,太小可能信息不全。3~5 是常用经验值。
八、完整项目文件结构
项目拆成三个文件,各司其职:
tlbb/
├── .env # 密钥和配置(不提交 git)
├── package.json # 依赖管理
├── src/
│ ├── main.mjs # 入库:加载 → 切片 → embedding → 存入 Milvus
│ ├── query.mjs # 简单查询:搜 + 打印结果
│ └── rag.mjs # 完整 RAG:搜 + 拼 prompt + 大模型回答
└── 天龙八部.epub # 数据源
.env 配置结构:
MODEL_NAME=qwen-plus
OPENAI_API_KEY=你的API密钥
EMBEDDING_MODEL_NAME=text-embedding-v3
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MILVUS_ADDRESS=https://xxx.serverless.ali-cn-hangzhou.cloud.zilliz.com.cn
MILVUS_TOKEN=你的Milvus密钥
九、知识点速查表
Loader
| 概念 | 一句话 |
|---|---|
| Loader 作用 | 把各种格式文件读成统一的 Document 对象 |
splitChapters: true | EPUB 按章节拆分,一个章节一个 Document |
| 常见 Loader | PDFLoader, CSVLoader, TextLoader, WebBaseLoader |
Splitter
| 概念 | 一句话 |
|---|---|
chunkSize | 每段最多多少字,500 是常用值 |
chunkOverlap | 相邻段重叠多少字,防止关键信息跨边界丢失 |
separator | 优先在哪里切割,\n 表示优先在换行处断 |
Embedding
| 概念 | 一句话 |
|---|---|
| Embedding 是什么 | 把文字映射成固定维度的数字数组(向量) |
embedQuery vs embedDocuments | 前者用于搜索时转单个问题,后者用于入库时批量转文档 |
| 维度 1024 | text-embedding-v3 的输出维度,百万字级文本够用 |
Milvus
| 概念 | 一句话 |
|---|---|
| Collection | 相当于数据库的表 |
| IVF_FLAT | 先聚类再暴力搜索,平衡速度和精度 |
| COSINE | 余弦相似度,衡量向量方向接近程度 |
| nlist | K-Means 聚类簇数,≈ 4 × √数据量 |
Promise.all | 并发调 embedding API,不等排队 |
| id 设计 | 书号_章号_段号,唯一 + 可追溯 |
|| 0 | 异常时兜底返回 0,不影响上层计数 |
RAG
| 概念 | 一句话 |
|---|---|
| RAG 三阶段 | 检索 → 增强(拼 prompt)→ 生成(大模型回答) |
| top_k | 每次搜索返回几个最相似片段,3~5 常用 |
| Prompt 工程 | 明确角色、给上下文、约束回答范围 |
十、总结
从头到尾跑通一个 RAG 项目,核心就是五个步骤:
Loader 加载 → Splitter 切片 → Embedding 转向量 → Milvus 存储/检索 → 大模型回答
《天龙八部》168 章 → 3042 个向量片段,之后问"鸠摩智会什么武功"、"段誉怎么学的六脉神剑",AI 都能从书里找到原文并回答。
这篇文章覆盖了每个环节的核心概念和踩坑经验,希望能帮你少走弯路。跑通之后,你就可以把 EPUB 换成自己的 PDF、网页、文档,搭建自己的私有知识库了。