把粗排换到精排时踩过的坑

这在某些场景下表现不错:

  • 搜精确的技术名词:“TypeScript interface”
  • 找特定的文档标题:“产品需求评审流程”
  • 按关键词过滤内容:“Python 装饰器”

但很快问题就暴露出来了:

问题背景

我们的搜索服务基于 Elasticsearch,主要用的是 BM25 算法。这在某些场景下表现不错:

  • 搜精确的技术名词:“TypeScript interface”
  • 找特定的文档标题:“产品需求评审流程”
  • 按关键词过滤内容:“Python 装饰器”

但很快问题就暴露出来了:

  1. 语义理解不足:用户搜"如何优化查询速度",但文档里用的是"性能调优",完全匹配不上
  2. 上下文缺失:搜"连接失败"时,搜不出"网络异常排查"的相关内容
  3. 个性化差:同一个搜索词,后端开发想看API文档,前端想看UI组件,但返回的结果一样

传统搜索引擎的局限很清楚:它擅长精确匹配,但不擅长理解意图。

为什么需要重排

在深入解决方案前,先搞清楚"重排"这个概念。

重排(Rerank),简单说就是在初始搜索结果的基础上,再用一个更精细的模型重新打分排序。通常分两步:

  1. 粗排(Coarse Ranking):快速从海量数据中筛选出候选结果,比如用 BM25、向量检索,范围是前 100-500 条
  2. 精排(Fine Ranking):对候选结果用更复杂的模型重新评分,找到真正相关的

为什么要这么麻烦?因为:

  • 效率问题:用大模型对所有结果打分太慢,成本也太高
  • 质量需求:粗排追求速度和召回率,精排追求准确度和相关性
  • 多目标平衡:粗排可能只看文本相似度,精排可以综合考虑时效性、点击率、用户偏好

用一张简单的流程图表示:

graph LR A[用户查询] --> B[粗排检索<br/>BM25/向量检索] B --> C[候选结果<br/>100-500条] C --> D[精排模型<br/>BERT/Cross-Encoder] D --> E[最终结果<br/>Top 10-20] E --> F[返回用户]

这张图解释了两件事:为什么分两步,以及每一步的产出是什么。

实现方案

环境和约束

先说清楚我们的真实环境:

  • 搜索引擎:Elasticsearch 7.x
  • 数据规模:约 50 万条文档
  • 搜索 QPS:峰值约 200
  • 模型推理环境:Python 3.9,推理服务单独部署
  • 硬件:单张 NVIDIA A100(推理服务),16G 内存

这些约束直接影响技术选择:不能用太重的模型,推理延迟必须控制在 100ms 以内,成本要可控。

粗排实现

粗排我们用了两部分:BM25 + 向量检索。

BM25 也就是 Elasticsearch 默认的文本相关性算法,没啥好说的,开箱即用。向量检索则是基于 Sentence-BERT 生成文档嵌入。

文档索引时同时生成向量:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')

def generate_embedding(text):
    # 生成文档向量
    embedding = model.encode(text, convert_to_numpy=True)
    return embedding.tolist()

搜索时并行查询,取并集:

def coarse_search(query, top_k=100):
    # BM25 搜索
    bm25_results = es.search(
        index="documents",
        body={
            "query": {
                "match": {
                    "content": query
                }
            },
            "size": top_k
        }
    )
    
    # 向量检索
    query_vector = model.encode(query).tolist()
    vector_results = es.search(
        index="documents",
        body={
            "query": {
                "script_score": {
                    "query": {"match_all": {}},
                    "script": {
                        "source": "cosineSimilarity(params.query_vector, 'embedding') + 1.0",
                        "params": {"query_vector": query_vector}
                    }
                }
            },
            "size": top_k
        }
    )
    
    # 合并去重
    all_ids = set()
    candidates = []
    for hit in bm25_results['hits']['hits'] + vector_results['hits']['hits']:
        doc_id = hit['_id']
        if doc_id not in all_ids:
            all_ids.add(doc_id)
            candidates.append({
                'id': doc_id,
                'score': hit['_score'],
                'content': hit['_source']['content'],
                'title': hit['_source']['title']
            })
    
    return candidates[:top_k]

这个粗排方案有两个好处:

  • 召回率高:关键词匹配和语义检索互补,不容易漏掉相关结果
  • 速度可接受:Elasticsearch 本身就很快,两个查询并发执行

精排模型选择

精排模型选型花了不少时间。主要看了三个方向:

  1. Cross-Encoder(交叉编码器):将 query 和文档一起输入模型,直接输出相关性分数
  2. Bi-Encoder(双向编码器):分别编码 query 和文档,计算向量相似度
  3. LLM 重排:用大语言模型直接判断相关性

最终选了 Cross-Encoder,原因很简单:

  • 准确度高:模型能看到 query 和文档的完整上下文,语义理解更好
  • 成本可控:比 LLM 便宜很多,推理速度也更快
  • 开源成熟:微软的 mMARCO、Cohere 的 rerank-english 都是现成选择

我们用的是 BAAI/bge-reranker-base 模型,效果和速度的平衡还不错。

精排实现

精排模型部署成独立的 gRPC 服务:

import grpc
from concurrent import futures
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification

