
第 09 篇重排——Rerank 与 RRF第 08 篇结尾把一件事按下了“名单怎么从候选变成上下文是拿到名单以后的事。”这一篇就把它打开。夹在检索与生成之间的那一步叫重排它回答的是同一个问题的两半几十条候选里到底哪几条该进提示词但这一篇最值钱的结论不是“重排有用”——那是意料之中的。真正意外的是第六节那个实测把向量库从 Elasticsearch 换成 Qdrant向量分数变了重排分数却一位都没变。这个结果把“哪一步可以跨后端复用、哪一步不行”这条线画清楚了。下面这张是本篇全部实测数据汇总出来的对照面板左边是同一批候选重排前后、以及“向量 / 融合 / 重排”三种排序的并列右边是耗时、原始响应体、本工程接口返回最下面是换到 Qdrant 之后的跨后端核对。后面每一节都会从这块面板里取一格来讲。一、粗筛之后为什么还要再排一遍1.1 粗筛的两处先天不足向量检索为了让上百万条数据能在毫秒级查完做了两件事各留了一处不足做了什么换来了什么代价近似最近邻ANN搜索快近似不是精确的最近邻把一整段文本压成一个固定长度向量可比、可索引段内细节被平均掉第 08 篇实测到的那个反例正是第二处的后果查Redis真正含这个词的块02-知识库#0在向量分支只排第二输给了一个字面上压根不含它的块纯向量✘ 01-员工手册#1 0.2281 | ✔ 02-知识库#0 0.2190 ← 差 0.0091 就换了名次 纯全文✔ 02-知识库#0 1.244 ← 只有它含这个词1.2 重排换了做法把 query 和候选放在一起过模型粗筛向量/全文检索query 与文档各自变成向量然后比距离。两边从没见过面相似度是“两个压缩结果的夹角”。重排rerank 模型query 和每一条候选拼在一起送进模型模型直接判断“这段文本能不能回答这个问题”。做法更细代价也更贵N 条候选就是 N 次前向计算。全库几万条块不可能都送进去所以它天然只能做精排——先粗筛几十条再重排取前几条用户问题粗筛向量检索 / 全文检索目标别漏代价毫秒级候选池 10~50 条精排rerank 模型逐条打分目标排序准代价与候选数成正比截断 topK 条拼进上下文两次排序的目标不一样粗筛怕漏召回优先重排怕错精度优先。所以粗筛阶段不该设相似度阈值——提前砍掉几条等于让重排少了可挑的料。这一点在代码里是显式写死的// 粗筛池子放宽到 topK 的两倍至少 10 条且不设阈值——// 阈值是事后砍而重排正是那个更该决定砍谁的角色先砍会让它无从下手。poolSizeMath.max(topK*2,10);ListDocumentpoolretrievalService.search(question,poolSize,0.0);hitsrerankService.rerankDocuments(question,pool,topK);1.3 RRF 是什么和重排差在哪第 08 篇已经实现了 RRF倒数排名融合。它和重排常被混为一谈其实是两件事RRF 融合重排模型输入两路已有的名次query 每条候选原文输出名次的重排全新的相关性分数0~1会不会引入新信息不会只是两路名次的加权会模型重新读了一遍文本能不能推翻共识不能两路都排前面的必然还在前面能依赖需要两路检索都可用只需要一个重排接口两者的差别在第五节的数据里会非常清楚融合名次永远落在两路名次之间而重排名次可以把向量检索排第 6 的块直接提到第 3。二、接上 rerank本系列的第三套协议2.1 同一家厂商三种协议到这一节为止整条链路已经用到三家服务的三种协议。这是接真实模型时最容易被低估的成本能力厂商协议地址对话sensenova 网关OpenAI 兼容https://token.sensenova.cn不能带/v1向量化阿里云百炼OpenAI 兼容https://dashscope.aliyuncs.com/compatible-mode也不能带/v1重排阿里云百炼原生协议https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank同一家厂商embedding 有兼容端点、rerank 没有。实测把同样的请求打给兼容端点POST https://dashscope.aliyuncs.com/compatible-mode/v1/rerank - HTTP 404响应体为空耗时 120 ms空响应体是这几类错误里最难查的一种401密钥错、429限流都会带一段说明而 404 空响应既不像配置错、也不像限流唯一的定位手段是“换个地址再试一次”。所以这一路必须自己拼 JSON、自己解析 JSON跟对话/向量化是两套完全不同的代码。2.2 原生的请求体与响应体真实抓取POSThttps://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank{model:qwen3.7-text-rerank,input:{query:年假有多少天,documents:[员工每年享有 5 天带薪年假入职满一年后开始计算未休完的年假可以结转至次年三月底。,公司为全体员工提供补充医疗保险覆盖门诊与住院费用报销比例最高 90%。,运维值班分为白班与夜班白班 9:00 到 18:00夜班 18:00 到次日 9:00交接必须有记录。]},parameters:{return_documents:false,top_n:3}}真实响应214 ms325 token{output:{results:[{index:0,relevance_score:0.913086706435771},{index:1,relevance_score:0.1489603740749516},{index:2,relevance_score:0.007197711682332408}]},usage:{prompt_tokens:325,total_tokens:325},request_id:f6ca7b8e-6c65-9bf4-bc9d-e949ba75d98a}三件事值得记下真正有用的只有output.results每项是index原候选里的下标从 0 起与relevance_score0~1。它不会回传正文也不保证index连续——只有进了top_n的候选才出现上面三条演示文本的分数是0.9131 / 0.1490 / 0.0072一条讲年假的文本拿 0.91一条讲医保的拿 0.15一条讲值班的只有 0.007。区分度肉眼可见return_documents设成false正文我们手里已经有了让接口原样回传纯属浪费带宽。2.3 客户端的四个关键决定publicRerankResponsererank(Stringquery,ListStringdocuments,inttopN){if(documents.isEmpty()){returnnewRerankResponse(List.of(),0,0,候选为空未发起请求);}...Stringrawclient.post().uri(properties.baseUrl()).header(Authorization,Bearer properties.apiKey()).contentType(MediaType.APPLICATION_JSON).body(body)// 用 String 接响应而不是直接反序列化成对象// 原始响应体要回显给接口和日志出问题时能一眼看到后端到底说了什么.body(String.class);...ListHithitsparse(raw,documents.size());用String收响应再自己解析。多写十行换来两件事原始响应体能回显给接口排查时不用抓包解析失败时能把原文一起抛出来index越界立即抛异常下标越界意味着“调用方与接口对候选的理解不一致”这是必须暴露的编程错误不能默默丢掉读超时单独放宽到 30 秒。重排是逐对计算候选多时耗时会线性上涨超时给小了会表现成“偶发网络抖动”而真正的原因是模型没算完候选为空时不发请求。省一次调用也避免“空数组”这种边界情况引发接口的奇怪响应。配置单独一份因为它是独立的模型服务rag:rerank:base-url:https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerankapi-key:${DASHSCOPE_API_KEY:...}model:qwen3.7-text-rerankread-timeout-ms:30000启动时踩的一个坑rerank包最初放在com.example.rerank下而启动类在com.example.rag——Spring Boot 的组件扫描根就是启动类所在的包于是启动直接失败Parameter 1 of constructor in com.example.rag.chat.RagChatService required a bean of type com.example.rerank.RerankService that could not be found.解法不是放宽ComponentScan而是把包移到com.example.rag.rerank与工程其余部分保持一致。“用String收响应、再回显给接口”这条决定落到实处就是本工程对外的那个只读接口——一次调用同时给出before向量顺序与after重排顺序外加原始响应体三、重排到底改了什么前后对照3.1 一个问题看得最清楚问题“年假有多少天”粗筛池 6 条库里总共就 6 块重排取前 5序号重排前向量名次与分数重排后重排名次与分数101-员工手册#0 0.568801-员工手册#00.9251向量第 1201-员工手册#1 0.377701-员工手册#1 0.3252向量第 2303-运维值班规范#1 0.294002-知识库#1 0.2476向量第 6402-知识库#0 0.293802-知识库#0 0.1962向量第 4503-运维值班规范#0 0.277503-运维值班规范#0 0.0946向量第 5602-知识库#1 0.2630未进前 5接口不返回两件事同时发生名次换了向量第 6 名的02-知识库#1被提到第 3把向量第 3 名的03-运维值班规范#1挤出前五。重排后挤进前 5、原本不在前 5 的1 条分数的分布变了向量的第一名与第二名只差0.19110.5688 − 0.3777重排差0.59990.9251 − 0.3252——区分度是向量的 3.1 倍。第二件事比第一件更实用向量分数挤在 0.26~0.57 这个窄带里阈值稍微动一点命中数就从 5 跳到 1第 08 篇的扫描表就是证据。而重排分数是 0.9251 / 0.3252 / 0.2476 / 0.1962 / 0.0946——第一名拉开一大截剩下几条明显是陪跑这种分布才敢拿来卡阈值。3.2 五个问题不是每个问题都需要重排问题token耗时向量前 5 → 重排后前 5提升年假有多少天2494158 ms01#0, 01#1, 03#1, 02#0, 03#0→01#0, 01#1, 02#1, 02#0, 03#01数据订正超过一万行由谁执行2530129 ms03#1, 01#1, 01#0, 03#0, 02#1→03#1, 03#0, 01#0, 01#1, 02#01云枢知识库支持哪些文件格式2512125 ms02#0, 02#1, 01#1, 01#0, 03#0重排后完全一致0180 天2506127 ms01#1, 01#0, 03#1, 03#0, 02#1→01#1, 01#0, 03#0, 02#0, 02#11v3.2.12506134 ms02#0, 03#0, 03#1, 01#0, 02#1→02#0, 01#0, 03#0, 02#1, 01#1101#001-员工手册#0以此类推逐条读下来有三个观察五个问题里四个各提升了 1 条改动幅度不大但方向一致都是把“更该进上下文的那条”提上来“云枢知识库支持哪些文件格式”一条没动且向量分数本来就分得开0.6667 / 0.5298 / 0.4558 …。粗筛已经排对的时候重排不会画蛇添足——这一点很重要说明重排是“修正”而不是“打乱”前两名在全部五个问题里都没被改动过。重排动的是中后段的名次也就是决定“哪一条是第 3、第 4 名”这种真正影响上下文质量的位置。3.3 一个容易被忽略的实现细节重排只返回进了top_n的候选。上表里02#0向量第 6在重排结果里是不存在的接口一行都不给。所以回填代码不能写成“按返回顺序取第 i 条候选”必须按下标对// 按 index 回填。注意只有进了 top_n 的候选会出现在 results 里// 没进榜的候选不会返回所以这里不能假设返回项数 候选数。for(intrank0;rankresponse.hits().size();rank){RerankClient.Hithitresponse.hits().get(rank);intcandidateIndexhit.index();Documentcandidatecandidates.get(candidateIndex);...}写错这一行的后果很隐蔽名次看着有变化但换到的是另一条文档——比“完全没重排”更难发现因为接口返回的一切看起来都正常。四、它救回了什么字面串案例第 08 篇留下的那批“向量守不住的字面串”正好拿来验收重排。判定基准是“真正含这个字面串的块”用 ES 的match_phrase直接查出来字面串含它的块向量第一名重排第一名结论Redis02-知识库#0✘ 01-员工手册#1✔02-知识库#0重排救回DELETE03-运维值班规范#1✔ 03-运维值班规范#1✔ 03-运维值班规范#1都命中18:0001-员工手册#1✔ 01-员工手册#1✔ 01-员工手册#1都命中DBA03-运维值班规范#1、#0✔ 03-运维值班规范#1✔ 03-运维值班规范#1都命中xlsx02-知识库#1、#0✔ 02-知识库#1✔ 02-知识库#1都命中五个里四个本来就是对的唯一错的那个被纠正了。这个比例5 个错 1 个比“重排能修一切”更接近真实它是一层保险不是万能药。绝大多数问题粗筛就排对了重排的价值恰好体现在剩下的少数上——而那少数往往就是线上用户投诉的那几个查询。五、三种排序放一起看同一批 6 条候选同一个问题三种排序并列ES 侧实测问题“年假有多少天”块向量名次向量分融合名次重排名次重排分01-员工手册#010.5679110.925101-员工手册#120.3796220.325203-运维值班规范#130.29825未进前 5-02-知识库#040.2979340.196203-运维值班规范#050.2798450.094602-知识库#160.2661630.2476问题“数据订正超过一万行由谁执行”块向量名次向量分融合名次重排名次重排分03-运维值班规范#110.6088111.000001-员工手册#120.3365240.168601-员工手册#030.3270430.215403-运维值班规范#040.2905320.406602-知识库#150.27106未进前 5-02-知识库#060.2571550.1655三件事一眼可见融合名次永远夹在两路原始名次之间。它是“名次的加权”不引入新信息。看03-运维值班规范#1第 1 行向量第 3、融合第 5——因为全文那一路没排它重排名次可以跳出两路给出的顺序。02-知识库#1向量第 6、融合第 6重排直接给到第 3。融合永远做不到这件事两路都排最后的块融合只会继续把它排最后重排分数出现 1.0000。第二张表里讲数据订正的那块拿了满分——它确实是唯一直接回答“由 DBA 执行”的段落。这种明确的分数是向量余弦给不出来的。六、换掉向量库重排结果会不会变这是这一篇最值得单独立一节的结果。同样的问题、同样这批候选分别在Elasticsearch与Qdrant上跑一遍切 profile 重启应用即可检索代码一行没改块向量分(ES)向量分(Qdrant)重排分(ES)重排分(Qdrant)重排分差值01-员工手册#00.56790.56690.92510.9251一致01-员工手册#10.37960.37560.32520.3252一致03-运维值班规范#10.29820.2953-未进前 5-未进前 5-02-知识库#00.29790.30080.19620.1962一致03-运维值班规范#00.27980.28330.09460.0946一致02-知识库#10.26610.27690.24760.2476一致向量分数两库不同这正是第 07 篇量过的分数噪声重排分数逐位相同。四个问题的重排名次也逐一核对过全部一致问题ES 重排名次Qdrant 重排名次年假有多少天01#0, 01#1, 02#1, 02#0, 03#0同数据订正超过一万行由谁执行03#1, 03#0, 01#0, 01#1, 02#0同云枢知识库支持哪些文件格式02#0, 02#1, 01#1, 01#0, 03#0同180 天01#1, 01#0, 03#0, 02#0, 02#1同原因不复杂重排模型的输入只有 query 与文本它根本不知道向量库是谁。前面所有的分数差异都发生在“文本如何变成向量、向量如何比距离”这一段而重排压根不在这一段里。6.1 顺带得到的第二个结论融合在 Qdrant 上直接不可用同一批实验在 Qdrant 上跑“三路对照”时融合那一列整列是空的问题 [年假有多少天] 融合可用False 重排 token2494 耗时158 ms 提升 1 条 块 向量名次 向量分 融合名次 重排名次 重排分 01-员工手册#0 1 0.5669 None 1 0.9251 ... 03-运维值班规范#1 4 0.2953 None None -这正是第 08 篇的结论在两处能力上的分界能力依赖什么ESQdrant跨后端可复用混合检索向量 全文后端自身的全文检索能力✔✘ 拿不到ElasticsearchClient否重排一个独立的重排接口✔✔是判据很简单这一步是在“用后端的能力”还是在“用文本”混合检索用的是后端的倒排索引所以换库就断重排只吃文本所以换库无感。这个判据可以直接拿来做架构决策——哪些能力该封装成独立服务、哪些必须绑在某个存储上。七、代价重排不是免费的7.1 候选越多越贵问题“数据订正超过一万行由谁执行”逐步加大候选数请求候选数实际池大小token接口耗时每条候选返回条数331058123 ms41 ms3551819133 ms27 ms51062530128 ms21 ms52062530124 ms21 ms5两点token 与耗时随候选数上升这是“逐对计算”的直接体现。本库只有 6 块所以请求 10 和 20 实际都只送了 6 条池大小那列就是证据——语料太小这个实验只能看出趋势看不出线性每条候选约21~41 ms。按这个量级推算50 条候选约 1~2 秒。这个延迟对同步接口是可以接受的但它决定了候选池不能随便放大更不可能拿它替代粗筛。7.2 真正的代价在提示词上实测这是最容易忽略的一处。同一个问题走两条路径问答问题路径池检索/重排prompt token上下文答案年假有多少天不重排1154 ms / 0685900 字年假按司龄计算… [1]年假有多少天重排10321 ms / 174 ms20013173 字年假天数按司龄计算… [1]数据订正超过一万行由谁执行不重排1154 ms / 0248204 字数据订正超过一万行由 DBA 执行 [1]数据订正超过一万行由谁执行重排10278 ms / 128 ms20773301 字由 DBA 执行 [1]答案两两几乎一样但 prompt token 涨了 3~8 倍685 → 2001248 → 2077。原因是我在实现里做的一个选择重排路径取消了相似度阈值为了让重排有料可挑于是上下文从“阈值筛剩下的 1 条”变成“重排要的前 5 条”。这不是重排本身的问题而是重排必须配一个截断策略要么把topK降下来比如重排前 10 条、只取 2 条进上下文要么用重排分数做二级阈值——本例里 0.5 就够第一名 0.9251 稳稳过线第二名 0.3252 与以下全部被挡掉上下文只留真正相关的那一条。重排真正的收益场景是“粗筛把答案排在第 3~10 名”那时重排把它提到第 1同时把陪跑的块挡在截断线外既提质量又省 token。如果粗筛本来就只返回 1 条重排只是徒增成本。7.3 共享池网关的限流错误码会被包装跑实验时连续问答很快撞上限流。日志里的原始错误是HTTP 429 - {error:{message:inference exceeds tpm/rpm limit, type:rate_limit_error,code:insufficient_quota}}但接口对外返回的是 HTTP 500异常被统一包装过。这个差别很容易带偏排查方向看到 500 会去翻代码实际原因在日志里是模型侧限流。第一次写的重试逻辑只匹配429于是永远不触发重试实验直接中断。改成“任何失败都退避重试”之后等 35 秒重来即可通过。这条对生产同样成立限流要按“业务失败”兜底而不是按错误码兜底——错误码在到达你手里之前可能已经被包装过一轮。八、工程规则重排只做精排。候选池按 10~50 条设计别拿它替代粗筛粗筛阶段不设阈值。先给重排一个完整的池子截断留给重排之后重排之后一定要截断。实测不截断会让 prompt token 涨 3~8 倍而答案不变重排分数可以当二级阈值用。它 0~1、区分度是余弦的 3 倍以上本例 0.5 就能把陪跑块全部挡掉接新协议先探测。同一家厂商 embedding 有兼容端点、rerank 没有兼容端点还会返回 404 空响应体——不探测就只能在启动时报错里猜回填要按index不能按返回顺序。未进top_n的候选根本不会返回限流按失败退避不按错误码。错误码可能已被框架包装成 500判据这一步用后端的能力还是用文本用文本的重排可以跨后端复用用后端能力的全文检索换库即断。小结这一篇把“从候选到上下文之间”那一步填上了重排换了个做法粗筛是 query 与文档各自压成向量再比距离重排是把两者放在一起过模型。更准但代价是逐对计算只能做精排它确实改动了排序五个问题里四个各提升 1 条前两名一次没动过——动的是决定上下文质量的中后段分数分布的变化比名次更实用向量第一名只领先 0.1911重排领先 0.5999区分度 3.1 倍这种分布才敢拿来卡阈值它救回了字面串Redis从“输给不含它的块”被纠正为第一名五个探测词里只有一个原本是错的重排是保险不是万能药三种排序的定位清楚了融合只在两路已有名次之间做加权、不引入新信息重排能跳出两路顺序把向量第 6 名提到第 3它不吃后端换向量库后向量分数变了重排分数逐位相同——重排的输入只有 query 与文本。这条判据比结论本身更值钱代价在提示词上不配截断时 prompt token 涨 3~8 倍而答案不变重排分数当二级阈值本例 0.5是最省事的收口方式限流会把错误码包装掉网关 429 到了接口层变成 500重试要按失败兜底。到这里“检索 → 重排 → 取前几条”这条挑选链路是完整的了。但把这几条拼成提示词还有它自己的问题总共能放多少字、超了砍谁、资料不够时怎么让模型坦白说不知道——那属于上下文与生成那一侧的事按下不表。