第6章 第4节:自定义分词器
内置的 standard 分词器对英文尚可,对中文搜索、同义词匹配、"边输入边提示"这类需求就显得力不从心。自定义 Analyzer 把字符过滤、分词、token 过滤三步自由组合,是打磨搜索体验的核心手段。本节讲透 analyzer 的组装语法、常用组件、同义词扩展与收缩,并完成一个"索引阶段带同义词、搜索阶段不带"的标准实战。
🎯 学习目标
- 理解 analyzer 的三段式结构:char_filter → tokenizer → filter
- 会用
settings.analysis声明自定义 analyzer,并知道为什么 filter 之间有顺序讲究 - 熟悉 lowercase、stop、synonym、asciifolding、ngram 等常用 filter/tokenizer
- 会用
_analyzeAPI 调试任何 analyzer 的分词结果 - 能构建"index analyzer 用同义词、search analyzer 不用同义词"的生产级搜索方案
📖 理论讲解
1. Analyzer 的三段式结构
一篇文章进入倒排索引前要经过三道工序:
原始文本: "The C++ Developer's Guide"
│
▼ ① char_filter 字符过滤(在分词前改写原始字符流)
│ html_strip: 去掉 <b> 标签
│ mapping: 把 "C++" 替换为 "cpp"(防止 + 被切丢)
▼ ② tokenizer 分词(切成一个个 token,决定"词"从哪里断开)
│ standard: [ The, C++, Developer's, Guide ]
│ 空格/标点切分;ik_max_word: 中文细粒度切词
▼ ③ filter token 过滤(逐个加工 token)
│ lowercase → [ the, c++, developer's, guide ]
│ stop → 去掉 the
│ asciifolding → é → e, ñ → n
▼
最终索引的 term: [ cpp, developer's, guide ]
- char_filter:0 到多个,作用于"字符流"
- tokenizer :有且只有一个,是 analyzer 的核心
- filter :0 到多个,顺序很重要 ------例如
synonym放在lowercase之前,大写词就匹配不上同义词表
2. 常用 tokenizer
| tokenizer | 切分方式 | 适用 |
|---|---|---|
standard |
Unicode 词边界,中文逐字切 | 通用默认 |
whitespace |
按空白切 | 已预分词的文本 |
keyword |
不切,整段一个 token | 精确匹配字段 |
letter / lowercase |
按非字母切 | 简单英文 |
pattern |
按正则切 | 自定义分隔符 |
ngram / edge_ngram |
切成固定长度的滑窗片段 | 搜索提示/前缀匹配 |
ik_max_word / ik_smart |
中文细/粗粒度(需安装 IK 插件) | 中文搜索 |
3. 常用 token filter
| filter | 作用 | 示例 |
|---|---|---|
lowercase |
转小写 | Hello → hello |
stop |
去停用词 | the, is, a |
synonym / synonym_graph |
同义词替换 | 手机 → 手机, 电话 |
asciifolding |
变音符号归一 | café → cafe |
ngram |
生成 1~n 长度的所有片段 | abc → a, ab, abc, b, bc, c |
edge_ngram |
只保留前缀片段 | abc → a, ab, abc |
length / truncate |
按长度过滤/截断 | 去掉超长 token |
同义词的两个方向:
- 扩展(expansion) :一个词映射到多个词。
手机 => 手机,移动电话------ 搜"手机"能命中含"移动电话"的文档,索引里同时出现两个 term - 收缩(contraction) :多个词映射到一个词。
ipod, i-pod, i pod => ipod------ 文档中三种写法都被归一成ipod,搜索词也随之归一
格式上支持两种语法:
# 单向(扩展):左 => 右
手机, 移动电话 => 手机, 移动电话
# 等价写法(双向):用逗号分隔、无箭头
手机, 移动电话
synonym_graph 是 synonym 的增强版,支持多词同义词(multi-word synonyms)在图结构上正确处理,生产推荐用它(需放在 filter 中且搜索侧配合 match 查询)。
4. index analyzer 与 search analyzer 分离
ES 允许索引和搜索使用不同的 analyzer:
写入文档 ──► index analyzer(带 synonym) → 索引里存有同义词 term
用户搜索 ──► search analyzer(不带 synonym) → 只用原词查倒排表
为什么搜索侧通常不放同义词 filter?因为索引侧已经把同义词都编入倒排索引了,搜索时再扩展一次是重复劳动,还可能因同义词规则变化导致新旧文档行为不一致。这是官方文档给出的最佳实践。
🛠 实践操作
实践 1:用 _analyze 调试分词
先看基线行为,再学习临时拼装组件------_analyze 既能测试内置 analyzer,也能随手指定 tokenizer + filter 组合。
javascript
// 1. 标准分词器基线:4 个 token,已 lowercase(token 还带 start_offset/end_offset/type,此处省略)
GET /_analyze
{
"analyzer": "standard",
"text": "The Café is Beautiful"
}
预期返回:{"tokens": [{"token": "the", "position": 0}, {"token": "café", "position": 1}, {"token": "is", "position": 2}, {"token": "beautiful", "position": 3}]}。
javascript
// 2. 指定 filter 组合:lowercase + asciifolding + stop(_analyze 支持临时拼装组件)
GET /_analyze
{
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", { "type": "stop", "stopwords": ["the", "is"] }],
"text": "The Café is Beautiful"
}
预期返回(the、is 被 stop 去掉,café 归一为 cafe;token 含 start_offset/end_offset/type 字段,此处省略):
json
{
"tokens": [
{ "token": "cafe", "position": 1 },
{ "token": "beautiful", "position": 3 }
]
}
实践 2:创建第一个自定义 Analyzer
javascript
// 3. 建索引,定义 my_analyzer(char_filter/tokenizer/filter 也可直接内联在 analyzer 里)
PUT /products_search
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"type": "custom",
"char_filter": ["html_strip"],
"tokenizer": "standard",
"filter": [
"lowercase",
{ "type": "stop", "stopwords": ["the", "a", "is"] }
]
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "my_analyzer"
}
}
}
}
预期返回:{"acknowledged": true, "shards_acknowledged": true, "index": "products_search"}。
javascript
// 4. 验证自定义 analyzer 的分词效果(用索引名限定作用域)
POST /products_search/_analyze
{
"analyzer": "my_analyzer",
"text": "The <b>Best</b> iPhone is here"
}
预期返回(html 标签被去掉,the/is 被 stop 过滤):
json
{
"tokens": [
{ "token": "best", "position": 1 },
{ "token": "iphone", "position": 2 },
{ "token": "here", "position": 4 }
]
}
实践 3:同义词实战(index/search analyzer 分离)
javascript
// 5. 重建索引:索引阶段用 synonym 扩展,搜索阶段只用 lowercase
PUT /products_search
{
"settings": {
"analysis": {
"filter": {
"my_synonym": {
"type": "synonym_graph",
"synonyms": [
"手机, 移动电话, smart phone",
"笔记本 => 笔记本, laptop, 手提电脑"
]
}
},
"analyzer": {
"index_with_synonym": {
"tokenizer": "standard",
"filter": ["lowercase", "my_synonym"]
},
"search_no_synonym": {
"tokenizer": "standard",
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "index_with_synonym", // 索引阶段:带同义词
"search_analyzer": "search_no_synonym" // 搜索阶段:不带同义词
}
}
}
}
预期返回:{"acknowledged": true, "shards_acknowledged": true, "index": "products_search"}。
javascript
// 6/7. 对比索引侧与搜索侧 analyzer 对同一文本的分词结果
POST /products_search/_analyze
{ "analyzer": "index_with_synonym", "text": "买手机" }
预期返回(索引侧做同义词扩展:移动电话、smart phone 都被编入;注意 synonym_graph 的 token position 按 100、200 间隔排列以避免与原词位置冲突):
json
{
"tokens": [
{ "token": "买手机", "position": 0 },
{ "token": "移动电话", "position": 100 },
{ "token": "smart", "position": 200 },
{ "token": "phone", "position": 201 }
]
}
而同样的文本换用 search_no_synonym 分析,返回只有一个 token:{"token": "买手机", "position": 0}------搜索侧不做扩展。```javascript
// 8. 写入文档:标题里是"移动电话",索引侧会自动扩展出"手机"term
POST /products_search/_doc/1
{ "title": "新款移动电话发布" }
预期返回:`{"_index": "products_search", "_id": "1", "result": "created"}`。
```javascript
// 9. 用"手机"搜索,能命中"移动电话"的文档------同义词生效
GET /products_search/_search
{
"query": { "match": { "title": "手机" } }
}
预期返回:
json
{
"hits": {
"total": { "value": 1, "relation": "eq" },
"hits": [
{ "_id": "1", "_source": { "title": "新款移动电话发布" } }
]
}
}
实践 4:ngram 实现边搜边提示
需求:用户输入 ip 就能提示 iphone------这是前缀/子串匹配,靠 edge_ngram 实现。
javascript
// 10. 建一个 suggest 索引:索引侧用 edge_ngram 生成所有前缀(filter 内联写法)
PUT /suggest_demo
{
"settings": {
"analysis": {
"analyzer": {
"suggest_index_analyzer": {
"tokenizer": "standard",
"filter": [
"lowercase",
{ "type": "edge_ngram", "min_gram": 1, "max_gram": 10 }
]
},
"suggest_search_analyzer": {
"tokenizer": "standard",
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "suggest_index_analyzer",
"search_analyzer": "suggest_search_analyzer"
}
}
}
}
预期返回:{"acknowledged": true, "shards_acknowledged": true, "index": "suggest_demo"}。
javascript
// 11. 看看 "iphone" 在索引侧被切成什么
POST /suggest_demo/_analyze
{
"analyzer": "suggest_index_analyzer",
"text": "iphone"
}
预期返回(edge_ngram 从头开始的所有前缀,同 position):tokens 依次为 i、ip、iph、ipho、iphon、iphone。
javascript
// 12. 写入候选词并测试输入 "ip"
POST /suggest_demo/_doc/1
{ "name": "iphone 15 pro" }
POST /suggest_demo/_doc/2
{ "name": "ipad air" }
GET /suggest_demo/_search
{
"query": { "match": { "name": "ip" } }
}
预期返回:两个文档都命中(iphone 与 ipad 的索引 term 里都含 ip),这就是"边搜边提示"的雏形:
json
{
"hits": {
"total": { "value": 2, "relation": "eq" },
"hits": [
{ "_id": "1", "_source": { "name": "iphone 15 pro" } },
{ "_id": "2", "_source": { "name": "ipad air" } }
]
}
}
搜索侧 analyzer 必须不带 edge_ngram:索引已经存了所有前缀,搜索时再切一遍 ngram 会造成过度匹配。
实践 5:组合实战------支持同义词的搜索方案
把本节组件整合成完整的电商商品搜索方案。settings.analysis 部分与实践 3 完全一致(synonym_expand filter + 一带一不带的两个 analyzer),这里只展示补充了 keyword 子字段的 mapping------title.raw 用于精确匹配和聚合:
javascript
// 13. 完整索引(settings.analysis 照抄实践 3,此处省略)
PUT /products_search
{
"settings": { "...同实践 3...": "" },
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "title_index_analyzer",
"search_analyzer": "title_search_analyzer",
"fields": {
"raw": { "type": "keyword" }
}
}
}
}
}
预期返回:{"acknowledged": true, "shards_acknowledged": true, "index": "products_search"}。
javascript
// 14. 完整链路验证:写"笔记本"文档,用"电脑"搜
POST /products_search/_doc/10
{ "title": "轻薄笔记本推荐" }
GET /products_search/_search
{
"query": { "match": { "title": "电脑" } }
}
预期返回:命中 1 条------电脑 在索引侧映射为 {电脑, 笔记本, laptop} 三个 term,其中 笔记本 与文档匹配:
json
{
"hits": {
"total": { "value": 1, "relation": "eq" },
"hits": [
{ "_id": "10", "_source": { "title": "轻薄笔记本推荐" } }
]
}
}
⚠️ 常见坑
- 同义词更新后旧文档不生效 。同义词 filter 是写入时应用的,改同义词表只影响之后写入的文档,存量文档必须
_update_by_query重建。这也是"同义词放索引侧"的代价------更新成本比放搜索侧高,需要权衡。 - filter 顺序错误导致同义词失配 。
synonym/synonym_graph放在lowercase之前时,词表里的小写词匹配不上大写 token(如iPhone)。惯例:lowercase永远放在同义词 filter 前面。 - search analyzer 误加 edge_ngram / synonym 。索引侧已生成的 term,搜索侧再处理一遍轻则多余、重则搜"ip"把所有含
i的词都带出来。原则:索引侧做"重"处理,搜索侧保持轻量。 - ngram 参数过大导致索引爆炸 。普通
ngram对每个词生成 O(n²) 级 term,max_gram=10会让索引膨胀数倍。提示场景优先用edge_ngram(O(n) 个前缀),max_gram 控制在 10~15 以内。
📝 小结与练习
要点回顾:
- Analyzer = char_filter(0...n)+ tokenizer(有且仅 1 个)+ filter(0...n),filter 顺序有讲究
_analyze是分词调试的瑞士军刀,可临时组合 tokenizer/filter 测试- 同义词有扩展(一词变多词)和收缩(多词归一)两种方向;生产用
synonym_graph - 最佳实践:index analyzer 带同义词(写入时把同义词编入倒排),search analyzer 保持轻量
- edge_ngram(而非 ngram)是"边搜边提示"的性价比之选,搜索侧同样不加 ngram
动手练习:
- 为本节的产品索引新增一个
brand字段,自定义 analyzer 实现:keyword 分词 + lowercase + asciifolding(让Café与cafe都能精确匹配到同一 term)。 - 在同义词表中加入一条收缩规则(如
iphone, 苹果手机 => iphone),用_analyze分别在 index analyzer 和 search analyzer 上验证行为差异。 - 把实践 4 的 edge_ngram 改为普通
ngram(min 1 / max 4),重新_analyze单词abc,观察 term 数量的增长,并解释为什么提示场景更推荐 edge_ngram。