class RerankerServicer:
    def __init__(self):
        self.tokenizer = AutoTokenizer.from_pretrained('BAAI/bge-reranker-base')
        self.model = AutoModelForSequenceClassification.from_pretrained('BAAI/bge-reranker-base')
        self.model.eval()
        
    def Rerank(self, request, context):
        query = request.query
        documents = [doc.content for doc in request.documents]
        
        # 构建 query-doc 对
        pairs = [[query, doc] for doc in documents]
        
        with torch.no_grad():
            inputs = self.tokenizer(pairs, padding=True, truncation=True, 
                                   return_tensors='pt', max_length=512)
            scores = self.model(**inputs, return_dict=True).logits.view(-1).float()
        
        # 返回重新排序的结果
        scored_docs = list(zip(request.documents, scores.tolist()))
        scored_docs.sort(key=lambda x: x[1], reverse=True)
        
        return RerankResponse(
            documents=[doc for doc, score in scored_docs],
            scores=[score for doc, score in scored_docs]
        )

调用端实现:

def fine_rank(query, candidates, top_k=20):
    if not candidates:
        return []
    
    # 调用重排服务
    stub = rerank_pb2_grpc.RerankerStub(channel)
    request = rerank_pb2.RerankRequest(
        query=query,
        documents=[rerank_pb2.Document(
            id=doc['id'],
            title=doc['title'],
            content=doc['content']
        ) for doc in candidates]
    )
    
    response = stub.Rerank(request, timeout=0.5)  # 500ms 超时
    
    # 返回排序后的结果
    results = []
    for doc, score in zip(response.documents, response.scores):
        results.append({
            'id': doc.id,
            'title': doc.title,
            'content': doc.content,
            'rerank_score': score
        })
    
    return results[:top_k]

完整流程:

def search(query):
    # 粗排
    candidates = coarse_search(query, top_k=100)
    
    # 精排
    results = fine_rank(query, candidates, top_k=20)
    
    return results

踩过的坑

模型推理延迟

第一个坑是延迟。刚开始直接用 CPU 推理,精排 100 条文档要 2-3 秒,完全不能接受。

试了几种优化:

  1. GPU 加速:换 A100 后延迟降到 300ms 左右
  2. 批处理:一次性处理所有候选文档,减少模型加载开销
  3. 量化:FP16 精度,速度提升约 30%,精度损失可忽略

最终稳定在 150-200ms,基本能满足要求。

候选数量选择

一开始粗排取前 100 条,精排取前 20 条。但测试发现有些相关文档被粗排漏掉了。

做过几次实验:

粗排数量精排数量平均延迟召回率
501080ms72%
10020180ms85%
20030350ms89%
50050800ms91%

粗排取多少、精排留多少,本质上是在召回率和推理延迟之间找平衡点——下图能直接看出 100/20 为什么成了我们的最终选择。

搜索重排:不同粗排/精排数量组合下的召回率与平均延迟权衡曲线

100/20 在延迟可控的前提下已经拿到 85% 召回,继续加大候选池收益递减,不值得再牺牲响应时间。

文档长度问题

有些文档特别长,超过 512 token 会被截断,导致关键信息丢失。

试过几个方案:

  1. 分段重排:把长文档按段落切分,分别打分后取最高分
  2. 关键段落提取:先用简单规则提取摘要,再重排摘要
  3. 混合策略:标题+摘要+前两段

实际用的是混合策略,效果相对稳定,复杂度也不高。

def prepare_document_for_rerank(doc):
    # 提取关键部分
    parts = [doc.get('title', '')]
    
    if 'summary' in doc:
        parts.append(doc['summary'])
    
    content = doc.get('content', '')
    paragraphs = content.split('\n\n')[:2]  # 前两段
    parts.extend(paragraphs)
    
    return ' '.join(parts)[:1000]  # 限制长度

冷启动问题

新发布的文档没有点击数据,重排模型可能打分偏低。

我们的处理方式:

  1. 时效性加权:新文档在发布一周内额外加分
  2. 人工标注:对重要新文档进行人工相关性标注,用于模型微调
  3. AB 测试:小流量测试新文档的点击情况,调整权重

这个问题没有完美解,只能在实际业务中不断调整。

结果和效果

上线后做了两周的 AB 测试,主要指标:

  • CTR(点击率):从 18.5% 提升到 26.3%,提升约 42%
  • 首位准确率:用户点击第一个结果的比例从 45% 提升到 61%
  • 搜索次数:平均搜索次数从 1.8 次降到 1.4 次,说明更少需要翻页或重新搜索
  • 用户反馈:内部支持工单中关于"搜不到"的投诉减少约 60%

虽然离"智能搜索"还很远,但至少从"能搜"进步到了"好搜"。

一些思考

这次实践让我对"AI + 传统系统"有了更深的理解。

重排不是要完全替代传统搜索,而是在现有基础上做最后一道精细化处理。粗排的召回率 + 精排的准确度,比单独任何一端都更有效。

另一个感受是:没有银弹。模型再强,也得解决实际问题——延迟、成本、冷启动、长文档,这些都是工程问题,不是纯算法问题。

最后,用户搜索这件事,本质上是在表达需求,而我们的工作就是更准确地理解这个需求。从这个角度看,重排只是起点,后面还有个性化、上下文理解、意图识别,路还很长。

但至少现在,搜"Git 分支管理规范"时,排在第一的真的是那份文档了。

版权声明: 本文首发于 指尖魔法屋-把粗排换到精排时踩过的坑https://blog.thinkmoon.cn/post/367-ai-reranking-coarse-rank-fine-rank-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!