用 Node.js 搭建 EPUB 问答助手:从文本切片、向量检索到 RAG
摘要:本文以 Node.js 为主线,完整走通一个 EPUB 问答助手的搭建流程:先解析 EPUB 电子书并抽取纯文本,再按语义边界做文本切片,接着用向量化模型建立本地向量索引,最后通过 RAG(检索增强生成)把检索到的片段拼进提示词,交给大模型生成有依据的回答。文中给出可直接运行的代码示例,并说明分块策略、召回质量与成本之间的取舍。
1. 为什么选择 Node.js 做 EPUB 问答助手
EPUB 本质上是一个 ZIP 压缩包,内部由 XHTML 章节、OPF 清单和 NCX/NAV 目录组成。Node.js 生态里有成熟的流式解压、XML 解析和 HTTP 客户端库,配合 JavaScript 的异步模型,处理几十兆的电子书和并发调用向量接口都比较顺手。
更重要的是,RAG 的整条链路——解析、切片、向量化、检索、生成——都可以用同一门语言串起来,不需要在 Python 和 Node.js 之间来回切换,部署时也只需要一个 Node 运行时。
本文的实践目标如下:
- 可运行:每个环节都给出可复制的代码,能跑通一本真实 EPUB。
- 可替换:向量模型和对话模型都通过统一接口封装,方便换成其他服务。
- 可解释:回答时附带引用来源章节,便于读者核对原文。
2. 环境准备与依赖选型
先初始化项目并安装依赖。下面这套组合覆盖了解压、解析、向量化和命令行交互:
mkdir epub-rag && cd epub-rag
npm init -y
npm install adm-zip cheerio fast-xml-parser
npm install @xenova/transformers
npm install dotenv
各依赖的职责说明:
| 依赖 | 用途 |
|---|---|
| adm-zip | 读取 EPUB 的 ZIP 结构,按条目取出 XHTML 文件 |
| cheerio | 以类 jQuery 语法解析 XHTML,抽取正文文本 |
| fast-xml-parser | 解析 container.xml 与 OPF 清单,确定阅读顺序 |
| @xenova/transformers | 在本地运行向量化模型,无需外部 API |
| dotenv | 管理对话模型的 API Key 等配置 |
如果希望减少本地计算压力,也可以把向量化换成远程 Embedding 接口,后文的封装方式保持不变。
3. 解析 EPUB:从压缩包到纯文本
EPUB 的入口是 META-INF/container.xml,它指向 OPF 文件;OPF 里的 manifest 列出所有资源,spine 定义阅读顺序。按这个顺序读取 XHTML,才能保证章节内容不乱序。
import AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import * as cheerio from 'cheerio';
import path from 'node:path';
const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
export function extractChapters(epubPath) {
const zip = new AdmZip(epubPath);
const containerXml = zip.readAsText('META-INF/container.xml');
const container = xmlParser.parse(containerXml);
const opfPath = container.container.rootfiles.rootfile['@_full-path'];
const opfXml = zip.readAsText(opfPath);
const opf = xmlParser.parse(opfXml);
const baseDir = path.posix.dirname(opfPath);
const manifest = {};
const items = opf.package.manifest.item;
for (const item of Array.isArray(items) ? items : [items]) {
manifest[item['@_id']] = item['@_href'];
}
const spineItems = opf.package.spine.itemref;
const order = Array.isArray(spineItems) ? spineItems : [spineItems];
const chapters = [];
for (const ref of order) {
const href = manifest[ref['@_idref']];
if (!href) continue;
const fullPath = path.posix.join(baseDir, href);
const html = zip.readAsText(fullPath);
const $ = cheerio.load(html);
$('script, style').remove();
const title = $('h1, h2, title').first().text().trim() || href;
const text = $('body').text().replace(/\s+/g, ' ').trim();
if (text.length > 0) {
chapters.push({ title, text, href });
}
}
return chapters;
}
这段代码返回的 chapters 数组保留了章节标题和正文,后续切片时可以把标题作为上下文前缀,显著提升检索命中率。
4. 文本切片:决定检索质量的关键一步
切片过粗,检索到的片段包含大量无关内容,模型容易被干扰;切片过细,单块语义不完整,回答会缺少上下文。实践中常用的折中方案是:按段落聚合,控制单块长度在 300 到 600 字之间,并保留一定重叠。
export function splitIntoChunks(chapters, { maxLen = 500, overlap = 80 } = {}) {
const chunks = [];
for (const chapter of chapters) {
const paragraphs = chapter.text.split(/(?<。|!|?|\.)\s*/).filter(Boolean);
let buffer = '';
for (const para of paragraphs) {
if ((buffer + para).length > maxLen && buffer.length > 0) {
chunks.push({
title: chapter.title,
text: buffer.trim(),
source: chapter.href,
});
buffer = buffer.slice(-overlap) + para;
} else {
buffer += para;
}
}
if (buffer.trim().length > 0) {
chunks.push({ title: chapter.title, text: buffer.trim(), source: chapter.href });
}
}
return chunks;
}
几个值得注意的细节:
- 按句子边界切分:避免把一句话从中间截断,语义更完整。
- 保留重叠区:相邻块共享尾部内容,减少跨块信息丢失。
- 携带章节标题:向量化时把标题拼进文本,能帮助模型区分不同主题。
5. 向量化与本地索引
向量化把文本映射成高维向量,语义相近的文本在向量空间中距离更近。下面用 @xenova/transformers 在本地加载一个轻量 Embedding 模型,并把结果持久化为 JSON 文件,避免每次启动重复计算。
import { pipeline } from '@xenova/transformers';
import fs from 'node:fs/promises';
let embedder;
export async function getEmbedder() {
if (!embedder) {
embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
}
return embedder;
}
export async function embedText(text) {
const model = await getEmbedder();
const output = await model(text, { pooling: 'mean', normalize: true });
return Array.from(output.data);
}
export async function buildIndex(chunks, indexPath = 'index.json') {
const records = [];
for (const chunk of chunks) {
const vector = await embedText(`${chunk.title}\n${chunk.text}`);
records.push({ ...chunk, vector });
}
await fs.writeFile(indexPath, JSON.stringify(records));
return records;
}
索引文件里同时保存原文和向量,检索时直接计算余弦相似度即可。对于几千个块规模的书,暴力遍历完全够用;如果块数上万,可以再引入近似最近邻库。
6. 检索:从问题到相关片段
检索阶段把用户问题向量化,与索引中的每个向量计算相似度,取 Top-K 作为上下文。余弦相似度在向量已归一化时等价于点积,实现非常简洁。
function cosine(a, b) {
let dot = 0;
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
return dot;
}
export async function retrieve(question, records, topK = 4) {
const qVec = await embedText(question);
return records
.map((r) => ({ ...r, score: cosine(qVec, r.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
实际使用中建议加一个相似度阈值,过滤掉明显不相关的片段。否则当书中没有相关内容时,模型仍会被强行塞入无关上下文,容易产生幻觉。
7. 组装 RAG 提示词并生成回答
RAG 的核心是把检索结果作为“参考资料”放进提示词,并明确要求模型只依据资料回答。下面是一个通用的提示词模板:
export function buildPrompt(question, contexts) {
const contextText = contexts
.map((c, i) => `[片段${i + 1}] 章节:${c.title}\n${c.text}`)
.join('\n\n');
return `你是一个基于电子书内容的问答助手。
请仅依据下面的参考资料回答问题;如果资料中没有答案,请直接说明“书中未找到相关内容”,不要编造。
参考资料:
${contextText}
问题:${question}
回答:`;
}
调用对话模型时,把 buildPrompt 的结果作为用户消息发送即可。为了让回答可追溯,可以在返回结果中附上引用片段的章节标题:
export async function ask(question, records) {
const contexts = await retrieve(question, records);
if (contexts.length === 0 || contexts[0].score < 0.3) {
return { answer: '书中未找到相关内容。', sources: [] };
}
const prompt = buildPrompt(question, contexts);
const answer = await callChatModel(prompt);
return {
answer,
sources: contexts.map((c) => ({ title: c.title, source: c.source })),
};
}
其中 callChatModel 按所选服务商的 SDK 实现即可,保持输入输出为字符串,便于替换。
8. 串起完整流程
把前面的模块组合成一个入口脚本,完成“解析—切片—建索引—问答”的闭环:
import { extractChapters } from './epub.js';
import { splitIntoChunks } from './chunk.js';
import { buildIndex } from './indexer.js';
import { ask } from './rag.js';
const chapters = extractChapters('./book.epub');
const chunks = splitIntoChunks(chapters);
console.log(`共解析 ${chapters.length} 章,切分为 ${chunks.length} 块`);
const records = await buildIndex(chunks);
const result = await ask('这本书主要讲了什么?', records);
console.log(result.answer);
console.log('引用来源:', result.sources);
首次运行会下载 Embedding 模型并生成索引文件,之后可以直接读取 index.json 跳过建索引步骤。
9. 效果优化与常见问题
跑通之后,真正影响体验的是细节。下面列出几个高频问题与对应做法:
- 回答不准确:优先检查切片粒度,尝试把
maxLen调小或增大topK,观察召回片段是否切题。 - 回答出现幻觉:在提示词中强化“仅依据资料”的约束,并加入相似度阈值,资料不足时明确拒答。
- 中文效果一般:
all-MiniLM-L6-v2以英文为主,中文场景建议换成多语言模型,如Xenova/paraphrase-multilingual-MiniLM-L12-v2。 - 建索引太慢:向量化是串行执行的,可以按批并发处理,或把索引结果缓存到磁盘复用。
- 长书内存占用高:索引文件较大时可改用向量数据库,按需加载而非全量读入内存。
经验上,切片策略对最终效果的影响往往大于更换模型。先把分块和提示词调好,再考虑升级向量模型或引入重排序。
10. 总结
本文用 Node.js 串起了一个 EPUB 问答助手的完整链路:用 adm-zip 与 cheerio 解析电子书,按句子边界做带重叠的文本切片,用本地 Embedding 模型建立向量索引,通过余弦相似度召回相关片段,最后把片段拼进提示词交给大模型生成有依据的回答。
整条链路没有黑盒,每个环节都可以单独替换和调优。下一步可以尝试的方向包括:引入 BM25 与向量检索的混合召回、增加重排序模型、把索引迁移到向量数据库,以及为回答补充更精确的页码或段落定位。
更多推荐



所有评论(0)