Skip to content

Elasticsearch 倒排索引、分词与 BM25 评分原理

ES 查询快,不是因为它简单地“比数据库快”,而是因为它在写入时提前做了大量工作:分词、建倒排索引、记录词频、位置、偏移量、列式字段值。查询时就不用逐行扫描文本,而是按词快速找到候选文档。

这一页讲清楚倒排索引到底长什么样、分词如何影响搜索、BM25 为什么能决定排序。

这一页解决这些问题

问题你要掌握到什么程度
倒排索引是什么知道它是 term 到 document 的映射
分词器做什么知道 char filter、tokenizer、token filter 的流程
textkeyword 为什么不同知道一个分词,一个不分词
matchterm 为什么结果不同知道查询词是否分析
BM25 为什么能排序知道词频、稀有度、字段长度对分数的影响
为什么搜不到或搜不准能从 Mapping、分词、查询方式、评分解释

正排索引和倒排索引

普通数据库表更像正排索引:从文档 ID 找字段内容。

docIdtitle
1无线蓝牙降噪耳机 Pro
2运动蓝牙耳机 入耳式
3降噪头戴式耳机

如果用户搜索“降噪耳机”,逐条扫描 title 会很慢。倒排索引反过来存:从词找到文档。

termposting list
蓝牙doc1, doc2
降噪doc1, doc3
耳机doc1, doc2, doc3
运动doc2
头戴式doc3

查询“降噪耳机”时,ES 可以直接拿到:

text
降噪 -> doc1, doc3
耳机 -> doc1, doc2, doc3

再求交集、并集、评分、排序,而不是扫描每条商品标题。

mermaid
flowchart TD
    A["商品标题"] --> B["分词"]
    B --> C["term: 蓝牙"]
    B --> D["term: 降噪"]
    B --> E["term: 耳机"]
    C --> F["posting list"]
    D --> F
    E --> F
    G["用户搜索"] --> H["查询分词"]
    H --> I["查 posting list"]
    I --> J["合并候选文档"]
    J --> K["评分排序"]

倒排索引里不只保存文档 ID

面试里如果只说“词到文档 ID”,只能算入门。实际倒排索引为了支持评分、短语查询、高亮,会保存更多信息。

信息作用
docId这个词出现在哪些文档里
term frequency词在当前文档出现几次
document frequency有多少文档包含这个词
position词在字段中的位置,支持短语查询
offset词在原文中的字符位置,支持高亮
norm字段长度等归一化信息,影响评分

例如:

text
term = 降噪
posting list =
  doc1: freq=1, position=[2], offset=[4,6]
  doc3: freq=1, position=[1], offset=[0,2]

如果查短语“蓝牙 降噪”,ES 不仅要知道两个词都出现,还要看位置是否相邻或接近。

Analyzer 分词流程

Analyzer 通常由三部分组成:

mermaid
flowchart TD
    A["原始文本"] --> B["char filter"]
    B --> C["tokenizer"]
    C --> D["token filter"]
    D --> E["tokens"]
阶段做什么示例
char filter预处理字符HTML 去标签、符号替换
tokenizer切分 token按空格、按词典、按 ngram
token filter处理 token小写、停用词、同义词、拼音

以“无线蓝牙降噪耳机 Pro”为例,不同分词器结果可能不同:

分词策略可能结果影响
细粒度中文分词无线、蓝牙、降噪、耳机、Pro召回更强
粗粒度中文分词无线蓝牙、降噪耳机、Pro噪音更少
keyword无线蓝牙降噪耳机 Pro只能整体精确匹配
ngram无、无线、线蓝、蓝牙...适合建议,但索引更大

所以分词不是越细越好。切得太细会召回很多无关结果,切得太粗又可能搜不到。

索引分词和查询分词必须配合

写入时使用的是索引分词,查询时使用的是查询分词。

mermaid
flowchart TD
    A["写入 title"] --> B["index analyzer"]
    B --> C["倒排索引中的 tokens"]
    D["用户输入关键词"] --> E["search analyzer"]
    E --> F["查询 tokens"]
    F --> G{"tokens 是否能匹配"}
    C --> G
    G -- "能" --> H["召回文档"]
    G -- "不能" --> I["搜不到"]

