Elasticsearch

IK 分词器实战:ik_smart、ik_max_word 与自定义词典

自定义词典解决的不是「搜不到」,而是分词边界错了导致精度和召回双输。这篇用三种分词粒度的实测数据说明:为什么单字索引精确率只有 40%,只收长词的索引召回率会掉到 0,以及为什么索引用 ik_max_word、查询用 ik_smart。

2026-03-03 约 12 分钟 4944 字 Elasticsearch IK 分词器 中文分词 相关性

一、一个反直觉的现象

给中文商品库建索引,很多人会想:既然分词是为了让检索更准,那不分词、按单字建索引岂不是召回最全?用户搜「洗地机」,只要标题里有「洗」「地」「机」三个字就能命中,绝不会漏。

这个推理只对了一半。单字索引的召回率确实是 100%,但精确率只有 40%——搜「洗地机」会把洗衣机、吹风机、空调全都带出来,因为它们的标题里都有「机」。

反过来,如果词典只收录完整的专有名词、不做任何子词切分,精确率能到 100%,但用户搜「躺平」「护发」这类短词时返回 0 条。

两个极端都不能用。这篇文章用一组实测数据把这件事讲清楚,并给出可落地的词典配置方案。

二、同一个标题,三种分词粒度切出来完全不同

拿一个典型标题做对比:

智能洗地机X1 躺平洗地 全屋清洁
索引粒度 切出来的词
A 单字切分(无自定义词典) 智 / 能 / 洗 / 地 / 机 / x / 1 / 躺 / 平 / 洗 / 地 / 全 / 屋 / 清 / 洁
B 长词 + 子词(ik_max_word) 智能 / 洗地机 / 洗地 / x / 1 / 躺平洗地 / 躺平 / 洗地 / 全屋清洁 / 全屋 / 清洁
C 只有最长词(ik_smart) 智能 / 洗地机 / x / 1 / 躺平洗地 / 全屋清洁

三个细节值得注意:

  • A 里根本没有 洗地机 这个 term,它被切成了「洗 / 地 / 机」。用户搜「洗地机」时,查询侧如果也按单字切,就会变成「洗 AND 地 AND 机」——只要标题里同时出现这三个字就算命中,空调标题里的「洗」和「机」一样能凑齐。
  • B 既有 躺平洗地 整词,也有 躺平 子词。用户搜长词能精确命中,搜短词也能命中,代价是索引体积变大。
  • C 只有 躺平洗地,没有 躺平。用户搜「躺平」时查不到这个 term,直接漏掉。

这就是 ik_smart 和 ik_max_word 的本质区别:前者做的是「最粗粒度切分」,每个字只归属一个词;后者做的是「穷尽切分」,一个字符串可能被切成多种组合,长词和它包含的子词都会输出。

ik_smart ik_max_word
切分策略 粗粒度,尽量少切 细粒度,穷尽所有可能
输出词数 少 多
索引体积 小 大
召回率 低(短词可能查不到) 高
精确率 高(噪声少) 取决于查询侧如何收窄
典型用途 查询侧 索引侧

三、实测:同一个查询,三种索引的检索质量

光看切词结果还不够,得看实际检索效果。

评价口径:相关文档 = 标题里真的出现这个查询词的文档。表格格式为「召回条数,召回率 / 精确率」。

查询 索引A 单字 索引B 长词+子词 索引C 只长词
躺平(相关 5 条) 召回 5,100% / 100% 召回 5,100% / 100% 召回 0,0% / 0%
洗地机(相关 4 条) 召回 10,100% / 40% 召回 4,100% / 100% 召回 4,100% / 100%
护发(相关 3 条) 召回 4,100% / 75% 召回 3,100% / 100% 召回 0,0% / 0%
宠物(相关 3 条) 召回 3,100% / 100% 召回 3,100% / 100% 召回 2,67% / 100%

四个查询分别暴露了不同的失效模式:

「洗地机」暴露了单字索引的精确率问题。 4 条相关文档,索引 A 召回了 10 条——多出来的 6 条是洗衣机、吹风机、空调之类,它们的标题里也有「洗」「地」「机」中的字。用户搜洗地机却看到洗衣机,这是最直观的体验损伤。

「躺平」「护发」暴露了长词索引的召回率问题。 索引 C 返回 0 条,因为词典只收了 躺平洗地 这样的长词,躺平 这个 term 压根不在倒排表里。用户搜的词不在词典里,倒排索引再快也没用。

「宠物」暴露了长词索引的另一种漏召回。 相关 3 条只召回 2 条——有一条标题里的「宠物」处在一个更长的词内部,被最长匹配吃掉了,所以没有单独输出。

「躺平」在索引 A 里却是 100%。 因为单字切分下「躺」「平」都在,查询侧按单字处理也能命中。这说明单字索引并非一无是处,它的召回是最稳的,问题全在精确率。

三种索引的取舍因此一目了然:

特点 原因
索引A 单字 召回率高,精确率崩 家电词被切碎,跟无关商品共享单字。搜「洗地机」把洗衣机、吹风机、空调全带出来,精确率只有 40%
索引C 只长词 精确率高,召回率崩 词典只收录长词,用户搜短词彻底搜不到(搜「躺平」「护发」都返回 0 条)
索引B 长词 + 子词 两者兼顾 整词精确命中,子词保证短查询也能召回

四、自定义词典到底解决了什么

很多资料把自定义词典说成「解决搜不到的问题」,这个描述不准确。真正的问题是:分词边界错了,导致精度和召回双输。

  1. 精度问题:专有名词被切碎后,跟大量无关商品共享碎片(如「机」),召回噪声大、相关性排序失效。噪声一多,BM25 打分也救不回来——因为参与打分的 term 本身就是错的。
  2. 召回问题:词典只收长词的话,用户搜短词(「躺平」「护发」)直接漏。

解法是一套组合拳,四个动作缺一不可:

动作 作用
ext.dic 收录家电专有名词(洗地机、吹风机护发、躺平吸尘…) 让专有名词成为独立 term
建索引用 ik_max_word(长词 + 子词都进索引) 保召回
查询用 ik_smart(粗粒度) 压噪声、保精度
词典热更新用 remote_ext_dict 改词典不用重启 ES

为什么组合的方向是「索引细、查询粗」

这是整套方案里最容易被记反的一点,想清楚就不容易错:

  • 索引侧要「宁可多存」。索引一旦建成,term 就固定了。少存一个 term,将来任何查询都补不回来。所以用 ik_max_word 把长词和子词全塞进去,保证无论用户怎么搜都有 term 可命中。
  • 查询侧要「宁可少切」。查询词切得越碎,AND 条件越松,噪声越大。用户搜「洗地机」,ik_smart 只切出 洗地机 一个 term,倒排表里精确匹配整词;如果切碎成三个单字,就又回到了索引 A 的 40% 精确率。
  • 两侧不对称不会漏,因为索引侧的细粒度是查询侧粗粒度的超集。ik_max_word 建索引时输出了 洗地机,ik_smart 查询时正好查的就是 洗地机,能对上。

五、配置与验证

索引映射

PUT /sku
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik_index_analyzer": {
          "type": "custom",
          "tokenizer": "ik_max_word"
        },
        "ik_search_analyzer": {
          "type": "custom",
          "tokenizer": "ik_smart"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_index_analyzer",       // 写入时:细粒度,保召回
        "search_analyzer": "ik_search_analyzer" // 查询时:粗粒度,保精度
      },
      "brand":   { "type": "keyword" },        // 筛选维度用 keyword,不走分词
      "price":   { "type": "scaled_float", "scaling_factor": 100 },
      "sales":   { "type": "integer" },
      "on_sale_time": { "type": "date" }
    }
  }
}

analyzer 和 search_analyzer 分开配置是这套方案能落地的关键。如果只配一个 analyzer,写入和查询会用同一套切分逻辑,「索引细、查询粗」就无从谈起。

