搜索重排序(Rerank)实战:让最相关的结果排第一

搜索重排序(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判断相关性,每次查询可能要几分钟甚至更久。

所以最优方案是两阶段架构

  1. 召回阶段:快速筛选,降低候选数量(100万 → 50个)
  2. 重排序阶段:精准排序,提升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. 混合检索:第1名"深层清洁"不适合敏感肌,第2、3名不是洗面奶
  2. BGE重排:准确识别出最佳商品,但3、4、5名还有优化空间
  3. 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)

写在最后

重排序是搜索系统的最后一道关卡,也是提升用户体验的关键。

核心要点:

  1. 召回≠排序:召回负责广度,重排序负责精度
  2. 成本与质量的权衡:BGE便宜快速,LLM准确但贵
  3. 多层防护:BGE → LLM → Fallback
  4. 性能优化:缓存、Token控制、结构化输出
  5. 可观测性:监控准确率、延迟、成本

技术选型建议:

  • 电商搜索:BGE重排(性价比最高)
  • 企业搜索:LLM重排(准确率最重要)
  • 新闻推荐:BGE + LLM两级(平衡成本和质量)

希望这篇文章能帮到正在做搜索/推荐系统的同学。欢迎交流~


参考资源

技术栈

bash 复制代码
# 核心依赖
FlagEmbedding          # BGE重排模型
langchain-openai       # LLM集成
pydantic              # 结构化输出

本文基于真实生产环境的重排序系统实现,代码示例经过简化处理。

相关推荐
__True3 小时前
LLM导购生成:如何让AI不说谎、不编造、不乱推荐
aigc
小小工匠5 小时前
AIGC - 使用 Nano Banana Pro 生成卡通信息图的完整指南
aigc
用户5191495848456 小时前
7-ZiProwler:CVE-2025-11001 漏洞利用工具
人工智能·aigc
Mintopia7 小时前
🪄 生成式应用的 **前端 orchestration 层(编排层)指南**
人工智能·llm·aigc
Mintopia7 小时前
🤖✨ 生成式应用架构师的修炼手册
人工智能·llm·aigc
安思派Anspire8 小时前
数据科学和ML领域的趋势是什么?为2026年做准备
aigc·openai·agent
艺术是真的秃头8 小时前
Trae:当编程从“编写”转向“对话”与“委派”
人工智能·python·ai·aigc
小霖家的混江龙11 小时前
不再费脑, 写给 AI 爱好者的矩阵 (Matrix) 入门指南
人工智能·llm·aigc
猫头虎11 小时前
猫头虎AI分享|可把GitHub代码库变成实时文档中心的一款实用型MCP工具:GitMCP,让AI随时访问最新文档代码,消除代码幻觉
人工智能·github·aigc·ai编程·ai写作·agi·ai-native