「搜索效果不好」是产品反馈里最常见也最含糊的一句话。改了 boost 之后效果到底好了还是差了,如果没有评测集,答案只能靠感觉。相关性调优的本质是一个实验工程问题:先有可复现的判定集,再有可比较的指标,最后才是参数调整。跳过前两步直接调 boost,等于在没有刻度的尺子上量长度。本文讲清判定集怎么建、指标怎么算、参数怎么调、线上怎么验证。
1. 相关性为什么需要评测
1.1 相关性的主观性
同一个查询「苹果」,生鲜电商期望返回水果,科技媒体期望返回公司新闻。相关性不是文档的固有属性,而是「查询 + 用户意图 + 场景」三者的匹配程度。没有明确的场景定义,任何调优都无的放矢。
1.2 从拍脑袋到可度量
工程上常见两种坏模式:一是靠个别 bad case 调参,改好了这个查询又弄坏那个查询;二是靠「我觉得更相关了」主观判断,无法跨人跨时间比较。评测集的作用是把主观判断固化成标注数据,让每次改动都有可回归的基准。
1.3 评测的三个层次
| 层次 | 手段 | 成本 | 可信度 |
|---|---|---|---|
| 离线 | 判定集 + 指标 | 中 | 高(受标注质量约束) |
| 近线 | 影子流量回放 | 高 | 高 |
| 在线 | A/B 实验 | 高 | 最高(受样本量约束) |
实践路径是:离线快速迭代参数,选出候选方案;近线验证工程可行性;在线做最终决策。三层不是替代关系,而是漏斗。
2. 人工判定集构建
2.1 查询集采样
判定集的第一步是选出有代表性的查询。常见采样方式:
- 按线上搜索日志的频次取头部查询,覆盖主要流量;
- 按频次分层,头部、腰部、长尾各取一部分;
- 补充业务关键查询(如运营指定的品类词);
- 加入已知的难例与 bad case。
只取头部查询会让评测偏向高频词,只取长尾则噪声过大。经验比例是头部 40%、腰部 40%、长尾 20%。
2.2 相关性分级
二元标注(相关/不相关)信息量太低,实践中用分级:
| 等级 | 含义 | 示例 |
|---|---|---|
| 3 | 完全满足意图 | 查询「蓝牙耳机」返回蓝牙耳机 |
| 2 | 部分满足 | 返回有线耳机但标注支持蓝牙 |
| 1 | 边缘相关 | 返回耳机配件 |
| 0 | 不相关 | 返回手机壳 |
分级让 NDCG 能区分「把等级 3 排在第 1 位」与「把等级 1 排在第 1 位」,这是它比 Precision 更有判别力的原因。
2.3 标注流程
标注不是找个人随便点,需要流程保障:
- 先写标注手册,定义每个等级的标准与边界例子;
- 用 20~30 条查询做校准,让标注员对齐口径;
- 正式标注时每条查询至少两人独立标,冲突由第三人裁决;
- 定期算标注者间一致性(Cohen’s Kappa),低于 0.6 说明手册需要修订。
2.4 判定集规模与维护
判定集不需要很大,但要稳定。经验值是 100~300 条查询、每条查看看前 20 个结果,就能稳定区分参数优劣。判定集一旦冻结就不要随意改:改了基准,历史指标就失去可比性。新增查询走「扩充版本」,旧指标继续用旧基准,两个版本并行一段时间。
2.5 判定集的数据结构
标注结果用简单的 JSON 或 CSV 存储即可:
{
"qid": "q-001",
"query": "蓝牙耳机",
"judgments": {
"doc-1024": 3,
"doc-2048": 2,
"doc-4096": 0
}
}
键是文档 id,值是等级。评测脚本按这个结构算指标,与搜索引擎实现解耦。
3. 离线指标
3.1 Precision 与 Recall
最基础的两个指标:
- Precision@k:前 k 个结果里相关文档的比例。
- Recall@k:前 k 个结果覆盖了多少全部相关文档。
它们的问题是不关心顺序。对搜索而言顺序就是一切——把最相关的放第 10 位,用户根本翻不到。
3.2 NDCG
NDCG(Normalized Discounted Cumulative Gain)是搜索评测的主力指标。先算 DCG:
DCG@k = Σ (2^rel_i - 1) / log2(i + 1)
rel_i 是第 i 位的相关等级,分母的 log2(i+1) 是位置折损:越靠后贡献越小。再除以理想排序的 IDCG 做归一化:
NDCG@k = DCG@k / IDCG@k
NDCG 落在 0~1 之间,1 表示排序完全理想。它同时考虑了等级与位置,是分级标注场景的标准选择。
3.3 MRR 与 MAP
- MRR(Mean Reciprocal Rank):只关心第一个相关结果的位置,取倒数的均值。适合「用户只要一个正确答案」的场景,如导航类查询。
- MAP(Mean Average Precision):对每个相关文档位置算 Precision 再平均,适合二元标注的评测。
| 指标 | 关注点 | 适用场景 |
|---|---|---|
| NDCG@k | 分级 + 位置折损 | 通用搜索、推荐 |
| MRR | 首个相关结果位置 | 导航、问答、实体查询 |
| MAP | 二元相关 + 召回 | 文档检索 |
| Recall@k | 召回覆盖 | 候选生成阶段 |
3.4 指标选择建议
主指标选 NDCG@10,因为它对大多数搜索场景都有判别力。辅指标按场景选:导航类看 MRR@10,召回敏感看 Recall@100。不要同时盯五六个指标,改动方向会互相冲突时无法决策。
4. 参数与加权调优
4.1 BM25 参数
BM25 有两个参数:
{
"settings": {
"similarity": {
"custom_bm25": {
"type": "BM25",
"k1": 1.2,
"b": 0.75
}
}
}
}
k1 控制词频饱和点:值越大,词频高的文档加分越多;b 控制长度归一化:0 表示完全不惩罚长文档,1 表示完全归一化。短文本字段(标题)适合 b 取小一些,长文本(正文)取大一些。调参前先用默认值建立基线,再单变量试。
4.2 字段权重
多字段检索时用 boost 或 multi_match 的 fields 加权:
{
"query": {
"multi_match": {
"query": "蓝牙耳机",
"fields": ["title^3", "tags^2", "description"],
"type": "best_fields",
"tie_breaker": 0.3
}
}
}
title^3 表示标题得分乘 3。权重不是越大越好:标题权重过高会让只有标题命中的长尾文档压过正文深度匹配的文档。tie_breaker 让非最佳字段也贡献一部分分数,取值 0.1~0.3 通常有效。
4.3 function_score 调优
业务权重(销量、上新、评分)用 function_score 叠加:
{
"query": {
"function_score": {
"query": { "match": { "title": "耳机" } },
"functions": [
{ "field_value_factor": { "field": "sales", "factor": 0.1, "modifier": "log1p" } },
{ "gauss": { "created_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } }
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
}
modifier: log1p 压平销量长尾,避免爆款垄断;gauss 做时间衰减,让新品有机会曝光。这两个函数的参数就是评测要扫的空间。
4.4 搜索模板参数化
把可调参数抽到搜索模板里,评测时只需换参数不用改代码:
{
"script": {
"lang": "mustache",
"source": {
"query": {
"multi_match": {
"query": "{{query_string}}",
"fields": ["title^{{title_boost}}", "description"]
}
}
},
"params": { "title_boost": 3 }
}
}
模板让「参数网格搜索」成为可能:脚本遍历一组参数组合,跑判定集算 NDCG,取最优。注意模板参数只能填值,不能改变查询结构,结构变化仍需新模板。
4.5 参数搜索的实践
参数组合数会随参数个数指数增长,全网格搜索不现实。可行策略是先粗后细:先在大步长上扫(如 title_boost 取 1、3、5、10),锁定区间后再小步长细化。每次只动一个参数,保持其他不变,这样指标变化才能归因。
5. A/B 与线上验证
5.1 线上指标
离线指标好不代表业务指标好。线上要观测的是行为信号:
| 指标 | 含义 |
|---|---|
| CTR | 结果点击率 |
| 首条点击位置 | 用户点第几个结果 |
| 转化率 | 点击后下单/注册比例 |
| 零结果率 | 无结果查询占比 |
| 改查率 | 用户改了查询词的比例 |
改查率高说明用户对首次结果不满意,是相关性的强负向信号。
5.2 流量切分
A/B 实验要保证两组流量可比:
用户 id 哈希 % 100 < 50 → 对照组(旧参数)
用户 id 哈希 % 100 >= 50 → 实验组(新参数)
按用户 id 而非请求 id 切分,避免同一用户在两组间跳变导致体验不一致。样本量要事先估算:CTR 提升 1% 这类小效应需要数万次曝光才有统计显著性。
5.3 离线与在线不一致
最常见的困惑是「离线 NDCG 涨了,线上 CTR 没动」。原因通常有三:一是判定集与真实流量分布不一致,标注的查询不代表线上主力;二是标注的「相关性」与用户实际点击的「有用性」不同;三是位置偏差——用户倾向点击靠前的结果,与相关性无关。解决办法是补充行为数据做判定(如用点击日志弱标注),并对位置偏差做校正。
6. 工程实践
6.1 评测脚本骨架
评测脚本的核心是「跑查询、取结果、算指标」三步:
def evaluate(es, judgments, index, params, k=10):
ndcgs = []
for qid, item in judgments.items():
resp = es.search(index=index, body=build_query(item["query"], params), size=k)
hits = [h["_id"] for h in resp["hits"]["hits"]]
ndcgs.append(ndcg(hits, item["judgments"], k))
return sum(ndcgs) / len(ndcgs)
build_query 负责把参数注入查询,ndcg 按公式计算。脚本要能一次性输出所有查询的指标,便于定位是哪类查询退化了。
6.2 回归防护
把评测脚本接入 CI:每次改动查询构造逻辑就跑一遍判定集,NDCG 跌破阈值就阻断合并。这样能防止「为了修一个 bad case 弄坏一片查询」。阈值建议设为主指标的 1%~2%,留出正常波动空间。
6.3 常见误区
| 误区 | 后果 | 纠正 |
|---|---|---|
| 只用 bad case 调参 | 修东坏西 | 用判定集做全量回归 |
| 判定集随改随动 | 指标不可比 | 冻结基准,新版本另存 |
| 单指标崇拜 | 忽视召回 | 主指标 + 辅指标组合 |
| 离线当成终局 | 上线效果落空 | 必须做 A/B |
| 标注无手册 | 一致性差 | 先校准再标注 |
7. 总结
| 环节 | 要点 |
|---|---|
| 判定集 | 分层采样查询,分级标注,冻结基准 |
| 主指标 | NDCG@10 兼顾等级与位置 |
| 辅指标 | MRR 看首条,Recall 看召回 |
| 参数调优 | BM25 的 k1/b、字段 boost、function_score |
| 参数化 | 用搜索模板承载可调参数,做网格搜索 |
| 线上验证 | A/B 按用户切流,看 CTR 与转化 |
| 回归防护 | 评测接入 CI,指标跌破阈值阻断 |
| 核心原则 | 先有度量再调优,先离线再在线 |
相关性调优是长期工程,没有一劳永逸的最优参数。建立判定集、跑通离线指标、接入 CI 回归,这三件事做完,后续每次调整才有意义。查询构造细节可阅读《Query DSL 与相关性打分》,打分函数与加权可阅读《打分与加权策略》。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。