一个知识库的搜索"零结果率"从 8% 涨到 22%,团队的结论是"中文分词不好",于是换了分词器。换完涨到 24%——因为真正的原因是"文档入库时标题被截断到 30 字",而用户搜的是"完整标题里的后半段"。
"搜不到"是最容易"归错因"的问题,因为它有五种完全不同的原因,而它们"表现一致"(都是零结果)。这个"改错地方"有代价:换分词器是"一次大改动"(要重建索引、回归相关性),而它换来的是"零结果率不降"——于是"更大的改动"掩盖了"更小的真相"。
而归因错了,修法就会"改错地方"。
1. 五种"零结果"
| 原因 | 特征 |
|---|---|
| 词没进索引(分词/字段未索引) | 短词、专名搜不到 |
| 索引里没有内容(缺失/未入库) | 近期文档搜不到 |
| 查询被过滤(权限/状态/时间) | 某些人搜不到 |
| 字段被截断/截短 | 长标题搜不到 |
| 查询构造错(拼错/多加了条件) | 特定入口才出 |
第五项"最不该发生但最常见":前端拼了一个不该拼的条件第五项的"隐蔽"在于它"只影响一个入口":站内搜索正常、某个页面的搜索异常——于是它像"数据问题",而实际是"代码问题"。
(如默认加了 status=published),于是"草稿"永远搜不到。
2. 归因的三种手段
| 手段 | 能区分 |
|---|---|
| 用"文档里一定有的词"试搜 | 索引/过滤 |
| 直接查"这条文档在不在索引里" | 缺失 |
| 用"最小查询"(只一个词)试 | 查询构造 |
第二项的"价值"是"把问题切成两半":"索引里有"与"索引里没有"是两类完全不同的排查路径——而"猜"永远在这两者之间摇摆。
第二项是"最直接"的:按文档 ID 查一次——在,说明"索引有、查询有问题";不在,说明"入库有问题"。
// 三步归因(每步一条命令)
1 按 ID 查:GET /index/_doc/123 → 有 → 排除"缺失"
2 用"标题里的原词"搜 → 有 → 排除"分词/索引"
3 用"用户的原查询"搜 → 无 → 问题在"查询构造/过滤"
// 判据:三步之后,原因只剩一种
"刷新"是"近期文档搜不到"的最常见原因,而它有个"近实时"的窗口:
// 三种"可见性"延迟
1 refresh_interval: 1s(默认)→ 最多 1 秒才可搜
2 手动 refresh(写完立刻刷)→ 立即可搜,但"段变多、变慢"
3 写入"不走索引"(只在主库)→ 永远搜不到
// 判据:查"文档写入时间"与"首次可搜时间"的差
3. "字段被截断"为什么最隐蔽
| 现象 | 说明 |
|---|---|
| 短词能搜到 | 因为落在前 30 字 |
| 长标题搜不到 | 后半段没进索引 |
| 换个说法能搜到 | 用了前半段的词 |
这类问题"看起来像分词问题",因为"同一个字段,有的词能搜、有的不能"这个"看起来像"的误导性很强:"同一个字段部分可搜"会被解释成"分词不完整"——而它其实是"文本在入库时被裁剪"。
——而分词器对"整段文本"是一致的,唯一的解释是"文本本身少了后半段"。
4. "过滤"类零结果的三种来源
| 来源 | 表现 |
|---|---|
权限过滤(acl) |
特定用户搜不到 |
状态过滤(status) |
草稿/归档搜不到 |
| 时间过滤(默认近一年) | 老文档搜不到 |
第三项的"产品视角"要补一句:"默认范围"必须"可见"——在搜索框旁写"近一年",或给"扩大范围"的链接——否则"没搜到"会被读成"不存在"。
第三项最常见也最"合理":产品要求"默认只搜近一年"——于是"2019 年的制度"永远搜不到,而"用户以为它不存在"。
"最小查询"是排查的"基线",而它只要一个词:
// 从"用户查询"逐步退化到"最小查询"
{"query": {"bool": {"must": [{"match": {"title": "差旅住宿标准"}},
{"term": {"status": "published"}}]}}} // 用户的实际
{"query": {"match": {"title": "差旅"}}} // 最小查询
// 若"最小查询"有结果 → 问题在"额外条件"(status/权限/must 组合)
5. "查询构造"的三种错
| 错法 | 表现 |
|---|---|
多加了 must 条件 |
结果变少 |
该用 should 用了 must |
本该"可选"的条件变成"必须" |
| 引号/转义破坏了查询 | 特定字符触发 |
第二项是"逻辑错":"分类=制度 OR 标签=制度"被写成 must第二项的"识别"是"看 DSL 里 must/should 的数量":must 越多,结果越少——而"查询越复杂结果越少"是个反直觉的规律。
——于是"两个都满足"才命中,而"两个都满足"的文档几乎没有。
"归因"可以写成一张"决策树",于是"每一步都有判据":
// 四步决策树(每步一个判据)
Q1 按 ID 能查到吗? 否 → 入库问题(流水线/refresh)
Q2 用标题原词能搜到吗? 否 → 索引问题(字段/分词/截断)
Q3 用别人账号能搜到吗? 否 → 过滤问题(acl/status/time)
Q4 用最小查询能搜到吗? 否 → 查询构造(must/should/转义)
// 四步走完仍"零结果" → 语料里真没有
6. 排错:五种"零结果问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 短词能搜长词不能 | 字段截断 | 查入库映射 |
| 近期文档搜不到 | 未刷新/未入库 | 查 refresh 与流水线 |
| 某用户搜不到 | 权限过滤 | 对照双账号 |
| 老文档搜不到 | 时间过滤 | 放开范围试 |
| 特定入口搜不到 | 查询构造 | 打最小查询 |
"语料里真没有"这件事要"能被证明",否则"归因"会无限循环:
// 三步证明"内容缺失"(而不是"搜不到")
1 按"可能的标题词"搜(不搜全文)
2 按"来源系统"的 ID 搜(如果有)
3 直接问"内容由谁生产、最近有没有入库"
// 判据:三步都确认"没有" → 结论是"补内容",而不是"改检索"
7. 边界:什么情况"零结果"是正常的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 语料里真没有 | 内容缺失 | 补文档,不是调搜索 |
| 查询本身无意义(乱输) | 无需支持 | 忽略 |
| 精确匹配(编号) | 必须完全一致 | 属预期 |
| 权限本就不允许 | 设计如此 | 属预期 |
最后一条与"指标"有关的边界:"零结果率"要分群看(按入口/关键词/用户权限),整体值会把"某入口 60%"平均成"整体 22%"——于是"看起来还行",而"某个入口基本不可用"。
判断口径一句话:"这条内容,语料里有没有"。有(能被按 ID 查到),问题在"检索链路";没有(压根没入库),问题在"内容生产"——而"调整分词"对后者毫无帮助。
再补一个"组织"习惯:把"零结果的原始查询"按周抽样 20 条,逐条走一遍那棵决策树——七成会落在"过滤/查询构造",两成是"内容缺失",只有一成是"分词"。这个比例很有用:它让"换分词器"这种大改动,在动手前就被排除掉。
而这个习惯的成本,一周只有半小时。
最后提醒一句:"分词器"是所有零结果问题里"最贵的修法",而它只解释其中一成——所以"先归因、再改动"在这个问题上不是流程要求,而是"省钱的顺序"。
8. 自测
- 五种"零结果"分别是什么?为什么第五种"最不该发生但最常见"?
- "三步归因"分别排除了什么?为什么"按 ID 查"是最直接的一步?
- "字段被截断"为什么"看起来像分词问题"?唯一的解释是什么?
- "默认只搜近一年"为什么会造成"用户以为文档不存在"?
must与should的误用会造成什么?举一个"两个都满足"几乎不存在的例子。- 判断"零结果是否正常"的那个问题是什么?