先交代身份:我叫老朱,33 岁,写了 12 年前端为主的全栈(Vue/React/TS 为主),最近在做求职方向转换,主攻 AI 应用。手上这个企业知识库问答系统(RAG),前端是我写的,Node 服务端(Nuxt 的 Nitro server)也是我写的,整条链路从头手搓到尾。
外面讲 RAG 的文章一开口就是「检索增强生成」的三段论定义,我不重复这个。这篇只回答一个问题:这套系统是怎么跑通的。 全文按两条线走——「存」和「找」。每个环节末尾有一句话小结,链到我已经写好的深拆文章,想抠细节的点开看,不想抠的看完这条线也够你搭出同款。
定调:RAG 就两件事,存和找
一句话把 RAG 说尽:把公司文档喂给大模型之前,先检索出最相关的几段,让模型只根据资料回答。所以系统的上限在哪里?我的结论是:RAG 的上限在检索,不在大模型。 模型只负责把资料组织成人话,检索决定它能看到什么。资料给错了,再贵的模型也只是把错误说得更流畅。
顺带回答一个选型问题:为什么是 RAG 而不是微调?这是整个系列的前置判断,我在 「RAG 还是微调?大模型应用 3 种模式别选错」 里拆过——企业知识库这种「资料持续更新、答案必须可溯源」的场景,RAG 是默认答案,不在这里展开。
下面进入正题。
存这条线:让知识库有进有出
环节一:文档入库,本质是一套状态机
第一版我图省事,上传即入库,很快就后悔了——知识库变成垃圾堆,什么过期报价单、写了一半的文档都在里面被检索出来。现在的设计:
- 格式:支持 txt / docx / pdf / xlsx,上传后统一解析成纯文本;
- 元数据:文档表带完整生命周期字段——密级、访问范围、归属部门、版本号,改了哪版一清二楚;
- 状态机:草稿 → 入库 → 已发布 → 下架。只有 published 状态的文档块参与检索;发布后锁死,想改必须先下架,重新入库会覆盖旧向量。
这个状态机不值钱吗?太值钱。密级和访问范围让「哪个部门能问什么」有了落点,这是企业知识库和个人玩具的分水岭。
一句话小结:入库不是把文件塞进数据库,是给它上户口。 至于文档怎么切块、切完检索到小块要不要回查整篇,我另外写过 「RAG 父页面召回?这 3 种场景不值得做」,先把边界讲清楚,别盲目上父文档召回。
环节二:切块 + 向量化,三个坑替你们踩了
长文档切成小 chunk,用 bge-m3 转成向量,存进 Chroma 向量库(我库里从头到尾就一个 collection,没必要整花活)。提问时把问题也转向量,找距离最近的片段——这就是向量检索的全部原理,剩下的全是工程细节。
踩坑实录,三条都真金白银花时间:
- Chroma 查询的
include参数要传字符串数组,随手传个字符串就报错; - metadata 字段名统一用下划线,混用驼峰和下划线会让你查库时一脸懵;
- query 和入库必须用同一个 embedding 模型——换模型不是升级,是两个宇宙的对不上,换模型就得全量重灌,新旧向量绝不混库(同维度也不行)。
一句话小结:向量检索翻车,八成是入库和查询不同路。
找这条线:四步把答案捞上来
环节三:双路粗召——向量 + 手写 BM25
纯向量检索有个死穴:专有名词、缩写、产品型号会翻车,向量对这种精确匹配就是不准。所以关键词这一路必须有。
我没上 ES 这种现成轮子,BM25 引擎是自己手写的(这大概是最能体现「手搓」的一个环节):
- jieba 中文分词 + 倒排索引,核心参数 k1=1.5、b=0.75,没有引第三方 BM25 库;
- Nitro 服务启动时从数据库里 published 状态的 chunks 预热建索引,零冷启动,预热失败也不阻塞服务启动;
- 召回后回查数据库补全文,向量库只管「找到哪块」,不管「给出全文」。
检索时两路并行:向量和 BM25 各粗召 top-20,用 Promise.allSettled 包着——单路挂了,另一路自动兜底,服务不停摆。
一句话小结:混合检索 = 广撒网两路并行 + 一路挂掉另一路兜底。 四步怎么搭、每步参数怎么定,看 「RAG 检索总跑偏?混合检索四步直接抄」。
环节四:RRF 融合——扔掉分数,只认名次
两路都召回了,分数没法直接比:向量给的是余弦相似度,BM25 给的是另一套量纲,硬加权平均是玄学。
我的方案是 RRF(Reciprocal Rank Fusion),k=60:分数全部扔掉,只用名次。两路都认的片段自然浮到前面,融合后取 top-20。每一路的名次都记进日志,后面调试全靠它。
一句话小结:量纲不同别硬融合,名次是唯一通用货币。 k 值为什么取 60、融合细节,同样在 混合检索四步 里拆过。
环节五:Query 改写——用户不会提问,系统替他补全
真实用户的提问又短又含糊:「那个多少钱」「上次说的发货呢」。直接拿这种话去检索,神仙难救。
所以在粗召前面加了一步改写:规则 6 条、类型 6 种,输出 JSON,temperature 压到 0.1;两道成本短路(首条消息无历史直接跳过、启发式指代词判断),失败全部降级用原话。最关键的一条设计:改写只用于检索,生成时模型看到的还是用户原话——改写错了不污染答案。
一句话小结:改写是给检索器用的,不是给用户看的。 四步流程看 「RAG 怕问得含糊?Query 改写四步直接抄」,规则和类型清单看 「RAG 改写没头绪?6 规则配 6 类型直接抄」。
环节六:精排 + 生成——最后五段决定答案质量
粗召是广撒网,top-20 里良莠不齐。最后一步用 bge-reranker-v2-m3 做 cross-encoder 精排:把问题和每条候选成对过一遍模型,逐条打分重排,只取 top-5 注入上下文,交给 DeepSeek 流式输出(SSE,打字机效果)。rerank 服务不可用时自动降级:按 RRF 分或召回原始序截断——精度掉一点,服务不断。
生成侧的 system prompt 上了硬约束:只根据资料回答,找不到就说找不到,不许编。 配合前端把命中片段的名次、来源全部展示出来,答错了能追责到具体文档。
一句话小结:精排是唯一「问题+资料同场打分」的环节,值得为它留降级通道。
全链路可观测:效果差的时候,先知道差在哪
最后一环不是某个算法,是工程习惯:
- 混合 / 纯关键词 / 纯向量三种模式页面一键切换,同一个问答抽屉按 mode 切换,切模式时强制重建组件清掉历史答案,防止新旧混着看;
- 单独的关键词检索、向量检索调试接口,支持单路对比;还有个
retrieveOnly开关——只看检索结果,不触发生成; - 每一路的名次、得分全部打日志。
这套 observability 的回报巨大:用户反馈「答得烂」,我十分钟就能定位是检索没捞到、还是捞到了模型没用好。排查思路我写成了一篇 「RAG 效果差怎么调试?面试官要听的排查思路」,面试和干活都用得上。
说点效果,也说实话
链路参数都在明处:双路各 top-20、RRF 融合取 top-20、精排取 top-5、k=60、k1=1.5、b=0.75。但你要问我「准确率提升多少个百分点」——说实话没有脸说,因为我没建标准评估集,上线前后的评测数字我没记下来,这是这期的欠账,补上评测再写。我唯一敢负责的话是:加精排和改写之前,专有名词类问题经常答不上来;之后这类问题基本能稳定命中——这是体感,不是数据。
手搓一圈最大的收获不在算法,在心态:RAG 系统 80% 的工程问题,最后都归结为「数据进库时没管好,查询进来时没接住」。 状态机、日志、降级通道,这些最土的工程实践,恰恰是 AI 应用最缺的东西。
这是「老朱的 RAG 系列」总开篇,六个环节各有一篇深拆,链接都在文中。下一篇不在 RAG 系列里,但和这套系统的账单直接相关:四个 AI API 平台的价格/速度/稳定性实测对比——手搓很爽,token 很贵,怎么选不花冤枉钱,下篇见。