Elasticsearch 倒排索引与中文分词剖析
Elasticsearch 的速度秘密全藏在一个数据结构里——倒排索引;而中文搜索的质量,取决于你多懂分词。本文不讲泛泛的「入门到精通」,只聚焦两块硬骨头:倒排索引的底层机制(FST、BM25、Segment)和 IK 中文分词的实战细节(自定义词典、同义词、拼音、排查方法论)。
1. 什么是 Elasticsearch
Elasticsearch(简称 ES)是一个基于 Apache Lucene 构建的分布式搜索与分析引擎。它的核心能力可以概括为三个词:
| 能力 | 说明 |
|---|---|
| 近实时搜索 | 数据写入后在 1 秒内即可被搜索到(默认刷新间隔) |
| 全文检索 | 对文本进行分词、倒排索引、相关性打分,支持模糊、高亮等 |
| 聚合分析 | 类似 SQL 的 GROUP BY,能在海量数据上做实时统计 |
ES 天然支持分布式部署,一个集群可以横跨数十台服务器,承载 PB 级别的数据,同时保持毫秒级的查询响应。
典型应用场景
- 站内搜索:商品搜索、文章检索、用户搜索
- 日志分析:配合 Logstash + Kibana(ELK Stack)做日志收集与可视化
- 监控告警:APM 数据存储与实时告警
- 推荐系统:基于内容的相似度搜索
- 地理空间搜索:LBS 附近的人/店铺/路线
2. 核心概念
ES 有几个绕不开的基础概念,这里用一张关系图来理解:
集群(Cluster)
├── 节点 A(Node)── 主节点(Master)
│ ├── 索引 0(Index)
│ │ ├── 分片 0(Primary Shard)
│ │ └── 分片 1(Replica Shard)
│ └── 索引 1(Index)
│ ├── 分片 0(Replica Shard)
│ └── 分片 1(Primary Shard)
└── 节点 B(Node)── 数据节点
├── 索引 0
│ ├── 分片 1(Primary Shard)
│ └── 分片 0(Replica Shard)
└── 索引 1
├── 分片 0(Primary Shard)
└── 分片 1(Replica Shard)
2.1 索引(Index)
索引 ≈ MySQL 中的 Database。一个索引是一类文档的集合,比如 products(商品)、articles(文章)。
每个索引由若干个分片组成,分片是 ES 进行数据分布和并行处理的最小单元。
2.2 文档(Document)
文档 ≈ MySQL 中的 Row。一条 JSON 记录就是一个文档。
{
"id": 1,
"title": "Elasticsearch 入门指南",
"author": "张三",
"content": "本文详细介绍了 ES 的核心概念...",
"tags": ["elasticsearch", "搜索"],
"created_at": "2025-06-01T10:00:00"
}
2.3 映射(Mapping)
映射 ≈ MySQL 中的 Schema。定义文档中每个字段的类型。
{
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ik_max_word" },
"author": { "type": "keyword" },
"price": { "type": "float" },
"created_at": { "type": "date" }
}
}
}
2.4 分片与副本
| 概念 | 说明 |
|---|---|
| 主分片(Primary Shard) | 数据的第一写入入口,数量在索引创建时确定,不可修改 |
| 副本分片(Replica Shard) | 主分片的拷贝,可动态调整数量,提供冗余和读负载均衡 |
一个经验公式:
主分片数 × (副本数 + 1) = 数据总份数。例如 3 个主分片 + 1 个副本 = 6 个分片,数据冗余率为 2。
3. 倒排索引:ES 之所以快
这是 ES 最核心的数据结构,也是面试最高频的考点。理解了它,你就理解了 ES 的全部。
3.1 从一个场景说起
假设你写了三篇技术博客,想做一个搜索功能:
| 文档 ID | 标题 | 正文 |
|---|---|---|
| Doc 1 | Elasticsearch 入门 | Elasticsearch 是一个分布式搜索引擎,基于 Lucene 构建 |
| Doc 2 | MySQL 索引原理 | MySQL 的 B+Tree 索引和倒排索引完全不同 |
| Doc 3 | 搜索引擎对比 | Elasticsearch 和 Solr 都是基于 Lucene 的搜索引擎 |
用户搜索「Elasticsearch 搜索引擎」,你希望快速找到 Doc 1 和 Doc 3。
方案 A:暴力扫描(正向索引)
遍历三篇文章,逐篇检查是否包含搜索词:
Doc 1: "Elasticsearch" ← 匹配! "入门" ← 不匹配 → 部分匹配
Doc 2: "MySQL" ← 不匹配 "索引" ← 不匹配 → 不匹配
Doc 3: "搜索" ← 匹配! "引擎" ← 匹配! → 部分匹配
每个词都要扫一遍所有文档。当你有 100 万篇文档时,这就是 O(n × m) 的复杂度——这是 MySQL LIKE '%keyword%' 做不到高效的原因。
方案 B:倒排索引
提前建立一个「词 → 文档」的映射表:
"Elasticsearch" → [Doc 1, Doc 3]
"搜索引擎" → [Doc 1, Doc 3]
"分布式" → [Doc 1]
"Lucene" → [Doc 1, Doc 3]
"MySQL" → [Doc 2]
"B+Tree" → [Doc 2]
"Solr" → [Doc 3]
现在搜「Elasticsearch 搜索引擎」,直接查表取交集:
"Elasticsearch" 对应的文档: [Doc 1, Doc 3]
"搜索引擎" 对应的文档: [Doc 1, Doc 3]
取交集 → [Doc 1, Doc 3]
不管你有多少文档,每个词的查找都是 O(1),最后做一个文档列表求交集即可。这就是倒排索引的威力。
3.2 倒排索引的四层结构
Lucene 的倒排索引不是一张简单的 HashMap,而是一个精心设计的四层存储结构:
┌────────────────────────────────────────────────────┐
│ Term Index (FST 有限状态转换器) │
│ 快速定位 Term Dictionary 中的大致位置 │
│ 常驻内存,极小 │
├────────────────────────────────────────────────────┤
│ Term Dictionary (词项字典) │
│ 所有词项的有序列表,存储 Term → Posting 指针 │
│ 磁盘存储,支持二分查找 │
├────────────────────────────────────────────────────┤
│ Posting List (倒排列表) │
│ 每个 Term 对应的文档 ID 列表 + 词频 + 位置信息 │
│ 磁盘存储,高度压缩 │
├────────────────────────────────────────────────────┤
│ Stored Fields (原始文档) │
│ 文档的完整 JSON 内容,用于 _source 返回 │
│ 磁盘存储,压缩 │
└────────────────────────────────────────────────────┘
查询时的协作流程:
用户输入 "elasticsearch"
│
▼
┌──────────────────┐
│ Term Index (FST) │ ← "elast..." 前缀定位
│ 内存中,微秒级 │
└────────┬─────────┘
│ 跳转到
▼
┌──────────────────┐
│ Term Dictionary │ ← 二分查找 "elasticsearch"
│ 磁盘,毫秒级 │
└────────┬─────────┘
│ 获取 Posting 指针
▼
┌──────────────────┐
│ Posting List │ ← 读取 [Doc 1, Doc 3]
│ 解压并返回 │
└──────────────────┘
3.3 FST:为什么 ES 不直接用 HashMap?
初学者经常问:为什么不用 HashMap 直接存 “词 → 文档列表”,O(1) 查出来不好吗?
答案很简单:内存放不下。
假设你的索引有 1000 万个不同的词(Term),每个词平均 20 字节,HashMap 的指针和元数据开销是词本身的 3-5 倍。粗略计算:
1000 万词 × (20 字节词 + 60 字节指针开销) ≈ 800 MB
光是词项索引就吃掉近 1 GB 堆内存,JVM GC 压力会非常大。而 FST(Finite State Transducer,有限状态转换器) 能把这 800 MB 压缩到几十 MB。
FST 的核心思想:共享公共前缀和后缀。
词列表: "mon", "monday", "month", "moon"
传统 HashMap:
"mon" → 独立存储
"monday" → 独立存储
"month" → 独立存储
"moon" → 独立存储
FST:
┌── n ── (mon)
│
m ── o ── n ── d ── a ── y ── (monday)
│ │
│ └── t ── h ── (month)
│
└── o ── n ── (moon)
共享了公共前缀 m → o 和 m → o → n,空间占用大幅降低。FST 不仅能压缩,还支持「输出值」——每个终态可以携带一个指针(指向 Term Dictionary 中的位置),这正是它作为 Term Index 的关键能力。
一句话总结:FST 是 Lucene 最精妙的设计之一。它用极少的内存承载了海量词项的快速前缀查找,是倒排索引能大规模部署的底层保障。
3.4 Posting List:不只是文档 ID
Posting List 远不止一个「文档 ID 列表」。为了支持短语查询、高亮、相关性打分,Lucene 为每个词记录了丰富的元数据:
Term: "elasticsearch"
│
├── DocFreq (文档频率) = 3 ← 这个索引中有 3 个文档包含该词
│
├── Posting 1: DocID=1
│ ├── TermFreq = 2 ← 在 Doc 1 中出现了 2 次
│ ├── Positions = [3, 15] ← 出现的位置(第 3 和第 15 个词)
│ └── Payloads = null ← 可选的自定义权重
│
├── Posting 2: DocID=5
│ ├── TermFreq = 1
│ ├── Positions = [8]
│ └── Payloads = null
│
└── Posting 3: DocID=9
├── TermFreq = 3
├── Positions = [1, 7, 22]
└── Payloads = null
各字段的用途:
| 字段 | 用途 |
|---|---|
| DocFreq | 计算 IDF 时的分母,决定词的重要性 |
| TermFreq | 计算 TF 时的分子,词出现越多越相关 |
| Positions | 短语查询(match_phrase)和高亮(highlight)的关键——需要知道词在文档中的精确位置 |
| Offsets | 高亮时定位原始文本的字符起止位置(可选,默认关闭以节省空间) |
为什么需要 Positions?
// 短语查询
GET /articles/_search
{
"query": {
"match_phrase": {
"content": "分布式搜索引擎"
}
}
}
这个查询要求「分布式」和「搜索引擎」相邻出现且顺序一致。ES 通过 Positions 字段判断:Doc 1 中「分布式」在位置 0,「搜索引擎」在位置 1,正好相邻 → 匹配。
没有 Positions 信息,match_phrase 就无法工作。
3.5 压缩算法:FOR 编码与 Roaring Bitmaps
Posting List 要存储的文档 ID 可能长达数百万个。直接存 int[] 太浪费内存了。Lucene 用了两种核心压缩技术:
FOR 编码(Frame of Reference)
文档 ID 列表的特点:有序且差值较小。
原始文档 ID: [1, 3, 5, 8, 10, 12, 15]
增量编码后: [1, 2, 2, 3, 2, 2, 3] ← 最大增量只有 3,用 2 bit 就够
如果不压缩:7 × 32 bit = 224 bit。FOR 编码后:7 × 2 bit = 14 bit。压缩了 16 倍。
Lucene 将 Posting List 分成 128 个文档一块(Block),每块独立选择最省空间的编码方式。
Roaring Bitmaps(用于 Filter 缓存)
当执行 filter 查询时,ES 会把匹配的文档集合缓存为 Roaring Bitmap。
Roaring Bitmap 是分段式的位图结构:
文档总数 = 100 万,匹配文档 = [3, 500, 70000, 800000]
传统 Bitmap: 100 万 bit ≈ 125 KB(大量连续的 0 浪费空间)
Roaring Bitmap:
┌─ Bucket 0 (doc 0~65535): 位图 → [3, 500]
├─ Bucket 1 (doc 65536~131071): 位图 → [70000]
└─ Bucket 12 (doc 786432~851967): 位图 → [800000]
只存有数据的 Bucket,空 Bucket 不占空间 → 约 12 KB
核心价值:两个 Roaring Bitmap 做 AND/OR 操作非常高效——这恰好是 bool 查询 filter 子句的真实场景。
3.6 多词布尔查询的求交优化
用户搜「Elasticsearch 搜索引擎 入门」时,ES 需要对三个词的 Posting List 求交集。Lucene 的优化策略非常巧妙:
"Elasticsearch" → [1, 3, 5, 8, 12, 100, 200, 300, ......]
"搜索引擎" → [1, 3, 5, 8, 10, 12, ......]
"入门" → [1, 2, 3, 5, 8, ......]
算法:从最短的 Posting List 开始,用它作为「探针」去其他列表中查找(利用 Skip List 跳跃,而非逐个对比)。
Step 1: 选最短列表 "入门" [1, 2, 3, 5, 8, ...]
Step 2: 取 doc=1,用 Skip List 跳到 "搜索引擎" 的 doc=1 附近 → 匹配!
用 Skip List 跳到 "Elasticsearch" 的 doc=1 附近 → 匹配!
→ doc=1 是交集
Step 3: 取 doc=2,在 "搜索引擎" 中找不到 → 跳过
Step 4: 取 doc=3,两者都找到 → 交集
...
Skip List(跳表)的作用:当文档 ID 差距很大时,不需要逐一遍历,可以直接跳到接近的位置。这就是 ES 能在数十亿文档中做布尔查询毫秒级返回的原因。
3.7 相关性打分:TF-IDF 与 BM25(公式详解)
ES 不只是「找到」,更要「排序」——把最相关的排在前面。
TF-IDF(经典模型)
Score(d, q) = Σ TF(t, d) × IDF(t)
t∈q
其中:
TF(t, d) = sqrt(freq(t, d)) ← 词频的平方根(非线性)
IDF(t) = 1 + ln( N / (df(t) + 1) ) ← 逆文档频率
freq(t, d): 词 t 在文档 d 中的出现次数
df(t): 包含词 t 的文档数
N: 索引中的文档总数
TF-IDF 的问题在于:长文档天然有更高的词频,一篇 5000 词的文档比 500 词的文档更容易得高分,尽管内容并不一定更相关。
BM25(ES 5.0+ 默认,公认效果最佳)
BM25(d, q) = Σ IDF(t) × ( TF(t,d) × (k1+1) ) / ( TF(t,d) + k1 × (1-b + b × |d|/avgdl) )
t∈q
其中:
|d|: 当前文档的长度(词数)
avgdl: 索引中所有文档的平均长度
k1: 词频饱和度参数(默认 1.2)
b: 长度归一化参数(默认 0.75)
BM25 解决的三个核心问题:
| 问题 | BM25 如何解决 |
|---|---|
| 长文档词频过高 | TF×k1 / (TF + k1) 让 TF 的增长曲线饱和——出现 5 次和 50 次得分差别不大 |
| 短文档被低估 | `b × |
| 高频词(如"的"“了”)干扰 | IDF 让高频词得分趋近于 0 |
直觉理解:一个词在文档中出现 1 次很重要,但出现 100 次并不意味着相关性高 100 倍。BM25 通过非线性饱和巧妙地模拟了这种直觉。
// 用 explain 接口看 BM25 计算的每一步
GET /articles/_search
{
"query": { "match": { "content": "elasticsearch 搜索引擎" } },
"explain": true
}
explain: true 返回的 JSON 会详细展示每个词的 IDF、TF、字段长度归一化等中间计算结果——排查排序不符合预期时的利器。
3.8 Segment(段):倒排索引的物理形态
一个重要的认知:ES 的索引不是一个大文件,而是由无数个不可变的 Segment(段)组成的。
索引 articles
│
├── Segment_0 (已提交,不可变)
│ ├── 倒排索引: terms (.tim, .tip, .doc)
│ ├── 原始数据: stored fields (.fdt, .fdx)
│ └── 文档值: doc values (.dvd, .dvm)
│
├── Segment_1 (已提交,不可变)
│ └── ...
│
└── Segment_2 (正在写入,可变)
└── ...
Segment 不可变的意义:
- 写入友好:新数据追加到新 Segment,不修改老文件,天然无锁
- 缓存友好:Segment 不变 → 缓存不失效 → Filter 缓存长期有效
- 删除和更新:不会真正修改 Segment,而是标记删除(.del 文件),后台 Merge 时物理清除
Merge 策略:Lucene 定期将小 Segment 合并成大 Segment,减少文件碎片和搜索时需要打开的 Segment 数量。ES 默认使用 Tiered Merge Policy,平衡写入速度和搜索性能。
3.9 小结:为什么倒排索引 + MySQL 是经典组合
| MySQL (B+Tree) | ES (倒排索引) | |
|---|---|---|
| 按 ID 精确查找一行 | 主键查询 O(log n) | doc_id 查询 |
| 按关键词搜索多行 | LIKE 全表扫描 | 倒排索引 |
| 按条件范围过滤 | 索引范围扫描 | Filter 缓存 |
| 事务 + 关联查询 | JOIN + ACID | 不支持 |
| 数据一致性 | 强一致 | 近实时 |
实际架构:MySQL 做事务存储 → Canal/Logstash 同步 → ES 做全文搜索。各取所长,完美互补。
4. 分词器与中文搜索
分词决定了什么能被搜到。英文按空格分词,中文没有天然分隔符——所以中文分词是 ES 搜索质量的第一道坎。
4.1 为什么中文分词这么难?
英文: "Hello World, Elasticsearch is great!"
→ 按空格和标点: ["hello", "world", "elasticsearch", "is", "great"]
→ 几乎不用动脑子
中文: "我爱北京天安门"
→ 没有空格分隔!
→ 同一个字符串可以有多种合法切分:
一刀切下去,千差万别:
| 分词方案 | 结果 | 问题 |
|---|---|---|
| 不分词 | ["我爱北京天安门"] |
只能精确匹配整句,搜「北京」→ 找不到 |
| 单字切分 | ["我","爱","北","京","天","安","门"] |
搜「天安门」→ 被拆成三个字各自匹配,精度极差 |
| 二元切分 | ["我爱","爱北","北京","京天","天安","安门"] |
「我爱」是噪声,「北京」正确但噪声太多导致误匹配 |
| IK 智能分词 | ["我","爱","北京","天安门"] |
恰到好处 |
中文分词的难点在于歧义消解:
"乒乓球拍卖完了"
方案 A: 乒乓球 / 拍卖 / 完了 → 球拍被拍卖了
方案 B: 乒乓球拍 / 卖 / 完了 → 球拍卖光了
哪个正确?需要结合上下文和词频统计来判断。IK 分词器通过词典匹配 + 歧义消解算法来逼近最优解。
4.2 ES 的文本分析全链路
分词不是单独一个动作,而是一个完整的流水线(Analysis Pipeline):
原始文本
│
▼
┌─────────────────────┐
│ 1. Character Filter │ ← 字符过滤器(可选,0 个或多个)
│ 去除 HTML 标签 │
│ 统一全角半角 │
│ 表情符号替换 │
└────────┬────────────┘
▼
┌─────────────────────┐
│ 2. Tokenizer │ ← 分词器(必选,有且仅有一个)
│ 把字符串切成词 │
│ standard / ik / │
│ whitespace / ... │
└────────┬────────────┘
▼
┌─────────────────────┐
│ 3. Token Filter │ ← 词元过滤器(可选,0 个或多个)
│ 转小写 (lowercase) │
│ 去停用词 (stop) │
│ 同义词扩展 │
│ 拼音转换 │
└────────┬────────────┘
▼
最终词项 (Term) 存入倒排索引
Standard Analyzer 处理英文的过程:
输入: "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone."
Char Filter → (无)
Tokenizer → ["The", "2", "QUICK", "Brown", "Foxes", "jumped", "over", "the", "lazy", "dog's", "bone"]
├─ Lowercase → ["the", "2", "quick", "brown", "foxes", "jumped", "over", "the", "lazy", "dog's", "bone"]
├─ Stop → ["2", "quick", "brown", "foxes", "jumped", "lazy", "dog's", "bone"] ← the/over 被去除
└─ Stemmer → ["2", "quick", "brown", "fox", "jump", "lazi", "dog", "bone"] ← 词干提取
最终倒排索引中的 Term: ["2", "quick", "brown", "fox", "jump", "lazi", "dog", "bone"]
现在搜 “jumping” → 被词干化为 “jump” → 匹配到 “jumped” 的文档。这就是全文搜索智能的地方。
4.3 _analyze 接口:分词调试的终极武器
做中文搜索,一定要学会用 _analyze 接口。它能让你看到 ES 实际把用户的输入切成了哪些词,而不是猜。
// 查看 standard 分词器对中文的处理效果
POST _analyze
{
"analyzer": "standard",
"text": "我爱北京天安门"
}
返回:
{
"tokens": [
{ "token": "我", "position": 0 },
{ "token": "爱", "position": 1 },
{ "token": "北", "position": 2 },
{ "token": "京", "position": 3 },
{ "token": "天", "position": 4 },
{ "token": "安", "position": 5 },
{ "token": "门", "position": 6 }
]
}
Standard analyzer 把中文当成单字处理——效果极差。
// 用 IK 分词器
POST _analyze
{
"analyzer": "ik_max_word",
"text": "我爱北京天安门"
}
返回:
{
"tokens": [
{ "token": "我", "position": 0 },
{ "token": "爱", "position": 1 },
{ "token": "北京", "position": 2 },
{ "token": "天安门", "position": 3 }
]
}
实战技巧:当你怀疑某个搜索词搜不到结果时,分别对「索引时用的分词器」和「搜索时用的分词器」调用 _analyze,对比两个结果。如果切出来的词不一致,搜不到就是必然的。
4.4 IK 分词器深度解析
ES 中文搜索的事实标准是 IK Analyzer,由林良益(medcl)开发并长期维护。
# 安装 IK 分词器(版本号必须与 ES 一致)
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.12.0/elasticsearch-analysis-ik-8.12.0.zip
ik_max_word vs ik_smart:一个例子看懂区别
以「中华人民共和国国歌」为例:
| 模式 | 切分结果 | 词数 |
|---|---|---|
ik_max_word |
["中华人民共和国", "中华人民", "中华", "华人", "人民共和国", "人民", "共和国", "共和", "国", "国歌"] |
10 |
ik_smart |
["中华人民共和国", "国歌"] |
2 |
max_word 的核心思想:穷尽词库中所有可能的词——哪怕产生重叠和冗余。索引时使用,保证「不管用户怎么搜,我都有对应的词可以命中」。
smart 的核心思想:选择最合理的切分路径——保持词语完整,避免碎片化。搜索时使用,保证「用户的搜索意图不会被过度拆解」。
为什么索引和搜索可以用不同的分词器?
这是 ES 的一个巧妙设计——索引和搜索的 analyzer 可以分开配置:
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word", // 索引时:细粒度 → 提高召回
"search_analyzer": "ik_smart" // 搜索时:粗粒度 → 提高精度
}
}
}
}
原理:索引时把文档切成细碎的词,用户搜索「国歌」时,虽然只用 ik_smart 切出了「国歌」,但倒排索引中已经存了「国歌」这个词项(由 ik_max_word 产生),所以能命中。同时「中华」和「人民」这些碎片词也在索引中,用户搜「中华」也能找到这篇文章。
工作流示例:
文档: "中华人民共和国国歌"
│ 索引时 (ik_max_word)
▼
倒排索引: "中华人民共和国"→[doc1], "中华"→[doc1], "人民"→[doc1], "国歌"→[doc1], ...
│
│ 搜索时 (ik_smart)
▼
用户搜 "国歌" → ik_smart → ["国歌"]
│ 查倒排索引 "国歌" → [doc1]
▼
匹配成功!
用户搜 "中华" → ik_smart → ["中华"]
│ 查倒排索引 "中华" → [doc1]
▼
也匹配成功! (因为 ik_max_word 已经切出了"中华")
4.5 IK 自定义词典:让分词器认识你的业务
IK 分词器内置了 27 万+ 中文词条,但你的业务总会遇到专有名词。比如你做户外出行平台「山野智行」,就可能遇到:
"山野智行环湖绿道攻略"
IK 默认切分: ["山野", "智", "行", "环", "湖", "绿道", "攻略"]
期望切分: ["山野智行", "环湖绿道", "攻略"]
「山野智行」被拆成碎片后,用户搜这个品牌名可能匹配不到。解决方法:添加自定义词典。
配置步骤
第一步:进入 IK 配置目录,创建自定义词典文件:
cd $ES_HOME/config/analysis-ik/
vim my_custom_dict.dic
第二步:每行一个词,写入专有名词:
# my_custom_dict.dic
山野智行
环湖绿道
CityWalk
逃离城市计划
飞盘露营
桨板瑜伽
第三步:编辑 IKAnalyzer.cfg.xml,引用自定义词典:
<?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">my_custom_dict.dic</entry>
<!-- 用户自定义停用词词典 -->
<entry key="ext_stopwords">my_stopwords.dic</entry>
</properties>
第四步:重启 ES 节点,验证分词效果:
POST _analyze
{
"analyzer": "ik_max_word",
"text": "山野智行环湖绿道攻略"
}
// 返回:
{
"tokens": [
{ "token": "山野智行", "position": 0 }, ← 新词生效!
{ "token": "环湖绿道", "position": 1 },
{ "token": "攻略", "position": 2 }
]
}
热更新(不用重启)
生产环境频繁重启 ES 不可行。IK 支持词典热更新——配置远程词典 URL:
<entry key="remote_ext_dict">http://your-server.com/ik_dict/hot_words.dic</entry>
IK 会每隔 60 秒自动拉取最新词典,检测到变化后热加载。词条一行一个,支持 # 注释。
热更新的词典文件本身很小(通常 KB 级别),但如果有意外的大文件下载或网络故障,IK 会降级使用本地缓存的上一版词典,不会影响服务。
4.6 同义词:搜「笔记本」也能找到「笔记本电脑」
同义词功能解决了「用户搜的词」和「文档写的词」不一致的问题:
// config/analysis/synonym.txt
笔记本电脑, 笔记本, laptop
自行车, 单车, 脚踏车, bike
户外, 野外, outdoor
登山, 爬山, 徒步, hiking
在索引中配置同义词过滤器:
{
"settings": {
"analysis": {
"filter": {
"my_synonym_filter": {
"type": "synonym",
"synonyms_path": "analysis/synonym.txt",
"updateable": true // 支持热更新
}
},
"analyzer": {
"ik_synonym_analyzer": {
"type": "custom",
"tokenizer": "ik_max_word",
"filter": ["my_synonym_filter"]
}
}
}
}
}
同义词有两种展开方式:
| 方式 | 配置 | 行为 |
|---|---|---|
| 等价替换 | a, b, c(逗号分隔) |
所有词地位相同,双向替换 |
| 单向替换 | a => b |
搜 a 时替换为 b,反之不行 |
实战建议:等价替换适合「同义表达」(自行车=单车),单向替换适合「纠正俗语→标准词」(脚踏车→自行车)。
注意:同义词在索引阶段展开(不在搜索阶段),这意味着文档中「自行车」会在倒排索引中被扩展为
["自行车", "单车", "脚踏车"],无论用户搜哪个都能命中。
4.7 拼音搜索:用户打拼音也能搜到
在国内产品中,拼音搜索是常见需求:
{
"settings": {
"analysis": {
"filter": {
"pinyin_filter": {
"type": "pinyin",
"keep_first_letter": true,
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true, // 全拼连在一起(beijing)
"keep_original": true, // 保留原始中文词
"limit_first_letter_length": 16,
"remove_duplicated_term": true
}
},
"analyzer": {
"ik_pinyin_analyzer": {
"type": "custom",
"tokenizer": "ik_max_word",
"filter": ["pinyin_filter", "lowercase"]
}
}
}
}
}
分词效果演示:
POST _analyze
{
"analyzer": "ik_pinyin_analyzer",
"text": "北京长城"
}
// 返回:
{
"tokens": [
{ "token": "北京", "position": 0 },
{ "token": "beijing", "position": 0 }, ← 全拼
{ "token": "bj", "position": 0 }, ← 首字母
{ "token": "长城", "position": 1 },
{ "token": "changcheng", "position": 1 },
{ "token": "cc", "position": 1 }
]
}
现在用户搜 beijing、bj、北京 任意一种都能命中!
拼音搜索的一个坑:keep_original: true 是关键配置,如果设成 false,原始中文词会被丢弃,导致中文搜索也失效——因为倒排索引中只有拼音没有中文了。
4.8 搜不到?三步定位分词问题
生产环境中「搜不到」是最常见的问题,排查方法如下:
Step 1: 确认文档确实被索引了
GET /your_index/_doc/doc_id
先确认文档存在且字段内容正确。
Step 2: 模拟索引时的分词
POST /your_index/_analyze
{
"field": "title",
"text": "山野智行环湖绿道攻略" // ← 写入文档的原文
}
看看ES实际把文档切成了哪些词。关键词是否在其中?
Step 3: 模拟搜索时的分词
POST /your_index/_analyze
{
"field": "title",
"text": "山野智行" // ← 用户输入的搜索词
}
如果用了 search_analyzer,用 _analyze 验证搜索词的实际切分结果。
对照检查清单
| 可能原因 | 现象 | 解决 |
|---|---|---|
| 索引和搜索用的分词器不同 | 两边 _analyze 结果不一致 |
统一 analyzer 或使用 search_analyzer 策略 |
| 关键词不在 IK 词库中 | 被切成单字 | 添加到自定义词典 |
| 同义词未配置 | 搜「笔记本」找不到「laptop」 | 配置 synonym filter |
| 停用词误杀 | 短词被过滤掉了 | 移除或调整停用词列表 |
| 字符过滤器干扰 | HTML标签/特殊字符未清理 | 添加 Character Filter 预处理 |
| 字段类型错误 | 用了 keyword 而非 text | 检查 mapping,keyword 不分词 |
调试技巧:用 explain: true 查看打分详情——即使搜到了,也能看到每个词对得分贡献了多少,从而判断分词是否正确。
GET /your_index/_search
{
"query": { "match": { "title": "山野智行" } },
"explain": true,
"size": 1
}
注意:
ES 不是数据库,它是搜索引擎。做搜索和分析是王者,做事务和关系查询是青铜。实际项目中,ES 通常与 MySQL 配合使用:MySQL 做事务存储,数据通过 Canal / Logstash 同步到 ES 做搜索。
更多推荐



所有评论(0)