一个典型错误是:索引时没有把“降噪耳机”拆成“降噪”和“耳机”,查询时却拆成了两个词;或者查询时用 term,根本不分词。

textkeywordmatchterm 的底层区别

组合是否分词适合场景
text + match字段分词,查询词也分词商品名、标题、描述全文检索
keyword + term字段不分词,查询词不分词状态、品牌、ID、枚举
text + term字段分词,查询词不分词容易搜不到
keyword + match通常会按 keyword 整体处理精确值可用,但不如 term 语义清晰

错误示例:

json
{
  "query": {
    "term": {
      "title": "无线蓝牙降噪耳机"
    }
  }
}

如果 titletext,底层倒排里可能是“无线”“蓝牙”“降噪”“耳机”,而不是完整的“无线蓝牙降噪耳机”,所以 term 整句查可能搜不到。

正确示例:

json
{
  "query": {
    "match": {
      "title": "无线蓝牙降噪耳机"
    }
  }
}

精确过滤品牌:

json
{
  "query": {
    "term": {
      "brand.keyword": "SoundMax"
    }
  }
}

BM25 评分怎么理解

BM25 是 ES 常见相关性评分模型。你不必背完整公式,但要能解释它看重什么。

BM25 主要考虑:

因素直观理解
词频 TF查询词在文档中出现越多,相关性通常越高,但不会无限加分
逆文档频率 IDF越少见的词越能区分文档,权重越高
字段长度同样命中一个词,短标题通常比超长描述更相关

举例:

文档title命中情况
doc1蓝牙降噪耳机短标题,完整命中
doc2蓝牙耳机耳机耳机配件大全词频高,但内容泛
doc3专业主动降噪头戴式耳机命中降噪、耳机,语义接近

BM25 会综合判断,不是简单“出现次数越多越靠前”。这能避免商家靠重复堆关键词无限提高排名。

mermaid
flowchart TD
    A["候选文档"] --> B["计算词频"]
    A --> C["计算词的稀有度"]
    A --> D["考虑字段长度"]
    B --> E["BM25 分数"]
    C --> E
    D --> E
    E --> F["按分数排序"]

为什么 ES 查询快

ES 查询快来自多个结构共同作用:

结构或机制解决什么问题
倒排索引全文检索不用扫全文
term dictionary快速定位 term
posting list快速得到候选 docId
skip list/blockposting list 中快速跳过无关 doc
doc values排序、聚合不用从 _source 逐条解析
filter bitset硬过滤条件可复用
segment 不可变查询期间结构稳定,缓存友好
分片并行多个分片同时查询

但快是有代价的:

  1. 写入时要分词和建索引。
  2. 存储会变大。
  3. 更新不是原地修改。
  4. Mapping 和分词一旦设计错,修复成本高。
  5. MySQL 到 ES 同步要处理最终一致。

查询快的完整底层链路

“ES 查询快”不是一个单点能力,而是一条从定位 term、拿到候选文档、过滤、排序、取回 _source 的链路。把这条链路拆开,就能理解为什么 ES 适合搜索,也能理解它什么时候会慢。

mermaid
flowchart TD
    A["用户查询关键词"] --> B["查询分词"]
    B --> C["term dictionary 定位 term"]
    C --> D["读取 posting list"]
    D --> E["合并候选 docId"]
    E --> F["filter bitset 过滤"]
    F --> G["BM25 或排序字段计算 TopN"]
    G --> H["doc values 支持排序聚合"]
    H --> I["Fetch 阶段取 _source"]
    I --> J["返回结果"]

第一步:term dictionary 快速定位词

倒排索引不可能每次都把所有 term 扫一遍。Lucene 会维护词典结构,用来快速定位某个 term 是否存在以及它的倒排列表在哪里。

可以把它理解成一本字典的目录:

没有词典会怎样有词典后怎样
查询“降噪”要扫描所有词先定位“降噪”这个词是否存在
term 很多时查询成本不可控词典结构帮助快速跳到目标 term
前缀、范围类查询更难优化有序 term 可以做范围和前缀定位