用 _analyze 验证切词

改完配置先验证,别急着重建索引:

GET /sku/_analyze
{
  "analyzer": "ik_max_word",
  "text": "智能洗地机X1 躺平洗地 全屋清洁"
}

如果返回的 token 里有 洗地机、躺平洗地、全屋清洁,说明自定义词典已经生效。这一步能拦掉大部分「词典路径配错」「编码不对导致词典没加载」的问题。

自定义词典

ext.dic 每行一个词,收录行业专有名词:

洗地机
吹风机护发
躺平洗地
全屋清洁
除螨仪
扫地机器人

词典文件的编码必须是 UTF-8,不带 BOM——带 BOM 时 IK 会把 BOM 字符当成第一个词的一部分,导致词典里第一个词永远匹配不上。这个坑很隐蔽,因为表面上看词典「加载成功了」。

停用词放 stopword.dic,把「的」「了」「和」以及无区分度的词去掉,能减少倒排表体积和无意义的打分项。

热更新:remote_ext_dict

改词典要重启 ES 才能生效是不能接受的,生产环境用远程词典:

<!-- IK 插件的 config/IKAnalyzer.cfg.xml -->
<properties>
  <comment>IK Analyzer 扩展配置</comment>
  <entry key="ext_dict">custom/ext.dic</entry>
  <entry key="remote_ext_dict">http://config-server/dict/ext.dic</entry>
  <entry key="remote_ext_stopwords">http://config-server/dict/stopword.dic</entry>
</properties>

工作机制:

  • ES 节点启动时拉取远程词典,之后定期轮询(约 1 分钟一次)
  • 判断是否需要更新的依据是 HTTP 响应头 Last-Modified 和 ETag,两者都不变就直接跳过
  • 检测到变化后自动加载新词,不需要重启节点

因此词典服务要正确返回这两个响应头。用静态文件服务器时通常会自动带上;如果是自己写的接口,漏掉这两个头会导致「词典明明更新了但 ES 一直不生效」,排查起来很费时间。

热更新只影响新写入的文档。已有数据不会自动重新分词,需要重建索引(或对已变更的文档重新写入)才能让新词进入倒排表。

六、改词典之后要做什么

词典不是改一次就完事的,商品类目上新、品牌术语变化都会带来新词。一套可持续的流程是:

  1. 从查询日志里挖词。统计零结果查询和低点击查询,这些是词典的候选来源,比拍脑袋想专有名词准得多。
  2. 加词后先验证 _analyze,确认切分符合预期,再走重建索引。
  3. 重建索引用别名切换,不要直接删了重建——读写会中断。给索引起别名,新索引建好再原子切换。
  4. 定期回归评测。同一批查询跑一遍,看召回率和精确率有没有退化。加词可能引入新的噪声,比如把过于宽泛的词收进 ext.dic,会让它变成一个到处命中的高频 term,反而拉低相关性。

小结

  • 自定义词典解决的不是「搜不到」,而是分词边界错了导致精度和召回双输
  • 单字索引召回 100% 但精确率只有 40%(搜「洗地机」带出洗衣机、吹风机、空调)
  • 只收长词的索引精确 100% 但召回率会掉到 0(搜「躺平」「护发」返回 0 条)
  • ik_max_word 是穷尽切分,长词和子词都输出;ik_smart 是粗粒度切分,只出最长词
  • 组合拳:ext.dic 收专有名词 + 建索引用 ik_max_word 保召回 + 查询用 ik_smart 压噪声
  • 方向不能记反:索引侧宁可多存,查询侧宁可少切,因为索引细粒度是查询粗粒度的超集
  • 配置上必须把 analyzer 与 search_analyzer 分开写,否则粒度差异无从生效
  • remote_ext_dict 靠 Last-Modified / ETag 判断是否更新,热更新只影响新写入文档,老数据要重建索引
  • 改词典后要跑回归评测,加词同样可能引入噪声

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。