IK 分词器实战:ik_smart、ik_max_word 与自定义词典
自定义词典解决的不是「搜不到」,而是分词边界错了导致精度和召回双输。这篇用三种分词粒度的实测数据说明:为什么单字索引精确率只有 40%,只收长词的索引召回率会掉到 0,以及为什么索引用 ik_max_word、查询用 ik_smart。
一、一个反直觉的现象
给中文商品库建索引,很多人会想:既然分词是为了让检索更准,那不分词、按单字建索引岂不是召回最全?用户搜「洗地机」,只要标题里有「洗」「地」「机」三个字就能命中,绝不会漏。
这个推理只对了一半。单字索引的召回率确实是 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 长词 + 子词 | 两者兼顾 | 整词精确命中,子词保证短查询也能召回 |
四、自定义词典到底解决了什么
很多资料把自定义词典说成「解决搜不到的问题」,这个描述不准确。真正的问题是:分词边界错了,导致精度和召回双输。
- 精度问题:专有名词被切碎后,跟大量无关商品共享碎片(如「机」),召回噪声大、相关性排序失效。噪声一多,BM25 打分也救不回来——因为参与打分的 term 本身就是错的。
- 召回问题:词典只收长词的话,用户搜短词(「躺平」「护发」)直接漏。
解法是一套组合拳,四个动作缺一不可:
| 动作 | 作用 |
|---|---|
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 一直不生效」,排查起来很费时间。
热更新只影响新写入的文档。已有数据不会自动重新分词,需要重建索引(或对已变更的文档重新写入)才能让新词进入倒排表。
六、改词典之后要做什么
词典不是改一次就完事的,商品类目上新、品牌术语变化都会带来新词。一套可持续的流程是:
- 从查询日志里挖词。统计零结果查询和低点击查询,这些是词典的候选来源,比拍脑袋想专有名词准得多。
- 加词后先验证
_analyze,确认切分符合预期,再走重建索引。 - 重建索引用别名切换,不要直接删了重建——读写会中断。给索引起别名,新索引建好再原子切换。
- 定期回归评测。同一批查询跑一遍,看召回率和精确率有没有退化。加词可能引入新的噪声,比如把过于宽泛的词收进
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_* 四个变量后自动开启。