Lucene 内部还会使用类似 FST 的紧凑结构来压缩和查找 term。面试不需要手写 FST,但要知道:term dictionary 解决的是“先找到词”这个问题,posting list 解决的是“词对应哪些文档”这个问题。

第二步:posting list 拿候选文档

定位到 term 后,ES 会读取这个 term 的 posting list,也就是包含该词的文档列表。

例如:

text
降噪 -> doc1, doc3, doc8, doc21
耳机 -> doc1, doc2, doc3, doc5, doc8

搜索“降噪耳机”时,不是扫描所有商品标题,而是对这些 posting list 做合并:

查询语义posting list 处理
必须同时包含求交集
任意包含即可求并集
短语查询继续检查 position 是否相邻
相关性排序结合词频、稀有度、字段长度计算分数

如果不用 posting list,全文搜索就退化成“逐条读取标题、逐条判断字符串是否包含”,大数据量下会非常慢。

第三步:skip 和 block 跳过无关文档

posting list 可能很长。比如“手机”“耳机”“商品”这种词会出现在大量文档中。如果每次从头逐个 docId 比较,仍然会慢。

Lucene 会把 posting list 做压缩和分块,并提供跳跃能力。你可以把它理解成:

text
doc1, doc2, doc3, ... doc1000, doc1001, ...

不是只能一个一个走,而是可以根据目标 docId 跳过一大段不可能命中的区间。

mermaid
flowchart TD
    A["长 posting list"] --> B["按 block 存储"]
    B --> C["记录跳跃信息"]
    C --> D["合并多个列表时跳过无关区间"]
    D --> E["减少 docId 比较次数"]

这就是为什么倒排索引不仅是“词到文档 ID”,还包含很多为了查询性能服务的辅助结构。

第四步:filter bitset 加速硬条件

商业搜索里很少只有关键词,通常还有状态、租户、权限、类目、价格、时间范围。

例如:

json
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "降噪耳机" } }
      ],
      "filter": [
        { "term": { "status": "ON_SALE" } },
        { "term": { "tenant_id": "1001" } }
      ]
    }
  }
}

filter 不参与相关性评分,它更像“硬过滤”。ES 可以把某些过滤条件转成 bitset:某个 docId 是否满足条件,用位图表示。

条件适合放哪里原因
关键词全文检索must / match需要召回和评分
上架状态filter只判断是否满足
租户权限filter不影响相关性
时间范围filter范围硬条件
品牌、类目filter精确筛选,可复用

如果把所有条件都写进评分查询,ES 要做更多评分计算,查询解释也更复杂。正确区分 mustfilter,是商业搜索性能优化的基础。

第五步:doc values 支撑排序和聚合

倒排索引适合“通过词找文档”,但排序和聚合是另一类问题:要按字段值看很多文档。

例如商品搜索按价格排序、按品牌聚合:

json
{
  "query": {
    "match": { "title": "耳机" }
  },
  "sort": [
    { "price": "asc" }
  ],
  "aggs": {
    "brand_count": {
      "terms": { "field": "brand" }
    }
  }
}

如果每个候选文档都去解析 _source 里的 JSON,再取 price 和 brand,会非常慢。doc values 是列式存储,适合按字段读取排序和聚合需要的值。

能力主要依赖
全文检索倒排索引
精确过滤倒排索引 / BKD tree / bitset
排序doc values
聚合doc values
返回完整文档_source

所以 text 字段通常不直接聚合,keyword、数值、日期字段更适合排序和聚合。

第六步:Query Phase 只合并 TopN

在分布式 ES 中,多个分片会并行查询。每个分片先返回自己的 TopN,协调节点再合并成全局 TopN。

mermaid
flowchart TD
    A["协调节点"] --> B["分片 1 查询 TopN"]
    A --> C["分片 2 查询 TopN"]
    A --> D["分片 3 查询 TopN"]
    B --> E["协调节点合并"]
    C --> E
    D --> E
    E --> F["得到全局 TopN"]

