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 → om → 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 }
  ]
}

现在用户搜 beijingbj北京 任意一种都能命中!

拼音搜索的一个坑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 做搜索。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