从零构建《天龙八部》知识库:EPUB 加载→文本分割→向量嵌入→Milvus 存储→RAG 问答,一条链路打通
写在前面
把一个 EPUB 电子书变成一个可以问答的知识库,这件事看起来简单,但真正动手时会发现链条很长:加载文档、分割文本、生成向量、存入向量数据库、检索相关片段、拼装 Prompt、调用 LLM 生成回答。每一个环节都有值得深究的细节。
本文基于一个真实可跑的项目代码,逐行拆解这个过程。我们以金庸的《天龙八部》为素材,把整本小说灌入 Milvus 向量数据库,然后实现自然语言问答------"段誉会什么武功""鸠摩智会什么武功",系统能从书里找出答案。
一、整体架构与数据流
整个项目由三个模块组成,分别对应数据处理的三个阶段:
sql
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ main.mjs │ │ query.mjs │ │ rag.mjs │
│ 数据摄入 │ │ 纯向量检索 │ │ 完整 RAG │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ EPUB → Split │ Query → Embed │ Query → Embed
│ → Embed → Insert │ → Search │ → Search → LLM
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Milvus 向量数据库 │
│ Collection: ebook2 (1024维) │
└─────────────────────────────────────────────────────────┘
| 模块 | 文件 | 职责 |
|---|---|---|
| 数据摄入 | main.mjs |
加载 EPUB → 分章 → 切片 → 向量化 → 存入 Milvus |
| 向量检索 | query.mjs |
接收问题 → 向量化 → Milvus 搜索 → 返回相似片段 |
| 完整 RAG | rag.mjs |
接收问题 → 检索片段 → 拼装 Prompt → LLM 生成回答 |
流程上,先跑 main.mjs 把数据灌进去,然后 query.mjs 或 rag.mjs 任意一个都能完成问答------区别在于 query.mjs 只返回原文片段,rag.mjs 会让 LLM 基于片段组织出一段完整回答。
二、环境准备:为什么选这套技术栈
先看 .env 文件中的配置:
ini
MODEL_NAME=qwen-plus
EMBEDDINGS_MODEL_NAME=text-embedding-v3
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MILVUS_ADDRESS=https://in03-...cloud.zilliz.com.cn
MILVUS_TOKEN=0d72573cd8...
这里有两个值得注意的设计选择:
第一,Embedding 模型和对话模型都走阿里云 DashScope。 注意 OPENAI_BASE_URL 指向了阿里云的兼容端点。这意味着代码中使用的是 OpenAI 兼容的 API 格式(因为 LangChain 的 OpenAIEmbeddings 和 ChatOpenAI 原生对接 OpenAI),但实际调用的是阿里云的通义千问模型。这种"借 OpenAI 的壳、走国内模型的魂"的做法在国内开发中非常实用------不需要改任何 SDK,只换一个 baseURL 和 key 即可。
第二,Milvus 用的是 Zilliz Cloud 的 Serverless 实例。 免去了本地部署 Milvus 的运维成本,适合开发和小规模使用。连接方式与自建 Milvus 完全一致,只是地址和 Token 来自云端。
思考: 这套组合的本质是用 OpenAI 兼容协议作为通用接口层------Embedding 模型、Chat 模型、向量数据库三者解耦,任何一个组件都可以独立替换。比如后面想把 Embedding 模型换成
text-embedding-ada-002,只需要改.env中的模型名,代码一行不动。
三、数据摄入(main.mjs):把一本书变成向量
main.mjs 是整个系统的基石,它用一条流水线完成了"书 → 分段 → 向量 → 数据库"的转换。下面按代码执行顺序逐段拆解。
3.1 路径解析:从文件名提取书名
javascript
import { parse } from 'path'; // path 解析路径
const EPUB_FILE = './天龙八部.epub';
const { name: BOOK_NAME } = parse(EPUB_FILE);
console.log(BOOK_NAME); // 输出: 天龙八部
path.parse() 是 Node.js 内置的方法,它把文件路径拆解为一个包含 root、dir、base、ext、name 五个字段的对象。这里用 ES6 解构赋值,把其中的 name(文件名去掉后缀)重命名为 BOOK_NAME。
csharp
// parse('./天龙八部.epub') 返回:
{
root: '',
dir: '.',
base: '天龙八部.epub',
ext: '.epub',
name: '天龙八部' // ← 解构取出的就是它
}
这个 BOOK_NAME 后面会作为 book_name 字段存入 Milvus,用于区分不同书籍的数据。这是一个好习惯:不要在代码里硬编码书名,而是从文件名推导出来。 以后换一本书,只需要改 EPUB_FILE 路径,书名自动跟进。
注意: Node.js 的
path.parse()不会验证文件是否存在------它纯做字符串级别的路径解析。文件存不存在,是后续EPubLoader的事。
3.2 初始化 Embedding 模型
arduino
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME, // text-embedding-v3
configuration: {
baseURL: process.env.OPENAI_BASE_URL // 阿里云兼容端点
},
dimensions: VECTOR_DIM // 1024
});
VECTOR_DIM 设为 1024 是因为 text-embedding-v3 支持动态指定维度。Embedding 模型的核心原理是把一段文本映射到高维空间中的一个点:
- 维度越高,表达能力越强,能捕捉更细粒度的语义差异
- 维度越高,存储和检索成本也越高
- 1024 维 是在语义表达和计算开销之间的一个实用平衡点
为什么不是更常见的 1536 维(text-embedding-ada-002)?因为 text-embedding-v3 允许通过 dimensions 参数按需降维。降维到 1024 后,精度下降不到 2%,但向量存储减少了 33%,在百万字级别的小说场景下这个取舍很划算。
javascript
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text);
return result; // 返回 [0.023, -0.451, 0.789, ...] 共 1024 个 float
}
这个封装虽然简单,但它把 embeddings.embedQuery 包裹了一层,隔离了依赖。以后如果要加缓存("同一段文本已经被向量化过就不重复调用 API"),只需要在这个函数内部修改,外部调用方无感知。
3.3 Milvus 集合设计
ini
const COLLECTION_NAME = 'ebook2'; // 编程习惯
注释"编程习惯"指的是用常量命名而不是魔术字符串。'ebook2' 这个名字在多个函数中出现(建表、查表、写入),如果直接写字符串字面量,改一次要全局搜索替换,容易遗漏。抽成常量就只需要改一处。
然后是 Collection 的字段设计------这相当于传统数据库的建表语句:
yaml
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 |
VarChar(100) | 主键,格式 {bookId}_{chapter}_{index} |
字符串拼接的主键,自带业务含义,方便追踪每条记录来源 |
book_id |
VarChar(100) | 书籍编号 | 为以后扩展多本书做准备 |
book_name |
VarChar(200) | 书名 | 冗余存储,搜索时直接展示书名而不需要 JOIN |
chapter_num |
Int32 | 章节编号 | 32 位有符号整数,范围 ±21 亿,对章节数绰绰有余 |
index |
Int32 | 切片序号 | 同一章内切片的顺序,用于还原上下文 |
content |
VarChar(10000) | 文本内容 | 每个切片最多 500 字符(实际由 splitter 控制),预留 10000 是安全上限 |
vector |
FloatVector(1024) | 向量嵌入 | 核心字段,文本的数学表示,用于余弦相似度检索 |
设计思考:
id 的格式 {bookId}_{chapterNum}_{chunkIndex} 是一种信息编码------只用主键就能知道这个片段属于哪本书、哪一章、什么位置,不需要额外的索引回表查询。这对调试和追踪非常友好。
chapter_num 用 Int32 而非 Int8 或 Int16,是出于防御性编程:一本小说可能只有 50 章,但代码可能被复用到其他场景(比如按日期的数据,日期编号可以是 20240101),留足余量避免溢出。
vector 的 dim: VECTOR_DIM 必须和 Embedding 模型输出的维度严格一致。如果这两个值不匹配------比如模型输出 1536 维但 Milvus 定义 1024 维------插入会直接报错。这里用一个变量 VECTOR_DIM 同时传给 OpenAIEmbeddings 和 Collection 定义,保证了两个地方数字永远同步。
3.4 索引策略:IVF_FLAT + COSINE
php
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
// nlist 是 K-Means 聚类的簇数
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: 1024 }
})
// cosine 高维相识度, 不慢 , 数据量大了
这一步是影响检索性能的关键。
IVF_FLAT 索引(Inverted File with Flat encoding) 的原理分两步:
- 建立索引时 :对所有向量做 K-Means 聚类,分成
nlist个簇。每个簇有一个中心点。 - 查询时 :先计算查询向量到各簇中心点的距离,只在最近的几个簇(由
nprobe参数控制)里做暴力全量比对。
这相当于把全库扫描缩小为几个候选区域的扫描 。对于《天龙八部》这种数据量(全书约 120 万字,按 500 字符一切片约 2400 条记录),这个优化已经足够。注释中说"数据量大了"才需要升级,指的是百万级以上数据要考虑 IVF_PQ 或 HNSW 等更高级的索引。
COSINE 余弦相似度:
css
cosine(A, B) = (A·B) / (|A| × |B|)
分子是向量点积,分母是各自模长的乘积。这个公式衡量的是两个向量方向的接近程度,不受向量长度影响。为什么选择 COSINE 而非 L2(欧几里得距离)?
- L2 距离受向量长度影响很大------一段很长的文本和一段很短的文本,即使语义相同,Embedding 后的向量长度也可能差很多,L2 会认为它们不相似
- Cosine 只看方向不看大小,对文本长度不敏感,更适合语义搜索
朴素理解: 把每个向量看作高维空间中的一根箭。L2 问"箭头末端离我多远",Cosine 问"箭指向哪里"。语义检索中,"指向哪里"比"离多远"更重要。
nlist 为什么设为 1024? 这是一个经验选择。nlist 太小 → 每个簇的向量太多 → 簇内暴力搜索仍然很慢;nlist 太大 → 簇太多 → 查找中心点的开销上升 → 而且可能导致遗漏(一个查询的最近邻被分到了未搜索的簇)。通常的建议是 nlist = 4 × sqrt(N),对约 2400 条数据,4 × sqrt(2400) ≈ 196,1024 取大了一些,但对于 Serverless 云服务来说,多分几个簇的开销可以忽略。
3.5 加载 EPUB:splitChapters 的抉择
javascript
const loader = new EPubLoader(EPUB_FILE, {
// 加载后就会按章节生成多个 document
// 内存需求的必然
splitChapters: true
});
const documents = await loader.load();
console.log(`加载完成, 共${documents.length}个章节`);
EPubLoader 是 LangChain 社区包提供的文档加载器。它内部依赖 epub2 这个 npm 包来解析 EPUB 格式------EPUB 本质上是一个 ZIP 压缩包,内含 HTML/XHTML 文件,每个文件通常对应一个章节。
splitchapters: true 这一个选项,决定了后续所有数据处理的粒度:
ini
splitChapters: false → documents = [整本书作为一个 Document]
splitChapters: true → documents = [第1章, 第2章, 第3章, ...第50章]
注释"内存需求的必然" 点出了一个工程事实:如果把整本 120 万字的小说全部读入内存,然后做一次全量 Embedding......首先 Embedding API 会直接拒绝(超出 token 限制),其次即使能处理,120 万字的浮点运算也会让内存崩掉。按章节拆分是内存和业务双重驱动的必然选择。
深入: EPubLoader 底层调用
epub2解析 EPUB 文件结构。epub2解析后的每个章节是一个Chapter对象,包含title、content(HTML 字符串)等信息。EPubLoader 将这些转换为 LangChain 的Document对象,每个Document有pageContent(纯文本正文)和metadata(章节标题等元信息)。这就是为什么后续我们可以用chapter.pageContent取到每一章的文本。
3.6 文本分割:RecursiveCharacterTextSplitter
arduino
const textSplitter = new RecursiveCharacterTextSplitter({
// 没有传 separator 就用默认的 \n
chunkSize: CHUNK_SIZE, // 500
chunkOverlap: 50, // 重叠 50 个字符,保持上下文连贯性
});
分隔器的参数设计体现了一个核心矛盾:chunk 太大 → 检索不够精准;chunk 太小 → 碎片丢失上下文。
chunkSize: 500:每个文本切片最多 500 个字符。500 个中文字符大约是一个自然段落到两个自然段落的长度,刚好能承载一个完整的小情节或对话片段。chunkOverlap: 50:相邻两个切片之间重叠 50 个字符。这意味着每个切片的最后 50 个字符,就是下一个切片的前 50 个字符。
为什么需要 overlap?考虑这段原文:
erlang
...段誉心中一惊,暗想:"这老僧内功竟如此深厚。"他缓缓起身,
双手合十,说道:"大师在上,晚辈段誉有礼了。"那老僧微微一笑,
浑浊的眼中闪过一丝精光...
如果没有 overlap,切片可能刚好把"段誉心中一惊..."和"双手合十..."切成两段。当用户搜索"段誉和老僧的对话"时,相关的上下文被割裂在两个 chunk 里,检索效果大打折扣。50 个字符的重叠保证了关键信息不会恰好落在切片边界上。
RecursiveCharacterTextSplitter 的分割策略(源码层面的实现):
- 先用
\n\n(段落间空行)分割 - 如果得到的 chunk 太长,再用
\n(换行)分割 - 还是太长的,用
。(句号)分割 - 继续用
!、?、;逐级递减 - 最后兜底:按字符数硬切
这是一种递归降级策略 :从"最自然的语义边界"开始,逐步降级到"纯粹的长度切分"。每一级都尽量在一个语义完结点切分开。注释里说的"没有传 separator 就用默认的 \n",指的就是这个默认分割符序列。
3.7 逐章处理与批量插入
ini
for (let chapterIndex = 0;
chapterIndex < documents.length; chapterIndex++) {
// Document 这一章的
const chapter = documents[chapterIndex];
const chapterContent = chapter.pageContent;
console.log(`处理第 ${chapterIndex + 1} / ${documentLen} 章...`);
const chunks = await textSplitter.splitText(chapterContent);
console.log(`拆分为 ${chunks.length} 个片段`);
if (chunks.length === 0) {
console.log(`跳过空章节\n`);
continue; // 空章节跳过,避免后续 Insert API 收到空数组报错
}
console.log(`生成向量并插入中...`);
const insertedCount = await insertChunksBatch(
chunks,
bookId,
chapterIndex + 1 // 章节号从 1 开始,数组索引从 0 开始
);
totalInserted += insertedCount;
}
设计分析:
循环中三次用到 documents 的数量信息:
documents.length:循环的上限documentLen:缓存了同一个值chapterIndex + 1:转化为人类友好的 1-based 编号
注释"Document 这一章的"提醒阅读者:chapter 就是一个 Document 对象,这是上一环节 EPubLoader.load() 产出的基本单元。
空章节检查的 continue 很重要。有些 EPUB 中会有空章节(比如仅包含插图或标题页),如果不跳过,后续 insertChunksBatch 可能收到空数组导致 API 调用异常。
3.8 核心:并行向量化与批量插入
javascript
// 将一批 chunk 插入向量数据库
async function insertChunksBatch(chunks, bookId, chapterNum) {
try {
// 为空 不需要做的
if (chunks.length === 0) {
return 0;
}
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbedding(chunk);
return {
id: `${bookId}_${chapterNum}_${chunkIndex}`,
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;
} catch(err) {
console.error(`插入章节${chapterNum}的数据时出错:`, err.message);
throw err;
}
}
这个函数是整个系统最核心、最精妙的部分,我们逐层分析。
第一层:并行 Embedding
javascript
const insertData = await Promise.all(
chunks.map(async (chunk, chunkIndex) => {
const vector = await getEmbedding(chunk);
// ...
})
);
这一段是所有切片的 Embedding 请求同时发出的。假设一个章节被切成了 20 个 chunk,20 个 Embedding API 调用并行发起,总耗时 ≈ 单次调用的耗时(而非 20 次串行的累积)。
为什么可以并行?因为 Embedding API 是无状态的------每个请求相互独立,不依赖其他 chunk 的向量结果。这种场景是 Promise.all 最典型的应用:独立 IO 操作并发执行。
代价和风险: 并行度太高可能触发 API 的速率限制(Rate Limiting)。不过对于阿里云 DashScope 这类服务,默认的 QPM 限制通常在几百到几千,一个章节几十个 chunk 的并发完全在安全范围内。
第二层:主键设计
bash
id: `${bookId}_${chapterNum}_${chunkIndex}`,
这行代码没有写在注释里,但值得琢磨。用下划线拼接三个信息编码进主键,是一种业务语义嵌入 的做法。当你在 Milvus 里搜到一条结果,看一眼 id: "1_3_7",不用查其他字段就知道:这是第 1 本书、第 3 章、第 7 个切片。这个设计减少了调试时的心智负担。
第三层:返回值的防御性处理
javascript
// 函数的返回结果要有可预测性 一致。
return Number(insertResult.insert_cnt) || 0;
这条 return 值得单独拎出来分析。注释"函数的返回结果要有可预测性,一致",是编程中一个非常重要但常被忽视的原则。
Milvus SDK 的 insertResult.insert_cnt 返回的类型在不同版本中可能不同:
- 可能是
number - 可能是
string(如"5") - 可能是
bigint - 甚至可能是
undefined(异常但没抛错的情况)
这行代码的两层处理:
| 步骤 | 输入 | 输出 | 保护了什么 |
|---------------|----------------|--------------|----------------------------|-----|------------------------------------|
| Number(...) | "5" (string) | 5 (number) | 防止字符串加法 "0" + "5" = "05" |
| Number(...) | 5n (bigint) | 5 (number) | 防止 bigint + number 类型报错 |
| ` | | 0` | NaN / undefined | 0 | 防止 totalInserted += NaN 永久变成 NaN |
最后一条是最关键的:如果 insert_cnt 是 undefined,Number(undefined) = NaN,而 NaN 是 JavaScript 中最"恶毒"的值------NaN + 任何数 = NaN,一旦出现,整个 totalInserted 计数器就永久成了 NaN,无法恢复。|| 0 这道防线把 NaN 拦在了外面。
设计哲学: 一个被外部模块调用的函数,不应该把类型转换的负担转嫁给调用方。调用方
loadAndProcessEPubStreaming做totalInserted += insertedCount时,它期望insertedCount就是一个可以正常相加的数字。函数的职责之一就是对外屏蔽依赖的不确定性。
四、向量检索(query.mjs):没有 LLM 的"搜索"
在数据入库之后,可以先不接入 LLM,直接做一次纯向量搜索,确保"问题 → 向量 → 搜索"这条链路是通的。
4.1 建立连接与加载集合
javascript
import {
MilvusClient, // C/S B/S
MetricType, // 相似度求方法
} from '@zilliz/milvus2-sdk-node';
注释 // C/S B/S 是对 Milvus 部署架构的标注------Client-Server 或 Browser-Server,本质上都是远程连接。Milvus SDK 连接的是服务端,所有操作都是 RPC 调用。
4.2 发起搜索
php
const query = '段誉会什么武功?';
const queryVector = await getEmbedding(query);
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"]
});
数据流的全过程:
scss
"段誉会什么武功?"
│
▼
OpenAI Embedding API (text-embedding-v3)
│
▼
[0.023, -0.451, ..., 0.789] ← 1024维浮点向量
│
▼
Milvus.search() → IVF_FLAT 索引 → COSINE 相似度top-3
│
▼
[{id: "1_1_5", content: "段誉...", score: 0.92}, ...]
limit: 3 为什么不是 1 或 10?
limit: 1:只取最相似的 1 个片段。如果这个片段恰好不包含答案(虽然相似但不相关),检索就失败了。limit: 10:返回太多片段,后续拼 Prompt 时上下文过长,可能超出 LLM 的 token 限制。limit: 3:给 LLM 留 2-3 个候选片段,提高命中率,同时控制 Prompt 长度。这是一个经验值,可以根据实际效果调整。
output_fields 的优势: 只在返回结果中包含需要的字段,不需要的(比如 1024 维的 vector 本身)不传输。对于 FloatVector 这种大字段,只传输内容部分可以显著减少网络开销。
4.3 展示搜索结果
javascript
searchResult.results.forEach((item, index) => {
console.log(`
${index + 1}.[Score:${item.score.toFixed(4)}]
ID: ${item.id}
BookId: ${item.book_id}
Content: ${item.content}
`)
})
item.score 是余弦相似度值,范围在 [-1, 1] 之间。1 表示方向完全一致(语义完全相同),0 表示正交(无关),-1 表示完全相反。实际应用中,相似度通常落在 0.7-0.95 之间。
toFixed(4) 保留 4 位小数是为了可读性------0.92345678 和 0.9235 在肉眼判断中没有区别,但前者会让日志看起来杂乱。
五、完整 RAG(rag.mjs):让 LLM 基于小说回答
rag.mjs 是最终的集大成者。它把检索和生成两个环节串了起来,形成一个完整的 RAG 问答系统。
5.1 双模型配置
arduino
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME, // text-embedding-v3
dimensions: VECTOR_DIM
});
const model = new ChatOpenAI({
temperature: 0.1,
model: process.env.MODEL_NAME, // qwen-plus
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL
}
});
两个模型各司其职:
- Embedding 模型负责"理解"文本,输出向量,用于相似度检索
- Chat 模型负责"生成"文本,基于检索到的片段,组织自然语言回答
temperature: 0.1 设得很低。在知识问答场景中,我们不希望 LLM "发挥创意"------小说情节必须忠于原文。低温度让输出更确定、更保守、幻觉更少。如果是创意写作场景(如"帮我写一段武侠小说"),temperature 应该设到 0.7-0.9。
5.2 检索函数:单一职责
php
// RAG 图书业务知识库化
// 函数名可读性
// 一个函数一个功能
// 只有一个返回值
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,
metric_type: MetricType.COSINE,
output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
});
return searchResult.results;
} catch(err) {
console.error('检索内容时出错');
return [];
}
}
注释里的三条原则是整个项目代码质量的集中体现:
函数名可读性 :
retrieveRelevantContent直译就是"检索相关内容"。相比search或getData,这个命名明确表达了函数做了什么、返回什么。好的函数名应该在调用时读起来像自然语言------await retrieveRelevantContent(question, 3)读起来就是"等待根据问题检索 3 条相关内容"。
一个函数一个功能:这个函数只做检索。它不关心 Prompt 怎么拼、LLM 怎么答。解耦意味着可测试------你可以单独测试检索的精度,也可以单独测试生成的质量。
只有一个返回值 :函数体内部只有两个 return 语句,但它们在逻辑上是同一条路径的分支------"正常返回 results"和"异常返回空数组"。调用方拿到的一定是数组,不需要判断typeof result === 'undefined'或result === null。
异常处理的策略: catch 中返回空数组 [] 而不是 null 或 undefined。这样调用方可以直接写 if (result.length === 0) 而不用担心空指针。这也是"可预测性"原则的延续。
5.3 回答函数:Prompt 工程
javascript
async function answerEbookQuestion(question, k = 3) {
const retrievedContent = await retrieveRelevantContent(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');
const prompt = `你是一个专业的《天龙八部》小说助手。
基于小说回答问题,用准确、详细的语言。
请根据以下小说片段内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细准确的回答。
2. 可以综合多个片段的内容,提供完整的答案。
3. 如果片段中没有相关信息,请如实告知用户。
4. 回答要准确,符合小说的情节和人物设定。
5. 可以引用原文内容来支持你的回答。
AI 助手的回答:
`;
const response = await model.invoke(prompt);
return response.content;
}
这个 Prompt 的设计值得细看。
角色设定(System Persona):
你是一个专业的《天龙八部》小说助手。
第一句话就给 LLM 锚定了一个明确身份。没有说"你是一个 AI 助手",而是限定到这部具体的小说。这种约束能有效减少 LLM 回答其他无关内容的概率。
上下文注入(Context Injection):
css
[片段1]
章节:第2章
内容:段誉跟着钟灵来到无量山脚下...
----
[片段2]
章节:第2章
内容:那老僧盘膝坐在洞中...
----
[片段3]
章节:第3章
内容:段誉只觉丹田中一股热气...
用 \n\n----\n\n 作为片段分隔符,在视觉上清晰地区分了不同来源。章节号的标注让 LLM 在引用时可以说"根据第 2 章的描写...",增加回答的可信度。
指令约束(Constraint Instructions)的 5 条要求:
| 要求 | 目的 | 对应的 Prompt Engineering 技巧 |
|---|---|---|
| 结合小说内容给出详细准确的回答 | 防止泛泛而谈 | 锚定上下文 |
| 综合多个片段 | 防止只盯着第一个片段回答 | 全局覆盖 |
| 没相关信息就如实告知 | 防止幻觉编造 | 坦诚原则 |
| 符合小说情节和人物设定 | 防止和原文矛盾 | 一致性约束 |
| 可以引用原文 | 增强回答可信度 | 证据支持 |
最后一行 AI 助手的回答: 是一个回答格式触发器 。OpenAI 的对话格式训练数据中,System 或 User 消息结束后紧跟 Assistant: 是一个高频模式,能让 LLM 以更自然的助手口吻开始回答。
5.4 整合调用
javascript
async function main() {
await client.connectPromise;
try {
await client.loadCollection({
collection_name: COLLECTION_NAME
});
console.log('集合加载成功');
} catch(err) {
// 静默处理:集合可能已经在加载状态
}
const result = await answerEbookQuestion('鸠摩智会什么武功?', 5);
console.log(result);
}
loadCollection 放在 try-catch 里静默处理,是因为集合可能在之前的操作中已经处于加载状态,再次加载会抛错,但这个错不影响后续使用------这属于"可恢复的预期错误"。
k = 5 传了一个比默认值更大的值,说明提问者预期"鸠摩智的武功"这个信息可能在书中分散在多处,需要更多的上下文候选。
六、完整数据流总结
把三个文件串起来,整个系统的数据流是这样的:
scss
┌──────────────────────────────────────────────────────────────────┐
│ main.mjs(数据摄入) │
│ │
│ 天龙八部.epub │
│ │ │
│ ▼ EPubLoader (splitChapters: true) │
│ 50个章节 Document[] │
│ │ │
│ ▼ RecursiveCharacterTextSplitter (500/50) │
│ ~2400个文本切片 │
│ │ │
│ ▼ OpenAIEmbeddings (text-embedding-v3, 1024d) │
│ ~2400个向量 [0.023, ...] × 1024 │
│ │ │
│ ▼ Milvus.insert() │
│ Milvus Collection: ebook2 │
│ │
├──────────────────────────────────────────────────────────────────┤
│ query.mjs / rag.mjs(查询) │
│ │
│ 用户问题 "鸠摩智会什么武功" │
│ │ │
│ ▼ OpenAIEmbeddings │
│ 问题向量 [0.112, ...] × 1024 │
│ │ │
│ ▼ Milvus.search() - IVF_FLAT + COSINE, top_k=5 │
│ 5 个最相似文本片段 + 相似度分数 │
│ │ │
│ │ ┌─── query.mjs: 直接展示片段 │
│ │ │ │
│ ▼ ▼ rag.mjs: 拼装 Prompt → ChatOpenAI → 自然语言回答 │
│ "鸠摩智精通小无相功、火焰刀、拈花指法..." │
│ │
└──────────────────────────────────────────────────────────────────┘
七、可以进一步做的方向
1. 多轮对话上下文
当前每次问答是独立的。如果用户问完"段誉的六脉神剑怎样"接着问"他什么时候用的",LLM 不知道"他"指谁。加入对话历史(ChatOpenAI 支持传入 messages 数组)可以解决这个问题。
2. 混合搜索(Hybrid Search)
纯向量搜索可能遗漏精确的字符串匹配。比如搜索"降龙十八掌"------如果 Embedding 对这个专有名词的语义建模不够好,可能会漏掉。Milvus 支持向量 + 标量过滤的混合搜索,可以结合关键词匹配提升召回率。
3. 重排序(Re-ranking)
检索到 5 个候选片段后,可以用 Cross-Encoder 模型对它们做一次精排,把最相关的放在最前面喂给 LLM,提高准确率。这比单纯增加 top_k 更有效。
4. Embedding 缓存
同一本书的相同文本被 Embedding 过一次后,结果可以缓存到本地文件或 Redis。这样重新建库时不需要再次调用 API,节省成本和时间。
写在最后
这个项目虽然代码量不大(三个文件加起来不到 300 行),但每行都充满了工程决策------从 splitChapters 的选择到 nlist 的取值,从 Promise.all 的并行策略到 Number(...) || 0 的类型防御,从 Prompt 的 5 条指令约束到 catch 返回空数组而非 null。RAG 不是简单的"搜索 + 问 LLM",而是一系列精心设计的工程选择串联起来的结果。
把这些细节理清楚、写下来,本身也是加深理解的过程。希望这篇文章能帮你少踩一些坑。