摘要:本文以 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 与向量检索的混合召回、增加重排序模型、把索引迁移到向量数据库,以及为回答补充更精确的页码或段落定位。

Logo

更多推荐