把粗排换到精排时踩过的坑
这在某些场景下表现不错:
- 搜精确的技术名词:“TypeScript interface”
- 找特定的文档标题:“产品需求评审流程”
- 按关键词过滤内容:“Python 装饰器”
但很快问题就暴露出来了:
问题背景
我们的搜索服务基于 Elasticsearch,主要用的是 BM25 算法。这在某些场景下表现不错:
- 搜精确的技术名词:“TypeScript interface”
- 找特定的文档标题:“产品需求评审流程”
- 按关键词过滤内容:“Python 装饰器”
但很快问题就暴露出来了:
- 语义理解不足:用户搜"如何优化查询速度",但文档里用的是"性能调优",完全匹配不上
- 上下文缺失:搜"连接失败"时,搜不出"网络异常排查"的相关内容
- 个性化差:同一个搜索词,后端开发想看API文档,前端想看UI组件,但返回的结果一样
传统搜索引擎的局限很清楚:它擅长精确匹配,但不擅长理解意图。
为什么需要重排
在深入解决方案前,先搞清楚"重排"这个概念。
重排(Rerank),简单说就是在初始搜索结果的基础上,再用一个更精细的模型重新打分排序。通常分两步:
- 粗排(Coarse Ranking):快速从海量数据中筛选出候选结果,比如用 BM25、向量检索,范围是前 100-500 条
- 精排(Fine Ranking):对候选结果用更复杂的模型重新评分,找到真正相关的
为什么要这么麻烦?因为:
- 效率问题:用大模型对所有结果打分太慢,成本也太高
- 质量需求:粗排追求速度和召回率,精排追求准确度和相关性
- 多目标平衡:粗排可能只看文本相似度,精排可以综合考虑时效性、点击率、用户偏好
用一张简单的流程图表示:
这张图解释了两件事:为什么分两步,以及每一步的产出是什么。
实现方案
环境和约束
先说清楚我们的真实环境:
- 搜索引擎: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 本身就很快,两个查询并发执行
精排模型选择
精排模型选型花了不少时间。主要看了三个方向:
- Cross-Encoder(交叉编码器):将 query 和文档一起输入模型,直接输出相关性分数
- Bi-Encoder(双向编码器):分别编码 query 和文档,计算向量相似度
- 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 秒,完全不能接受。
试了几种优化:
- GPU 加速:换 A100 后延迟降到 300ms 左右
- 批处理:一次性处理所有候选文档,减少模型加载开销
- 量化:FP16 精度,速度提升约 30%,精度损失可忽略
最终稳定在 150-200ms,基本能满足要求。
候选数量选择
一开始粗排取前 100 条,精排取前 20 条。但测试发现有些相关文档被粗排漏掉了。
做过几次实验:
| 粗排数量 | 精排数量 | 平均延迟 | 召回率 |
|---|---|---|---|
| 50 | 10 | 80ms | 72% |
| 100 | 20 | 180ms | 85% |
| 200 | 30 | 350ms | 89% |
| 500 | 50 | 800ms | 91% |
粗排取多少、精排留多少,本质上是在召回率和推理延迟之间找平衡点——下图能直接看出 100/20 为什么成了我们的最终选择。

100/20 在延迟可控的前提下已经拿到 85% 召回,继续加大候选池收益递减,不值得再牺牲响应时间。
文档长度问题
有些文档特别长,超过 512 token 会被截断,导致关键信息丢失。
试过几个方案:
- 分段重排:把长文档按段落切分,分别打分后取最高分
- 关键段落提取:先用简单规则提取摘要,再重排摘要
- 混合策略:标题+摘要+前两段
实际用的是混合策略,效果相对稳定,复杂度也不高。
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] # 限制长度
冷启动问题
新发布的文档没有点击数据,重排模型可能打分偏低。
我们的处理方式:
- 时效性加权:新文档在发布一周内额外加分
- 人工标注:对重要新文档进行人工相关性标注,用于模型微调
- 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。