从零构建《天龙八部》知识库:EPUB 加载→文本分割→向量嵌入→Milvus 存储→RAG 问答,一条链路打通

从零构建《天龙八部》知识库: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.mjsrag.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 的 OpenAIEmbeddingsChatOpenAI 原生对接 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 内置的方法,它把文件路径拆解为一个包含 rootdirbaseextname 五个字段的对象。这里用 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_numInt32 而非 Int8Int16,是出于防御性编程:一本小说可能只有 50 章,但代码可能被复用到其他场景(比如按日期的数据,日期编号可以是 20240101),留足余量避免溢出。

vectordim: 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) 的原理分两步:

  1. 建立索引时 :对所有向量做 K-Means 聚类,分成 nlist 个簇。每个簇有一个中心点。
  2. 查询时 :先计算查询向量到各簇中心点的距离,只在最近的几个簇(由 nprobe 参数控制)里做暴力全量比对。

这相当于把全库扫描缩小为几个候选区域的扫描 。对于《天龙八部》这种数据量(全书约 120 万字,按 500 字符一切片约 2400 条记录),这个优化已经足够。注释中说"数据量大了"才需要升级,指的是百万级以上数据要考虑 IVF_PQHNSW 等更高级的索引。

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 对象,包含 titlecontent(HTML 字符串)等信息。EPubLoader 将这些转换为 LangChain 的 Document 对象,每个 DocumentpageContent(纯文本正文)和 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 的分割策略(源码层面的实现):

  1. 先用 \n\n(段落间空行)分割
  2. 如果得到的 chunk 太长,再用 \n(换行)分割
  3. 还是太长的,用 (句号)分割
  4. 继续用 逐级递减
  5. 最后兜底:按字符数硬切

这是一种递归降级策略 :从"最自然的语义边界"开始,逐步降级到"纯粹的长度切分"。每一级都尽量在一个语义完结点切分开。注释里说的"没有传 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 SDKinsertResult.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_cntundefinedNumber(undefined) = NaN,而 NaN 是 JavaScript 中最"恶毒"的值------NaN + 任何数 = NaN,一旦出现,整个 totalInserted 计数器就永久成了 NaN,无法恢复。|| 0 这道防线把 NaN 拦在了外面。

设计哲学: 一个被外部模块调用的函数,不应该把类型转换的负担转嫁给调用方。调用方 loadAndProcessEPubStreamingtotalInserted += 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 直译就是"检索相关内容"。相比 searchgetData,这个命名明确表达了函数做了什么、返回什么。好的函数名应该在调用时读起来像自然语言------await retrieveRelevantContent(question, 3) 读起来就是"等待根据问题检索 3 条相关内容"。
一个函数一个功能:这个函数只做检索。它不关心 Prompt 怎么拼、LLM 怎么答。解耦意味着可测试------你可以单独测试检索的精度,也可以单独测试生成的质量。
只有一个返回值 :函数体内部只有两个 return 语句,但它们在逻辑上是同一条路径的分支------"正常返回 results"和"异常返回空数组"。调用方拿到的一定是数组,不需要判断 typeof result === 'undefined'result === null

异常处理的策略: catch 中返回空数组 [] 而不是 nullundefined。这样调用方可以直接写 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",而是一系列精心设计的工程选择串联起来的结果。

把这些细节理清楚、写下来,本身也是加深理解的过程。希望这篇文章能帮你少踩一些坑。

相关推荐
Muscleheng1 小时前
Spring Boot 3.x 集成 DeepSeek 实现 Function Calling(工具调用)
人工智能·spring boot·后端·ai·spring ai·deepseek
犀利豆2 小时前
Claude Code Tools 研究系列-前置篇(tool 机制)
人工智能
阿里云大数据AI技术2 小时前
AI Native, Now|阿里云 Milvus AI Function,从能力集成走向产品化落地
人工智能
阿三08122 小时前
跨境电商售后自动化分级标准:哪些能全自动、哪些半自动、哪些禁止自动化
大数据·人工智能·自动化
IT_陈寒2 小时前
JavaScript的this又双叒叕让我怀疑人生了
前端·人工智能·后端
颜酱2 小时前
09 | 重构项目结构
人工智能·python·langchain
ZhengEnCi3 小时前
AI Agent(AI智能体) 记忆管理系统设计 — 从向量库边界到生产级 Memory(记忆) 架构
人工智能
柚yuzumi3 小时前
变量明明定义了,为什么访问不到?深入理解 JavaScript 作用域
javascript
何时梦醒3 小时前
React + TypeScript + Vite 实战:从零构建 Color Picker 应用
前端·javascript·架构