Node.js搭建RAG实战:拆解《天龙八部》小说存入Milvus全流程
文章目录前言第一步加载EPUB一行代码拆出168章第二步二次切分保住检索精度第三步向量入库核心四步走3.1 初始化Embedding模型3.2 设计集合Schema3.3 构建向量索引3.4 批量并行插入进阶玩法断点续传翻车也不怕最后说两句P.S. 无意间发现了一个巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看传送门https://blog.csdn.net/HHX_01前言学RAG这事真的太容易陷入“一看就会一写就废”的魔咒了。前阵子我刷了一堆理论文章什么向量检索、增强生成讲得头头是道真打开编辑器动手才发现坑比想象中多十倍。索性拿《天龙八部》电子书练手硬生生把168章小说拆成了3042个向量片段全塞进了Milvus总算是跑通了完整的数据入库流程。今天就给大伙拆解全程的操作还有我踩过的那些哭笑不得的坑正在入门RAG的Node.js开发者直接照着抄就行。第一步加载EPUB一行代码拆出168章搁以前要处理EPUB电子书那叫一个麻烦。本质就是个ZIP包得自己解压、提取HTML、清洗标签一套流程走下来眼睛都快花了跟给古籍做校对似的。现在不用这么费劲了LangChain的EPubLoader直接把这些活全干了一行代码就能按章节拆分好。import{EPubLoader}fromlangchain/community/document_loaders/fs/epub;constloadernewEPubLoader(./天龙八部.epub,{splitChapters:true,});constdocumentsawaitloader.load();console.log(加载完成, 共${documents.length}个章节);// 输出加载完成, 共 168 个章节开启splitChapters之后它会自动按小说的章节边界切分每个章节都是一个独立的Document对象自带章节号、标题这些元信息省心到不行。这里必须吐槽一个巨坑类名是EPubLoaderP是大写的我当初照着网上老教程抄写的是小写p的EpubLoader跑起来一直报找不到导出。我对着文件路径检查了八遍依赖也重装了两次折腾了半小时才发现就是一个字母大小写的问题。说出来都丢人主打一个千里之堤溃于蚁穴。当然按章节拆分只是第一步。一章动辄几千字直接拿去做embedding效果很差信息太杂检索精度会被稀释得没法看。所以还得再切一刀。第二步二次切分保住检索精度为啥非要把章节再切成小片段道理很简单embedding就像给文本拍“语义照片”一段文字里信息越多越杂特征就越模糊。你拿“段誉会什么武功”去搜一个三千字的章节里面连他喝茶吃饭、跟人聊天的内容都有相似度直接被无关内容拉低搜出来的结果经常八竿子打不着。所以得把每章再切成小片段让每块尽量聚焦一个独立的情节检索才能准。我用的是RecursiveCharacterTextSplitter核心思路就是递归切割先按段落切太长就按行切还长就按空格切直到每块都符合设定的长度。import{RecursiveCharacterTextSplitter}fromlangchain/textsplitters;consttextSplitternewRecursiveCharacterTextSplitter({separator:\n,chunkSize:500,chunkOverlap:50,});小说类的文本按换行切最自然所以我把分隔符设成了换行。每块500字符大概就是一个完整的小情节不会太长也不会太碎。这里重点说下chunkOverlap也就是相邻块的重叠字数这东西真的不能省。你想啊要是刚好把一句关键台词切在两块中间比如“段誉使出凌波微步”前一块末尾是“段誉使出凌波”后一块开头是“微步身形飘忽”。那你搜“凌波微步”匹配不到前一块搜“段誉使出”匹配不到后一块关键信息直接就丢了。就跟看电视剧刚好被广告掐在关键剧情一样不上不下的难受死了。留50字的重叠就能完美避开这个问题。第三步向量入库核心四步走切完片段就到最核心的一步了把文本转成向量存进Milvus。拆成四个环节给大伙讲每个环节都有坑。3.1 初始化Embedding模型我用的是千问的embedding模型兼容OpenAI的接口格式所以直接用LangChain的OpenAIEmbeddings就行以后换模型厂商也不用大改代码。import{OpenAIEmbeddings}fromlangchain/openai;constembeddingsnewOpenAIEmbeddings({apiKey:process.env.VITE_QWEN_API_KEY,model:text-embedding-v3,configuration:{baseURL:https://dashscope.aliyuncs.com/compatible-mode/v1,},dimensions:1024,});说个我踩过的低级坑环境变量名一定要对上我一开始代码里写的是VITE_QWEN_BASE_URL结果.env文件里写的是VITE_QWEN_API_URL拿了个undefined就去发请求直接超时。我一开始还以为是网络炸了换了好几个代理都不行最后定睛一看变量名差点给自己一巴掌。这种低级错误说出来都显得我从业年限注水了。3.2 设计集合SchemaMilvus的集合就相当于关系型数据库的表得提前定义好字段结构。我设计的字段如下亲测实用。awaitclient.createCollection({collection_name:ebook2,fields:[{name:id,data_type:DataType.VarChar,max_length:100,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:index,data_type:DataType.Int32},{name:content,data_type:DataType.VarChar,max_length:10000},{name:vector,data_type:DataType.FloatVector,dim:1024},],});这里划个重点content字段一定要加我见过不少新手踩这个坑吭哧吭哧把向量全存进去了一检索返回一堆浮点数人直接傻了。大哥RAG的“R”是检索检索出来的原文是要喂给大模型的你只存向量不存原文跟去图书馆借书只借了个目录似的有啥用啊主键我用的是“书ID_章节号_片段序号”的格式天然唯一后面做断点续传也方便。3.3 构建向量索引向量数据量上来之后不建索引的话每次搜索都全量比对慢得像蜗牛爬。我用的是IVF_FLAT索引十万条以内的数据量用它最合适。awaitclient.createIndex({collection_name:ebook2,field_name:vector,index_type:IVF_FLAT,metric_type:COSINE,params:{nlist:1024},});相似度用余弦相似度文本语义搜索的标配。nlist设成1024是按经验公式4×√N算的三千多条数据刚好合适。3.4 批量并行插入插入数据的时候千万别串行调用embedding接口不然速度慢到你怀疑人生。同一章里的片段之间没有依赖关系直接用Promise.all并行调用接口一章几十条片段只需要等最慢的那一个返回就行效率直接翻好几倍。asyncfunctioninsertChunksBatch(chunks,bookId,chapterNum){constinsertDataawaitPromise.all(chunks.map(async(chunk,chunkIndex){constvectorawaitgetEmbedding(chunk);return{id:${bookId}_${chapterNum}_${chunkIndex},book_id:bookId,book_name:BOOK_NAME,chapter_num:chapterNum,index:chunkIndex,content:chunk,vector:vector,};}));constresultawaitclient.insert({collection_name:ebook2,data:insertData,});returnNumber(result.insert_cnt)||0;}别小看这点优化168章跑下来能省出一整集电视剧的时间。进阶玩法断点续传翻车也不怕跑大批量数据最怕啥怕中途翻车啊。要么token用完了要么网络断了要么电脑突然蓝屏要是没做断点续传前面跑的全白费又得从头再来。embedding API可不是免费的重跑一遍那都是真金白银的损失心疼都来不及。实现起来其实很简单每次跑之前先去数据库里查一下哪些章节已经入库了存到一个Set里循环的时候直接跳过就行。asyncfunctiongetProcessedChapters(bookId){constresultawaitclient.query({collection_name:ebook2,filter:book_id ${bookId},output_fields:[chapter_num],limit:100000,});constchaptersresult.data.map(rr.chapter_num);returnnewSet(chapters);}然后在循环里加个判断constprocessedChaptersawaitgetProcessedChapters(bookId);for(leti0;idocuments.length;i){constchapterNumi1;if(processedChapters.has(chapterNum)){console.log(第${chapterNum}章已处理跳过);continue;}// 正常处理流程}用Set存已处理的章节has查询是O(1)的一百多章毫秒级就能判断完。不管你中途断多少次重跑都从断点继续绝不重复干活。最后说两句整套流程跑下来168章全部入库一共生成了3042个向量片段。现在整本《天龙八部》的“数字记忆”已经躺在向量数据库里了。今天讲的是数据入库的部分下一篇我会接着讲怎么用向量搜索找相关片段再让大模型基于原文生成答案让AI真的能“读懂”《天龙八部》到时候想怎么提问都行。完整的项目代码已经整理好了clone下来改个配置就能直接跑新手也能快速复现。P.S. 无意间发现了一个巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看传送门https://blog.csdn.net/HHX_01