从查询走到结果:AI搜索排序笔记
听起来很高端,但实际做下来踩了不少坑,特别是从传统BM25到learning-to-rank的过渡阶段。
最近接了个需求,要把我们产品的搜索结果排序从纯规则改成AI驱动的学习排序。
为什么要写这篇文章
最近接了个需求,要把我们产品的搜索结果排序从纯规则改成AI驱动的学习排序。听起来很高端,但实际做下来踩了不少坑,特别是从传统BM25到learning-to-rank的过渡阶段。
最痛苦的是:网上讲算法原理的很多,但讲"怎么在生产环境中落地"的很少。很多教程都是跑个demo就完事了,没人告诉你真实环境中有哪些坑——数据质量、特征工程、模型部署、线上效果验证,每一步都有细节。
所以写下这篇文章,把整个从查询到结果的实践过程记录下来,希望能帮到同样在做搜索排序的同学。
背景:为什么需要学习排序
我们原来的搜索很简单:用户输入关键词,我们用BM25计算相关性分数,然后按分数降序返回结果。这在大词量查询时效果还行,但遇到一些场景就暴露问题了:
场景1:用户搜索"最佳相机"
- BM25只会关注"最佳"和"相机"的词频匹配
- 但用户真正关心的是哪款相机最好,而不是哪个页面包含"最佳相机"这个词
场景2:搜索历史行为的影响
- 老用户经常搜索技术文档,应该优先返回权威内容
- 新用户可能更适合返回入门教程
- BM25完全不知道用户是谁
场景3:时效性内容
- 搜索"最新iPhone价格",BM25可能返回2023年的老文章
- 我们需要知道内容是何时发布的、是否还在更新
这些问题都指向一个核心:我们需要一个能综合考虑多维度因素的排序模型。
需求分析
在动手之前,先梳理清楚我们的需求:
核心需求
- 提升搜索准确率,特别是长尾查询
- 支持个性化排序(基于用户历史行为)
- 考虑内容质量(原创性、时效性、互动数据)
- 性能要可控,不能增加太多延迟
非功能需求
- 线上推理时间 < 100ms
- 支持A/B测试,可对比新旧排序效果
- 模型训练周期可控(不超过1天)
- 支持特征回溯,便于问题排查
实现方案
整体架构
先看整体流程图:
检索阶段
我们采用两阶段检索:粗排+精排。
粗排阶段还是用BM25,但做了点优化:
- 加入了N-gram匹配(2-gram和3-gram)
- 对长查询做query expansion
- 过滤明显不相关的内容
def retrieve_bm25(query: str, top_k: int = 100) -> List[Dict]:
# 基础BM25检索
base_results = bm25.search(query, k=top_k * 2)
# Query expansion for long queries
if len(query.split()) > 3:
expanded_terms = extract_expanded_terms(query)
expanded_results = bm25.search(" ".join(expanded_terms), k=top_k // 2)
base_results = merge_results(base_results, expanded_results)
# 过滤低质量内容
filtered = filter_low_quality(base_results)
return filtered[:top_k]
特征工程
这是最耗时也最重要的部分。我们提取了以下几类特征:
文本特征
- BM25分数
- 查询词在标题/正文中的位置
- 查询词与标题的语义相似度(用sentence-transformers计算)
- 文本长度、段落数
内容质量特征
- 原创性评分(通过比对相似内容)
- 发布时间、更新时间
- 阅读量、点赞数、评论数
- 站内点击率历史
用户特征
- 用户历史点击的相关内容
- 用户兴趣标签
- 搜索历史
- 地理位置匹配度
def extract_features(query: str, doc: Dict, user: Dict) -> np.ndarray:
features = []
# 文本特征
features.append(calculate_bm25(query, doc))
features.append(query_title_similarity(query, doc['title']))
features.append(term_position_score(query, doc['content']))
# 质量特征
features.append(doc['view_count'] / max(doc['view_count'], 1))
features.append(time_decay(doc['publish_date']))
features.append(originality_score(doc))
# 用户特征
features.append(user_interest_match(user, doc))
features.append(click_history_similarity(user, doc))
return np.array(features)
模型选择
试了几个模型:
| 模型 | 优点 | 缺点 | 最终选择 |
|---|---|---|---|
| Linear Regression | 训练快,解释性强 | 表达能力弱 | 否 |
| Random Forest | 非线性能力强 | 推理慢,难以部署 | 否 |
| XGBoost | 性能好,速度快 | 特征重要性解释一般 | 备选 |
| LambdaMART | 专为排序设计 | 训练稍复杂 | 选中 |
最后选择LambdaMART,原因是:
- 专门为排序任务设计
- 直接优化排序指标(NDCG、MRR)
- XGBoost实现成熟,性能好
- 社区支持好,资料多
模型训练
训练数据是关键。我们采用了两种方式:
方式1:人工标注数据
让内部员工对搜索结果进行 relevance 打分(0-4分)
- 优点:数据质量高
- 缺点:成本高,规模小
方式2:弱监督学习
基于用户行为自动生成训练数据:
- 点击率高的结果 -> relevance高
- 停留时间长的 -> relevance高
- 转化成功的 -> relevance最高
def generate_training_data(click_logs: List[Dict]):
training_data = []
for session in group_by_session(click_logs):
query = session['query']
results = session['results']
# 计算每个结果的pseudo relevance
relevance_scores = []
for result in results:
score = 0
if result['clicked']:
score += 1
if result['dwell_time'] > 30:
score += 0.5
if result['converted']:
score += 2
relevance_scores.append(score)
# 归一化到0-4
relevance_scores = normalize_to_0_4(relevance_scores)
training_data.append({
'query': query,
'results': results,
'relevance': relevance_scores
})
return training_data
训练代码:
import xgboost as xgb
from sklearn.metrics import ndcg_score
def train_lambdamart(training_data):
# 准备训练样本
X_train = []
y_train = []
qids = []
qid = 0
for data in training_data:
features = [extract_features(data['query'], doc) for doc in data['results']]
relevance = data['relevance']
X_train.extend(features)
y_train.extend(relevance)
qids.extend([qid] * len(features))
qid += 1
# 转换为DMatrix
dtrain = xgb.DMatrix(X_train, label=y_train)
dtrain.set_group([len(data['results']) for data in training_data])
# 训练参数
params = {
'objective': 'rank:pairwise',
'metric': 'ndcg',
'max_depth': 6,
'eta': 0.1,
'gamma': 1.0,
'min_child_weight': 0.1,
'subsample': 0.8,
'colsample_bytree': 0.8
}
# 训练
model = xgb.train(params, dtrain, num_boost_round=100)
return model
踩坑记录
坑1:特征爆炸
刚开始一下子提取了200多个特征,结果:
- 训练时间爆炸(从1小时变成6小时)
- 推理延迟超标(从50ms变成200ms)
- 很多特征噪音很大
解决方案:
- 用SHAP值分析特征重要性
- 剔除相关性高的冗余特征(保留一个即可)
- 降维:PCA到50维(但解释性变差)
坑2:训练数据分布偏差
初期只用了点击数据,结果模型偏向"标题党":
- 标题夸张的点击率高 -> 被认为是好结果
- 但用户实际停留时间短 -> 体验差
解决方案:
- 引入停留时间、跳出率等用户满意度指标
- 人工抽样检查训练数据质量
- 加入对抗样本(标题夸张但内容差的负样本)
坑3:冷启动问题
新内容没有点击数据,模型倾向于排到后面:
- 导致新内容很难被展示
- 陷入恶性循环
解决方案:
- 新内容给予"探索性曝光"(展示给部分用户)
- 使用内容特征预测潜在质量
- 降权时间衰减因子
坑4:线上特征不一致
训练时的特征和线上推理的特征不一致:
- 训练时用了"过去7天点击率",但线上是"实时点击率"
- 导致模型效果下降
解决方案:
- 统一特征计算逻辑
- 做特征一致性监控
- 训练时模拟线上环境
结果评估
离线指标
| 指标 | BM25 | LambdaMART | 提升 |
|---|---|---|---|
| NDCG@10 | 0.62 | 0.71 | +14.5% |
| MRR | 0.48 | 0.55 | +14.6% |
| Precision@5 | 0.56 | 0.63 | +12.5% |
离线三项指标的提升幅度相近,说明 LambdaMART 在整体排序质量上是均衡改善而非单点优化:

三项离线指标均提升约 12–15%,为后续线上 A/B 测试提供了可信的离线信号。
线上指标
A/B测试结果(7天数据):
| 指标 | 对照组 | 实验组 | 提升 |
|---|---|---|---|
| 搜索点击率 | 18.3% | 20.1% | +9.8% |
| 平均停留时长 | 42s | 48s | +14.3% |
| 搜索转化率 | 3.2% | 3.7% | +15.6% |
| 无结果率 | 8.5% | 7.1% | -16.5% |
性能指标
- 端到端延迟:从 80ms 降至 95ms(可接受)
- QPS:支持 2000 QPS
- GPU使用率:30%(部署在单卡T4)
遗留问题
虽然效果有提升,但还有一些问题没解决:
1. 长尾查询效果仍不理想
- 对冷门查询,BM25召回质量就不够好
- 学习排序只是在现有召回基础上优化
- 需要优化查询理解和召回策略
2. 多意图查询处理
- “苹果"是水果还是公司?
- 当前只按单一意图排序
- 需要做意图识别和结果分组
3. 实时性不够
- 热点事件需要几个小时才能体现在排序中
- 需要实现流式特征更新和热部署
4. 模型漂移
- 随时间推移,用户行为分布变化
- 需要定期监控和重训练
- 目标是自动化模型更新流水线
总结
从BM25到Learning-to-Rank的实践,踩了不少坑,但收获也很多:
关键成功因素:
- 先把召回做好,精排才有基础
- 特征工程比模型选择更重要
- 监控数据质量比模型复杂度更关键
- A/B测试是验证效果的唯一标准
如果重来一次:
- 先做好数据标注管道,而不是后期补
- 更早关注特征一致性监控
- 从第一天就设计好实验方案
- 投入更多时间做特征工程
下一步计划:
- 引入深度学习模型(如BERT reranker)
- 优化长尾查询和多意图处理
- 实现自动化模型训练和部署
- 探索联邦学习,保护用户隐私
搜索排序是个持续优化的过程,没有一劳永逸的方案。重要的是建立一套可靠的实验和迭代机制,让数据驱动决策。
希望能给同样在做搜索排序的同学一些参考,少走弯路。如果你也在做类似的工作,欢迎交流讨论!
版权声明: 本文首发于 指尖魔法屋-从查询走到结果:AI搜索排序笔记(https://blog.thinkmoon.cn/post/328-ai-search-ranking-query-result-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。