把 EPUB 做成可检索问答:一套最小 RAG 实战链路

把 EPUB 做成可检索问答:一套最小 RAG 实战链路
把 EPUB 做成可检索问答一套最小 RAG 实战链路本文基于一个本地练习项目的静态代码阅读整理技术栈为 Node.js、LangChain、Milvus/Zilliz 与 OpenAI 兼容接口。运行未验证。很多人第一次做 RAG 时会把重点放在“调用大模型”。但真正决定回答是否能依据资料的是前半段文档能否切对、向量能否写对、检索结果能否正确带回原文。本文用一个《天龙八部》EPUB 问答项目串起完整流程EPUB 入库、向量检索、RAG 生成并整理几个最容易卡住的接口细节。文章目录把 EPUB 做成可检索问答一套最小 RAG 实战链路先看全链路RAG 到底在做什么第一步从 EPUB 到文本块一个高频坑splitText() 返回什么第二步设计 Milvus 集合第三步写入数据后为什么还要 flush第四步把问题转成向量并检索第二个高频坑SDK 返回字段名不能想当然第五步将检索结果交给 Chat 模型从这次项目中提炼的排错表最小自检清单结尾先把三条链路拆开先看全链路RAG 到底在做什么这个项目并没有训练一个“读过小说”的模型而是将小说内容做成向量知识库。用户提问时系统先找相关片段再把片段交给聊天模型组织答案EPUB → 按章节加载 → 按文本块切分 → 文本转 Embedding 向量 → 写入 Milvus 用户问题 → 问题转向量 → Milvus 检索相似片段 → 片段拼入 Prompt → Chat 模型回答这里的关键判断是Embedding 模型负责“找资料”聊天模型负责“基于资料回答”。两者不是同一个职责。第一步从 EPUB 到文本块项目的入库入口位于main.mjs它使用EPubLoader并配置splitChapters: true先将电子书拆成多个章节文档。之后再用RecursiveCharacterTextSplitter做二次切分consttextSplitternewRecursiveCharacterTextSplitter({chunkSize:500,chunkOverlap:50,})constchunksawaittextSplitter.splitText(chapterContent)这里有两个参数需要理解参数当前值作用chunkSize500单个文本块的目标字符数决定检索粒度chunkOverlap50相邻块重复的字符数降低切断上下文的影响为什么不把整章直接入库因为一章通常很长向量会概括过多内容检索命中后也会带来大量无关文字。切成 chunk 后检索结果更精确传给模型的上下文也更可控。一个高频坑splitText()返回什么splitText()返回的是字符串数组[第一段文本,第二段文本]因此入库时应直接使用chunkcontent:chunk不能写成content:chunk.pageContentpageContent是 LangChainDocument对象上常见的属性而这里的chunk已经是普通字符串。把字符串当 Document 读取会得到undefined最终会让保存的原文内容不正确。第二步设计 Milvus 集合项目创建了名为ebook的集合保存文本、章节号和向量{name:id,data_type:DataType.VarChar,is_primary_key:true}{name:chapter_num,data_type:DataType.Int32}{name:content,data_type:DataType.VarChar}{name:vector,data_type:DataType.FloatVector,dim:1024}可以把它理解成一张“带语义坐标的表”content用于回答的原始文本chapter_num帮助定位片段来自哪一章vector用于相似度计算id唯一标识每一个 chunk。项目使用IVF_FLAT索引和COSINE度量awaitclient.createIndex({collection_name:COLLECTION_NAME,field_name:vector,index_type:IndexType.IVF_FLAT,metric_type:MetricType.COSINE,params:{nlist:1024},})对于这个练习先记住即可COSINE用来比较两个向量在“方向”上是否相近分数越高通常表示语义越接近。索引和度量类型的选择会影响检索效率与结果但第一版项目应先保证“维度一致、可写入、可查询”。第三步写入数据后为什么还要 flush每个 chunk 会先调用 Embedding 接口生成向量再通过client.insert()写入集合。全部章节处理结束后项目调用awaitclient.flush({collection_names:[COLLECTION_NAME],})flush()不是“插入数据”而是让已提交的数据刷新到可稳定查询的状态。它适合放在全部批量插入完成后不建议每个 chunk 或每章都调用否则会产生不必要的频繁刷新。第四步把问题转成向量并检索查询脚本的任务很单一把“段誉会什么武功”转成向量然后在ebook集合中找最相近的 3 段文本。constqueryVectorawaitgetEmbedding(query)constsearchResultawaitclient.search({collection_name:COLLECTION_NAME,vector:queryVector,limit:3,metric_type:MetricType.COSINE,output_fields:[chapter_num,content],})随后读取searchResult.results它代表检索结果数组。每个元素包含相似度分数和请求返回的字段。第二个高频坑SDK 返回字段名不能想当然项目调试中出现过searchResult.result.forEach(...)报错的根因是result为undefined。实际应读取results。同理输出结果时也不应默认字段被嵌套在item.fields应先打印一次item以实际 SDK 响应结构为准。推荐的排错方式console.log(searchResult.results)先确认返回对象再决定用item.content、item.chapter_num还是其他层级。不要仅凭其他语言 SDK 或旧教程猜字段名。第五步将检索结果交给 Chat 模型RAG 脚本将检索与回答拆为两个函数retrieveRlevantContent(question, k) → 返回相关片段 answerEbookQuestion(question, k) → 拼接片段为 context → 调用 ChatOpenAI → 返回回答上下文拼接的核心是constcontextretrievedContent.map((item,index){return片段${index1}章节${item.chapter_num}内容${item.content}}).join(\n\n------\n\n)再将context和用户问题同时传入 Prompt。这样模型不是只看到“鸠摩智会什么武功”而是还看到了检索出的小说片段回答会更有资料依据。从这次项目中提炼的排错表表象根因应检查什么日志显示插入 0 条可能读错 SDK 返回字段如inserted_count与实际字段不一致打印完整insertResult入库内容为空或undefined把splitText()的字符串当成 Document使用content: chunk第一章就无法入库Embedding 模型名不受当前 OpenAI 兼容接口支持检查供应商支持的模型名与账户权限forEach报错对searchResult读取了不存在的result打印响应确认是否为results搜到了结果但打印undefined假设有item.fields嵌套对象先打印item按真实字段读取RAG 启动时报OpenAI is not defined使用了未导入的类使用并导入ChatOpenAI最小自检清单在扩展功能前按下面顺序逐条确认Embedding 输出向量长度与 MilvusFloatVector.dim相同每个 chunk 的content是实际字符串不是undefinedinsert()后记录一次完整返回值确认计数属性批量写入完成后执行一次flush()search()结果先整体打印一次再读取字段检索为空时RAG 不继续访问retrievedContent.length之外的内容聊天模型与 Embedding 模型分别配置并分别验证。结尾先把三条链路拆开一个 RAG 项目最容易出现“看似都是模型问题”的错觉。实际上它至少包含三条需要独立验证的链路EPUB → chunk → 向量库写入问题 → 向量 → 相似文本检索检索文本 → Prompt → 聊天模型回答。先让每条链路都有可观察的输入和输出再把它们组合起来排错效率会高很多。下一步可以为检索加入book_id等元数据过滤并把固定问题改为命令行输入形成一个可重复演示的最小 RAG Demo。