Node.js + LangChain.js + Milvus 实战:从 EPUB 入库到《天龙八部》RAG 问答系统
- 前言
- [1. 项目目标与核心原理](#1. 项目目标与核心原理)
-
- [1.1 为什么需要 RAG](#1.1 为什么需要 RAG)
- [1.2 Embedding、Chunk 与 VectorStore](#1.2 Embedding、Chunk 与 VectorStore)
- [1.3 项目的两条数据链路](#1.3 项目的两条数据链路)
- [2. 初始化项目与安装依赖](#2. 初始化项目与安装依赖)
-
- [2.1 使用 npm 初始化](#2.1 使用 npm 初始化)
- [2.2 使用 pnpm 安装全部依赖](#2.2 使用 pnpm 安装全部依赖)
- [2.3 配置目录与环境变量](#2.3 配置目录与环境变量)
- [3. `main.mjs`:加载 EPUB 并建立向量知识库](#3.
main.mjs:加载 EPUB 并建立向量知识库) -
- [3.1 文件职责与总体流程](#3.1 文件职责与总体流程)
- [3.2 导入模块、读取配置并初始化 Embedding](#3.2 导入模块、读取配置并初始化 Embedding)
- [3.3 封装向量函数并创建 Milvus 客户端](#3.3 封装向量函数并创建 Milvus 客户端)
- [3.4 创建 Collection、字段与索引](#3.4 创建 Collection、字段与索引)
- [3.5 加载 EPUB 并递归切片](#3.5 加载 EPUB 并递归切片)
- [3.6 并发生成片段向量并批量插入](#3.6 并发生成片段向量并批量插入)
- [3.7 入口函数串联全部入库步骤](#3.7 入口函数串联全部入库步骤)
- [4. `query.mjs`:验证 Milvus 向量检索](#4.
query.mjs:验证 Milvus 向量检索) -
- [4.1 文件职责与总体流程](#4.1 文件职责与总体流程)
- [4.2 导入依赖并初始化查询组件](#4.2 导入依赖并初始化查询组件)
- [4.3 搜索 Top 3 并打印结果](#4.3 搜索 Top 3 并打印结果)
- [5. `rag.mjs`:组合检索结果并生成答案](#5.
rag.mjs:组合检索结果并生成答案) -
- [5.1 文件职责与总体流程](#5.1 文件职责与总体流程)
- [5.2 初始化 Embedding、ChatOpenAI 与 Milvus](#5.2 初始化 Embedding、ChatOpenAI 与 Milvus)
- [5.3 封装 Retriever 检索函数](#5.3 封装 Retriever 检索函数)
- [5.4 构造上下文与 Prompt](#5.4 构造上下文与 Prompt)
- [5.5 执行完整 RAG 问答](#5.5 执行完整 RAG 问答)
- [6. 运行顺序与结果验证](#6. 运行顺序与结果验证)
-
- [6.1 第一次运行的正确顺序](#6.1 第一次运行的正确顺序)
- [6.2 常见运行现象与排查方向](#6.2 常见运行现象与排查方向)
- [7. 三个文件的完整流程复盘](#7. 三个文件的完整流程复盘)
-
- [7.1 从电子书到向量数据库](#7.1 从电子书到向量数据库)
- [7.2 从问题到相似片段](#7.2 从问题到相似片段)
- [7.3 从相似片段到自然语言答案](#7.3 从相似片段到自然语言答案)
- 总结
前言
让大模型回答一部一百多万字的小说,真正的难点并不在"调用一次聊天接口",而在于如何让模型找到与问题有关的原文。整本电子书无法直接塞进一次请求,模型本身也不一定准确记得每一处人物、武功和情节细节。要解决这个问题,需要先把电子书加工成一个可以按语义检索的知识库。
**RAG(Retrieval-Augmented Generation,检索增强生成)**的思路很直接:先从外部知识库检索相关内容,再把检索结果连同用户问题一起交给大模型。这样一来,Milvus 负责"找到原文",大模型负责"结合原文组织答案",两部分职责清晰,也便于分阶段调试。
本项目使用 Node.js、LangChain.js、OpenAI 兼容接口与 Milvus,围绕《天龙八部》EPUB 完成一条完整链路。项目的编写和讲解顺序严格保持为:
main.mjs:加载 EPUB、切分章节、生成向量并写入 Milvus;query.mjs:把问题转换为向量,验证 Milvus 的相似度检索结果;rag.mjs:把召回片段组装成上下文,调用聊天模型生成答案。
下面不会一次性堆出三个完整文件,而是按照源码顺序分块讲解。每个 JavaScript 代码块都直接取自项目源码,按顺序拼接后就是对应文件的原始代码。
1. 项目目标与核心原理
1.1 为什么需要 RAG
只把"段誉会什么武功"交给大模型,模型会根据训练阶段形成的参数知识回答。面对小说、企业文档、内部手册等资料时,这种方式存在几个明显限制:
- 内容容量有限:整本书远远超过一次请求适合承载的文本量;
- 细节不可控:模型可能知道主要人物,却不一定记得具体情节;
- 回答难以验证:没有检索片段时,很难判断答案来自原文还是模型推测;
- 知识更新麻烦:资料变化后,直接更新知识库比重新训练模型更灵活。
RAG 将系统拆成"检索"和"生成"两个阶段。检索阶段先从 Milvus 找到与问题最相近的片段;生成阶段再让聊天模型基于这些片段回答。
| 方案 | 匹配依据 | 能做什么 | 主要特点 |
|---|---|---|---|
| 关键词检索 | 文本中是否出现相同词语 | 找到字面匹配内容 | 简单直接,但不擅长理解同义表达 |
| 向量检索 | 文本向量之间的距离 | 找到语义相近内容 | 能识别不同说法背后的相近含义 |
| RAG | 向量召回 + 大模型生成 | 检索资料并组织自然语言答案 | 同时利用外部知识与模型表达能力 |
1.2 Embedding、Chunk 与 VectorStore
Embedding 是把一段文本转换为浮点数向量的过程。语义相近的文本,通常会在向量空间中拥有更接近的方向或位置。
项目将电子书拆成多个文本片段,再把每个片段转换为向量。用户提问时,问题也会被转换为向量,然后由 Milvus 比较问题向量与文档向量的相似程度。
这里涉及三个基础概念:
- Chunk:从长文本中切出来的小片段,是检索和入库的基本单位;
- Embedding:Chunk 或问题经过模型计算后得到的数值向量;
- VectorStore:保存向量、原文和元数据,并支持相似度搜索的数据库,本项目使用 Milvus。
项目使用 MetricType.COSINE 计算余弦相似度。其核心公式为:
cosine ( q , d ) = q ⋅ d ∥ q ∥ ∥ d ∥ \operatorname{cosine}(q,d)=\frac{q\cdot d}{\lVert q\rVert\lVert d\rVert} cosine(q,d)=∥q∥∥d∥q⋅d
其中 q 表示问题向量,d 表示文档片段向量。二者方向越接近,语义通常越相似。搜索结果中的 score 就是排序的重要依据,代码会把它保留四位小数输出。
1.3 项目的两条数据链路
项目分为离线入库和在线问答两条链路。
离线入库由 main.mjs 完成:
EPUB → Document → Chunk → Embedding → Milvus
在线查询与问答由 query.mjs、rag.mjs 完成:
用户问题 → 问题向量 → Top K 片段 → Prompt → 大模型答案
三个文件之间不是平行关系,而是存在明确的前置顺序:
| 文件 | 输入 | 核心任务 | 输出 |
|---|---|---|---|
main.mjs |
天龙八部.epub |
创建集合、切片、生成向量并入库 | Milvus 中的知识库记录 |
query.mjs |
"段誉会什么武功?" | 执行 Top 3 向量搜索 | 分数、主键、书籍 ID 和原文 |
rag.mjs |
"鸠摩智会什么武功?" | 检索 Top 5 片段并调用聊天模型 | 基于小说片段生成的回答 |
当前 EPUB 经 EPubLoader 解析后会得到 168 个 Document,总文本量约 124.5 万字符;使用 chunkSize: 500、chunkOverlap: 50 切分后,可以形成约 3042 个片段。这里的 168 是 EPUB 内部文档节点数量,其中可能包含内容简介、序言、回目和其他页面,因此代码中的 chapter_num 表示加载后的文档顺序。
2. 初始化项目与安装依赖
2.1 使用 npm 初始化
先创建项目目录并进入该目录:
bash
mkdir tlbb
cd tlbb
npm init -y
npm init -y 会快速生成 package.json。三个源码文件使用 .mjs 扩展名,Node.js 会按照 ES Module 解析,因此代码中可以直接使用 import。
2.2 使用 pnpm 安装全部依赖
项目需要安装以下全部依赖:
bash
pnpm i @langchain/community @langchain/core @langchain/openai @langchain/textsplitters @zilliz/milvus2-sdk-node dotenv epub2 html-to-text
各依赖的职责如下:
| 依赖 | 核心作用 | 在项目中的位置 |
|---|---|---|
dotenv |
把 .env 加载到 process.env |
三个文件顶部的 import 'dotenv/config' |
@langchain/core |
提供 LangChain 的核心抽象 | LangChain 组件运行所依赖的基础能力 |
@langchain/community |
提供社区 Loader | EPubLoader 从 EPUB 加载文档 |
@langchain/textsplitters |
提供文本切分器 | RecursiveCharacterTextSplitter 拆分章节 |
@langchain/openai |
对接 OpenAI 兼容接口 | OpenAIEmbeddings 与 ChatOpenAI |
@zilliz/milvus2-sdk-node |
Node.js 版 Milvus SDK | 创建集合、索引、插入与搜索 |
epub2 |
解析 EPUB 的内部结构 | EPubLoader 的运行时依赖 |
html-to-text |
把章节 HTML 转为纯文本 | 为切片和 Embedding 提供干净文本 |
EPubLoader 位于 @langchain/community,但底层还需要 epub2 读取 EPUB、需要 html-to-text 去除 HTML 标签,因此这两个包也必须安装。
2.3 配置目录与环境变量
项目目录保持如下结构:
text
tlbb/
├── .env
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readmd.md
├── 天龙八部.epub
└── src/
├── main.mjs
├── query.mjs
└── rag.mjs
.env 中需要提供六个变量。真实值由各自使用的模型服务和 Milvus 服务决定:
dotenv
MODEL_NAME=聊天模型名称
OPENAI_API_KEY=接口密钥
OPENAI_BASE_URL=OpenAI兼容接口地址
MILVUS_ADDRESS=Milvus服务地址
MILVUS_TOKEN=Milvus访问令牌
EMBEDDING_MODEL_NAME=向量模型名称
| 环境变量 | 使用位置 | 作用 |
|---|---|---|
MODEL_NAME |
rag.mjs |
指定生成答案的聊天模型 |
OPENAI_API_KEY |
三个文件 | 为模型接口提供身份认证 |
OPENAI_BASE_URL |
三个文件 | 指定 OpenAI 兼容接口地址 |
MILVUS_ADDRESS |
三个文件 | 指定 Milvus 服务地址 |
MILVUS_TOKEN |
三个文件 | 为 Milvus 连接提供认证 |
EMBEDDING_MODEL_NAME |
三个文件 | 指定文本向量模型 |
3. main.mjs:加载 EPUB 并建立向量知识库
3.1 文件职责与总体流程
main.mjs 是整个项目的入库程序。它先读取配置并初始化 Embedding 模型和 Milvus 客户端,再检查 ebook 集合是否存在;第一次运行时创建字段与向量索引,随后加载 EPUB、按章节切片、为每个切片生成向量,最后将数据批量插入集合。
它的执行顺序可以概括为:
初始化配置 → 连接 Milvus → 确保集合存在 → 加载 EPUB → 按章节切片 → 生成向量 → 批量插入
Milvus 中不仅保存 vector,还保存 content、chapter_num、index 等信息。向量负责匹配语义,原文和元数据则负责在检索命中后还原内容。
3.2 导入模块、读取配置并初始化 Embedding
这一部分完成依赖导入、常量声明、书名解析和向量模型初始化:
javascript
import 'dotenv/config'
import { parse } from 'path';// path 解析路径
import {
MilvusClient,
DataType,
MetricType,
IndexType
} from '@zilliz/milvus2-sdk-node'
import {
OpenAIEmbeddings
} from '@langchain/openai'
import {
EPubLoader
} from '@langchain/community/document_loaders/fs/epub'
import {
RecursiveCharacterTextSplitter
} from '@langchain/textsplitters'
// config
const COLLECTION_NAME = 'ebook';// 编程习惯
const VECTOR_DIM = 1024;
const CHUNK_SIZE = 500;
const EPUB_FILE = './天龙八部.epub'
const ADDRESS = process.env.MILVUS_ADDRESS// 云端地址
const TOKEN = process.env.MILVUS_TOKEN// API Key
const {name:BOOK_NAME} = parse(EPUB_FILE);
console.log(BOOK_NAME);
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: {
baseURL:process.env.OPENAI_BASE_URL,
},
dimension: VECTOR_DIM,
});
import 'dotenv/config' 是一个有副作用的导入。模块载入后,根目录 .env 中的内容会进入 process.env,后续代码便能读取 Milvus 地址、令牌和模型配置。
parse(EPUB_FILE) 来自 Node.js 的 path 模块。EPUB_FILE 是 ./天龙八部.epub,解构得到的 BOOK_NAME 是不带扩展名的文件名,它会作为每条切片的书名元数据。
这一块中的常量各自控制一个核心环节:
| 常量 | 当前值 | 作用 |
|---|---|---|
COLLECTION_NAME |
ebook |
Milvus 集合名称 |
VECTOR_DIM |
1024 |
项目使用的向量维度 |
CHUNK_SIZE |
500 |
每个切片的目标字符大小 |
EPUB_FILE |
./天龙八部.epub |
电子书相对路径 |
ADDRESS |
环境变量 | Milvus 服务地址 |
TOKEN |
环境变量 | Milvus 访问凭证 |
OpenAIEmbeddings 接收接口密钥、向量模型名称、兼容服务地址和维度配置。这个实例会被后续 getEmbedding() 统一调用。
3.3 封装向量函数并创建 Milvus 客户端
第二块代码把向量生成封装为单一职责函数,同时创建数据库客户端:
javascript
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text);
return result;
};
// 向量数据库初始化
const client = new MilvusClient({
address: ADDRESS,
token: TOKEN
});
embeddings.embedQuery(text) 接收一段字符串并返回一个浮点数数组。虽然函数名中带有 Query,在当前项目中它被统一作为单文本向量生成入口:main.mjs 用它处理文本切片,query.mjs 和 rag.mjs 用它处理用户问题。
MilvusClient 保存服务器地址和认证令牌。客户端实例会在集合创建、数据插入和向量查询环节复用,避免每次操作都重新构造连接对象。
3.4 创建 Collection、字段与索引
ensureCollection(bookId) 负责"没有集合就创建,有集合就直接加载"。这让第一次执行和后续执行可以共用同一个入口:
javascript
async function ensureCollection(bookId){
// 没有就建立
// 有就忽略
try {
// 判断是否已经创建了集合
const hasCollection = await client.hasCollection({
collection_name: COLLECTION_NAME,
});
console.log(hasCollection.value);
if(!hasCollection.value){
console.log('创建集合...');
await client.createCollection({
collection_name: COLLECTION_NAME,
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
}
]
});
console.log('集合创建完成');
console.log('创建索引...');
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 高维相似度,不慢,数据量大
console.log('索引创建完成');
}
// 细节捕捉错误
// 每次要做的
try{
await client.loadCollection({
collection_name: COLLECTION_NAME
});
console.log('集合加载完成');
}catch(err){
console.error('集合已经处于加载状态');
}
}catch(err){
console.error('创建集合时出错');
}
}
client.hasCollection() 返回集合是否存在,结果保存在 hasCollection.value。只有值为假时,程序才执行 createCollection() 和 createIndex()。
Collection Schema 中每个字段都有明确用途:
| 字段 | Milvus 类型 | 作用 |
|---|---|---|
id |
VarChar |
主键,格式为"书籍 ID_章节序号_切片序号" |
book_id |
VarChar |
保存书籍业务标识 |
book_name |
VarChar |
保存从 EPUB 文件名解析出的书名 |
chapter_num |
Int32 |
保存当前文档在加载结果中的顺序 |
index |
Int32 |
保存切片在当前文档中的序号 |
content |
VarChar |
保存用于展示与 RAG 的原文 |
vector |
FloatVector |
保存用于相似度搜索的向量 |
id 被设置为主键,content 的最大长度是 10000,vector 的维度来自 VECTOR_DIM。Schema 的作用是提前约束每条记录的形状,保证后续插入的数据字段一致。
索引部分使用 IndexType.IVF_FLAT。它会通过 K-Means 思想将向量空间划分为多个聚类,params: { nlist: 1024 } 中的 nlist 表示聚类簇数量。距离度量采用 MetricType.COSINE,因此查询时也继续使用 COSINE。
无论集合是刚创建还是已经存在,函数最后都会调用 loadCollection()。加载 Collection 是后续在 Milvus 中执行向量检索的重要准备步骤。内层 try...catch 负责处理集合已经处于加载状态的情况,外层 try...catch 则统一捕获集合创建阶段的异常。
3.5 加载 EPUB 并递归切片
这一块负责把电子书变成可以逐批入库的文本片段:
javascript
async function loadAndProcessEPubStreaming(bookId){
try{
console.log(`\n 开始加载EPUB文件: ${EPUB_FILE}`);
const loader = new EPubLoader(EPUB_FILE,{
// 加载后就会按章节生成多个document
// 内存需求的必然
splitChapters: true
});
const documents = await loader.load();
console.log(`\n 加载完成, 共 ${documents.length} 个章节`);
const textSplitter = new RecursiveCharacterTextSplitter({
// 没有传separtor 就用默认的 \n
chunkSize: CHUNK_SIZE,
chunkOverlap: 50, // 重叠50个字符,保持上下文连贯性
});
let totalInserted = 0; // 计数
const documentLen = documents.length;// 缓存
for(let chapterIndex = 0; chapterIndex<documentLen;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;
}
console.log(`生成向量并插入中...`);
const insertedCount = await insertChunksBatch(chunks,bookId,chapterIndex+1);
totalInserted += insertedCount;
console.log(`已插入 ${totalInserted} 条记录`);
}
console.log(`\n总共插入 ${totalInserted} 条记录\n`);
return totalInserted;
}catch(err){
console.error('加载EPUB文件时出错:', err.message);
}
}
new EPubLoader(EPUB_FILE, { splitChapters: true }) 会读取 EPUB 的内部 flow,并为加载到的章节节点生成 Document。每个 Document 的正文位于 pageContent,因此循环中通过 chapter.pageContent 取得待切分文本。
RecursiveCharacterTextSplitter 接收两个关键参数:
| 参数 | 当前值 | 作用 |
|---|---|---|
chunkSize |
500 |
控制每个片段的目标长度 |
chunkOverlap |
50 |
让相邻片段保留一部分重复上下文 |
递归字符切分器会尝试沿段落、换行和更细的字符边界切分。chunkOverlap 让前一片段末尾与后一片段开头共享部分内容,减少一句话刚好跨越边界后语义被拆散的情况。
外层 for 循环按 documents 顺序处理,每个章节节点完成切片后立即调用 insertChunksBatch()。totalInserted 会累计每一次批量写入返回的数量,并持续打印进度。若某个章节切分结果为空,则通过 continue 进入下一轮。
3.6 并发生成片段向量并批量插入
insertChunksBatch() 接收当前章节的全部 chunks,为它们生成向量并一次性提交给 Milvus:
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;
}
}
chunks.map() 会为当前章节的每个片段创建一个异步任务,任务内部调用 getEmbedding(chunk)。由于 map() 返回的是 Promise 数组,所以外层使用 Promise.all() 等待全部向量生成完毕。最终 insertData 是一组结构完整的 Milvus 行记录。
每条记录的主键由三个值拼接:
text
bookId_chapterNum_chunkIndex
例如书籍 ID 为 1、文档序号为 5、切片序号为 2 时,主键就是 1_5_2。同一条记录还保留书名、章节位置、切片位置、原文和向量,检索命中后便能直接拿到所需上下文。
client.insert() 的 collection_name 指向 ebook,data 是待写入的记录数组。SDK 返回的 insert_cnt 会通过 Number() 转换成数字;若转换结果不可用,则返回 0,使函数返回值保持统一的数值类型。
3.7 入口函数串联全部入库步骤
main 函数是 main.mjs 的总入口:
javascript
const main = async () => {
try{
console.log('='.repeat(50));
console.log('电子书处理程序');
console.log('='.repeat(50));
console.log('\n连接Milvus数据库...');
await client.connectPromise;
console.log('已连接');
const bookId = 1;
// 确保集合建立了
await ensureCollection(bookId);
// 加载和处理EPUB文件
// 一边切割一边embedding,一边存数据库
await loadAndProcessEPubStreaming(bookId);
}catch(err){
console.error('程序执行出错:', err);
}
}
main().catch(err => console.log(err));
await client.connectPromise 等待 Milvus 连接完成。bookId 设置为 1,随后依次传给 ensureCollection(bookId) 和 loadAndProcessEPubStreaming(bookId),并最终进入每条记录的主键和 book_id 字段。
整个入口的调用关系如下:
main() → ensureCollection() → loadAndProcessEPubStreaming() → insertChunksBatch() → getEmbedding()
main().catch(...) 位于最外层,用于接收入口 Promise 的异常;各业务函数内部也通过 try...catch 输出当前阶段的错误信息。
4. query.mjs:验证 Milvus 向量检索
4.1 文件职责与总体流程
知识库入库完成后,先使用 query.mjs 验证纯检索效果。这个文件不会调用聊天模型,它只把问题转换为向量,然后从 ebook 集合中搜索最相近的三条记录。
流程可以概括为:
连接 Milvus → 加载 Collection → 问题向量化 → Top 3 搜索 → 打印 Score 与原文
把检索独立出来有一个重要价值:如果最终 RAG 回答不理想,可以先观察 Milvus 是否召回了正确内容,从而区分问题出在检索阶段还是生成阶段。
4.2 导入依赖并初始化查询组件
query.mjs 的第一部分创建 Embedding 和 Milvus 客户端,并封装 getEmbedding():
javascript
import 'dotenv/config';
import {
MilvusClient, // C/S B/S
MetricType, // 相似度求方法
IndexType,
DataType // 字段数据类型约束
} from '@zilliz/milvus2-sdk-node';
import {
OpenAIEmbeddings
} from '@langchain/openai';
const ADDRESS = process.env.MILVUS_ADDRESS;
// api key
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL
},
dimensions: VECTOR_DIM
});
const client = new MilvusClient({
address: ADDRESS,
token: TOKEN
})
const getEmbedding = async (text) => {
const result = await embeddings.embedQuery(text);
return result;
}
这里继续使用与入库阶段相同的 COLLECTION_NAME、VECTOR_DIM、EMBEDDING_MODEL_NAME 和服务配置。文档向量与问题向量只有位于同一个语义空间中,距离比较才有意义。
IndexType 和 DataType 一并从 Milvus SDK 导入,使代码中的 SDK 类型来源保持集中;实际执行搜索时使用的是 MilvusClient 与 MetricType。
4.3 搜索 Top 3 并打印结果
第二部分进入查询主流程:
javascript
async function main() {
try {
console.log('Connecting to Milvus');
await client.connectPromise;
console.log('connected\n');
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,
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)}]\n
ID: ${item.id} \n
BookId: ${item.book_id}\n
Content: ${item.content} \n
`)
})
} catch(err) {
console.log(err);
}
}
main()
.catch(err => {
console.log(err);
})
这段代码先等待连接完成,再通过 loadCollection() 把 ebook 集合加载到可搜索状态。问题保存在 query 中,getEmbedding(query) 返回查询向量。
client.search() 的参数含义如下:
| 参数 | 当前值 | 作用 |
|---|---|---|
collection_name |
ebook |
指定搜索的集合 |
vector |
queryVector |
传入问题向量 |
limit |
3 |
返回相似度最高的三条记录 |
metric_type |
MetricType.COSINE |
使用余弦相似度排序 |
output_fields |
五个业务字段 | 返回主键、书籍 ID、章节、序号和原文 |
搜索完成后,searchResult.results 是结果数组。forEach() 按排名逐条输出,item.score.toFixed(4) 将分数保留四位小数,item.id 与 item.book_id 用于定位记录,item.content 则显示真正命中的小说片段。
验证时应同时观察分数和原文。分数体现向量相似度,原文则决定结果是否真正回答了问题。Top 1、Top 2、Top 3 的内容越集中,说明这次语义检索越稳定。
5. rag.mjs:组合检索结果并生成答案
5.1 文件职责与总体流程
rag.mjs 在向量检索基础上加入 ChatOpenAI。它先搜索相关小说片段,再把片段拼接为 context,最后用 Prompt 告诉聊天模型应如何使用这些内容。
完整链路为:
问题 → Embedding → Milvus Top K → context → prompt → ChatOpenAI.invoke() → response.content
在这个文件中,OpenAIEmbeddings 负责理解"问题和文档是否相似",ChatOpenAI 负责把多个片段组织成通顺、详细的自然语言答案。
5.2 初始化 Embedding、ChatOpenAI 与 Milvus
第一块代码完成两个模型组件和数据库客户端的初始化:
javascript
import 'dotenv/config'
import {
MilvusClient, // C/S架构 B/S
MetricType, // 相似度求方法
} from '@zilliz/milvus2-sdk-node';
import {
ChatOpenAI,
OpenAIEmbeddings
} from '@langchain/openai'
// 云端地址
const ADDRESS = process.env.MILVUS_ADDRESS
// API key
const TOKEN = process.env.MILVUS_TOKEN
const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL
},
dimensions: VECTOR_DIM
});
const model = new ChatOpenAI({
temperature: 0.1,
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL
}
});
const client = new MilvusClient({
address: ADDRESS,
token: TOKEN
});
const getEmbedding = async (text) => {
const result = await embeddings.embedQuery(text);
return result;
}
OpenAIEmbeddings 继续处理问题向量,配置来自 EMBEDDING_MODEL_NAME。ChatOpenAI 则使用 MODEL_NAME 指定聊天模型。
temperature: 0.1 控制生成随机性。较低的 temperature 更适合知识问答:模型会更稳定地围绕现有上下文组织答案,而不是追求开放式创作。
| 组件 | 输入 | 输出 | 职责 |
|---|---|---|---|
OpenAIEmbeddings |
用户问题 | 数值向量 | 为 Milvus 搜索提供查询向量 |
MilvusClient |
查询向量 | 相似片段数组 | 找到与问题有关的小说内容 |
ChatOpenAI |
Prompt 字符串 | AIMessage |
基于上下文生成最终答案 |
5.3 封装 Retriever 检索函数
retrieveRelevantContent() 只负责检索,不负责生成答案,体现了源码注释中"一个函数一个功能、只有一个返回值"的思路:
javascript
// RAG 图书业务知识库化
// 函数名可读性
// 一个函数一个功能
// 只有一个返回值
async function retrieveRelevantContent(question, k = 3) {
try {
const queryVector = await getEmbedding(question);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vectors: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
});
return searchResult.results;
} catch (err) {
console.error('检索相关内容时出错:', err.message);
return [];
}
}
函数接收两个参数:question 是用户问题,k 是返回片段数量,默认值为 3。getEmbedding(question) 先生成问题向量,随后 client.search() 在 ebook 集合中进行余弦相似度搜索。
| 参数 | 含义 |
|---|---|
collection_name |
指定检索 ebook 集合 |
vectors |
传入当前问题的向量 |
limit |
由 k 决定返回多少条 |
metric_type |
使用 COSINE 保持与索引一致 |
output_fields |
返回构造上下文所需的字段 |
成功时函数返回 searchResult.results;检索发生异常时输出错误信息并返回空数组。这样调用方始终接收数组,可以直接通过 length 判断是否找到内容。
5.4 构造上下文与 Prompt
answerEbookQuestion() 负责把 Retriever 的结果加工成大模型可以阅读的上下文:
javascript
async function answerEbookQuestion(question, k = 3) {
try {
const relevantContent = await retrieveRelevantContent(question, k);
if (relevantContent.length === 0) {
console.log('没有找到相关的内容');
return "抱歉,我没有找到相关的《天龙八部》的内容。";
}
const context = relevantContent.map((item, i) => `
[片段${i + 1}]
章节: 第${item.chapter_num}章
内容: ${item.content}
`).join('\n');
const prompt = `
你是一个专业的《天龙八部》小说助手。
基于小说回答问题,用准确、详细的语言。
请根据以下小说片段内容回答问题
${context}
用户问题: ${question}
回答要求:
1.如果片段中有相关信息,请结合小说内容给出详细准确的答案。
如果没有,请说不知道
2.如果可以综合多个片段的内容,提供完整的答案。
3.如果片段中没有相关信息,请如实告诉用户。
4.回答要准确,符合小说的情节和人物设定。
5.可以引用原文内容来支持你的回答。
AI 助手的回答:
`
const response = await model.invoke(prompt);
return response.content;
} catch (err) {
console.error('回答问题时出错:', err.message);
}
}
函数先调用 retrieveRelevantContent(question, k)。如果结果数组为空,就直接返回未找到内容的提示,不再请求聊天模型。
找到内容后,map() 把每条记录转换为一个带编号的文本片段:
[片段1]、[片段2]用于区分多条召回结果;章节来自 Milvus 的chapter_num;内容来自 Milvus 的content。
所有片段通过 .join('\n') 合并成 context。Prompt 随后依次写入助手角色、小说片段、用户问题和回答要求。五条规则共同限制答案:
| 规则 | 目的 |
|---|---|
| 有相关信息时详细回答 | 充分使用召回内容 |
| 无相关信息时说不知道 | 避免脱离片段猜测 |
| 可以综合多个片段 | 支持跨片段整理信息 |
| 符合情节与人物设定 | 保证答案与小说语境一致 |
| 可以引用原文 | 让回答拥有文本依据 |
model.invoke(prompt) 返回消息对象,真正的答案位于 response.content。函数最后只返回这一字段,使调用方获得可直接输出的文本。
5.5 执行完整 RAG 问答
最后一块代码加载集合、提出问题并打印答案:
javascript
async function main() {
try {
await client.loadCollection({
collection_name: COLLECTION_NAME
});
console.log('集合加载成功');
const result =
await answerEbookQuestion('鸠摩智会什么武功?', 5);
console.log(result);
} catch (err) {
}
}
main()
.catch(err => {
console.log(err)
})
入口先调用 loadCollection(),再执行 answerEbookQuestion('鸠摩智会什么武功?', 5)。虽然函数默认 k = 3,这里显式传入 5,因此本次问答会取五个相关片段作为上下文。
调用链可以整理为:
main() → answerEbookQuestion() → retrieveRelevantContent() → getEmbedding() → client.search() → model.invoke()
这条链路先检索、后生成。只有在召回数组不为空时才会调用聊天模型,最后通过 console.log(result) 输出回答。
6. 运行顺序与结果验证
6.1 第一次运行的正确顺序
先执行入库程序:
bash
node src/main.mjs
main.mjs 会连接 Milvus、创建或加载 ebook 集合,并处理整本 EPUB。控制台会持续输出当前章节、切片数量和累计插入记录数。

入库完成后运行检索验证:
bash
node src/query.mjs
如果配置和入库流程正常,控制台会输出三条结果,每条包含 Score、ID、BookId 与 Content。重点观察 Content 是否与"段誉会什么武功"相关。



最后执行完整 RAG:
bash
node src/rag.mjs
rag.mjs 会检索五条相关片段,再由聊天模型围绕这些片段回答"鸠摩智会什么武功?"。


6.2 常见运行现象与排查方向
| 现象 | 优先检查内容 | 原因说明 |
|---|---|---|
| 找不到 EPUB | 当前终端所在目录、EPUB_FILE |
./天龙八部.epub 是相对项目根目录的路径 |
| 模型接口无法访问 | OPENAI_API_KEY、OPENAI_BASE_URL |
Embedding 与聊天请求都依赖兼容接口 |
| Milvus 无法连接 | MILVUS_ADDRESS、MILVUS_TOKEN |
地址或认证信息决定客户端能否访问服务 |
| 第一次运行等待较久 | EPUB 体积、切片数量、接口响应速度 | 需要为大量片段逐一生成向量 |
| Query 没有理想片段 | Score、输出的 Content |
需要先从检索结果判断问题与片段的语义匹配情况 |
| RAG 返回未找到内容 | Retriever 返回空数组 | answerEbookQuestion() 会直接返回预设提示 |
排查时最好沿着代码实际执行顺序进行:先确认 EPUB 能否加载,再观察切片数量,然后确认向量生成和数据插入,最后检查 query.mjs 的检索结果与 rag.mjs 的生成结果。
7. 三个文件的完整流程复盘
7.1 从电子书到向量数据库
main.mjs 启动后,先通过 client.connectPromise 连接 Milvus。ensureCollection(bookId) 检查 ebook 是否存在;不存在时创建包含主键、书籍信息、章节信息、原文和向量的 Schema,并为 vector 字段创建 IVF_FLAT + COSINE 索引。
随后,EPubLoader.load() 将电子书加载为 Document[],RecursiveCharacterTextSplitter.splitText() 将每个 pageContent 拆成多个 Chunk。insertChunksBatch() 使用 Promise.all() 并发执行 getEmbedding(chunk),生成当前章节的 insertData,再交给 client.insert() 一次写入 Milvus。
7.2 从问题到相似片段
query.mjs 将"段誉会什么武功?"传给 getEmbedding(),得到 queryVector。client.search() 在 ebook 集合中使用 MetricType.COSINE 排序,limit: 3 决定只返回前三条。
每个结果除了 score,还带有 id、book_id、chapter_num、index 和 content。这一步证明向量数据库不仅能返回"哪个向量相近",还能同时返回可阅读、可定位的原始片段。
7.3 从相似片段到自然语言答案
rag.mjs 将 Retriever 和聊天模型串联起来。retrieveRelevantContent() 负责返回片段数组,answerEbookQuestion() 负责把数组格式化为 context 并插入 Prompt,ChatOpenAI.invoke() 负责生成答案。
整个项目的数据变化可以用下表复盘:
| 阶段 | 核心 API | 输入 | 输出 |
|---|---|---|---|
| 加载 | EPubLoader.load() |
EPUB 文件 | Document[] |
| 切片 | splitText() |
pageContent |
chunks |
| 文档向量化 | embedQuery() |
单个 Chunk | 向量数组 |
| 入库 | client.insert() |
insertData |
插入数量 |
| 问题向量化 | embedQuery() |
用户问题 | queryVector |
| 召回 | client.search() |
问题向量 | Top K 片段 |
| 上下文构造 | map().join() |
检索结果数组 | context |
| 答案生成 | model.invoke() |
Prompt | AIMessage |
三份代码分别对应 RAG 系统中最重要的三层:数据加工层、语义检索层、答案生成层。先把每一层独立跑通,再通过函数调用把它们串起来,能够让长文本问答项目的结构保持清晰。
总结
这个项目完整跑通了 Loader、Splitter、Embedding、Milvus 与 RAG 的核心链路。main.mjs 负责把 EPUB 加载为文档,通过 500 字符切片与 50 字符重叠保留上下文,再为片段生成向量并写入 ebook 集合;query.mjs 使用同一个向量模型处理问题,以 COSINE 相似度取回 Top 3 原文;rag.mjs 进一步把 Top 5 结果组成 Prompt,由 ChatOpenAI 生成小说问答结果。理解三个文件之间的数据流后,就能清楚看到 RAG 的本质:模型不需要一次读完整本书,而是在每次提问时先找出最相关的内容,再围绕这些内容完成回答。