为什么 Elasticsearch 能在亿级文档里毫秒级返回搜索结果?答案是倒排索引(inverted index)。但倒排索引只是骨架,真正决定搜索质量的是分词(analysis)——同样的数据,分词器选错,搜"苹果手机"可能一条都搜不到。本文把倒排索引的结构、分词流程、以及二者如何配合讲透。
场景:明明有数据却搜不到
中文文档里明明有"北京大学",用户搜"北京"搜不到,搜"京大"反而搜到了;或者搜"Running"匹配不到含"running"的文档。这些都不是 bug,而是分词与查询的分词方式不一致导致的。要理解根因,先从倒排索引的结构讲起。
机制一:倒排索引的结构
传统数据库是"正排"——给定文档 ID,找它的内容。倒排索引反过来:给定一个词(term),找包含它的所有文档。
1 | 原始文档: |
每个 term 对应一个 posting list(倒排列表),记录哪些文档包含它。搜 “brown” 时,直接拿到 [doc1, doc2],无需扫描全部文档——这就是亚毫秒检索的根本。
类比:倒排索引就是书末尾的"索引页",你想找"倒排索引"这个概念在哪几页,翻索引页直接得到页码,而不是从第一页开始读。
posting list 里不只存文档 ID,通常还存:
1 | term: "brown" |
- 词频(term frequency):用于相关性打分(BM25)。
- 位置(position):支持短语查询(
match_phrase),判断 “quick brown” 是否相邻。
Term Dictionary 与 Term Index
term 数量巨大,如何快速定位某个 term 的 posting list?Lucene 用分层结构:
1 | Term Index(FST,常驻内存) |
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 |
- Character Filters:字符级预处理,如去 HTML 标签、字符替换。
- Tokenizer:核心,把文本切成 token。英文按空格/标点切,中文需要专门的分词器。
- Token Filters:对 token 加工——转小写(lowercase)、去停用词(stop)、提取词干(stemming,如 running→run)、同义词扩展等。
用 _analyze API 可以直接看分词结果:
1 | POST /_analyze |
这解释了开头 “Running 搜不到 running” 的问题:标准分词器带 lowercase filter,写入时 “Running” 被存成 “running”。如果查询绕过了分词(如用了 term 查询而非 match),拿 “Running” 原样去匹配倒排索引里的 “running”,自然落空。
机制三:中文分词的特殊性
英文有天然空格分隔,中文没有,标准分词器对中文是逐字切分:
1 | POST /_analyze |
逐字切分导致搜"北京"匹配不到(没有"北京"这个 term,只有单字),还会有大量误召回。所以中文几乎必须用专门分词器,如 IK:
1 | // ik_max_word:细粒度,尽可能多切(适合建索引,提高召回) |
常见工程实践:索引用 ik_max_word(细粒度多切,保召回),查询用 ik_smart(粗粒度,保精度),在字段映射里分别配 analyzer 和 search_analyzer。
机制四:写入与查询必须用一致的分析逻辑
这是 ES 最重要也最容易踩的原则:查询词也会被分词,而且必须能和倒排索引里的 term 对上。
match 查询会把查询字符串用 search_analyzer 分词后,逐 term 去倒排索引匹配。如果索引时用了某个分词器,查询时用了不兼容的,就对不上。
1 | PUT /articles |
特别注意 text 和 keyword 的区别:
1 | text → 经过分词,进倒排索引,支持全文检索(match) |
对 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)兼得。 - 踩坑:用
term查text字段搜不到,因为term不分词。全文检索一律用match。 - 误区:以为搜到的就是实时数据。受 refresh 影响,写入后默认最多 1 秒才可搜。
小结
Elasticsearch 的检索速度来自倒排索引(term → posting list),内存效率来自 FST + 分层 term 结构;而搜索质量取决于分词。记住贯穿始终的一条原则:写入分词和查询分词必须语义一致,term 对得上才搜得到。中文场景索引细切、查询粗切,text 与 keyword 各司其职,再理解 segment 不可变和 NRT 这两个边界,就抓住了 ES 全文检索的本质。