搜"手机充电器"搜不到标题写着"充电器 手机快充"的文档,搜"笔记本电脑"只命中"电脑"二字——这类问题八成不在打分,而在分词。ES 出厂不带中文分词器,standard 对中文逐字切;装错、装晚、或索引建在装之前,整个字段就按字索引了,之后怎么调 boost 都救不回来。
1. 中文为什么必须专门分词
| 现象 | 例子 | standard 的切法 |
后果 |
|---|---|---|---|
| 无空格 | 中文句子 | 逐字切 手/机/充/电/器 |
词边界消失,短语查询退化成字交集 |
| 歧义切分 | "南京市长江大桥" | 逐字,"市长"被误配 | 假命中:搜"市长"能搜到南京的桥 |
| 未登录词 | 产品名、人名、缩写 | 逐字,词整体不存在 | 该词永远无法作为一个 term 命中 |
| 混合文本 | iPhone15充电器 |
字母串保留、汉字逐字 | 中英边界处理不一致 |
直接症状:match_phrase 失去意义(任意相邻字都算短语),term 查询只能拿单字查,高亮拆成单字碎片。
2. 用 _analyze 看清切分结果
# ① 单独测 tokenizer
curl -s "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d '
{"tokenizer":"standard","text":"南京市长江大桥"}'
# ② 测已建索引某字段的真实切分(走 mapping 里的 analyzer / search_analyzer)
curl -s "localhost:9200/kb_article/_analyze?pretty" -H 'Content-Type: application/json' -d '
{"field":"title","text":"iPhone15 充电器快充"}'
| 返回字段 | 含义 | 排障价值 |
|---|---|---|
tokens[].token |
切出的 term | 直接看词有没有被切碎 |
position |
位置增量 | 短语查询依赖它,重叠/同义词会改变它 |
start_offset / end_offset |
原文字符偏移 | 高亮错位时看这里 |
type |
词的内部类型(如 CN_WORD) |
确认走的是 ik 而非 standard |
第二条才是有效验证:指定 field 走的是该字段真实配置,只测 tokenizer 看不出 mapping 配错。
3. ik 的两种模式与"索引细、查询粗"
| 模式 | 切法 | 产出 term 数 | 用在哪 |
|---|---|---|---|
ik_max_word |
最细粒度,穷举所有成词组合 | 多 | 索引期 |
ik_smart |
粗粒度,最长匹配优先 | 少 | 查询期 |
PUT /kb_article
{
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"sku": { "type": "keyword" }
}
}
}
理由:索引期切细,让"充电器""快充"这类子词都能被单独召回;查询期切粗,避免 query 碎成大量无意义 term 导致 should 子句爆炸与假命中。反过来(索引粗、查询细)会出现"查询词在索引里不存在",直接零召回。
硬要求是索引分析与查询分析产出一致的 term 形态;ik 大版本变更、或只改 analyzer 没改 search_analyzer,都会破坏它。
4. 词典的三种来源
<!-- config/IKAnalyzer.cfg.xml,ik 插件读取 -->
<?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/mystop.dic</entry>
<entry key="remote_ext_dict">http://cfg.internal/ik/dict.txt</entry>
<entry key="remote_ext_stopwords">http://cfg.internal/ik/stop.txt</entry>
</properties>
| 来源 | 位置 | 生效方式 | 适用 |
|---|---|---|---|
| 内置主词典 | 插件 jar 内 main.dic |
随插件版本 | 通用词,不随手改 |
扩展词典 ext_dict |
config/custom/*.dic |
重启或重新加载 | 业务专有名词,量小稳定 |
远程词典 remote_ext_dict |
HTTP 地址 | 热更新,插件按固定间隔轮询 | 词表频繁变化、多节点统一 |
热更新靠 HTTP Last-Modified / ETag 变化判定(轮询间隔按插件版本核对,量级在分钟级)。多节点必须拉同一 URL;词典放单机本地文件,扩节点后必然出现"某些节点切得不一样"。
# 词典必须 UTF-8 无 BOM,一行一词
file -i custom/mydict.dic
head -c 3 custom/mydict.dic | hexdump -C # 输出 efbbbf 即 BOM,须去掉
BOM 的后果很隐蔽:文件第一个词永远匹配不上,其余词正常。表现是"加了一个词,就它不生效"。
5. 自定义词该不该加
| 加词的收益 | 加词的代价 |
|---|---|
| 专有名词作为一个 term 精确召回 | 词典膨胀,内存与启动时间上升 |
| 短语查询、高亮边界正确 | 可能破坏原有切分(长词优先吃掉短词) |
| 同义/别名可控 | 与内置词典冲突时行为不确定 |
判断顺序:
- 该词在语料里是否高频且整体语义(如"向量检索""蓝绿发布")→ 加;
- 是否只是两个常用词的偶然相邻(如"的方""了不")→ 不加,会让正常切分变形;
- 加前后用
_analyze对比同一批样例,确认只有目标词变化; - 词表版本化,与索引版本一起记录,避免"某天开始搜不准"却查不到改动。
6. 停用词:不等于低频词
PUT /kb_article/_settings
{ "analysis": { "filter": { "my_stop": { "type": "stop",
"stopwords": ["的", "了", "是", "在", "_none_"] } } } }
| 误区 | 事实 |
|---|---|
| 停用词 = 低频词 | 停用词是高频且无区分度的词;低频词恰恰是检索目标 |
| 停用词越多越省 | 去掉"不""没"会翻转语义("不支持"→"支持") |
| 停用词表可随意改 | 索引期与查询期停用词不一致 → term 对不上,静默零召回 |
_none_ 是 Lucene 里"清空默认停用词表"的占位符;自定义表会整体替换默认表,不是追加。停用词改动后必须重建索引,只改 settings 无效。
7. 同义词:synonym 与 synonym_graph
| 维度 | synonym |
synonym_graph |
|---|---|---|
| 使用位置 | 索引期或查询期 | 仅查询期 |
| 多词映射 | 展开为并列 term,位置不精确 | 保留图结构,支持"多词→多词" |
| 索引体积 | 索引期展开会膨胀 | 不影响索引 |
| 典型用途 | 简单等价词 | "NY" => "New York"、"手机" => "移动电话" |
配置形态是在查询期分析器上挂一个 synonym_graph filter:
"filter": { "syn_q": { "type": "synonym_graph",
"synonyms": ["充电器,充电头,适配器", "笔记本 => 笔记本电脑"] } }
规则优先级:=> 是单向映射,, 是等价展开。写错方向的后果很具体——把 笔记本 => 笔记本电脑 写成 笔记本电脑 => 笔记本,搜"笔记本"就再也不命中"笔记本电脑"。
查询期展开的代价:一个 query 变成多个 should,长同义词链会让子句数暴涨。控制手段是限制每条规则的词数、避免链式传递(A=B, B=C 会连同 A=C 一起展开),并对同义词组做上限校验。
8. ngram 与 edge_ngram:子串与前缀
PUT /kb_article/_settings
{ "analysis": {
"tokenizer": { "edge_2_10": { "type": "edge_ngram", "min_gram": 2, "max_gram": 10,
"token_chars": ["letter", "digit"] } },
"analyzer": {
"idx_prefix": { "type": "custom", "tokenizer": "edge_2_10" },
"qry_prefix": { "type": "custom", "tokenizer": "keyword" } } } }
(mapping 里把 brand 配成 analyzer: idx_prefix + search_analyzer: qry_prefix。)
| 方案 | 索引膨胀 | 适用 | 注意 |
|---|---|---|---|
edge_ngram(前缀) |
中,与 max_gram 线性相关 |
搜索框联想、品牌前缀 | 查询侧必须用 keyword/standard,不能再用 edge_ngram |
ngram(任意子串) |
高,与 n 的平方相关 |
商品编码、车型号模糊匹配 | 组合数爆炸,慎用于长文本 |
keyword + prefix 查询 |
无 | 结构化编号 | 只能前缀,不能中缀 |
最常见的翻车是查询期也用了 edge_ngram:query 被切成 2–10 长度的一堆碎片,命中率虚高、排序混乱。
9. 一致性检查清单
curl -s "localhost:9200/_cat/plugins?v" # 插件版本须与 ES 一致
curl -s "localhost:9200/kb_article/_mapping?pretty" | grep -A2 analyzer
curl -s "localhost:9200/kb_article/_analyze?pretty" -H 'Content-Type: application/json' -d '
{"field":"title","text":"南京市长江大桥"}' # 索引/查询两套分析器对照
| 检查项 | 通过标准 | 失败表现 |
|---|---|---|
| 插件版本 = ES 版本 | _cat/plugins 版本号一致 |
节点启动失败或分析器不存在 |
字段 analyzer |
不是 standard |
中文被逐字切 |
search_analyzer |
与索引期同族 | term 对不上,零召回 |
| 词典编码 | UTF-8 无 BOM | 首个词不生效 |
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 装了 ik 但切分没变 | 索引先建、插件后装;mapping 还是 standard |
新建索引并 reindex,插件必须在建索引前就位 |
| 加的词不生效 | 文件带 BOM / 路径不在 config 下 / 未重载 |
去 BOM,改 ext_dict 相对路径,重启或触发热更新 |
| 搜"笔记本"命中"笔记本电脑"却搜不到"笔记本"本身 | 同义词方向写反 | 检查 => 左右 |
查询报 too_many_clauses |
同义词/分词导致子句爆炸 | 减少同义词链,或调 indices.query.bool.max_clause_count |
在 keyword 字段上配了分词器 |
keyword 不切分,配置被忽略 |
该字段本就不该分词,改查 term |
最后一条展开说:keyword 字段整段原样存储,挂 analyzer 不报错也不起作用。要做子串匹配应改字段类型或加 text 子字段(含 fields 多字段)。
11. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 中文全文检索 | ik + 索引细/查询粗 |
通用且成熟 |
| 词表频繁更新 | 远程词典热更新 | 免重启、多节点一致 |
一句话:分词决定 term 空间,打分只是在这个空间里做选择。术语集合错了,后面所有调优都在错误的集合上做优化。
12. 自测
standard分词器对中文的处理方式会带来哪些具体检索症状?- 为什么惯例是"索引期
ik_max_word、查询期ik_smart"?反过来会怎样? - 远程词典热更新的触发依据是什么?多节点部署要注意什么?
- 词典文件带 BOM 时,症状为什么只出现在第一个词上?
synonym与synonym_graph在使用位置上有什么硬性区别?- 在
keyword字段上配置analyzer会发生什么?