搜索重排序(Rerank)实战:让最相关的结果排第一
召回只是开始,精排才是关键
为什么需要重排序?
混合检索已经召回了50个候选商品,为什么还要再排一次?
来看个真实案例。用户搜索"适合敏感肌肤的温和洗面奶":
混合检索召回结果(按融合分数排序):
arduino
第1名: "温和氨基酸洗面奶 深层清洁"
→ 融合分数0.92(向量0.95 + 关键词0.85)
第2名: "敏感肌专用舒缓精华液"
→ 融合分数0.89(向量0.92 + 关键词0.83)
第3名: "温和无刺激卸妆水"
→ 融合分数0.87(向量0.88 + 关键词0.85)
第4名: "氨基酸洗面奶 敏感肌适用 修护屏障"
→ 融合分数0.82(向量0.75 + 关键词0.95)
问题来了:
- 第1名是洗面奶,但强调"深层清洁",不太适合敏感肌
- 第2名根本不是洗面奶,是精华液
- 第3名是卸妆水,也不对
- 第4名才是最佳答案,但排在第4位
为什么会这样?因为召回阶段的融合分数只考虑了文本匹配度,没有深度理解语义和意图。
这就是Rerank重排序要解决的问题:对召回结果进行精细化排序,让最相关的商品排在最前面。
重排序的核心思路
重排序(Rerank)是搜索系统的最后一道关卡:
markdown
召回阶段(Retrieval) 精排阶段(Rerank)
↓ ↓
从百万商品中 从50个候选中
快速筛选出 精确选出
50个候选 Top 5
特点: 特点:
- 快速 - 精准
- 粗粒度 - 细粒度
- 基于特征匹配 - 深度语义理解
- 可能有噪音 - 去除噪音
关键区别:
| 维度 | 召回阶段 | 重排序阶段 |
|---|---|---|
| 数据量 | 百万级 | 几十个 |
| 计算复杂度 | 低(向量点乘、BM25) | 高(Cross-encoder、LLM) |
| 响应时间 | <100ms | <500ms |
| 准确性 | 80% | 95% |
| 成本 | 低 | 高 |
为什么不直接用精准的重排模型做召回?
因为计算成本太高。如果对100万商品都用Cross-encoder或LLM判断相关性,每次查询可能要几分钟甚至更久。
所以最优方案是两阶段架构:
- 召回阶段:快速筛选,降低候选数量(100万 → 50个)
- 重排序阶段:精准排序,提升Top结果质量(50个 → 5个)
三种重排序方案
我们实现了三种重排序方案,分别适用于不同场景。
方案1:BGE Cross-Encoder重排序
核心思路:
BGE (BAAI General Embedding) 是智源研究院开源的重排序模型。与普通的Embedding模型(Bi-encoder)不同,Cross-encoder会同时输入查询和文档,直接输出相关性分数。
ini
# Bi-encoder(召回阶段)
query_vec = encode(query) # [768]
doc_vec = encode(doc) # [768]
score = cosine(query_vec, doc_vec) # 标量
# Cross-encoder(重排序阶段)
score = model(query, doc) # 直接输出相关性分数
实现代码:
ini
from FlagEmbedding import FlagReranker
class BGERerankService:
def __init__(self):
# 加载BGE重排模型
self.reranker = FlagReranker(
'BAAI/bge-reranker-base', # 或 bge-reranker-large
use_fp16=True # 使用半精度加速
)
def rerank(self, query, candidates):
# 准备查询-文档对
pairs = []
for candidate in candidates:
# 拼接商品信息
doc_text = f"{product.title} {product.description} {product.tags}"
pairs.append([query, doc_text])
# BGE计算相关性分数
scores = self.reranker.compute_score(pairs)
# 按分数排序
candidate_scores = list(zip(candidates, scores))
candidate_scores.sort(key=lambda x: x[1], reverse=True)
return candidate_scores[:5] # 返回Top 5
优势:
- ✅ 速度快:50个候选只需要50-100ms
- ✅ 准确:专门训练的重排模型
- ✅ 成本低:本地部署,无API费用
- ✅ 稳定:结果可预测
劣势:
- ❌ 需要GPU:CPU推理较慢
- ❌ 固定模型:无法针对业务定制
适用场景:
- 高并发场景(几千QPS)
- 对成本敏感
- 对延迟敏感(需要<200ms响应)
方案2:LLM重排序
核心思路:
直接让GPT这类大模型判断哪个商品最相关。LLM有强大的语义理解能力,能准确理解用户意图。
实现代码:
python
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field
class LLMRerankResponse(BaseModel):
"""结构化输出"""
ranked_skus: List[str] = Field(
description="按相关度排序的SKU ID列表"
)
class RerankService:
def __init__(self):
self.llm = ChatOpenAI(model="gpt-4o-mini")
# 使用结构化输出,避免JSON解析失败
self.structured_llm = self.llm.with_structured_output(
LLMRerankResponse
)
# 简洁的prompt,控制token消耗
self.prompt = ChatPromptTemplate.from_messages([
("system", """你是商品排序专家。
根据用户查询对候选商品进行精准排序。
排序优先级:
1. 语义相关度(最重要)
2. 商品质量匹配度
3. 文本丰富度
只返回top {top_n}个最相关SKU。"""),
("human", "查询: {query}\n\n候选商品:\n{candidates_info}")
])
def rerank(self, query, candidates):
# 格式化候选商品(控制token消耗)
candidates_info = self._format_candidates(candidates)
# 调用LLM
messages = self.prompt.invoke({
"query": query,
"candidates_info": candidates_info,
"top_n": 5
})
# 获取结构化输出
response = self.structured_llm.invoke(messages)
return response.ranked_skus
def _format_candidates(self, candidates):
"""简洁格式化,减少token"""
info_list = []
for i, candidate in enumerate(candidates, 1):
# 限制长度,避免超token
title = product.title[:100]
desc = product.description[:200]
tags = ', '.join(product.tags[:5])
info = f"{i}. {candidate.sku_id}: {title}"
if desc:
info += f" | {desc}"
if tags:
info += f" | 标签: {tags}"
info_list.append(info)
return "\n".join(info_list)
优势:
- ✅ 准确度高:语义理解能力强
- ✅ 灵活:可以加入业务规则(如"优先推荐新品")
- ✅ 无需训练:直接使用通用模型
劣势:
- ❌ 成本高:每次调用都要钱(gpt-4o-mini约$0.0001/次)
- ❌ 延迟高:API调用需要500ms-2s
- ❌ 不稳定:可能超时或限流
成本优化技巧:
ini
# 1. 控制候选数量
candidates = candidates[:20] # 只对前20个重排
# 2. 精简商品信息
title = product.title[:100] # 限制标题长度
desc = product.description[:200] # 限制描述
# 3. 使用更便宜的模型
llm = ChatOpenAI(model="gpt-4o-mini") # 比gpt-4便宜60倍
# 4. 批量处理
# 将多个查询合并成一个请求(降低固定开销)
成本对比:
bash
假设每天100万次查询
方案A:对所有查询用LLM重排
- 成本: 100万 × $0.0001 = $100/天
- 月成本: $3000
方案B:BGE重排 + LLM兜底(失败时)
- BGE成本: $0(本地部署)
- LLM兜底: 1% × 100万 × $0.0001 = $1/天
- 月成本: $30
节省97%!
适用场景:
- 低并发场景(<100 QPS)
- 对准确率要求极高
- 预算充足
方案3:Fallback(基于商品质量)
当BGE和LLM都不可用时(模型加载失败、API超时等),需要一个兜底方案。
核心思路:
基于商品自身的质量分数排序,而不是召回分数。
python
def _unified_fallback_rerank(candidates, top_n=5):
"""
基于商品质量排序
"""
candidates_with_score = []
for candidate in candidates:
# 基于商品质量的分数计算
quality_score = 0.0
# 标题完整度 (0-1)
title_score = min(len(product.title), 100) / 100.0
# 描述丰富度 (0-1)
desc_score = min(len(product.description), 500) / 500.0
# 标签数量 (0-1)
tag_score = min(len(product.tags), 10) / 10.0
# 综合质量分数
quality_score = (
0.5 * title_score +
0.3 * desc_score +
0.2 * tag_score
)
candidates_with_score.append((candidate, quality_score))
# 按质量分数排序
candidates_with_score.sort(key=lambda x: x[1], reverse=True)
return candidates_with_score[:top_n]
为什么不用召回分数排序?
召回分数(hybrid_score)已经在混合检索阶段用过了,如果重排序还用它,相当于没排序。
商品质量分数是一个独立维度,能在一定程度上提升排序质量。
适用场景:
- 兜底机制
- 系统初始化阶段(模型还没加载)
- 降级策略
工厂模式:灵活切换重排方案
实际生产中,我们希望能灵活切换重排方案,而不是hardcode。
python
def create_reranker(reranker_type=None, top_n=None):
"""
工厂函数:根据配置创建重排序服务
Args:
reranker_type: 'bge' 或 'llm'
top_n: 返回的Top-N数量
Returns:
重排序服务实例
"""
# 从环境变量读取配置
reranker_type = reranker_type or os.getenv('RERANKER_TYPE', 'bge')
top_n = top_n or int(os.getenv('RERANK_TOP_N', '5'))
if reranker_type == "bge":
try:
return BGERerankService(top_n=top_n)
except ImportError:
print("BGE模型未安装,降级到LLM")
reranker_type = "llm"
if reranker_type == "llm":
try:
llm = create_llm()
return RerankService(llm=llm, top_n=top_n)
except Exception as e:
print(f"LLM初始化失败: {e},使用Fallback")
return FallbackReranker(top_n=top_n)
# 最终兜底
return FallbackReranker(top_n=top_n)
配置文件 .env:
ini
# 重排序配置
RERANKER_TYPE=bge # 或 llm
RERANK_TOP_N=5
# BGE模型配置
RERANKER_MODEL=BAAI/bge-reranker-base # 或 bge-reranker-large
# LLM配置
OPENAI_MODEL=gpt-4o-mini
OPENAI_TEMPERATURE=0.1
使用方式:
ini
# 方式1:使用默认配置(从环境变量读取)
reranker = create_reranker()
# 方式2:显式指定
reranker = create_reranker(reranker_type="bge", top_n=10)
# 方式3:切换到LLM
# 只需修改.env文件,不用改代码
# RERANKER_TYPE=llm
性能优化技巧
1. 商品信息缓存
重排序需要频繁访问商品详情,如果每次都查数据库会很慢。
问题代码:
ini
def rerank(self, query, candidates):
for candidate in candidates:
# 每次都查数据库 - 慢!
product = db.get_product(candidate.sku_id)
doc_text = f"{product.title} {product.description}"
# ...
优化方案:模块级缓存
python
# 模块级缓存,所有实例共享
_cached_product_map = None
def _get_product_map():
"""获取缓存的商品信息"""
global _cached_product_map
if _cached_product_map is None:
db = ProductDatabase()
_cached_product_map = {
p["sku_id"]: p for p in db.products
}
return _cached_product_map
class BGERerankService:
def __init__(self):
# 使用缓存
self.product_map = _get_product_map()
def rerank(self, query, candidates):
for candidate in candidates:
# 直接从内存获取 - 快!
product = self.product_map.get(candidate.sku_id, {})
# ...
效果:
ini
查数据库: 50个商品 × 5ms = 250ms
查缓存: 50个商品 × 0.01ms = 0.5ms
提升500倍!
2. 控制LLM Token消耗
LLM按token计费,必须控制消耗。
Token消耗分析:
ini
# 假设50个候选商品
candidates_info = """
1. SKU001: 某某牌温和氨基酸洗面奶 深层清洁控油保湿 敏感肌适用 150ml |
本品采用进口氨基酸配方,温和不刺激,深层清洁毛孔,有效控油,补水保湿...(省略200字)|
标签: 氨基酸, 温和, 清洁, 控油, 保湿, 敏感肌, 进口, 深层清洁, 不刺激, 补水
2. SKU002: ...
...
50. SKU050: ...
"""
# Token统计
system_prompt: 100 tokens
candidates_info: 50 × 300 = 15000 tokens
总计: 约15100 tokens
优化策略:
python
def _format_candidates_compact(self, candidates):
"""精简格式化,减少token"""
info_list = []
for i, candidate in enumerate(candidates, 1):
# 1. 限制长度
title = product.title[:100] # 不要全部标题
desc = product.description[:200] # 描述只取前200字
tags = ', '.join(product.tags[:5]) # 只保留5个标签
# 2. 简化格式(去掉"本品"、"采用"等废话)
info = f"{i}. {candidate.sku_id}: {title}"
if desc:
info += f" | {desc}"
if tags:
info += f" | {tags}"
info_list.append(info)
return "\n".join(info_list)
效果对比:
| 优化项 | 优化前 | 优化后 | 节省 |
|---|---|---|---|
| 标题长度 | 不限 | 100字 | 40% |
| 描述长度 | 不限 | 200字 | 60% |
| 标签数量 | 全部 | 5个 | 50% |
| 总Token | 15000 | 6000 | 60% |
成本节省:
bash
优化前: 15000 tokens × $0.00001 = $0.00015/次
优化后: 6000 tokens × $0.00001 = $0.00006/次
每次节省$0.00009
100万次查询节省$90
3. 结构化输出(避免JSON解析失败)
LLM返回JSON时,经常会有格式问题:
objectivec
# 可能的错误格式
"""
```json
{
"ranked_skus": ["SKU001", "SKU002"]
}
"""
或者
""" 好的,根据查询"温和洗面奶",我推荐以下商品: { "ranked_skus": ["SKU001", "SKU002"] } """
ini
**传统做法:**
```python
response = llm.invoke(prompt)
content = response.content
# 清理markdown
if content.startswith("```json"):
content = content[7:]
# ...各种清理逻辑
try:
result = json.loads(content)
except JSONDecodeError:
# 解析失败,怎么办?
return fallback_result
新做法:使用Structured Output
ini
from pydantic import BaseModel, Field
class LLMRerankResponse(BaseModel):
ranked_skus: List[str] = Field(
description="按相关度排序的SKU ID列表"
)
# LangChain 1.1.0新特性
structured_llm = llm.with_structured_output(LLMRerankResponse)
# 直接获取结构化对象,无需手动解析JSON
response: LLMRerankResponse = structured_llm.invoke(messages)
ranked_skus = response.ranked_skus # 类型安全
优势:
- ✅ 无需手动解析JSON
- ✅ 类型安全
- ✅ 自动验证(Pydantic)
- ✅ 减少错误处理代码
实战效果对比
我们在电商搜索系统中对比了三种方案:
测试场景
Query: "适合敏感肌肤的温和洗面奶"
候选商品: 50个(混合检索召回)
结果对比
| 排名 | 混合检索 | BGE重排 | LLM重排 |
|---|---|---|---|
| 1 | 温和氨基酸洗面奶(深层清洁) | 氨基酸洗面奶(敏感肌专用) | 氨基酸洗面奶(敏感肌专用) |
| 2 | 敏感肌舒缓精华液 ❌ | 温和氨基酸洗面奶(深层清洁) | 温和低泡洗面奶(修护屏障) |
| 3 | 温和无刺激卸妆水 ❌ | 温和低泡洗面奶(修护屏障) | 温和氨基酸洗面奶(深层清洁) |
| 4 | 氨基酸洗面奶(敏感肌专用) | 敏感肌舒缓洗面奶 | 敏感肌舒缓洗面奶 |
| 5 | 温和低泡洗面奶 | 氨基酸泡沫洁面 | 弱酸性温和洁面乳 |
分析:
- 混合检索:第1名"深层清洁"不适合敏感肌,第2、3名不是洗面奶
- BGE重排:准确识别出最佳商品,但3、4、5名还有优化空间
- LLM重排:Top 5都非常相关,且排序最符合用户意图
性能指标
| 指标 | 混合检索 | BGE重排 | LLM重排 |
|---|---|---|---|
| Top-1准确率 | 65% | 88% | 92% |
| Top-5相关性 | 72% | 91% | 96% |
| 平均延迟 | 65ms | 145ms | 680ms |
| 每次成本 | $0 | $0 | $0.00006 |
| QPS支持 | 5000 | 1000 | 50 |
结论:
- 高并发场景:BGE重排(准确率↑25%,成本低)
- 低并发+高质量场景:LLM重排(准确率↑30%,但贵)
- 降级场景:Fallback(至少保证基本排序)
多级重排序策略
实际生产中,我们采用混合策略:
python
def rerank_pipeline(query, candidates):
"""
多级重排序管道
"""
# 第一级:BGE快速粗排(50个 → 20个)
if len(candidates) > 20:
bge_reranker = create_reranker("bge", top_n=20)
candidates = bge_reranker.rerank(query, candidates)
# 第二级:LLM精排(20个 → 5个)
llm_reranker = create_reranker("llm", top_n=5)
final_results = llm_reranker.rerank(query, candidates)
return final_results
优势:
- BGE处理大部分筛选工作(50→20),成本低
- LLM只处理20个,成本可控
- 两阶段结合,准确率最高
成本对比:
diff
方案A:直接LLM重排50个
- Token消耗: 15000
- 成本: $0.00015
方案B:BGE(50→20) + LLM(20→5)
- BGE成本: $0
- LLM Token: 6000
- 成本: $0.00006
节省60%!
踩坑经验总结
1. BGE模型选择
BGE有三个版本:
| 模型 | 参数量 | 准确率 | 速度 |
|---|---|---|---|
| bge-reranker-base | 278M | 基准 | 快 |
| bge-reranker-large | 560M | +3% | 慢2倍 |
| bge-reranker-v2-m3 | 560M | +5% | 慢3倍 |
建议:
- 高并发场景:base(够用了)
- 准确率敏感:large
- 多语言:v2-m3
2. LLM温度设置
重排序需要确定性结果,温度要低:
ini
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.1 # 低温度,结果稳定
)
如果temperature=0.7,同一个查询可能每次排序结果都不同!
3. 处理空结果
ini
def rerank(self, query, candidates):
# 边界情况处理
if not candidates:
return RerankOutput(ranked_candidates=[], rerank_type="empty")
if len(candidates) == 1:
# 只有一个候选,直接返回
return RerankOutput(
ranked_candidates=[candidates[0]],
rerank_type="single"
)
# 正常重排序逻辑...
4. 超时和降级
python
try:
# 设置超时
response = llm.invoke(messages, timeout=5.0)
except TimeoutError:
print("LLM超时,降级到BGE")
return bge_reranker.rerank(query, candidates)
except Exception as e:
print(f"LLM失败: {e},使用Fallback")
return fallback_rerank(candidates)
写在最后
重排序是搜索系统的最后一道关卡,也是提升用户体验的关键。
核心要点:
- 召回≠排序:召回负责广度,重排序负责精度
- 成本与质量的权衡:BGE便宜快速,LLM准确但贵
- 多层防护:BGE → LLM → Fallback
- 性能优化:缓存、Token控制、结构化输出
- 可观测性:监控准确率、延迟、成本
技术选型建议:
- 电商搜索:BGE重排(性价比最高)
- 企业搜索:LLM重排(准确率最重要)
- 新闻推荐:BGE + LLM两级(平衡成本和质量)
希望这篇文章能帮到正在做搜索/推荐系统的同学。欢迎交流~
参考资源
- BGE模型 : github.com/FlagOpen/Fl...
- LangChain : python.langchain.com/
- Pydantic : docs.pydantic.dev/
技术栈
bash
# 核心依赖
FlagEmbedding # BGE重排模型
langchain-openai # LLM集成
pydantic # 结构化输出
本文基于真实生产环境的重排序系统实现,代码示例经过简化处理。