一个文档库搜索"权限"二字,前三条是"权限模型""权限设计""权限校验"——三次刷新后它们的顺序变了。用户于是觉得"搜索不可靠"。技术上原因是:三条得分完全相同,而没有"兜底排序键",于是"引擎返回什么顺序,前端就显示什么顺序".
搜索的"排序"有两层:相关性得分(是否匹配)与兜底排序(同分怎么排)。前者是"搜索质量",后者是"结果稳定性"——而稳定性常被当成"不重要的细节".
1. 三种"同分"的成因
| 成因 | 说明 |
|---|---|
| 短语匹配(多字段同分) | 三条都"标题命中" |
| 短文档(长度归一化后接近) | TF 相近 |
| 自定义打分"截断"(取整/取档) | 人为并列 |
第三项最常被忽略:"把得分取整到 1 位小数"看起来"干净",实际上"制造大量并列".
2. "不稳定"的三种后果
| 后果 | 表现 |
|---|---|
| 用户"找不到刚看过的" | 位置每次不同 |
| 分页"重复/漏项" | 第 2 页出现第 1 页的内容 |
| A/B 实验"看不出差异" | 噪声掩盖了改动 |
第二项的"机制"很具体:"第 2 页"对应"offset 10",而"两次查询里'前 10 名'的同分顺序可能不同"——于是"第 2 页的第一条"可能"已经在第 1 页出现过".
第二项是"分页"的经典 bug:"没有兜底排序"时,引擎在两次查询里可能返回"不同的同分顺序"**——**于是"第 2 页"和"第 1 页"重叠或漏掉.
"三段排序"的写法在 DSL 里就三行,而它是"最小改动":
{"query": {"match": {"title": "权限"}},
"sort": [{"_score": "desc"},
{"updated_at": {"order": "desc"}},
{"id": {"order": "asc"}}]}
// 判据:同分时"新的在前",再同则"ID 小的在前"——顺序完全确定
3. "兜底排序"的三种选择
| 键 | 特点 |
|---|---|
| 更新时间(倒序) | 体验自然(新内容优先) |
| 文档 ID(升序) | 绝对稳定(但"无意义") |
| 热度/点击 | 有业务含义(但"变化快") |
第一项是"体验与稳定的折中":"同分时新的在前"第一项的"附加好处"是"业务友好":"同等相关时,用户通常更想看新的"——于是"稳定"与"合理"用同一个键就解决了.
——而它"不变"的前提是"更新时间不变".
第三项的"判据"是"这个键会不会'在两次查询之间变化'":"更新时间"只在新文档出现时变,"热度"每次点击都变——于是后者只能"进得分",不能"当兜底".
第三项要小心:"热度"会变,于是"同分组的顺序"仍会变**——**于是"热度"必须"作为得分的一部分",而不是"兜底键".
"游标分页"比"偏移分页"稳,而它要"把排序键带回":
// 第一页:取 10 条,记住最后一条的 sort 值
{"size": 10, "sort": [{"_score":"desc"},{"updated_at":"desc"},{"id":"asc"}]}
// 第二页:用 search_after
{"size": 10, "search_after": [0.5, "2026-11-01T00:00:00Z", "doc-128"],
"sort": [{"_score":"desc"},{"updated_at":"desc"},{"id":"asc"}]}
// 判据:翻页"不重复、不漏",且"深分页"仍快
4. "分页稳定"的三个要求
| 要求 | 做法 |
|---|---|
| 排序键"全序"(能唯一确定顺序) | 得分 + 时间 + ID |
| 翻页用"游标"而非"偏移" | search_after |
| 快照一致(可选) | 固定 preference/时间点 |
第一项是"根本":"得分 + 更新时间 + ID"三者组合,几乎不可能再并列第一项的"数学"很朴素:"ID 唯一 → 全序成立"——**于是"不管得分怎么并列,最后一步总能'分开'".
——于是"每次查询的顺序都一样".
第二项解决"深分页":"offset 10000"本身也慢,且"数据变动时结果漂移".
"得分截断"这条要"在展示层做",而不是"在排序里做":
// 三种"展示匹配度"的做法
1 排序用"原始得分",展示时"取整"(推荐)
2 排序用"取整后的分",展示"取整"(会制造并列)
3 排序用"档位"(高/中/低),展示"档位"(并列最多)
// 判据:展示"好看"不应该改变"排序"
5. "排序键"的三种写法
| 写法 | 说明 |
|---|---|
sort: [_score, {updated_at: desc}, {id: asc}] |
显式三段 |
只 sort: [_score] |
不稳定(同分随机) |
只 sort: [{updated_at: desc}] |
稳定但"不按相关性" |
第一项是"推荐":"先相关性,再时间,最后 ID"——**而"ID 兜底"是"绝对唯一"的保证.
"排序是否稳定"可以"用一条脚本验证",而它比"刷三次页面"可靠:
// 三次查询,比对"前 10 名的 id 序列"是否一致
for i in 1 2 3; do
curl -s "$Q" | jq -r '.hits.hits[]._id' | tr '
' ',' ; echo
done | sort -u | wc -l
// 判据:输出 1 → 稳定;输出 2 或 3 → 有并列且未兜底
6. 排错:五种"排序问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 刷新顺序变 | 无兜底键 | 加 updated_at+id |
| 分页重复/漏 | 偏移分页 + 同分 | 改游标 |
| 新文档不靠前 | 只按得分 | 加时间权重 |
| 同分"总在第一页" | 得分截断 | 别取整 |
| 实验无差异 | 噪声 | 先稳定排序 |
"推荐流"的随机要"可控",否则它变成"另一类不可复现":
// 三种"有意随机"的做法
1 固定随机种子(同用户同时间 → 同顺序)
2 只随机"同分档内"的位置(保留相关性)
3 每次全随机(最简单,也最不可解释)
// 判据:随机要"能被解释",否则"用户投诉"无从回应
7. 边界:什么情况"不稳定的顺序"可以接受
| 场景 | 为什么 | 做法 |
|---|---|---|
| 推荐流(每次不同是特性) | 要"新鲜感" | 允许随机 |
| 只取"前 3 条"(不分页) | 顺序变化影响小 | 可接受 |
| 结果集很小(全部展示) | 用户"看的是集合" | 可接受 |
| 有"个性化"(按用户) | 顺序本就因人而异 | 可接受 |
最后一条与"实验"有关的边界:"排序不稳定会让 A/B 实验失效":"两组结果的差异"可能小于"同分随机造成的波动"——于是"改了个权重看起来没效果",而"先稳定、再实验"是"能测出差异"的前提.
判断口径一句话:"用户会不会"翻页"或"回头看同一批结果""。会(搜索、列表、后台管理),必须有"全序兜底";不会(推荐、只取前几条),顺序"不必固定"——而"同分不兜底"这个问题,往往要等"用户投诉'结果乱跳'"才被重视.
8. 自测
- "相关性得分"与"兜底排序"分别解决什么?为什么后者常被当成细节?
- 三种"同分"成因里,哪一种"是人为制造的"?
- "没有兜底排序"为什么会造成"分页重复或漏项"?
- "热度"为什么"不能当兜底键"?它该放在哪一层?
- "全序"需要哪三个键?为什么"ID 兜底"能保证唯一?
- 判断"顺序是否需要固定"的那个问题是什么?
补一句"成本"的话:"加两个排序键"是"一行 DSL",而它解决的问题是"用户觉得搜索不可靠"——后者一旦形成印象,之后再怎么调相关性都"不被相信"。所以"稳定"这件事的性价比,高得离谱:它几乎不花成本,却是"可信"的地基。
而那"一行"的成本,比"重调一次打分函数"低两个数量级——这也是为什么它该"先加",而不是"等有空再加"。
而"先加"这件事,恰好是"工程质量"里最常见的一种拖延:大家都知道它重要,但都觉得它"不急"。
9. 一句话总结
"并列"在工程上不存在——只要"排序键"是"有限组合",就总会"撞"。所以"稳定的搜索结果"不是"更好的相关性",而是"在得分之后,再补两个能唯一确定的键"。而"得分 + 时间 + ID"这个三段式,成本几乎为零,收益是"用户不再觉得搜索在乱跳"。