这也是深分页慢的原因。from=10000&size=20 时,每个分片都可能要返回前 10020 条候选,协调节点合并后再丢掉前 10000 条。数据白白搬运和排序,成本很高。

什么时候 ES 也会慢

理解快的链路后,也要知道慢在哪里:

慢的原因本质
前缀通配 *xxxterm dictionary 难以快速定位
深分页分片返回大量候选再丢弃
大聚合需要读取大量 doc values 并维护 bucket
脚本排序每个候选文档执行脚本
text 字段聚合可能触发 fielddata,占用堆内存
分片过多协调和合并成本上升
小 segment 太多查询要跨更多 segment 合并结果
refresh 太频繁写入产生大量小 segment,拖累查询

所以 ES 快,不是所有 DSL 都快。它快在“正确 Mapping + 合理分词 + 受控 DSL + 合理分片 + 正确同步链路”的组合上。

为什么搜不到

搜不到通常不是 ES 坏了,而是某个环节不匹配。

mermaid
flowchart TD
    A["搜不到"] --> B["按 ID 查 ES 是否有文档"]
    B --> C{"文档存在吗"}
    C -- "不存在" --> D["查同步链路"]
    C -- "存在" --> E["查 Mapping"]
    E --> F["用 _analyze 看分词"]
    F --> G["检查 term 或 match"]
    G --> H["检查 filter 是否误伤"]
现象可能原因
按 ID 有,关键词搜不到分词不匹配、用错 term
某品牌搜不到查了 brand 而不是 brand.keyword
新数据搜不到refresh 未完成或同步延迟
改分词后旧数据搜不到历史倒排索引不会自动重建
部分用户搜不到权限 filter、租户 filter 误伤

为什么搜不准

搜不准一般是召回和排序的问题。

问题原因处理
结果太多太泛分词太细、should 太宽调整分词、minimum_should_match
精准结果不靠前boost 不合理、业务排序缺失字段加权、销量/库存/新鲜度排序
型号搜不到分词器把型号切碎自定义词典、keyword 子字段
同义词无效同义词配置时机不对明确索引期或查询期同义词
高亮奇怪offset 或分词不一致检查 analyzer 和 highlighter

可运行 Demo:用 _analyze 看分词

学习 ES 分词,最重要的命令是 _analyze

bash
curl -X POST "http://localhost:9200/_analyze?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "analyzer": "standard",
    "text": "Wireless Bluetooth Noise Cancelling Headphone Pro"
  }'

中文项目如果安装了 IK 分词器,可以测试:

bash
curl -X POST "http://localhost:9200/product_search/_analyze?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "analyzer": "ik_max_word",
    "text": "无线蓝牙降噪耳机 Pro"
  }'

最小 Mapping 示例:

json
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "brand": {
        "type": "keyword"
      },
      "price": {
        "type": "integer"
      },
      "status": {
        "type": "keyword"
      }
    }
  }
}

这里 title 用于全文检索,title.keyword 可以做精确匹配或排序,brand/status 用 keyword 做过滤。

面试标准回答

text
倒排索引是 term 到 document 的映射。写入文档时,ES 会根据 Mapping 和 analyzer 对 text 字段分词,把每个词写入倒排索引,并保存 docId、词频、位置、偏移量、字段长度等信息。查询时,match 会先对查询词分词,再去倒排索引找到候选文档,最后根据 BM25 等模型评分排序。

BM25 主要考虑词频、词的稀有度和字段长度。词出现越多通常越相关,但不会无限加分;越少见的词区分度越高;同样命中时,短字段通常比很长字段更相关。

ES 查询快是因为它把搜索成本前置到写入阶段,通过倒排索引、posting list、doc values、filter cache、segment 不可变和分片并行来加速查询。但代价是写入和更新成本更高,Mapping 和分词设计错误通常需要重建索引。

关联知识点

知识点说明
写入、Refresh 与 Segment倒排索引什么时候生成、为什么近实时
Query 与 Fetch 全过程倒排索引如何参与分布式查询
分词中文分词、同义词、搜不到排查
Mapping 与查询textkeywordtermmatch
性能优化与排查查询慢、搜不准、搜不到