为什么 Elasticsearch 能在亿级文档里毫秒级返回搜索结果?答案是倒排索引(inverted index)。但倒排索引只是骨架,真正决定搜索质量的是分词(analysis)——同样的数据,分词器选错,搜"苹果手机"可能一条都搜不到。本文把倒排索引的结构、分词流程、以及二者如何配合讲透。

场景:明明有数据却搜不到

中文文档里明明有"北京大学",用户搜"北京"搜不到,搜"京大"反而搜到了;或者搜"Running"匹配不到含"running"的文档。这些都不是 bug,而是分词与查询的分词方式不一致导致的。要理解根因,先从倒排索引的结构讲起。

机制一:倒排索引的结构

传统数据库是"正排"——给定文档 ID,找它的内容。倒排索引反过来:给定一个词(term),找包含它的所有文档。

1
2
3
4
5
6
7
8
9
10
11
原始文档:
doc1: "the quick brown fox"
doc2: "the lazy brown dog"

倒排索引(term → posting list):
brown → [doc1, doc2]
dog → [doc2]
fox → [doc1]
lazy → [doc2]
quick → [doc1]
the → [doc1, doc2]

每个 term 对应一个 posting list(倒排列表),记录哪些文档包含它。搜 “brown” 时,直接拿到 [doc1, doc2],无需扫描全部文档——这就是亚毫秒检索的根本。

类比:倒排索引就是书末尾的"索引页",你想找"倒排索引"这个概念在哪几页,翻索引页直接得到页码,而不是从第一页开始读。

posting list 里不只存文档 ID,通常还存:

1
2
3
term: "brown"
doc1 → 词频(tf): 1, 位置(position): [2]
doc2 → 词频(tf): 1, 位置(position): [2]
  • 词频(term frequency):用于相关性打分(BM25)。
  • 位置(position):支持短语查询(match_phrase),判断 “quick brown” 是否相邻。

Term Dictionary 与 Term Index

term 数量巨大,如何快速定位某个 term 的 posting list?Lucene 用分层结构:

1
2
3
Term Index(FST,常驻内存)
└─→ Term Dictionary(磁盘,term 有序)
└─→ Posting List(磁盘)

term dictionary 是排好序的所有 term,存在磁盘。在它之上建一个 FST(Finite State Transducer,有限状态转换器)作为 term index 常驻内存。FST 是一种极度压缩的前缀树,能用很小内存索引海量 term,先在内存 FST 里定位到 term dictionary 的大致区块,再去磁盘精确查找。这是 ES 内存效率高的关键。

机制二:分词(Analysis)流程

写入时,文本字段(text 类型)要经过 analyzer 处理成一个个 term 才能进倒排索引。analyzer 是三段流水线:

1
原文 → Character Filters → Tokenizer → Token Filters → terms
  1. Character Filters:字符级预处理,如去 HTML 标签、字符替换。
  2. Tokenizer:核心,把文本切成 token。英文按空格/标点切,中文需要专门的分词器。
  3. Token Filters:对 token 加工——转小写(lowercase)、去停用词(stop)、提取词干(stemming,如 running→run)、同义词扩展等。

_analyze API 可以直接看分词结果:

1
2
3
4
5
6
POST /_analyze
{
"analyzer": "standard",
"text": "The Quick Brown-Foxes!"
}
// 结果 terms: ["the", "quick", "brown", "foxes"]

这解释了开头 “Running 搜不到 running” 的问题:标准分词器带 lowercase filter,写入时 “Running” 被存成 “running”。如果查询绕过了分词(如用了 term 查询而非 match),拿 “Running” 原样去匹配倒排索引里的 “running”,自然落空。

机制三:中文分词的特殊性

英文有天然空格分隔,中文没有,标准分词器对中文是逐字切分:

1
2
3
POST /_analyze
{ "analyzer": "standard", "text": "北京大学" }
// terms: ["北", "京", "大", "学"] —— 几乎没法用

逐字切分导致搜"北京"匹配不到(没有"北京"这个 term,只有单字),还会有大量误召回。所以中文几乎必须用专门分词器,如 IK:

1
2
3
4
5
6
7
8
9
// ik_max_word:细粒度,尽可能多切(适合建索引,提高召回)
POST /_analyze
{ "analyzer": "ik_max_word", "text": "北京大学" }
// terms: ["北京大学", "北京", "大学", "京大", ...]

// ik_smart:粗粒度,切得少(适合查询,提高精度)
POST /_analyze
{ "analyzer": "ik_smart", "text": "北京大学" }
// terms: ["北京大学"]

常见工程实践:索引用 ik_max_word(细粒度多切,保召回),查询用 ik_smart(粗粒度,保精度),在字段映射里分别配 analyzersearch_analyzer

机制四:写入与查询必须用一致的分析逻辑

这是 ES 最重要也最容易踩的原则:查询词也会被分词,而且必须能和倒排索引里的 term 对上。

match 查询会把查询字符串用 search_analyzer 分词后,逐 term 去倒排索引匹配。如果索引时用了某个分词器,查询时用了不兼容的,就对不上。

1
2
3
4
5
6
7
8
9
10
11
12
PUT /articles
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
}
}
}
}

特别注意 textkeyword 的区别:

1
2
text    → 经过分词,进倒排索引,支持全文检索(match)
keyword → 整体作为一个 term,不分词,用于精确匹配/聚合/排序(term)

keyword 字段做 match 等于精确匹配整串;对 text 字段做 term(不分词)往往匹配不到,因为 term 查询拿原文去匹配已分词的索引。这是新手最常见的困惑来源。

工程权衡与边界

  • 细粒度 vs 粗粒度:ik_max_word 切得多,倒排索引更大、召回更高、但精度下降、存储和内存开销上升;ik_smart 反之。按"宁可错杀(召回)还是宁可漏掉(精度)"的业务诉求选。
  • 停用词:去掉 “the/的/了” 能减小索引、提速,但会让 “to be or not to be” 这类全停用词短语无法精确检索。
  • 不可变性与 segment:倒排索引写入 Lucene segment 后不可变,更新文档实为"标记删除旧的+写新的",删除标记累积靠后台 segment merge 清理。大量更新/删除会留下很多"墓碑",merge 消耗 CPU/IO,这是 ES 写入放大的来源。
  • refresh 与近实时:新写入要等 refresh(默认 1s)生成新 segment 才可被搜到,所以 ES 是"近实时"(NRT)而非实时。批量导入时调大 refresh_interval 能显著提速。

常见误区与踩坑

  • 踩坑:中文用默认 standard 分词器,逐字切分导致搜不准,必须装 IK 等中文分词器。
  • 踩坑:索引和查询分词器不一致,term 对不上,搜不到。改了 analyzer 必须 reindex,映射不能原地改分词器。
  • 误区:text 字段能用来精确匹配/排序。要精确匹配、聚合、排序请用 keyword,常用 text + keyword 多字段(multi-field)兼得。
  • 踩坑:用 termtext 字段搜不到,因为 term 不分词。全文检索一律用 match
  • 误区:以为搜到的就是实时数据。受 refresh 影响,写入后默认最多 1 秒才可搜。

小结

Elasticsearch 的检索速度来自倒排索引(term → posting list),内存效率来自 FST + 分层 term 结构;而搜索质量取决于分词。记住贯穿始终的一条原则:写入分词和查询分词必须语义一致,term 对得上才搜得到。中文场景索引细切、查询粗切,textkeyword 各司其职,再理解 segment 不可变和 NRT 这两个边界,就抓住了 ES 全文检索的本质。