Elasticsearch 倒排索引、分词与 BM25 评分原理
ES 查询快,不是因为它简单地“比数据库快”,而是因为它在写入时提前做了大量工作:分词、建倒排索引、记录词频、位置、偏移量、列式字段值。查询时就不用逐行扫描文本,而是按词快速找到候选文档。
这一页讲清楚倒排索引到底长什么样、分词如何影响搜索、BM25 为什么能决定排序。
这一页解决这些问题
| 问题 | 你要掌握到什么程度 |
|---|---|
| 倒排索引是什么 | 知道它是 term 到 document 的映射 |
| 分词器做什么 | 知道 char filter、tokenizer、token filter 的流程 |
text 和 keyword 为什么不同 | 知道一个分词,一个不分词 |
match 和 term 为什么结果不同 | 知道查询词是否分析 |
| BM25 为什么能排序 | 知道词频、稀有度、字段长度对分数的影响 |
| 为什么搜不到或搜不准 | 能从 Mapping、分词、查询方式、评分解释 |
正排索引和倒排索引
普通数据库表更像正排索引:从文档 ID 找字段内容。
| docId | title |
|---|---|
| 1 | 无线蓝牙降噪耳机 Pro |
| 2 | 运动蓝牙耳机 入耳式 |
| 3 | 降噪头戴式耳机 |
如果用户搜索“降噪耳机”,逐条扫描 title 会很慢。倒排索引反过来存:从词找到文档。
| term | posting list |
|---|---|
| 蓝牙 | doc1, doc2 |
| 降噪 | doc1, doc3 |
| 耳机 | doc1, doc2, doc3 |
| 运动 | doc2 |
| 头戴式 | doc3 |
查询“降噪耳机”时,ES 可以直接拿到:
降噪 -> doc1, doc3
耳机 -> doc1, doc2, doc3再求交集、并集、评分、排序,而不是扫描每条商品标题。
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 | 字段长度等归一化信息,影响评分 |
例如:
term = 降噪
posting list =
doc1: freq=1, position=[2], offset=[4,6]
doc3: freq=1, position=[1], offset=[0,2]如果查短语“蓝牙 降噪”,ES 不仅要知道两个词都出现,还要看位置是否相邻或接近。
Analyzer 分词流程
Analyzer 通常由三部分组成:
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 | 无、无线、线蓝、蓝牙... | 适合建议,但索引更大 |
所以分词不是越细越好。切得太细会召回很多无关结果,切得太粗又可能搜不到。
索引分词和查询分词必须配合
写入时使用的是索引分词,查询时使用的是查询分词。
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,根本不分词。
text、keyword、match、term 的底层区别
| 组合 | 是否分词 | 适合场景 |
|---|---|---|
text + match | 字段分词,查询词也分词 | 商品名、标题、描述全文检索 |
keyword + term | 字段不分词,查询词不分词 | 状态、品牌、ID、枚举 |
text + term | 字段分词,查询词不分词 | 容易搜不到 |
keyword + match | 通常会按 keyword 整体处理 | 精确值可用,但不如 term 语义清晰 |
错误示例:
{
"query": {
"term": {
"title": "无线蓝牙降噪耳机"
}
}
}如果 title 是 text,底层倒排里可能是“无线”“蓝牙”“降噪”“耳机”,而不是完整的“无线蓝牙降噪耳机”,所以 term 整句查可能搜不到。
正确示例:
{
"query": {
"match": {
"title": "无线蓝牙降噪耳机"
}
}
}精确过滤品牌:
{
"query": {
"term": {
"brand.keyword": "SoundMax"
}
}
}BM25 评分怎么理解
BM25 是 ES 常见相关性评分模型。你不必背完整公式,但要能解释它看重什么。
BM25 主要考虑:
| 因素 | 直观理解 |
|---|---|
| 词频 TF | 查询词在文档中出现越多,相关性通常越高,但不会无限加分 |
| 逆文档频率 IDF | 越少见的词越能区分文档,权重越高 |
| 字段长度 | 同样命中一个词,短标题通常比超长描述更相关 |
举例:
| 文档 | title | 命中情况 |
|---|---|---|
| doc1 | 蓝牙降噪耳机 | 短标题,完整命中 |
| doc2 | 蓝牙耳机耳机耳机配件大全 | 词频高,但内容泛 |
| doc3 | 专业主动降噪头戴式耳机 | 命中降噪、耳机,语义接近 |
BM25 会综合判断,不是简单“出现次数越多越靠前”。这能避免商家靠重复堆关键词无限提高排名。
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/block | posting list 中快速跳过无关 doc |
| doc values | 排序、聚合不用从 _source 逐条解析 |
| filter bitset | 硬过滤条件可复用 |
| segment 不可变 | 查询期间结构稳定,缓存友好 |
| 分片并行 | 多个分片同时查询 |
但快是有代价的:
- 写入时要分词和建索引。
- 存储会变大。
- 更新不是原地修改。
- Mapping 和分词一旦设计错,修复成本高。
- MySQL 到 ES 同步要处理最终一致。
查询快的完整底层链路
“ES 查询快”不是一个单点能力,而是一条从定位 term、拿到候选文档、过滤、排序、取回 _source 的链路。把这条链路拆开,就能理解为什么 ES 适合搜索,也能理解它什么时候会慢。
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,也就是包含该词的文档列表。
例如:
降噪 -> doc1, doc3, doc8, doc21
耳机 -> doc1, doc2, doc3, doc5, doc8搜索“降噪耳机”时,不是扫描所有商品标题,而是对这些 posting list 做合并:
| 查询语义 | posting list 处理 |
|---|---|
| 必须同时包含 | 求交集 |
| 任意包含即可 | 求并集 |
| 短语查询 | 继续检查 position 是否相邻 |
| 相关性排序 | 结合词频、稀有度、字段长度计算分数 |
如果不用 posting list,全文搜索就退化成“逐条读取标题、逐条判断字符串是否包含”,大数据量下会非常慢。
第三步:skip 和 block 跳过无关文档
posting list 可能很长。比如“手机”“耳机”“商品”这种词会出现在大量文档中。如果每次从头逐个 docId 比较,仍然会慢。
Lucene 会把 posting list 做压缩和分块,并提供跳跃能力。你可以把它理解成:
doc1, doc2, doc3, ... doc1000, doc1001, ...不是只能一个一个走,而是可以根据目标 docId 跳过一大段不可能命中的区间。
flowchart TD
A["长 posting list"] --> B["按 block 存储"]
B --> C["记录跳跃信息"]
C --> D["合并多个列表时跳过无关区间"]
D --> E["减少 docId 比较次数"]这就是为什么倒排索引不仅是“词到文档 ID”,还包含很多为了查询性能服务的辅助结构。
第四步:filter bitset 加速硬条件
商业搜索里很少只有关键词,通常还有状态、租户、权限、类目、价格、时间范围。
例如:
{
"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 要做更多评分计算,查询解释也更复杂。正确区分 must 和 filter,是商业搜索性能优化的基础。
第五步:doc values 支撑排序和聚合
倒排索引适合“通过词找文档”,但排序和聚合是另一类问题:要按字段值看很多文档。
例如商品搜索按价格排序、按品牌聚合:
{
"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。
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 也会慢
理解快的链路后,也要知道慢在哪里:
| 慢的原因 | 本质 |
|---|---|
前缀通配 *xxx | term dictionary 难以快速定位 |
| 深分页 | 分片返回大量候选再丢弃 |
| 大聚合 | 需要读取大量 doc values 并维护 bucket |
| 脚本排序 | 每个候选文档执行脚本 |
| text 字段聚合 | 可能触发 fielddata,占用堆内存 |
| 分片过多 | 协调和合并成本上升 |
| 小 segment 太多 | 查询要跨更多 segment 合并结果 |
| refresh 太频繁 | 写入产生大量小 segment,拖累查询 |
所以 ES 快,不是所有 DSL 都快。它快在“正确 Mapping + 合理分词 + 受控 DSL + 合理分片 + 正确同步链路”的组合上。
为什么搜不到
搜不到通常不是 ES 坏了,而是某个环节不匹配。
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。
curl -X POST "http://localhost:9200/_analyze?pretty" \
-H "Content-Type: application/json" \
-d '{
"analyzer": "standard",
"text": "Wireless Bluetooth Noise Cancelling Headphone Pro"
}'中文项目如果安装了 IK 分词器,可以测试:
curl -X POST "http://localhost:9200/product_search/_analyze?pretty" \
-H "Content-Type: application/json" \
-d '{
"analyzer": "ik_max_word",
"text": "无线蓝牙降噪耳机 Pro"
}'最小 Mapping 示例:
{
"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 做过滤。
面试标准回答
倒排索引是 term 到 document 的映射。写入文档时,ES 会根据 Mapping 和 analyzer 对 text 字段分词,把每个词写入倒排索引,并保存 docId、词频、位置、偏移量、字段长度等信息。查询时,match 会先对查询词分词,再去倒排索引找到候选文档,最后根据 BM25 等模型评分排序。
BM25 主要考虑词频、词的稀有度和字段长度。词出现越多通常越相关,但不会无限加分;越少见的词区分度越高;同样命中时,短字段通常比很长字段更相关。
ES 查询快是因为它把搜索成本前置到写入阶段,通过倒排索引、posting list、doc values、filter cache、segment 不可变和分片并行来加速查询。但代价是写入和更新成本更高,Mapping 和分词设计错误通常需要重建索引。关联知识点
| 知识点 | 说明 |
|---|---|
| 写入、Refresh 与 Segment | 倒排索引什么时候生成、为什么近实时 |
| Query 与 Fetch 全过程 | 倒排索引如何参与分布式查询 |
| 分词 | 中文分词、同义词、搜不到排查 |
| Mapping 与查询 | text、keyword、term、match |
| 性能优化与排查 | 查询慢、搜不准、搜不到 |
