用户反馈"搜索不好用"。这句话背后可能是三件完全不同的事:输入关键词没有结果(搜不到)、有结果但排在前面的不对(搜不准)、有结果但少了本该有的那条(搜不全)。三种问题的修法完全不同,而团队常常在"调相关性权重"上花了三周,实际问题出在数据没有入库。
归因是检索优化的第一步,也是最容易被跳过的一步。没有归因的优化就是猜,而搜索系统对猜测的反馈极其缓慢——一次权重调整要一周才知道好坏。
1. 三类失败的定义与判据
| 类型 | 判据 | 典型症状 |
|---|---|---|
| 搜不到 | 精确匹配存在但结果为 0 | 输入完整标题也没有命中 |
| 搜不准 | 结果包含目标但排名靠后 | 目标在第 3 页 |
| 搜不全 | 已知应命中的文档缺失 | 新数据搜不到 |
还有第四类常被单独提出的"搜不准",其实是产品问题而不是检索问题:用户输入的意思与库里的词不匹配(比如搜"退款"但库里叫"售后")。这类问题靠同义词与词典解决,不靠打分。
三者的检测方式不同:第一类靠"用原文搜",第二类靠"看排名位置",第三类靠"按 ID 反查"。
2. 搜不到的五种原因
| 原因 | 检查方式 |
|---|---|
| 分词把词切碎了 | 用分词工具看拆解结果 |
| 字段未建索引 | 查 mapping 设置 |
| 大小写/全半角未归一 | 用同一词的变体搜 |
| 停用词表误伤 | 查停用词列表 |
| 分析器与查询器不一致 | 对比 index/search analyzer |
排查这一项有个很直接的命令:让系统把同一个词的写入与查询拆解都打印出来。两边结果一比,问题立刻可见,不需要猜。
// 对比写入与查询的分词结果,最容易定位"搜不到"
curl -s -XPOST "localhost:9200/_analyze" -H 'Content-Type: application/json' -d '{"field":"title","text":"七天无理由退款"}'
curl -s -XPOST "localhost:9200/_analyze" -H 'Content-Type: application/json' -d '{"analyzer":"standard","text":"七天无理由退款"}'
// 前者是写入视角,后者是查询视角;两组 token 对不上就是根因
第五项最隐蔽:写入时用标准分词、查询时用关键字分词,两边对同一个词的处理不同,于是"写进去的"和"查出来的"永远对不上。
// 反例:写入与查询分析器不一致
"title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "standard" }
// 写入被拆成细粒度词,查询按标准分词整体匹配 → 命中率极低
// 正例:查询用细粒度(召回优先),写入用粗粒度需谨慎
"title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }
3. 搜不准的四个来源
| 来源 | 说明 |
|---|---|
| 打分字段权重不当 | 标题与正文权重相同 |
| 缺业务加权 | 新品/高销量应提权 |
| 字段长度归一化过度 | 短文档被过度提权 |
| 同义词未配置 | 用户词与库内词不同 |
第三项是 BM25 的默认行为:短文档天然得分高,于是一条只有标题的记录可能压过一条完整的长文。修法是调整 b 参数或引入字段级长度归一。
// 加权:把"标题命中"与"正文命中"区分开
"query": { "multi_match": {
"query": "退款",
"fields": ["title^3", "summary^2", "content"],
"type": "best_fields"
}}
// ^3 表示标题命中的权重是正文的 3 倍;系数需按样本调,不要凭感觉
4. 搜不全的五个原因
| 原因 | 检查方式 |
|---|---|
| 同步链路延迟或丢失 | 对账:库内条数 vs 索引条数 |
| 过滤条件误伤 | 用 ID 直查,逐步加过滤 |
| 分片与副本未分配 | 查集群健康 |
| 字段超长被截断 | 查 mapping 的 ignore_above |
| 索引写入被拒绝 | 查 bulk 的错误响应 |
第二项(过滤条件误伤)在带权限或软删的系统里极常见:索引里有这条,但查询里带了一个"只查未删除"的条件,而该字段在索引里是旧值。这类问题的排查方式是从"按 ID 直查"开始,逐步加条件,看哪一步丢了结果。
第一项是"新数据搜不到"的主因。同步链路的失败往往是静默的:bulk 请求部分失败会返回 200,错误藏在响应体里,如果代码不检查就完全无感。
// 检查同步完整性:库内条数与索引条数对齐
mysql -N -e "SELECT COUNT(*) FROM orders WHERE deleted_at IS NULL"
curl -s "localhost:9200/orders/_count" | jq .count
// 差异持续存在 → 同步链路问题;差异随时间扩大 → 有失败未重试
5. 归因的操作流程
| 步骤 | 动作 |
|---|---|
| 1 取真实查询 | 从搜索日志里抽样,不要自己造 |
| 2 分类 | 按三类归因打标 |
| 3 定优先级 | 按查询量加权,而不是按抱怨声 |
| 4 单点验证 | 每类各修一处,看指标变化 |
归因的结果应当落成一张"评估集":每类各留几十条真实查询作为回归样本。此后任何调参都要在这套样本上跑一遍,否则无法判断"这次改动是变好还是变坏"。
第三步最关键:一条被抱怨十次的问题可能只占查询量的 0.01%,而某个占 20% 的查询类型没人抱怨——因为用户已经放弃搜索了。所以优先级要按查询量加权。
-- 从搜索日志里按"零结果"聚类,找出高频失败词
SELECT normalized_query, COUNT(*) AS n
FROM search_log
WHERE result_count = 0 AND ts > NOW() - INTERVAL 7 DAY
GROUP BY normalized_query
ORDER BY n DESC LIMIT 30;
-- 零结果率高且查询量大的词,才是真正该修的
6. 排错:五种"调了权重没效果"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 调整权重指标不变 | 问题在"搜不到"层 | 先做归因分类 |
| 修了一类另一类变差 | 权重是全局的 | 按查询类型分开调 |
| 离线指标好、线上差 | 离线样本与线上分布不同 | 用线上日志做评测集 |
| 新数据一直搜不到 | 同步链路静默失败 | 加对账与失败重试 |
| 某些词永远零结果 | 同义词/分词问题 | 补词典与同义词 |
排错时还有个高效手段:看零结果查询的"改写行为"——用户是否在零结果后立刻换词重搜。换词率高说明是"搜不到",不换词直接离开说明是"没找到想要的",两者的修法不同。
第三项是检索优化的经典陷阱:离线评测集往往由人工构造,分布比真实查询更"规范"。用真实日志建评测集,才能反映线上效果。
7. 边界:什么情况不必做精细归因
| 场景 | 为什么 | 做法 |
|---|---|---|
| 数据量极小(千级) | 排序影响小 | 只保证能搜到 |
| 搜索是辅助入口 | 用户主要靠浏览 | 保证召回即可 |
| 查询高度同质 | 一类问题一种修法 | 直接修那一类 |
| 已用外部搜索服务 | 排序由服务方负责 | 只关注召回与数据同步 |
还有一条与数据质量有关的边界:检索的上限由数据决定。标题缺失、分类错误、描述为空的数据,再好的相关性算法也救不回来。所以在调排序之前,先看一眼"应命中的文档本身是否完整"。
判断口径一句话:用户搜不到东西时,会不会直接离开。会,三类归因都要做;不会,先把"搜不到"这一类修好就够。
8. 自测
- 三类失败各自的判据是什么?为什么检测方式不同?
- 写入与查询分析器不一致会造成什么后果?为什么它最隐蔽?
- 为什么短文档在默认打分下会天然占优?修法是什么?
- "新数据搜不到"最常见的原因是什么?为什么同步失败往往静默?
- 归因流程里第三步为什么按查询量加权而不是按抱怨次数?
- 离线评测集为什么容易给出偏乐观的结论?替代做法是什么?