第6章第4节:自定义分词器

第6章 第4节:自定义分词器

内置的 standard 分词器对英文尚可,对中文搜索、同义词匹配、"边输入边提示"这类需求就显得力不从心。自定义 Analyzer 把字符过滤、分词、token 过滤三步自由组合,是打磨搜索体验的核心手段。本节讲透 analyzer 的组装语法、常用组件、同义词扩展与收缩,并完成一个"索引阶段带同义词、搜索阶段不带"的标准实战。

🎯 学习目标

  • 理解 analyzer 的三段式结构:char_filter → tokenizer → filter
  • 会用 settings.analysis 声明自定义 analyzer,并知道为什么 filter 之间有顺序讲究
  • 熟悉 lowercase、stop、synonym、asciifolding、ngram 等常用 filter/tokenizer
  • 会用 _analyze API 调试任何 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 查询)。

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": "轻薄笔记本推荐" } }
    ]
  }
}

⚠️ 常见坑

  1. 同义词更新后旧文档不生效 。同义词 filter 是写入时应用的,改同义词表只影响之后写入的文档,存量文档必须 _update_by_query 重建。这也是"同义词放索引侧"的代价------更新成本比放搜索侧高,需要权衡。
  2. filter 顺序错误导致同义词失配 。synonym/synonym_graph 放在 lowercase 之前时,词表里的小写词匹配不上大写 token(如 iPhone)。惯例:lowercase 永远放在同义词 filter 前面。
  3. search analyzer 误加 edge_ngram / synonym 。索引侧已生成的 term,搜索侧再处理一遍轻则多余、重则搜"ip"把所有含 i 的词都带出来。原则:索引侧做"重"处理,搜索侧保持轻量。
  4. 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

动手练习:

  1. 为本节的产品索引新增一个 brand 字段,自定义 analyzer 实现:keyword 分词 + lowercase + asciifolding(让 Café 与 cafe 都能精确匹配到同一 term)。
  2. 在同义词表中加入一条收缩规则(如 iphone, 苹果手机 => iphone),用 _analyze 分别在 index analyzer 和 search analyzer 上验证行为差异。
  3. 把实践 4 的 edge_ngram 改为普通 ngram(min 1 / max 4),重新 _analyze 单词 abc,观察 term 数量的增长,并解释为什么提示场景更推荐 edge_ngram。
相关推荐
Adellle1 小时前
设计模式指南 (Java 版) ㊁
java·开发语言·设计模式
Delightrs1 小时前
Rust LeetCode238 算法题解
开发语言·算法·rust
云上工程笔记2 小时前
SRT字幕一键转手绘动画:srt-whiteboard-animation的复刻实录
大数据·开发语言·javascript·人工智能·机器学习
zhanghaha13142 小时前
C# 系列教程_12 字符串 string 保姆级系统教程(面向新手,大量示例 + 原理讲解)
开发语言·数据库·c#
eggwyw2 小时前
PHP8.5怎么用ffmpeg提取视频里的字幕轨道
开发语言·php
瑞雪兆丰年兮2 小时前
JavaWeb-day07-MySQL(DQL&多表设计)
java·开发语言·数据库·sql·mysql·javaweb·web
ShineWinsu5 小时前
对于Qt:QDial常见属性以及信号的解析
开发语言·c++·qt·gui·客户端·旋钮·qdial
Java后端的Ai之路7 小时前
RAG 还是 Agent 自主检索?Claude Code 删掉向量库之后,我把选型讲透了
开发语言·人工智能·prompt·agent·rag
starzy19907 小时前
深入理解Scala高阶函数:函数式编程核心
开发语言·scala