前端 RAG 工程化:混合检索 + 重排序 + 多轮,把“能跑“调成“能用“
前端 RAG 工程化混合检索 重排序 多轮把能跑调成能用第一篇把最小 RAG 链路跑通、第二篇把单路向量检索的坑踩透。这一篇是真刀真枪的续集一个数据治理 AI 助手怎么从能跑调到能用。全程还是 Node.js 原生fetch但这次的主角是三块工程化拼图——混合检索、规则重排序、多轮记忆。 本文目标读完你应该能讲清楚三件前两篇没讲过的事为什么纯向量检索对amount这种字段名会失灵、RRF凭什么比加权融合好重排序之后拒答阈值为什么必须用余弦分而不是重排分RAG 多轮的真难点为什么不是存历史而是指代消解。前两篇讲了什么这篇补什么这个系列到这里是第三篇了三篇层层递进第一篇《前端也能搞懂 RAG用 JS 手写一条最小检索增强链路》—— 手写最小链路embedding、余弦相似度、迷你向量库、拒答兜底。解决RAG 是什么、链路长什么样。第二篇《前端手写 RAG 踩坑实录》—— 单路向量检索接上真实文档后的四个坑切太碎、切太大、连接被重置、高分≠能回答。本篇—— 前两篇的检索只有向量一条腿且都是单轮问答。做一个真实可用的元数据治理助手这两点都不够本篇讲怎么补上。前两篇的基础概念RAG 原理、余弦相似度、分块策略本篇不再重复涉及处直接给出链接。 本篇的场景在有一定数据规模的团队里字段口径散落在建表注释、需求文档和同事的记忆里。写 SQL 取数、做报表时一个高频阻塞是搞不清口径amount含不含税order_date记的是下单时间还是支付时间口径不清算出来的数就是错的。本篇要做的是把**数据字典表结构 字段口径**做成一个 RAG 助手让需要用数的人直接问。文中所有设计都由这个场景推导而来。 第 1 章元数据的分块——一张表要能整表查也能单字段查 本章目标理解结构化元数据这种数据分块矛盾和普通文档不一样在哪。第二篇讲过一个大原则切块要顺着文档的语义边界切块质量是检索的天花板不记得的看踩坑实录·坑1。那篇的对象是 markdown 技术文档边界是标题层级。但元数据不是文章它的边界是表和字段的结构层级而且有个普通文档没有的矛盾同一份数据要同时支持两种粒度的提问。用户问法需要的检索粒度“订单表里都有哪些字段”表级——要召回整张表的概览“amount是什么意思、含不含税”字段级——要精准召回单个字段的口径如果只按表切成一大块问单个字段时这一大块里相关的只有一行相似度被整块稀释正是踩坑实录·坑2 的切太大如果只按字段切碎问整张表有啥时又拼不出全貌。本文所有例子都基于同一张订单表orders。先把它的结构摆出来后面的代码和检索示例都以它为输入{name:orders,comment:订单主表,fields:[{name:order_id,type:bigint,comment:订单号主键},{name:user_id,type:bigint,comment:下单用户 ID},{name:amount,type:decimal(10,2),comment:实付金额含税、已减优惠},{name:original_amount,type:decimal(10,2),comment:原价金额优惠前},{name:payment,type:varchar(20),comment:支付方式wechat / alipay / card},{name:order_date,type:datetime,comment:下单时间非支付时间},{name:status,type:varchar(20),comment:订单状态paid / refunded / closed}]}解法是双粒度分块同一份表结构既生成一个表级 chunk表名 所有字段清单又给每个字段生成一个字段级 chunk字段名 类型 口径说明。两种块都进库让检索自己按问题匹配到合适的粒度。// chunk.js —— 一张表拆成「1 个表级块 N 个字段级块」functionchunkTable(table){constchunks[]// 表级块回答这张表有哪些字段chunks.push({text:表${table.name}${table.comment}包含字段${table.fields.map(ff.name).join(、)},metadata:{type:table,table:table.name},})// 字段级块回答某个字段是什么意思for(constfoftable.fields){chunks.push({text:字段${f.name}${f.type}${f.comment},metadata:{type:field,table:table.name,field:f.name},})}returnchunks}注意每个 chunk 都带了metadatatype/table/field。这几个结构化字段现在看只是顺手写的但它们是第 4 章规则重排序的全部弹药——先埋在这。 承上启下块切好了、进了向量库。但接下来第一个真问题就来了用户问amount向量检索居然把original_amount、payment也排在前面——对字段名这种专有名词纯向量为什么会犯迷糊 第 2 章混合检索——给向量补一条精确匹配的腿 本章目标搞懂纯向量对专有名词为什么弱以及 BM25 RRF 怎么补上这条腿。现象问amount它把original_amount也端上来向量检索的长处是懂语义但这恰恰是它对专有名词的短处。用户问amount字段向量觉得original_amount、payment、total_price语义上都挺近一股脑排进来。可用户要的就是那个叫amount的字段一个字都不能差。向量比的是意思像不像但字段名要的是字对不对。这两件事得用两种检索。补一条腿BM25 数关键词BM25 是搜索引擎用了几十年的经典算法干的正是向量的反面按关键词精确匹配。amount这种字段名它一抓一个准。它内部有个打分公式词越稀有、命中越多分越高但那不是这篇的重点——公式背了就会面试也很少细抠。真正决定能不能用的是它的第一步分词把一句话切成一个个词再去匹配。而分词这一步藏着一个为字段名特意设计的细节。先看最常见的普通分词怎么写——按非字母数字的字符切开// 普通分词遇到字母数字以外的字符就切开查 order_date 字段.toLowerCase().match(/[a-z0-9]/g)// 结果[order, date] ❌ order_date 从下划线处被劈成了两个词问题就在下划线_既不是字母也不是数字普通规则把它当成了分隔符于是order_date被切成orderdate。这样一来用户搜完整的order_date库里已经没有这个词了BM25 永远匹配不到。修法很简单把_也塞进算作词的一部分的字符集里即[a-z0-9_]// bm25.js —— 关键词精确匹配专治向量搞不定的专有名词tokenize(text){// [a-z0-9_] 把下划线也算进词里 → order_date、user_id 保持完整的一个词// 后面的 [一-龥] 是为了兼容中文一个汉字算一个词returntext.toLowerCase().match(/[a-z0-9_]|[一-龥]/g)??[]// 现在 查 order_date 字段 → [查, order_date, 字段] ✅} 这一行很值钱差别只在正则里多了一个_效果却是天壤之别用户搜order_date到底能不能命中全系于此。这条规则是看懂了自己的数据里全是snake_case字段名才写得出来的——面试讲这个细节比背 BM25 公式打动人得多。两路怎么合为什么用 RRF不用加权相加现在有两路结果向量的分是0~1就是第一篇那个余弦相似度——夹角余弦严格来说是[-1, 1]但文本 embedding 几乎不会出现方向相反的负值工程上就当0~1用BM25 的分是0~十几。两把尺子的刻度根本不一样一个满分是 1一个能到十几你不能直接0.5×向量 0.5×BM25——这就像拿1 米加1 公斤数值上 BM25 天然大一截一相加就把向量那点分压得没影了。那先归一化再加权也不行——归一化对异常值极敏感一条特别高的 BM25 分会把其他全压扁。RRFReciprocal Rank Fusion倒数排名融合干脆不看分数、只看排名RRF_score(d) Σ 1 / (k rank(d)) k 取 60业界惯例一条结果在向量里排第 1、在 BM25 里排第 3它的融合分就是1/(601) 1/(603)。两路都靠前的结果最终分最高——这正是我们要的双保险。RRF 不需要归一化、不需要调参是目前最常用的融合法。// hybrid-search.js —— RRF 融合只看排名位置不看原始分数constRRF_K60functionrrfFuse(vectorHits,bm25Hits){constmapnewMap()// key textfor(const[tag,hits]of[[vector,vectorHits],[bm25,bm25Hits]]){hits.forEach((r,i){constranki1constcurmap.get(r.text)??{text:r.text,metadata:r.metadata,rrfScore:0,vectorScore:undefined,}cur.rrfScore1/(RRF_Krank)// 累加两路的倒数排名if(tagvector)cur.vectorScorer.score// ★ 单独把余弦分留住map.set(r.text,cur)})}return[...map.values()].sort((a,b)b.rrfScore-a.rrfScore)}注意那行加 ★ 的融合时我特意把向量的余弦分vectorScore单独存了下来。为什么要留它、留着干嘛用——这是第 3 章整章的伏笔先记住这里埋了一手。 承上启下混合检索粗召回了 Top-20两条腿都照顾到了。但粗召回里排序还不够精——明明字段名精确命中的可能没排到最前。我们需要再精排一次而且这次要动用第 1 章埋下的metadata。️ 第 3 章规则重排序——用 metadata 精排为什么先不上模型 本章目标理解重排序在干嘛以及先用规则、不上模型是一个怎样的工程判断。混合检索给了 20 条候选但它们的排序还只是排名融合的结果。真正最该看的那条——比如字段名和用户问的一字不差的那条——应该被顶到最前。这一步叫重排序rerank粗召回 Top-20 → 精排 Top-5。一提 rerank很多人第一反应是上 Cross-Encoder 模型。但我这个场景规则就够了而且更好——因为第 1 章我给每个 chunk 都写了metadatatype/table/field这些结构化信息本身就是最硬的排序信号// rerank.js —— 用 metadata 的结构化信息给候选加分每条规则返回 0~1再乘权重constRULES[{weight:0.5,// 字段名精确命中权重最高apply:(query,c)c.metadata.fieldquery.toLowerCase().includes(c.metadata.field)?1:0,},{weight:0.3,// 表名命中apply:(query,c)c.metadata.tablequery.includes(c.metadata.table)?1:0,},{weight:0.1,// 保留一点混合检索本身的排名信号兜底apply:(query,c)Math.min(1,(c.rrfScore??0)*30),},// …还可以按 type、问法意图继续加规则]functionrerank(query,candidates,topK5){returncandidates.map(c({...c,rerankScore:RULES.reduce((s,r)sr.weight*r.apply(query,c),0),})).sort((a,b)b.rerankScore-a.rerankScore).slice(0,topK)}为什么这个场景先用规则、不上模型三个理由全是工程判断规则的信号本就存在。字段名、表名是结构化的精确命中就该排前面是确定性事实用规则一句话就表达了模型反而要学这件它本该确定的事。零成本、可解释、可调权重。规则不额外调一次模型省延迟省钱每条候选为什么排这个位置一目了然权重不满意随手调。Cross-Encoder 是后手不是起手。等规则真的覆盖不了比如复杂自然语言问法再上模型精排也不迟。按需求选复杂度不是越复杂越显水平——这条判断会贯穿到本文结尾。注意最后那条权重 0.1 的规则万一某条候选一个结构化规则都没命中模糊问法还能靠混合检索本身的排名兜底不至于乱排。 承上启下现在排序很准了。但一个新问题冒出来了而且是本文最容易踩、最少人讲的坑——我手上现在有三种分数余弦分、RRF 分、重排分当用户问一个库里根本没有的问题、需要拒答时我该拿哪个分去和阈值比⚖️ 第 4 章重排分 ≠ 余弦分——拒答阈值必须用余弦全文最硬的一节 本章目标理解引入混合检索和重排序后拒答判断为什么会悄悄失灵以及怎么修。坑三种分数混在一起阈值不知道该信谁第一篇讲过拒答问一个库里没有的问题所有段落相似度都低、被阈值滤空助手就老实说无法回答不记得的看原理篇·第7章。那时候只有一种分——余弦相似度阈值0.6是拿它校准的。可现在经过混合检索和重排序一条结果身上挂了三种分分数量纲能不能当拒答阈值余弦相似度vectorScore0~1被校准过✅ 只有它能RRF 分rrfScore1/61这种极小的数❌ 跟 0.6 没可比性重排分rerankScore规则加权可能 0.7❌ 那是排序分不是相关度最坑的是重排分重排序后排第一的那条rerankScore可能是 0.7看着比阈值 0.6 高好像有相关内容。但那 0.7 是字段名命中规则给的排序分跟这条内容到底和问题相不相关是两码事。如果拿它当阈值一个库里根本没有的问题也会因为某条规则碰巧命中而看起来够格于是助手不拒答、开始硬编——第一篇辛苦建立的拒答能力就这么被悄悄废掉了。修让余弦分一路幸存到拒答判断这就是第 2 章那行 ★ 的用意——融合时死死把vectorScore单独带着一路传到主流程。拒答判断只认它// metadata-qa-hybrid.js —— 主流程先判要不要答再排怎么答constSIMILARITY_THRESHOLD0.6// 和基础版一致这是【余弦】阈值asyncfunctionanswerWithHybrid(searcher,question){// 1. 混合检索粗召回 20 条候选每条都带着 vectorScoreconstcandidatesawaitsearcher.search(question,20)// 2. 拒答判断用余弦分最高的那条看库里到底有没有相关内容// ★ 必须用 vectorScore不能用 rerankScore / rrfScore —— 量纲不对constbestVectorScoreMath.max(...candidates.map(cc.vectorScore??0))if(bestVectorScoreSIMILARITY_THRESHOLD){return{text:根据现有资料无法回答,hits:[]}// 库里没有直接拒答}// 3. 通过了才重排序精排 Top-5 喂给 LLMconsthitsrerank(question,candidates,5)return{text:awaitchat(question,hits),hits}}这里还有个顺序讲究先判要不要答余弦阈值再排怎么答重排序。如果库里压根没相关内容重排序也是白排——直接拒答更省。️ 这一节的一句话引入越多排序技巧就越要守住那个被校准过的相关度分。混合检索、重排序都是为了让该排前面的排前面但到底该不该答这个判断从头到尾只有余弦分有资格。谁负责排序、谁负责拒答权责必须分清——这是整章最容易踩的坑也是最能讲出工程成熟度的地方。 承上启下到这单轮问答已经又准又稳。但真到运营手里对话是连着问的问完amount是什么接一句它含税吗“——这个它”会让前面所有的检索努力瞬间归零。为什么 第 5 章多轮记忆——真难点不是存历史是指代消解 本章目标理解 RAG 多轮为什么比纯聊天多一道坎以及问题改写怎么解。为什么存历史解决不了这个问题一说多轮很多人以为就是把历史 messages 一起发给模型。纯聊天确实够了——模型自己看得到上文它指谁它自己会消解。但RAG 多了一道检索而检索器是个瞎子它看不到对话历史。用户第二轮问它含税吗拿去检索的就是它含税吗这四个字。它是个代词本身没有任何检索价值向量化之后只剩含税这个弱信号检索器根本不知道它指的是上一轮的amount于是召回一堆跟当前字段无关的东西答非所问。这才是 RAG 多轮的真难点指代消解coreference resolution。存历史解决的是模型记得上文解决不了检索看得懂代词。解法检索之前先把问题洗成自包含的正确顺序是先结合历史把代词还原成具体实体得到一个独立问题再去检索。这一步叫问题改写query rewriting用户它含税吗 │ ① 结合历史改写关键的一步 ▼ 独立问题amount 含税吗 │ ② 走第 2~4 章的混合检索 重排序一行没改原样复用 ▼ 答案 │ ③ 记录这一轮给下一轮改写用 ▼改写交给 LLM不用正则——代词太灵活“它/这个/那张表/该字段”正则永远有漏网的。但每句都调一次改写 API 又浪费所以加个省钱开关只有问题里含代词才改写自包含的问题直接放行// rewrite-query.jsconstPRONOUN/它|这个|那个|这张表|那张表|上面|刚才|该字段|这列|上述/exportasyncfunctionrewriteQuery(question,history){// 没历史、或问题本身就独立 → 直接返回省一次调用if(!history.length||!PRONOUN.test(question))returnquestionconsthistoryTexthistory.map(t用户${t.question}\n助手${t.answer}).join(\n)constsys根据对话历史把用户最新问题改写成一个不依赖上下文也能独立理解的问题。 把它/这个/该字段等代词替换成历史里它指代的具体字段名或表名。只输出改写后的问题本身。 对话历史${historyText}return(awaitchat(最新问题${question},[],sys)).trim()}再配上下文长度的两道闸防止对话越聊越长撑爆 prompt滑动窗口只留最近 3 轮token 上限拼历史时超了就丢最早的。两道闸各管一件事——窗口管看几轮token 管拼多长。// conversation.jsconstMAX_TURNS3// 滑动窗口最多看最近 3 轮constMAX_HISTORY_TOKENS800recentHistory(){constrecentthis.turns.slice(-MAX_TURNS)constkept[]lettokens0for(letirecent.length-1;i0;i--){constcostestimateTokens(recent[i].question)estimateTokens(recent[i].answer)if(tokenscostMAX_HISTORY_TOKENS)break// 再加就超了停kept.unshift(recent[i])tokenscost}returnkept}最后串起来多轮的全部新增逻辑只在检索的前面改写和后面记录——第 2~4 章的检索器一行都不用动// metadata-qa-multiturn.jsasyncfunctionanswerInConversation(searcher,conversation,question){consthistoryconversation.recentHistory()conststandaloneawaitrewriteQuery(question,history)// 前改写constresultawaitanswerWithHybrid(searcher,standalone)// 中复用检索没改conversation.addTurn(standalone,result.text)// 后记录改写后的独立问题return{...result,standalone}} 一个防套娃的小心思记录历史时存的是改写后的独立问题standalone不是用户原话。否则下一轮改写时历史里又冒出一个它它套它没完没了。存独立问题让历史天然自包含。 承上启下三块拼图——混合检索、规则重排序、多轮记忆——都装上了。回头看会发现它们背后是同一种思维方式。 结语工程化的分寸感是知道什么时候该停 只记一句话也够把 RAG 从能跑调到能用靠的不是堆最复杂的技术而是每一步都问一句这个场景到这个程度够不够。回头数一遍这篇里所有的我没有用更复杂的方案融合两路检索用RRF 只看排名没上归一化 加权调参那套重排序用metadata 规则没上 Cross-Encoder 模型多轮的 token 控制用糙估算中文 1 字≈1 token没引 tiktoken改写加了个代词开关独立问题不白调 API。每一个选择都不是我只会简单的而是这个场景简单的刚好够、且更可控。这恰恰是前两篇那条主线的延续第一篇说 RAG 没有魔法、是条你看得懂的数据流第二篇说跑通只是起点、能拆开看为什么不准才算会到这一篇——能判断什么时候该加复杂度、什么时候该收手才算把 RAG 做成了工程而不是堆技术。而这套检索器的价值还没榨干同一个searcher前面接改写就成了多轮记忆后面换一段 prompt还能长出Text-to-SQL把真实字段塞进 prompt让 LLM 别猜列名和报表解读结合字段口径解释数字——一套地基三个能力。那两块怎么做留到下一篇。 最后的最后这三篇代码加起来也就几百行。真正稀缺的从来不是知道有 RRF、有 rerank而是在自己的场景里讲得清每一步为什么这么选、为什么不那么选。那才是面试官眼里你和背过 RAG 八股的人之间的差距。原创声明本文首发于我的个人博客 https://rjy92.github.io/同步发布在掘金与 CSDN。如需转载请注明出处。如果这篇文章帮到了你欢迎在以下平台关注、交流掘金https://juejin.cn/spost/7657384851218645043CSDNhttps://blog.csdn.net/u012565530/article/details/162493267