用 standard 分析器给中文建索引,跑一次 _analyze 就能看见问题:中华人民共和国 被切成七个单字 term,中华 能匹配,人民共和国 却匹配不上。这不是 mapping 写错,是分析器选错——英文靠空格分词,中文没有空格,standard 内置的 UAX #29 分词规则对汉字只做单字边界切分。
分水岭在这一步:选错分析器,后面所有查询调优都建在错的地基上。下面按排障顺序走:输出形态 → 粒度 → 词典 → 查询侧配置 → "搜不到"。
1. 先用 _analyze 看清切分形态
三个分析器跑同一句话,形态差异一目了然。ES 8.x 里 _analyze 的请求体可以直接给 analyzer:
curl -s -XPOST 'http://localhost:9200/_analyze?pretty' \
-H 'Content-Type: application/json' -d'
{
"analyzer": "standard",
"text": "中华人民共和国国歌"
}'
典型响应的 tokens 数组是七个单字 token,start_offset/end_offset 连续递增,position 每次加 1:
{
"tokens": [
{ "token": "中", "start_offset": 0, "end_offset": 1, "position": 0 },
{ "token": "华", "start_offset": 1, "end_offset": 2, "position": 1 }
]
}
完整响应共七个 token。
换成 ik_smart,典型输出是 中华人民共和国、国歌 两个 token。换成 ik_max_word,同一句话会被切成 中华人民共和国、中华人民、中华、华人、人民共和国、人民、共和国 这一组重叠 term。
| 分析器 | 切分依据 | 中华人民共和国 的 term 数 |
能被"人民共和国"命中 |
|---|---|---|---|
standard |
Unicode 分词规则,汉字单字切 | 7(单字) | 否 |
ik_smart |
词典最少切分,不重叠 | 1 | 否,整词不拆 |
ik_max_word |
词典穷举所有可成词组合 | 6+ | 是 |
keyword |
不分词,整串入库 | 1(原文) | 否,只支持整串精确匹配 |
上面的 term 列表描述的是形态,不是"实测输出"。不同版本的 IK 主词典收词不同,
ik_max_word的具体 term 数量会变。核对方式就是自己跑一次_analyze。
2. ik_smart 与 ik_max_word:粒度与各自适用
两种粒度不是好与坏,是召回与精度的换向:
| 维度 | ik_smart |
ik_max_word |
|---|---|---|
| 切分策略 | 最少切分,不重叠 | 穷举,可重叠 |
| 同一文本 term 数 | 少 | 多(常见 2–6 倍) |
| 索引体积 / 召回率 | 小、低 | 大、高 |
| 查询评分 | 集中 | 重叠 term 会推高匹配分数 |
常见组合是索引侧 ik_max_word、查询侧 ik_smart:索引保留全部切法,查询只取最少歧义的一种。因为 max_word 的 term 集合是 smart 的超集,smart 切出的词必然能在索引里找到。
常见组合只是常见,不是唯一:
| 组合 | index / search | 适用 |
|---|---|---|
| 召回优先 | ik_max_word / ik_max_word |
短 query、口语化搜索、词库覆盖不全 |
| 体积优先 | ik_smart / ik_smart |
日志类、字段多、写入量大 |
| 平衡 | ik_max_word / ik_smart |
商品标题、文章标题等中等长度文本 |
| 反例 | ik_smart / ik_max_word |
不要用,查询 term 可能不在索引里,直接漏召回 |
最后一行最容易写错。约束只有一条:索引的 term 集合必须包含查询的 term 集合。
3. 自定义词典:文件、编码、热更新
配置入口是 $ES_HOME/config/IKAnalyzer.cfg.xml,8.x 里常见于 config/analysis-ik/ 或插件包内的 config,以你装的插件包路径为准:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<entry key="ext_dict">custom/mydict.dic</entry>
<entry key="ext_stopwords">custom/stopword.dic</entry>
<entry key="remote_ext_dict">http://dict.internal/ik/mydict.dic</entry>
<entry key="remote_ext_stopwords">http://dict.internal/ik/stopword.dic</entry>
</properties>
词典文件是一行一词的纯文本,可选追加词频与词性:
中华人民共和国 100 n
社会主义核心价值观 50 nz
向量数据库 30 n
| 配置项 | 值形态 | 相对路径基准 | 生效方式 |
|---|---|---|---|
ext_dict |
单文件或 ; 分隔多文件 |
config 目录 |
重启或触发词典重载 |
ext_stopwords |
同上 | config 目录 |
同上 |
remote_ext_dict |
http(s) URL |
— | 定时轮询,间隔约 60s |
remote_ext_stopwords |
http(s) URL |
— | 同上 |
热更新的机制要说准:监测线程按固定间隔拉远端 URL,只有响应头里的 Last-Modified 或 ETag 变化时才重新下载。所以自建词典服务必须让静态文件带上这两个头,否则内容改了、时间戳没变,客户端永远认为没更新。
nginx 侧要显式写 if_modified_since exact;(etag on 默认已开),不写的话秒级修改会被合并成同一个 Last-Modified。
词典变更只影响之后写入的文档,已有倒排不会自动重切,作用到历史数据要 _reindex。
4. analyzer 与 search_analyzer 为什么要分开配
一个 text 字段其实有两个分析器:写入用 analyzer,查询用 search_analyzer,不写则继承前者。
PUT /article
{
"settings": {
"number_of_shards": 1,
"analysis": {
"analyzer": {
"ik_index": { "type": "custom", "tokenizer": "ik_max_word", "filter": ["lowercase"] },
"ik_search": { "type": "custom", "tokenizer": "ik_smart",
"filter": ["lowercase", "my_synonym"] }
},
"filter": {
"my_synonym": { "type": "synonym_graph", "synonyms_path": "analysis/synonym.txt", "updateable": true }
}
}
},
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ik_index", "search_analyzer": "ik_search" },
"content": { "type": "text", "analyzer": "ik_index", "search_analyzer": "ik_search" },
"tags": { "type": "keyword" }
}
}
}
分开配的三个理由:
| 理由 | 说明 |
|---|---|
| 粒度不对称 | 索引要宽,不漏词;查询要窄,不引噪声 |
| 查询侧才需要同义词 | synonym_graph 只应出现在查询链路,放索引侧会污染倒排 |
| 查询侧才需要额外 filter | 停用词、拼音、纠错通常只在查询时加,避免写入时丢信息 |
两侧都写 ik_max_word 时,重叠 term 会同时参与打分,短 query 排序容易乱。
5. "索引建了却搜不到"的三个真实原因
原因一:粒度方向反了。 index 用 ik_smart、search 用 ik_max_word。查询 人民共和国 被切成 人民、共和国,而索引里只存了整词 中华人民共和国,两个 term 都不存在,零命中。
原因二:用 term 查 text 字段。 term 不分词,拿整串去倒排里精确找 term,只有索引里恰好存在这个完整 term 才命中。
原因三:词典是后加的,历史文档没重建。 新词只对新增文档生效。
定位方法把两侧 term 都打出来对比:
GET /article/_analyze
{ "field": "title", "text": "社会主义核心价值观" }
field 参数用的是索引侧分析器;要看查询侧就显式指定 analyzer 名:
GET /article/_analyze
{ "analyzer": "ik_search", "text": "社会主义核心价值观" }
把两次的 tokens[].token 取出来做集合比较:查询侧每个 token 都能在索引侧找到,才可能有召回。
6. synonym_graph 在搜索链路里的顺序
custom 分析器的执行顺序固定:char_filter → tokenizer → filter 数组按序执行。synonym_graph 是 token filter,位置决定成败。
| 位置 | 结果 |
|---|---|
lowercase 之前 |
大写形式的同义词规则匹配不上 |
lowercase 之后、stop 之前 |
推荐 |
| stemmer 之后 | 词干形态与词典 key 不一致,规则失效 |
synonym_graph 与老的 synonym 的区别:前者保留位置信息(positionLength),支持多词同义词与跨词扩展;后者只能做单 token 替换。查询侧一律用 synonym_graph。
它在 8.x 支持 updateable: true,配合 synonyms_path 可用 POST /_reload_search_analyzers 在线重载;改 analyzer 定义本身仍需关闭索引。
7. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
节点起不来,日志报 ClassNotFoundException: org.wltea.analyzer... |
插件版本与 ES 版本不完全一致 | 版本号逐位对齐,重装对应版本 |
elasticsearch-plugin install 报版本校验失败 |
包内 plugin-descriptor.properties 的 elasticsearch.version 不匹配 |
同上,不要改描述文件绕过 |
词典加了词,_analyze 仍切不出来 |
文件不是 UTF-8,或带 BOM(首词前有 \uFEFF) |
file mydict.dic 确认;sed -i '1s/^\xEF\xBB\xBF//' 去 BOM |
| 重启后词典仍不生效 | ext_dict 相对路径基准是 config,文件放错位置;IK 对不存在的文件静默忽略 |
改用绝对路径,或确认文件在 config/custom/ |
remote_ext_dict 内容改了不生效 |
远端没返回 ETag/Last-Modified |
检查响应头,确认时间戳随内容变化 |
| 新词只在部分文档命中 | 词典变更不回溯历史倒排 | _reindex 重建,或建新索引后切别名 |
| 短 query 命中过多、排序乱 | 两侧都是 ik_max_word,重叠 term 重复计分 |
查询侧换 ik_smart,或用 minimum_should_match 约束 |
8. 边界:什么时候不该继续用 IK
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 前缀补全、搜索建议 | completion suggester 或 edge_ngram |
IK 不做前缀索引 |
| 拼音搜索 | analysis-pinyin 叠在 IK 之后 |
拼音是独立维度,不该塞进主词典 |
| 词库每天大量变动 | 评估重建成本,或把可变部分挪到 synonym_graph |
词典变更要重建索引,热更新只解决加载 |
| 字段是 ID、枚举、标签 | keyword |
分词对这类字段只带来误命中 |
一句话:IK 解决"中文怎么切成词",不解决"词怎么被找到"。后者要靠 analyzer 分层、同义词、查询写法配合,缺一个都表现为"搜不到"。
9. 自测
standard把中华人民共和国切成几个 token,依据的是什么规则?- 索引侧
ik_smart、查询侧ik_max_word会出什么后果,为什么? remote_ext_dict的内容改了但客户端不更新,先查哪个响应头?- 为什么
synonym_graph放在索引侧会出问题? _analyze带field和带analyzer分别看的是哪一侧的分词结果?- 给已有索引加了新词,老文档为什么仍然搜不到,正确做法是什么?