做检索系统的同学多少都有这种体会:单路召回无论拼得多快,精排前如果底料不行,后面全白费。尤其现在RAG应用越来越普及,文档检索的质量直接决定了大模型输出的质量,用户问“怎么办”,模型答得靠不靠谱,一半取决于检索阶段有没有把真正相关的上下文捞回来。这篇我会从相似度算法讲起,把混合检索(Hybrid Search)和重排序(Rerank)这类“召回后处理”的完整链路过一遍,不光讲每个算法怎么算、怎么对比,重点是讲清楚在什么业务场景下选什么方案、工程上到底怎么落地。
内容会按“整体架构 → 相似度算法 → 混合检索 → 重排序 → 常见问题排查”这条线展开,适合正在搭搜索系统、做RAG应用、或者想优化召回精度的开发者和算法工程师参考。从前置基础到代码级细节我都尽量讲透,工具选型上会给你一个可以照着画的框架。
1. 检索系统的整体架构:召回阶段与重排序阶段的分工逻辑
1.1 为什么检索要被拆成两段式漏斗
几乎所有工业级的文本检索系统,内部都不是一个模型打天下,而是“粗召回 + 精排序”的两段设计。原因不难理解:候选文档可能是几百万、几千万条,任何一个复杂模型如果要对全量文档逐条打分,延迟和算力都扛不住。所以第一步必须用低成本方式快速圈定一个很小的候选集合,常见的规模是几十条到两三百条,第二步再对这个集合用更精细、更智能的方式重新打分。
召回阶段的典型手段有两类。一类是倒排索引加BM25,本质是稀疏词法匹配,快速、精确、可解释性强,工程上非常成熟。另一类是稠密向量检索,把文本编码成向量,用最近邻搜索的方式找语义上最接近的内容,能解决“写法不同、意思相同”这类词法匹配搞不定的事。这两类召回各有各的死穴,所以现在的主流架构往往让两路同时跑,取完再合并。
重排序阶段则完全不同。它的输入是已经被粗筛过的几十条内容,规模不大,所以可以上参数量更大的模型,比如Cross-Encoder。这个模型会把用户查询和候选文档拼接在一起做深度交互,打分比双塔/向量召回精细得多,最后再把真正最相关的前几条送进下游。这里的核心思想就是“漏斗逐层变窄、精度逐层变高”:每一层都用适合当前规模的手段,而不是用一个万能模型硬扛所有数据量。
1.2 两个阶段对算法能力的要求差异
召回和重排对算法的要求是截然相反的,理解这一点,很多技术选型就不会纠结了。
召回阶段的目标是高召回率,宁可多捞一些不相关的噪声,也别漏掉真正相关的内容。这个阶段对延迟极其敏感,通常要求在几十毫秒内返回结果,所以模型要大而快,复杂度必须可控。向量检索常用近似最近邻搜索(ANN),不是完全精确的最近邻,本质就是用可接受的精度损失换速度。
重排序阶段的目标是高精度,要把召回的噪声尽量过滤掉,把真正相关的几条顶到最前面。这个阶段候选集合很小,对单条的延迟容忍度可以放宽到几十甚至几百毫秒,所以模型完全可以做得更“重”,代价是只能在小规模候选上使用。Cross-Encoder每处理一个候选对都要做一次前向计算,复杂度随候选数线性增长,所以无论如何不能放到全库上跑。
| 维度 | 召回阶段 | 重排序阶段 |
|---|---|---|
| 候选规模 | 百万级以上 | 几十条以内 |
| 核心目标 | 高召回率 | 高精度 |
| 常用算法 | BM25、向量检索、双塔 | Cross-Encoder、精细排序模型 |
| 延迟要求 | 严苛,毫秒级 | 宽松,可到百毫秒级 |
| 特征复杂度 | 低,不能依赖复杂交互 | 高,可以做深度语义交互 |
| 计算开销 | 低 | 较高 |
这里我提一句个人经验:很多团队把精力全花在重排序模型上,觉得模型越强结果越好。但实际项目里,如果召回阶段就把真正相关的文档漏掉了,重排序再怎么精排也排不出空气里的东西。所以设计阶段一定要分清楚每一层的职责,召回保证“不丢”,重排保证“要准”,两个环节互相配合,而不是互相替代。
2. 相似度算法全景:从稀疏到稠密的核心公式与选型
2.1 稀疏匹配:从TF-IDF到BM25
先说说最经典的稀疏文本匹配。它的核心是“两个文本里有共同的关键词”,关键词重叠越多,得分越高。TF-IDF是入门必讲的算法,公式拆开也就两部分:TF表示词在文档里出现的次数,代表这个词对这篇文档有多重要;IDF表示“包含这个词的文档占比的倒数”,代表这个词在全库里有多稀缺。分到具体计算上,一个词的权重就是这个词在文档里的词频乘以这个词的逆文档频率,最终文档得分是查询里所有词的权重之和。
但TF-IDF有个明显问题:词频增长是线性的,一篇文章里同一个词出现很多次,得分会被不断抬高,可实际上一旦某个词出现超过一定次数,它对主题的贡献就不该继续等比例增加了。另外它也没考虑文档长度——长文档天然包含更多词,更容易撞上查询词,这不公平。
BM25就是为了解决这些问题出现的。它的核心改进了两点:第一,用“词频饱和函数”让词频的作用到一定程度后不再线性增长,而是逐渐趋缓;第二,引入文档长度归一化,用当前文档长度和全库平均长度的比值来调节词频权重,避免长文档占便宜。BM25的通用形式可以写成这样:
score(D, Q) = Σ IDF(q_i) × [ tf(q_i, D) × (k1 + 1) ] / [ tf(q_i, D) + k1 × (1 - b + b × |D| / avgdl) ]其中k1控制词频饱和的程度,取值一般在1.2到2.0之间,值越大,词频对分数的贡献越线性;b控制文档长度归一化的强弱,默认通常取0.75。调参的时候要注意一个细节:对长文档居多的语料库,适当调大b能更狠地压制长文档优势;对短文本为主的数据,b调得太高反而会误伤。
实际工程中,BM25能被落地本身还要依赖倒排索引的能力。系统先对文档做分词、去停用词、转小写,建立“词项→包含它的文档列表”的映射。查询时先查倒排表,找到可能相关的文档ID,再逐个计算BM25分数。性能上它比遍历全库快了不只一个数量级,所以到今天,它依然是很多工业搜索引擎的默认召回算法。
2.2 稠密向量匹配:余弦相似度、内积与欧氏距离
向量检索的核心思路是把文本用嵌入模型转换成固定维度的向量,然后在向量空间里比较距离。最常见的三种度量是余弦相似度、内积和欧氏距离。
余弦相似度计算的是两个向量之间的夹角余弦值,公式是向量点积除以两个向量模长的乘积。它的特点是只关心方向,不关心模长,所以两个文本只要“面对的方向”接近,哪怕一个向量整体更长、更“重”,余弦相似度也可能很高。文本本身受句子长度影响很大,所以在文本检索里,余弦相似度几乎是默认选择。
内积就是两个向量对应元素相乘再累加,它没有做任何归一化。如果两个向量都已经做了L2归一化(也就是单位向量),那么内积和余弦相似度是完全等价的。但如果没有归一化,内积会同时把模长信息混进来,在文本场景下模长通常和句子长短、词频高低有关,这往往不是我们想要的语义信号。所以用内积之前一定要先确认嵌入模型的向量是否归一化,很多预训练模型的输出不是单位向量,直接用内积会踩坑。
欧氏距离计算的是向量在空间中的真实直线距离,和余弦相似度呈负相关关系:向量越相似,距离越小。当两个向量都是单位向量时,欧氏距离和余弦相似度存在一个平方关系,只是单调映射,不影响排序结果。但在工程实现中,大多数向量数据库和索引库针对内积或余弦做了优化,欧氏距离要多一步平方根运算,性能上略吃亏,所以实际用得少一些。
这里我想强调另一个容易被忽略的点:向量分数绝对值没有跨库可比性。同一个模型对不同库、不同批次数据算出来的余弦相似度分布都可能不同,稳定的做法是回归到“看排序”而不是“看分数阈值”。如果非要用分数做规则,也要先在自己数据集上统计分布,再决定阈值,千万不要套别人文档里的“0.7以上才相关”这种结论。
2.3 相似度算法怎么选:一个简单的判定框架
很多人在选相似度算法时容易陷入“万物皆向量”的误区。我的建议是,先给你的查询和文档做个分类,分类下再选算法,这样更稳。
| 场景特征 | 推荐算法 | 原因 |
|---|---|---|
| 查询包含明确型号、编号、代码、专有名词 | BM25优先 | 这类查询要求精确匹配,向量模型对罕见词容易编码漂移 |
| 查询是短句,和文档存在同义改写、口语化表达 | 稠密向量 | 词法匹配覆盖不到“同义不同形” |
| 文档是长文本,混杂大量无关内容 | BM25+向量混合 | 单一算法容易被长文档噪声干扰 |
| 对召回结果可解释性有要求 | BM25或稀疏算法 | 可以追溯“是哪个词命中了”,方便排查 |
| 搜索量大,延迟敏感 | BM25+ANN向量召回并跑 | 两路都很快,重排阶段再精细打分 |
补充一个真实感很强的例子。用户搜“怎么把套餐流量转到下个月”,如果文档里写的是“套餐内剩余流量不可结转”,BM25能精确命中“流量”“下个月”这些词,但“转到下个月”和“结转”没有表面上的重合,所以纯BM25可能匹配不上;反过来,纯向量检索也可能因为“套餐”“流量”出现太频繁而把弱相关的文档排前面。这种场景只有两路结合才稳妥,这也自然引出了后面的混合检索。
2.4 工程实现中的几个细节
第一个细节是分词的一致性。中文场景下索引分词和查询分词必须用同一套配置,否则同一个词在两边被切成不同粒度,BM25直接失效,向量检索虽然不受影响,但输入不一致也会导致语义偏移。把分词器配置统一成公司内部同一个版本,是很基础但很有必要的规范。
第二个细节是多字段加权。文档往往有标题、正文、关键词等多个字段,标题里出现的查询词和正文里出现的查询词,重要性完全不同。工程上可以在计算BM25时对字段设置不同权重,比如标题权重3.0、正文权重1.0,甚至可以把字段加权建模成倒排索引里的boost因子。这一步对最终精度的提升,往往比换一个重排序模型更明显。
第三个细节是向量维度和精度的取舍。常见的嵌入模型输出是768维或1024维,高维度在存储和计算上都有成本。做索引前可以先做PCA降维,或者用训练好的降维矩阵压到256维,很多时候精度损失很小但性能提升明显。存储精度上,用float16代替float32在大多数场景下都能接受,内存占用直接砍半。
3. 混合检索(Hybrid Search)的融合机制
3.1 为什么单路检索总是不够用
单路检索的局限我再展开说。BM25这类稀疏算法,本质是词法匹配,它不了解词之间的语义关系。用户说“取消业务”,文档写的是“退订服务”,两个句子含义一样但字面差异巨大,BM25得分可能为零。反过来,向量检索依赖嵌入模型把文本映射到向量空间,但嵌入模型对数字、代码、生僻专有名词、稀有人名的处理并不稳,这些恰恰是最需要精确匹配的内容。把“Error Code 90210”映射成一个向量,很容易和别的错误码混淆;但BM25看到“90210”就是字面完全匹配。
两个检索通道的错误模式是互补的:一个擅长精确词匹配,一个擅长语义近似匹配。所以混合检索不是可选项,而是精度要求较高时的必然选择。比较好的比喻是:BM25像一个严谨的图书管理员,只认书单上的原词;向量检索像一个泛读的推荐系统,知道“你喜欢的书可能和这本书风格相近”。管理员漏掉的书,推荐系统往往能顶上。
3.2 RRF:基于排名的融合算法
混合检索面临一个很实际的问题:BM25的分数和向量相似度分数根本不是一个尺度,直接把两个分数相加没有任何意义。BM25可能打出0到20的分数,余弦相似度一般在0到1之间,维度不同、分布不同,直接加权很别扭。
于是排序融合算法RRF(Reciprocal Rank Fusion)被广泛采用。它的思路非常巧妙:既然分数不可比,那就忽略具体分数,只关注每个文档在两个通道里的排名。RRF给每个文档的融合得分定义为所有通道倒数排名的累加:
score(d) = Σ 1 / (k + rank_i(d))这里的rank_i(d)是文档d在通道i中的排名,k是一个平滑常数,通常取60。加k的目的有两个:一是防止排名为第一的文档得分过高,让第一名地位太“霸权”;二是对排名靠后的文档仍然赋予非零分数,保留“出现即有价值”的语义。
举个例子,假设k=60,文档A在BM25通道排第1,在向量通道排第10,那么它的RRF得分是 1/(60+1) + 1/(60+10) ≈ 0.0297。文档B在两个通道都排第5,得分是 1/65 + 1/65 ≈ 0.0308。虽然A有一个第一名,但B同时在两路都有较好的排名,综合得分反而更高。这个特性非常适合混合检索:真正优质的文档通常会在多个通道里都有较好表现,而只在单一通道靠前的文档可能是过拟合了某个通道的偏好。
RRF的实现特别简单,伪代码就这么几行:
def rrf_fusion(rankings, k=60): scores = {} for single_ranking in rankings: for rank, doc_id in enumerate(single_ranking, start=1): scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank) return sorted(scores.items(), key=lambda x: x[1], reverse=True)实际操作时,两个通道各自取top 100或top 200,做完RRF融合后再取top 30往下游走。这个流程稳定、可解释、调参少,所以它成了我现在做混合检索的首选方案。
3.3 加权分数融合:可控但调参真麻烦
加权分数融合是另一种思路,公式很简单:
score(d) = α × s_bm25_norm(d) + β × s_vector_norm(d) + ...关键在“norm”上。两个通道的原始分数必须先归一化到同一尺度,否则权重调了也白调。常用的归一化方法有三种:
- Min-Max归一化:
(score - min) / (max - min),把分数映射到0到1区间。但线上检索时,你没有全局最小值和最大值,通常得用离线统计的近似值,或者用当前候选集里的最大最小值做在线近似。 - Z-score归一化:先算整个分数分布的均值和标准差,再映射成标准正态分数。适合分数整体偏移明显的情况。
- 除以最大值:直接压缩到0到1,但会忽略分数分布的形状,只适合粗略场景。
加权融合最大的问题在于权重不好确定。α和β如果定得不合理,融合后的排序还不如单通道。比较靠谱的做法是准备一批人工标注的查询和期望文档,线下跑网格搜索,比如α从0.1试到0.9,步长0.1,找一个离线指标最优的组合。这个方案比起RRF多了一个需要维护的评测流程,但好处是融合之外还能加业务信号,比如文档热度、时间衰减、用户点击权重都可以作为额外的加权项一起加进去。
一条实践建议:如果你的系统刚起步,没有标注集,直接用RRF就够了;如果已经有了相对成熟的评测集,而且要融合的信号很多,加权分数融合更灵活。
3.4 混合检索的工程落地
落地时要考虑的工程细节不止是算法本身。第一,两路检索是并行还是串行。并行能压低延迟,但需要多占用资源;串行比如先跑BM25,如果命中数不足再用向量补召回,能省算力但要保证BM25出问题时兜底逻辑及时生效。我在实际项目里一般建议先并行跑,等量级稳定后再看是否需要优化资源。
第二,各通道的topK设置。两个通道如果各取100,融合后再取30,会比各取50融合后取30稳定很多,因为排名靠后的信息也能参与融合。但也不能各取太深,比如各取500以上,慢一点不说,排名太靠后的噪声反而会稀释融合效果。
第三,缓存。同一查询短时间内重复出现的频率很高,尤其To B场景里,很多用户的检索词高度集中。把最近一段时间的查询结果缓存起来,融合结果也一起缓存,能大幅降低计算压力。
工程上还有一个趋势值得关注:开源搜索引擎最近的版本已经原生支持RRF了,部分向量数据库也开始内置混合检索和重排序的接口。也就是说,如果不想自己手写融合逻辑,直接用现成的复合查询能力也能达到相似效果,只是自己实现一遍更容易把控细节和排查问题。
4. 重排序(Rerank)技术深度解析
4.1 从双塔到交叉编码:精度与效率的取舍
重排序为什么能够比召回更准?关键在于“交互早晚”的差异。
用来做召回的向量模型大多是双塔结构,也叫Bi-Encoder。它把query单独编码成一个向量,把document也单独编码成一个向量,两个向量完全独立,最后用余弦相似度去匹配。双塔的优点是doc向量可以离线全部算好存进向量库,线上只需要实时编码query,再去查库,延迟低、吞吐高。但缺点是query和document在编码时根本没有见过对方,所有语义交互只能发生在最后的向量比较阶段,信息损失很重。
Cross-Encoder则完全不同。它把query和document拼接成一个长序列塞给模型,比如:
[CLS] query [SEP] document [SEP]模型内部的注意力机制会让query中的每个词看到document里的每个词,所有交互都在模型内部完成。这种深度交互使得模型能捕捉到非常细粒度的匹配信号,精度高出一大截。代价是每条候选对都要单独算一次前向,query有30个候选文档,就要算30次,完全没法做全库检索。
一个形象但不严谨的类比:双塔相当于人力初筛,扫一眼简历的学历、年限、技能标签,快速把人分成“值得面试”和“不用浪费时间”;Cross-Encoder是真正的面试官,会针对候选人的具体经历提问,判断他到底适不适合这个岗位。初筛阶段人太多,只能看标签;到了面试环节人少了,才有条件深挖。
4.2 重排序模型的工程实现
重排序在工程上并不复杂,核心是几个可复用的套路。
第一步是控制重排候选集的大小。常见做法是召回阶段取top 20到top 50,重排后取前5到前10。如果候选集太大,比如超过100条,重排延迟会很难看,而且top 100里本身已经混入太多低质量结果,重排模型也很难把它们“救活”。先有召回质量,再有重排质量,这个顺序不能倒。
第二步是输入构造和长度控制。query和document拼接后,大多数Cross-Encoder模型有最大输入长度限制,比如512个token。文档太长直接截断,会丢掉尾巴上的关键信息;比较稳妥的做法是取文档开头和结尾的各一段拼起来,或者对超长文档先做段落切分,把段落级分数聚合为文档级分数。
第三步是批量推理优化。虽然单条延迟容忍度放宽了,但同一查询下的多条候选可以组成一个batch一起推理,把吞吐拉上去。要注意候选文档长度差异可能很大,如果按最长文档填充整个batch,padding浪费非常多。按文档长度分组batch能明显提速。
第四步是重排分数怎么用。有两种主流做法:一是直接用重排模型的分数作为最终排序依据;二是把重排分数和召回阶段的BM25分数、向量分数做加权融合,公式类似前面说的线性组合。用第二种方案的动机是,重排模型也是可能过拟合训练集的,融合召回阶段信号能起到正则化作用,我用下来的感觉是:数据量小、标注质量不高的场景,融合更稳;数据量大、标注质量高,直接用重排分数更干净。
4.3 Rerank 在 RAG 场景中的位置
RAG的完整链路大致是:文档解析 → 切块 → 向量化 → 索引 → 召回 → 重排 → 拼上下文 → 大模型生成。很多人把注意力都放在向量化和大模型提示词上,但重排这一步其实是决定生成质量的关键“闸门”。
原因很直观。RAG拼给大模型的上下文通常只有几段到十几段,如果这些片段里混入了不相关的噪声,大模型不会自动忽略它们,反而可能在生成时被带偏。更麻烦的是,大模型对上下文长度有上限,塞进去的内容越脏,留给有效信息的空间就越少。重排把最相关的片段顶到最前面,同时过滤掉低相关片段,直接改善了大模型的输入质量。
我自己的经验是,对一个20条候选的召回集重排后,只取前5条拼上下文,比直接把20条全部塞给大模型的效果好很多。不只是因为相关内容集中了,更是因为垃圾上下文的比例大幅下降,大模型的回答更聚焦、事实性错误也更少。
另外,重排还有一个容易被忽略的作用:它是RAG全链路里唯一一个能对“相关性”做最终把关、且不依赖大模型本身判断的环节。如果召回漏了,重排救不回来;但召回不精确时,重排能把最后的精度拉上来一截。放在整个系统里看,它的性价比非常突出。
4.4 重排与业务规则的融合
纯模型打分之外,业务场景里往往还要叠加一些非语义信号。比如新闻搜索讲究时效性,再相关的文章如果是三年前的,价值也大打折扣;电商搜索要考虑商品销量和好评率;企业内部知识库搜索可能要显示文档被访问的频率。
比较通行的做法是做一个final score:
final_score = λ1 × rerank_score_norm + λ2 × recency_norm - λ3 × uncertainity_penalty这里的重排分数和业务信号都要先各自归一化到同一个尺度,否则业务信号再强也会被模型分数淹没。取λ的权重时,同样建议离线评测确定,而不是拍脑袋。我踩过的坑是:有一段时间把时效性权重调得过重,导致系统把大量“时效性强但不相关”的结果顶上去了,用户体验直线下降。后来把时效性限制做成一个硬过滤条件而不是软加权,问题才解决。
5. 实测中的常见问题与排查技巧
5.1 召回为空或分数普遍偏低
特殊查询在BM25下命中为空,最常见的原因是分词不一致或停用词把查询词给吞了。比如用户搜“维生素C”里的“C”被当成单字过滤,“iPhone 15 Pro”里“Pro”没有被索引分词器切出来。排查方式是直接看这轮查询的分词结果,确认查询词和索引词是否对得上。
向量检索召回为空的情况相对少见,但会出现“召回非空、分数普遍偏低”的现象。如果所有候选的余弦相似度都在0.3到0.5之间,先别急着怀疑模型有问题,很可能只是当前业务的文档和查询本身相关性就偏弱。这时候可以拉出一批人工确认过“确实相关”的文档,统计它们和对应查询的分数分布,对比一下就能判断模型有没有问题。
千万不要不加分析地设置余弦相似度阈值,比如低于0.7全过滤掉。不同模型、不同领域数据的分数分布差异极大,一个对某领域合适的阈值,换一个领域可能把所有结果都过滤光了。要设阈值,也得基于自己数据集的统计分布来设。
5.2 RRF的k值与融合权重怎么调
RRF的k值看起来只有一个参数,调起来其实有讲究。k越小,第一名的权重相对越高,融合结果容易被单一通道的头部结果主导;k越大,排名差异的作用会被稀释,几乎所有参与融合的结果都能获得比较接近的基本分。经验上k=60是个很稳的起点,但不同数据量、不同通道质量,最优k可能差很远。我建议把k在10、30、60、100这几个档位各测一遍,每次跑同一个评测集,看指标变化。
加权融合的权重调试更麻烦一些。如果你的通道质量差异明显——比如向量通道明显比BM25准,可以把向量权重调到0.7以上;如果两个通道各有各的优势场景,0.5/0.5然后微调更合理。无论怎么调,前提都是有一份靠谱的标注评测集。没有评测集就调权重,基本等于盲调,上线后只能靠运气。
另一个容易踩的坑是,做了加权融合后忘了给BM25分数做归一化。BM25分数受文档长度和词频影响很大,两个通道分数直接相加后,向量分数可能会被BM25分数的波动完全淹没。归一化这一步不能省。
5.3 重排模型推理太慢
重排模型推理慢,常见原因和优化手段我整理一下:
- 候选集过大:top100以上重排基本不建议,降到top20或top30会快非常多。
- 输入过长:最长的候选文档占满了整个batch的最大长度,大量padding浪费算力。解决方法是按长度分组batch,或者把超长文档截断到512 token以内。
- 模型过重:Cross-Encoder模型本身很大,如果用训练好的小号模型,或者做INT8、FP16量化,推理速度能提升数倍。量化对重排分数的影响通常不明显,实测下来排序相关性损失极小。
- CPU跑大模型:如果资源紧张只能上CPU,至少用支持加速指令集的版本,再配合小batch,能把延迟控制在可接受范围内。
还有一个取巧的思路:如果query相似度很高且候选集变动不大,可以把重排结果做缓存,同时用一个轻量的“快速重排模型”先粗排一轮,只拿它认为可能相关的10条再上大模型重排。两级重排虽然在架构上多了一层,但整体延迟反而更稳。
5.4 一个端到端的检索Pipeline示例
把前面所有内容串起来,一个典型的混合检索+重排流程大概长这样:
def search_pipeline(query, top_k_recall=100, top_k_rerank=20, top_k_final=5): # 1. 两路召回并行 bm25_results = bm25_search(query, top_k=top_k_recall) # 稀疏通道 vector_results = vector_search(query, top_k=top_k_recall) # 稠密通道 # 2. 融合:先用RRF得一个粗排结果 fused_results = rrf_fusion([bm25_results, vector_results], k=60) # 3. 取粗排前N条进重排 rerank_candidates = fused_results[:top_k_rerank] rerank_scores = cross_encoder_rerank(query, rerank_candidates) # 4. 和业务信号融合后取最终结果 final_scores = [ (doc_id, 0.8 * rerank_score + 0.2 * business_score(doc_id)) for doc_id, rerank_score in rerank_scores ] final_results = sorted(final_scores, key=lambda x: x[1], reverse=True)[:top_k_final] return final_results这段代码看起来简单,但它把两个阶段、三类算法的核心逻辑都串起来了。实际项目里,你需要替换的是每一行背后的实现:BM25用倒排索引还是开源搜索引擎接口,向量检索用哪个向量库和索引类型,重排模型用哪一版Checkpoint。但整体架构和流程是一致的,照着这个框架填细节就行。
最后再分享一个我反复踩坑后总结出的认知:检索质量的提升,从来不是靠某一个环节突然变强实现的,而是靠“每一层少丢一点、多排准一点”累积出来的。BM25分词调好能涨一点,向量模型换好一点的能涨一点,混合检索的融合策略调对能涨一点,重排模型再精排一轮又能涨一点,叠加起来,最终效果往往远超换一个大模型的收益。这个体系没有银弹,但每一步都有实实在在的优化空间,只要肯下功夫去测量、去迭代,结果一定对得起投入。