用 Node.js 搭建 EPUB 问答助手:从文本切片、向量检索到 RAG
第一次接触 RAG 时,最容易产生的误解是:把一本书交给大模型,它就会永久"记住"书里的内容。实际上,大模型的参数并不会因为一次调用而改变;而一本长篇小说也通常无法完整塞进一次请求。
更实用的办法是先把书拆成许多小片段,为每个片段计算向量并存入向量数据库。用户提问时,程序先找出语义最接近的几个片段,再让模型只根据这些片段回答。这就是 RAG(Retrieval-Augmented Generation,检索增强生成)的基本思路。
本文将用 Node.js、LangChain 和 Milvus 完成一个 EPUB 问答助手。你会看到两条彼此衔接的数据流:
text
入库:EPUB → 章节 → 文本片段 → 向量 → Milvus
问答:问题 → 问题向量 → 相似片段 → Prompt → 大模型回答
重点不只是"把代码跑起来",还要弄清楚文本为什么要切片、向量维度为何必须一致、await 得到的究竟是什么,以及检索结果怎样进入模型上下文。
准备项目和运行环境
新建一个项目并安装依赖:
bash
mkdir epub-rag
cd epub-rag
npm init -y
npm install @langchain/community @langchain/openai @langchain/textsplitters @zilliz/milvus2-sdk-node dotenv epub2 html-to-text
本文使用 ECMAScript Module,因此代码文件采用 .mjs 后缀。项目结构如下:
text
epub-rag/
├── books/
│ └── novel.epub
├── src/
│ ├── config.mjs
│ ├── ingest.mjs
│ └── ask.mjs
└── .env
你需要一个可访问的 Milvus 实例,以及一个兼容 OpenAI 接口的嵌入模型和聊天模型服务。在 .env 中配置:
env
MILVUS_ADDRESS=your_milvus_address
MILVUS_TOKEN=your_milvus_token
OPENAI_API_KEY=your_api_key
OPENAI_BASE_URL=https://your-compatible-api.example/v1
EMBEDDINGS_MODEL_NAME=your_embedding_model
CHAT_MODEL_NAME=your_chat_model
如果直接使用 OpenAI 官方接口,可以删除 OPENAI_BASE_URL,同时在后面的模型配置中省略 configuration。不要把含有真实密钥的 .env 提交到 Git 仓库。
先创建 src/config.mjs,让入库和查询共用一套配置:
javascript
import "dotenv/config";
import { MilvusClient } from "@zilliz/milvus2-sdk-node";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
export const COLLECTION_NAME = "epub_books";
export const VECTOR_DIM = 1024;
export const CHUNK_SIZE = 500;
export const CHUNK_OVERLAP = 50;
const requiredEnv = [
"MILVUS_ADDRESS",
"OPENAI_API_KEY",
"EMBEDDINGS_MODEL_NAME",
"CHAT_MODEL_NAME",
];
for (const name of requiredEnv) {
if (!process.env[name]) {
throw new Error(`缺少环境变量:${name}`);
}
}
const configuration = process.env.OPENAI_BASE_URL
? { baseURL: process.env.OPENAI_BASE_URL }
: undefined;
export const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
dimensions: VECTOR_DIM,
configuration,
});
export const chatModel = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.CHAT_MODEL_NAME,
temperature: 0.1,
configuration,
});
export const milvus = new MilvusClient({
address: process.env.MILVUS_ADDRESS,
token: process.env.MILVUS_TOKEN,
});
process.env 保存环境变量。代码在启动时检查必要配置,可以让"密钥没读取到"尽早变成明确报错,而不是等到网络请求时才收到难以定位的认证错误。
VECTOR_DIM 是向量包含的数字个数。这里的 1024 不是随意的:嵌入模型必须支持输出 1024 维向量,Milvus 集合中的向量字段也必须声明为 1024 维。两边不同会导致插入或搜索失败。如果你的模型只支持固定维度,应把常量改成该模型的实际输出维度,并重新创建集合。
文本为什么要先变成向量
传统关键词搜索善于查找相同文字,却不一定理解相同含义。例如,"段誉掌握了哪些武功"和"段誉会什么功夫"用词不同,但语义很接近。
嵌入模型会把一段文本转换成数字数组:
javascript
const vector = await embeddings.embedQuery("段誉会什么武功?");
embedQuery() 立即返回的是一个 Promise,表示结果将在未来产生。await 会暂停当前 async 函数后续语句,等 Promise 成功后得到向量数组;这期间 JavaScript 运行时仍可处理其他任务,并不是整个进程停止。若请求失败,Promise 会被拒绝,错误会沿调用链抛出,因此外层需要 try...catch 或 .catch()。
Milvus 可以使用余弦相似度比较两个向量的方向。方向越接近,通常说明两段文本的语义越相似。需要注意:向量本身不可供模型阅读,它只用于寻找原始文本;真正放进 Prompt 的仍然是检索到的文字片段。
第一步:创建适合书籍片段的集合
在 src/ingest.mjs 中先写集合初始化逻辑:
javascript
import { parse } from "node:path";
import {
DataType,
IndexType,
MetricType,
} from "@zilliz/milvus2-sdk-node";
import { EPubLoader } from "@langchain/community/document_loaders/fs/epub";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import {
CHUNK_OVERLAP,
CHUNK_SIZE,
COLLECTION_NAME,
VECTOR_DIM,
embeddings,
milvus,
} from "./config.mjs";
const EPUB_FILE = "./books/novel.epub";
const BOOK_ID = "novel";
const { name: BOOK_NAME } = parse(EPUB_FILE);
async function ensureCollection() {
const existing = await milvus.hasCollection({
collection_name: COLLECTION_NAME,
});
if (!existing.value) {
await milvus.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{
name: "id",
data_type: DataType.VarChar,
max_length: 160,
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: "chunk_index", data_type: DataType.Int32 },
{
name: "content",
data_type: DataType.VarChar,
max_length: 10000,
},
{
name: "vector",
data_type: DataType.FloatVector,
dim: VECTOR_DIM,
},
],
});
await milvus.createIndex({
collection_name: COLLECTION_NAME,
field_name: "vector",
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: 1024 },
});
}
await milvus.loadCollection({
collection_name: COLLECTION_NAME,
});
}
一条记录代表一个文本片段。除了 content 和 vector,还保存书名、章节号和片段序号,查询后便能知道内容来自哪里。
id 使用字符串主键,稍后会按"书籍 + 章节 + 片段"生成稳定值。book_id 也统一保存字符串,避免数据库字段声明为 VarChar,代码却传入数字。字段类型看似是小事,却是数据库边界上最常见的问题之一。
索引可以减少大量数据下的搜索开销。IVF_FLAT 会把向量空间分成若干簇,nlist 表示簇的数量。1024 只是示例起点,不是适用于所有数据规模的固定答案;书很少时可以降低它,大型数据集则应通过召回率和延迟测试调参。
集合需要在搜索前加载。即使集合早已存在,也不能只在"首次创建"的分支里加载,所以 loadCollection() 放在条件语句外。
第二步:按章节加载,再切成有重叠的片段
继续在 src/ingest.mjs 中加入:
javascript
async function upsertChunks(chunks, chapterNum) {
const rows = await Promise.all(
chunks.map(async (content, chunkIndex) => {
const vector = await embeddings.embedQuery(content);
return {
id: `${BOOK_ID}_${chapterNum}_${chunkIndex}`,
book_id: BOOK_ID,
book_name: BOOK_NAME,
chapter_num: chapterNum,
chunk_index: chunkIndex,
content,
vector,
};
}),
);
const result = await milvus.upsert({
collection_name: COLLECTION_NAME,
data: rows,
});
return Number(result.upsert_cnt) || 0;
}
async function ingestBook() {
const loader = new EPubLoader(EPUB_FILE, {
splitChapters: true,
});
const chapters = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: CHUNK_SIZE,
chunkOverlap: CHUNK_OVERLAP,
separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
});
let total = 0;
for (let chapterIndex = 0; chapterIndex < chapters.length; chapterIndex += 1) {
const chapter = chapters[chapterIndex];
const chunks = await splitter.splitText(chapter.pageContent);
if (chunks.length === 0) {
continue;
}
const chapterNum = chapterIndex + 1;
const count = await upsertChunks(chunks, chapterNum);
total += count;
console.log(`第 ${chapterNum} 章:写入 ${count} 个片段`);
}
console.log(`入库完成,共写入 ${total} 个片段`);
}
async function main() {
await milvus.connectPromise;
await ensureCollection();
await ingestBook();
}
main().catch((error) => {
console.error("入库失败:", error);
process.exitCode = 1;
});
EPubLoader.load() 返回 Document 数组。开启 splitChapters 后,每个 Document 通常对应 EPUB 中的一个章节,其正文位于 pageContent。这里说"通常"是因为 EPUB 的内部结构由制作者决定,一个内容文件未必严格等于读者看到的一章。
长文本不能直接作为一个向量,主要有三个原因:
- 嵌入模型有输入长度上限;
- 片段过长会混入多个情节,检索结果不够精确;
- 把整章塞给聊天模型会浪费上下文窗口和调用成本。
chunkSize: 500 把目标片段控制在约 500 个字符,chunkOverlap: 50 让相邻片段保留约 50 个字符的重叠。重叠能缓解一句话恰好被边界切断的问题,但过大又会增加存储量并产生重复召回。
separators 按从优先到兜底的顺序尝试切分:先保留段落,再考虑换行和中文标点,最终才按空字符串强制拆开。它比只按固定长度截断更符合自然语言结构。
chunks.map(...) 把每段文本转换为一个异步任务,Promise.all() 等全部向量生成完成后得到 rows 数组。这样同一章内的请求会并发执行,速度较快,但也可能触发接口并发限制。生产环境应改成固定大小的批次或使用并发队列。
这里使用 upsert 而不是 insert。稳定主键配合 upsert,重复执行入库脚本时会更新同一批记录,而不是因为主键重复而失败。不过,如果新版 EPUB 比旧版少了章节,旧的多余记录不会自动消失;正式系统需要在重建前按 book_id 删除旧记录,或给每次导入增加版本号。
运行入库:
bash
node src/ingest.mjs
第三步:检索片段并让模型回答
创建 src/ask.mjs:
javascript
import { MetricType } from "@zilliz/milvus2-sdk-node";
import {
COLLECTION_NAME,
chatModel,
embeddings,
milvus,
} from "./config.mjs";
async function retrieveRelevantContent(question, limit = 5) {
const queryVector = await embeddings.embedQuery(question);
const searchResult = await milvus.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit,
metric_type: MetricType.COSINE,
output_fields: [
"book_name",
"chapter_num",
"chunk_index",
"content",
],
});
return searchResult.results;
}
function buildContext(results) {
return results
.map(
(item, index) => [
`[片段 ${index + 1}]`,
`书名:${item.book_name}`,
`章节:第 ${item.chapter_num} 章`,
`内容:${item.content}`,
].join("\n"),
)
.join("\n\n---\n\n");
}
async function answerQuestion(question, limit = 5) {
const results = await retrieveRelevantContent(question, limit);
if (results.length === 0) {
return "没有检索到足以回答这个问题的内容。";
}
const context = buildContext(results);
const prompt = `你是一个严谨的小说问答助手。
只能依据"参考片段"回答,不要使用片段之外的知识补全情节。
如果参考片段不足以回答,请明确说信息不足。
参考片段:
${context}
用户问题:${question}
请给出简洁、准确的中文回答,并在适合时说明信息来自第几章。`;
const response = await chatModel.invoke(prompt);
return typeof response.content === "string"
? response.content
: JSON.stringify(response.content);
}
async function main() {
const question = process.argv.slice(2).join(" ").trim();
if (!question) {
throw new Error('请提供问题,例如:node src/ask.mjs "主人公会什么武功?"');
}
await milvus.connectPromise;
await milvus.loadCollection({
collection_name: COLLECTION_NAME,
});
const answer = await answerQuestion(question);
console.log(answer);
}
main().catch((error) => {
console.error("问答失败:", error);
process.exitCode = 1;
});
运行时把问题作为命令行参数传入:
bash
node src/ask.mjs "主人公会什么武功?"
process.argv 是 Node.js 接收到的参数数组,前两项分别是 Node 可执行文件和脚本路径。slice(2) 取出真正的用户输入,join(" ") 又把可能被拆开的多个参数合并成一句话。
检索函数的参数 limit = 5 表示调用方不传第二个参数时默认返回 5 条结果。它返回给 answerQuestion() 的是 Milvus 搜索结果数组,而不是最终答案。随后 buildContext() 将数组映射成带有来源标记的文本,最终流向 Prompt。
temperature: 0.1 会降低回答的随机性,但不能保证模型绝不编造。因此 Prompt 仍然要明确限制信息边界。更严格的系统还会设置最低相似度阈值:即使数据库总能返回前 5 条,也不代表这 5 条真的足够相关。
程序从启动到回答经历了什么
把两次运行连起来看,完整流程如下:
- Node.js 加载
ingest.mjs及其导入的模块,dotenv/config将.env写入process.env。 - 程序创建嵌入模型客户端和 Milvus 客户端,并连接 Milvus。
ensureCollection()检查集合;不存在时创建字段与余弦索引,随后加载集合。EPubLoader读取 EPUB,await loader.load()最终得到章节Document数组。- 文本分割器读取每章的
pageContent,按段落、标点和长度拆成带重叠的片段。 map为每个片段发起嵌入请求,Promise.all等待并收集所有向量。- 程序将正文、章节元数据和向量组成记录,通过
upsert写入 Milvus。 - 查询脚本从命令行取得问题,再用同一个嵌入模型生成问题向量。
- Milvus 使用
COSINE度量搜索最接近的若干记录,返回分数、正文和指定的元数据字段。 - 程序把检索结果拼成参考上下文,再连同用户问题发送给聊天模型。
chatModel.invoke()返回 AI 消息对象;程序取出response.content并打印答案。- 任一步骤的 Promise 被拒绝,错误都会传播到最外层
.catch(),进程以非零退出码结束。
这也说明了 RAG 的职责边界:嵌入模型负责"把语义变成可比较的向量",Milvus 负责"找到相关资料",聊天模型负责"阅读资料并组织答案"。
常见错误与排查
向量维度不匹配
插入或搜索时可能看到类似"vector dimension mismatch"的错误。原因是集合的 dim、入库向量长度和查询向量长度不一致。
可以先打印实际长度:
javascript
const vector = await embeddings.embedQuery("维度测试");
console.log(vector.length);
确认嵌入模型是否支持 dimensions 参数,再让 VECTOR_DIM 与实际输出一致。已经按错误维度创建的集合不能只修改 JavaScript 常量,需要重建集合或换一个新集合名。
捕获错误时又引用了错误变量
下面的写法会掩盖真正的数据库异常:
javascript
try {
await saveData();
} catch (err) {
console.error(error.message);
}
catch 接收到的是 err,但日志使用了未定义的 error,于是又产生 ReferenceError。应统一变量名,并在底层记录后继续抛出:
javascript
try {
await saveData();
} catch (error) {
console.error("保存失败:", error.message);
throw error;
}
错误被静默吞掉
空的 catch 会让函数隐式返回 undefined,调用方却可能把它当作字符串继续处理。底层函数无法恢复时,最好让错误继续传播;只有"没搜到内容"这种正常业务分支才返回明确的空数组或提示语。
集合存在,但查询仍然失败
集合存在不等于已加载。检查查询前是否执行了:
javascript
await milvus.loadCollection({
collection_name: COLLECTION_NAME,
});
还要确认入库与查询使用相同的集合名、向量字段和相似度类型。若入库用余弦距离建索引,查询也应使用 MetricType.COSINE。
中文切片效果不理想
只使用分割器的通用默认分隔符,中文长段落可能最终在不自然的位置截断。应显式加入 。!?,; 等中文标点,并抽查实际片段:
javascript
const chunks = await splitter.splitText(chapter.pageContent);
console.log(chunks.slice(0, 3));
调试 RAG 时不要只看最终回答。依次检查原文是否正确加载、片段是否完整、检索结果是否相关、最后才检查 Prompt 和模型输出。
从教学示例走向可用系统
当前实现已经串通完整流程,但还有几个值得优先处理的限制。
控制并发和批量大小。 一章内用 Promise.all() 同时请求所有嵌入,章节特别长时可能触发限流。可以每次处理 10~50 个片段,失败时采用指数退避重试。
增加检索阈值。 Top K 搜索即使遇到完全无关的问题也可能返回结果。上线前应观察 score 分布,结合所用度量设置阈值;阈值必须用真实问题评估,不能照搬一个固定数字。
改善召回质量。 人名、招式名等精确词汇有时更适合关键词检索。数据规模扩大后,可以组合向量检索与全文检索,再用重排模型对候选片段排序。
记录可追溯来源。 除章节号外,还可以保存章节标题、EPUB 内部文件名和段落位置,让回答能够指出更准确的依据。
验证输入与内容安全。 服务化以后,应限制 EPUB 大小、支持的文件类型和单次问题长度。若用户能上传书籍,还需考虑版权、恶意文件及数据隔离,不能让不同用户的内容混入同一个无过滤集合。
总结
一个 EPUB 问答助手并不是"一次把书发给模型",而是两个阶段协作:先将章节切片、向量化并持久化,再将问题向量化、检索相关原文并交给聊天模型组织答案。
真正决定效果的往往不是最后那次模型调用,而是前面的数据工程细节:切片是否保留语义、入库与查询是否使用同一嵌入模型、向量维度是否一致、检索结果是否真的相关,以及错误有没有被完整传播。理解这条数据流后,把 EPUB 换成 Markdown、网页或产品文档,整体架构仍然成立。