用 Node.js 搭建 EPUB 问答助手:从文本切片、向量检索到 RAG

用 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,
  });
}

一条记录代表一个文本片段。除了 contentvector,还保存书名、章节号和片段序号,查询后便能知道内容来自哪里。

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 条真的足够相关。

程序从启动到回答经历了什么

把两次运行连起来看,完整流程如下:

  1. Node.js 加载 ingest.mjs 及其导入的模块,dotenv/config.env 写入 process.env
  2. 程序创建嵌入模型客户端和 Milvus 客户端,并连接 Milvus。
  3. ensureCollection() 检查集合;不存在时创建字段与余弦索引,随后加载集合。
  4. EPubLoader 读取 EPUB,await loader.load() 最终得到章节 Document 数组。
  5. 文本分割器读取每章的 pageContent,按段落、标点和长度拆成带重叠的片段。
  6. map 为每个片段发起嵌入请求,Promise.all 等待并收集所有向量。
  7. 程序将正文、章节元数据和向量组成记录,通过 upsert 写入 Milvus。
  8. 查询脚本从命令行取得问题,再用同一个嵌入模型生成问题向量。
  9. Milvus 使用 COSINE 度量搜索最接近的若干记录,返回分数、正文和指定的元数据字段。
  10. 程序把检索结果拼成参考上下文,再连同用户问题发送给聊天模型。
  11. chatModel.invoke() 返回 AI 消息对象;程序取出 response.content 并打印答案。
  12. 任一步骤的 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、网页或产品文档,整体架构仍然成立。

相关推荐
MomentYY1 小时前
RAG 建库:资料是怎么存进去的?
人工智能·agent·ai编程
Java编程爱好者1 小时前
AI Agent、架构决策记录与工程上下文治理:团队如何把隐性约束留在仓库里。
人工智能
自律懒人1 小时前
AI应用从原型到上线的最后一公里——灵光闪应用一键部署深度实测,30+免费API + Serverless零配置发布
人工智能·云原生·开源·serverless
大模型念念2 小时前
Codex:AI 编程助手的核心引擎
人工智能
AndrewHZ2 小时前
【LLM技术全景】多模态大模型:当语言模型学会“看“和“听“
人工智能·gpt·深度学习·语言模型·自然语言处理·llm·多模态
湘美书院--湘美谈教育2 小时前
湘美谈教育互联网逻辑:AI时代的社会学猜想
大数据·人工智能·深度学习·机器学习·生活
东方佑2 小时前
MA-RMSNorm:打破“缩放换智能”的魔咒,大模型归一化的一次范式革命!
数据库·人工智能·计算机视觉
KKKlucifer2 小时前
多源异构通信数据统一识别:运营商分类分级平台关键技术与落地成
人工智能·分类·数据挖掘
jinggongszh2 小时前
从长鑫科技的十年突围,看中国制造的“硬核”与“底座”
人工智能·科技·制造