"搜出来的东西用户不点",最容易被读成"分数给低了"。可能坏在三层完全不同的地方:库里没有那篇文档、有但排在第十七条、排第一但摘要看不懂。把三层混成一个"相关性分数"去调,是调优事故的起点。
只给判据:坏在哪层、每个杠杆的代价、何时停手。
1. 三层分解:先定位,再动手
| 层 | 用户原话 | 观测信号 | 便宜的手段 |
|---|---|---|---|
| 召回 | "搜不到""没这个东西" | 零结果率、目标文档是否在前 N | 同义词、词典、补字段入索引 |
| 排序 | "排太后面""第一条不对" | 目标文档平均名次、首条点击率 | 字段权重、BM25、rescore |
| 展示 | "点进去不是我要的" | 点击后跳出、二次改写率 | 摘要、标题、高亮 |
一次只动一层:同时改同义词和 boost,涨了不知是谁的功劳,跌了不知回滚哪个。判定顺序写死:
1 零结果率高 -> 先查 filter、mapping、分词(九成是过滤太严)
2 前 50 条命中率低 -> 查同义词、查询改写、字段是否入索引
3 平均名次低 -> 动 boost / BM25 / rescore
4 首条点击低 -> 改摘要、标题、结果条数
没有候选时调打分是纯粹的浪费。
2. 召回判据:用日志抽样,而不是感觉
判据不是"召回率高",而是在固定的一份抽样集里,已知目标文档是否出现在前 N 条。没有这份集合,"召回不够"只是一句感觉。
| 抽样方式 | 覆盖 | 主要用途 |
|---|---|---|
| 按频次分层(头/腰/长尾) | 高频与长尾兼顾 | 判断漏召回是否系统性 |
| 零结果查询全量 | 最痛的一批 | 分词与过滤问题富矿 |
| 有点击的查询 | 隐性失败 | 排序与展示 |
| 运营指定查询 | 业务底线 | 上线回归集 |
规模按"每层数十到数百条"起步,按查询总量与人力核对。要害在固定:每次调完换一批新查询,等于每次做新题。
-- 分层抽样(口径示例,按实际日志表核对)
WITH q AS (SELECT query, COUNT(*) cnt FROM search_log
WHERE dt >= CURRENT_DATE - 7 AND query <> '' GROUP BY query),
r AS (SELECT *, NTILE(3) OVER (ORDER BY cnt DESC) tier FROM q)
SELECT tier, query FROM r ORDER BY random() LIMIT 300;
标注只回答一件事:目标文档在不在前 N、在第几位,且要区分"库里没有"与"有但排得低"。这栏标错,后面全错。
3. _score 的构成与四类加权手段
| 手段 | 改什么 | 适用 | 代价 |
|---|---|---|---|
boost |
乘在子查询分数上 | 快速验证字段重要性 | 分数不可比,阈值与断点失效 |
function_score |
字段值、衰减、脚本 | 距离、时效、热度 | boost_mode 选错覆盖相关性;脚本慢难解释 |
rank_feature |
数值特征并入打分 | 点击率、销量 | 字段类型特殊,改动要重建索引 |
rescore |
前 N 条二次打分 | 精排窗口 | 窗口外一条不动 |
铁律:业务权重不进 _score。破坏的是评估——销量乘进分数后,NDCG 不再衡量相关性,你失去唯一标尺。
{"query": {"function_score": {
"query": {"multi_match": {"query": "退款", "fields": ["title^3", "body"]}},
"functions": [
{"filter": {"term": {"status": "active"}}, "weight": 1.2},
{"gauss": {"publish_at": {"origin": "now", "scale": "30d", "decay": 0.5}}}],
"score_mode": "multiply", "boost_mode": "multiply"}}, "size": 50, "explain": true}
读 explain 分两层:query 原始分与 function 乘数。混着看的典型误判是"字段权重没生效",实际是 score_mode 把乘数叠成常数。
4. BM25 的 k1 与 b 改变什么
| 参数 | 管什么 | 调小 | 调大 |
|---|---|---|---|
k1 |
词频饱和速度 | 重复词更快失增益 | 长文靠重复堆分 |
b |
长度归一化强度 | 长文档占便宜(b=0 关闭) |
强烈惩罚长文 |
默认 k1=1.2、b=0.75(BM25Similarity 默认值,按版本核对)。站内搜索"标题短、正文长、查询短",这套默认通常够用:
PUT /docs
{"settings": {"similarity": {"bm": {"type": "BM25", "k1": 1.0, "b": 0.8}}},
"mappings": {"properties": {"title": {"type": "text", "similarity": "bm"}}}}
字段级 similarity 建索引时绑定,改它要重建;全局 index.similarity 要重启节点。先用 explain 确认长度归一化占了多少分,再决定是否值得重建。为调 b 重建三次索引,是把可观测问题做成运维事故。
5. 字段权重与 multi_match 的四种 type
| type | 打分 | 适用 | 代价 |
|---|---|---|---|
best_fields(默认) |
取单字段最高分 | 一个字段够回答 | 多字段各命中一半时丢信息 |
most_fields |
各字段分相加 | 同义/多语言副本 | 同词重复计分,长文虚高 |
cross_fields |
多字段当一字段 | 结构化拆分(省/市) | 要求 analyzer 一致,minimum_should_match 语义变 |
phrase_prefix |
短语前缀 | 搜索框补全 | 末词扫描,长前缀慢 |
{"query": {"multi_match": {"query": "杭州 退款", "type": "cross_fields",
"fields": ["city", "title^2", "body"], "operator": "and"}}}
判据:概念拆进多字段 → cross_fields;概念有副本 → most_fields;只关心最强字段 → best_fields;用户还在打字 → phrase_prefix。 字段权重(title^3)对所有查询一视同仁,解决不了"意图判错"。
6. 同义词与查询扩展的双刃
扩召回与降精度是同一枚硬币:"退货"映射到"退款"能救一批查询,也会让"退款失败"命中"退款成功"。
| 做法 | 收益 | 代价 |
|---|---|---|
| 索引期同义词 | 查询期零开销 | 改词表要重建索引 |
| 搜索期同义词 | 词表可热更 | 每次展开,分数分布变宽 |
单向东扩 A => B |
不反向污染 | 维护成本高 |
PUT /docs/_settings
{"analysis": {"filter": {"syn": {"type": "synonym_graph",
"synonyms": ["退货 => 退款", "七天无理由 => 7天无理由"]}},
"analyzer": {"search_syn": {"type": "custom", "tokenizer": "standard",
"filter": ["lowercase", "syn"]}}}}
挂 search_analyzer 而非 index_analyzer,索引 term 保持原样、词表可热更。副作用两点:展开后 term 变多,operator: and 会严到零结果;minimum_should_match 不调则每 term 分摊变薄,本该第一的掉后。先 _analyze 看展开,再 explain 看分项。
7. 分层:先算相关,再叠业务,最后去重
| 阶段 | 输入 | 输出 | 不该做 |
|---|---|---|---|
| 粗排 | 查询、候选 | 相关序 top 100 | 掺业务权重 |
| 业务加权 | 相关序 + 业务字段 | 重排 top 100 | 改相关性分数 |
| 去重打散 | 重排结果 | 分页序列 | 重打分 |
去重与打散的幂等性
- 同 SPU 不同 SKU:按 SPU 去重,留分数最高一条
- 打散:第 N 页只由前 N*pageSize 候选决定
- 反例:按已展示集合动态打散 -> 翻页漂移
业务加权放第二段的好处:相关性评估只跑第一段,活动加权改版不触发相关性回归;写进 boost 则每次大促重校基线。
8. 个性化与 LTR 的门槛
| 阶段 | 触发条件 | 最小实现 | 失效信号 |
|---|---|---|---|
| 规则个性化 | 有可靠分层字段 | 按用户组套权重 | 分层假设与行为不符 |
| 反馈特征 | 稳定用户 ID + 足够曝光 | 点击率类特征入 rank_feature |
冷启动用户全排后 |
| LTR | 标注够 + 特征稳 + 有评估 | ES 8.4 起内置,旧版靠插件 | 离线涨、线上平 |
LTR 的门槛不是算法,是三件事同时成立:标注量随特征维度上升,特征多标注少则学噪声;特征训练与在线同源,否则训练-服务偏差吃掉全部收益;有分流实验,否则效果无法归因。冷启动顺序是规则 → 带位置偏置校正的反馈 → 模型;先上模型后补闭环,等于用没有标签的目标函数训练。
9. 指标偏差与 A/B 最小设计
| 指标 | 衡量 | 主要偏差 |
|---|---|---|
| NDCG@k | 分级相关性 + 名次折扣 | 依赖标注分级一致性 |
| MRR | 首个正确结果名次 | 多答案查询失真 |
| 点击率 | 用户实际选择 | 位置偏置、标题诱导 |
| 零结果率 | 覆盖 | 不反映质量,诱导滥扩召回 |
A/B 四项缺一结论就不可信
样本量 按基线指标与最小可检测效应估算,量级常在每桶数千到数万次查询(按实际流量核对)
分流 按用户或查询 hash,同一人多轮同组;按请求随机会让体验抖动
指标 一个主指标 + 护栏(延迟、零结果率、跳出),只看点击会漏掉延迟回退
周期 覆盖完整周期含周末,跨大促对比结论作废
10. 误判清单
| 现象 | 更可能的原因 | 验证 |
|---|---|---|
| 某文档怎么都搜不到 | 字段没进索引(_source 有、mapping 无) |
查 _mapping + _termvectors |
| 某些用户搜不到 | 过滤过严(状态、时间、租户) | 去掉 filter 再查 |
| 中文词搜不到 | 分词器与词典不匹配 | _analyze 看切分 |
| 同义词不生效 | 挂在 index_analyzer |
查 search_analyzer |
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 分数全变排序没变 | 全量同比例放大 | 看乘数是否恒定,改 boost_mode |
| 加同义词后搜不到 | term 变多、operator 变严 |
调 minimum_should_match |
function_score 后指标降 |
业务权重污染分数 | 挪到排序第二段 |
rescore 无效果 |
window_size 小于所需窗口 |
核对 window 与 size |
改 k1/b 分数不动 |
字段级 similarity 未绑定 | 查 mapping,必要时重建 |
12. 边界:什么时候规则排序就够
| 场景 | 更合适 | 理由 |
|---|---|---|
| 查询量很小(每日数百) | 规则排序 + 人工置顶 | 没有统计意义 |
| 结果集小(几十条) | 数据库排序 | 用户翻得完 |
| 后台管理检索 | 精确过滤 + 倒序 | 用户知道要什么 |
| 无标注无埋点 | 先做评估集 | 没有评估只有感觉 |
| 数据脏(标题缺失、类目错) | 先修数据 | 调优修不了脏数据 |
分界在是否有稳定的"好"的定义。定义得出、能量化、能回归,才值得上模型;定义不出时,人工置顶的成本永远低于无人能解释的模型。
13. 自测
- 用户说"搜不到",先看哪个指标?
- 为什么要分"库里没有"与"有但排得低"?
- 业务权重写进
_score破坏的是哪个环节? b=0意味着什么?什么现象提示查b?best_fields与most_fields差别在什么数据下暴露?- 同义词展开后结果变差,先查什么?