从零搭建《天龙八部》RAG 知识库:完整流程与核心知识点

1 阅读10分钟

一、这篇讲什么

最近完整跑通了一个 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来源
PDFLoaderPDF 文件
CSVLoaderCSV 表格
TextLoader纯文本
DirectoryLoader整个目录
NotionLoaderNotion 页面
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 },
  ],
});
字段类型含义
idVarChar主键,格式 书号_章号_段号,如 01_3_7
book_idVarChar书的编号
book_nameVarChar书名
chapter_numInt32第几章
indexInt32该章节的第几个切片
contentVarChar原始文本内容
vectorFloatVector(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
}

三个要点:

  1. Promise.all 并发:10 个 chunk 同时调 embedding API,不等排队
  2. id 设计 书号_章号_段号:唯一、可读、可追溯。想删第 3 章所有数据?id LIKE "01_3_%" 即可
  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: trueEPUB 按章节拆分,一个章节一个 Document
常见 LoaderPDFLoader, CSVLoader, TextLoader, WebBaseLoader

Splitter

概念一句话
chunkSize每段最多多少字,500 是常用值
chunkOverlap相邻段重叠多少字,防止关键信息跨边界丢失
separator优先在哪里切割,\n 表示优先在换行处断

Embedding

概念一句话
Embedding 是什么把文字映射成固定维度的数字数组(向量)
embedQuery vs embedDocuments前者用于搜索时转单个问题,后者用于入库时批量转文档
维度 1024text-embedding-v3 的输出维度,百万字级文本够用

Milvus

概念一句话
Collection相当于数据库的表
IVF_FLAT先聚类再暴力搜索,平衡速度和精度
COSINE余弦相似度,衡量向量方向接近程度
nlistK-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、网页、文档,搭建自己的私有知识库了。