【Elasticsearch从入门到精通】第43篇:Elasticsearch搜索过程原理——分词、查询树与BM25评分
上一篇【第42篇】Elasticsearch倒排索引原理——Lucene的核心数据结构
下一篇【第44篇】Elasticsearch分布式索引原理——分片路由与写入流程
摘要
搜索是Elasticsearch的核心能力,而理解其背后的原理是写好查询、调优相关性的关键。本文从搜索引擎的底层出发,逐层剖析一条查询从输入到返回结果的完整旅程:首先介绍文本分析的三层处理架构(字符过滤器→分词器→词元过滤器),然后讲解查询语法分析如何将用户输入构建为查询树,接着深入倒排索引的搜索流程(Term查找→文档列表合并→得分计算),最后重点解析TF-IDF的直觉理解与BM25算法的核心改进。文中通过完整的REST API代码示例和TF-IDF与BM25参数对比表格,帮助读者建立起从理论到实践的完整知识体系。关键词:Elasticsearch搜索原理、分词器、BM25评分、倒排索引、相关性排序。
一、搜索引擎为什么需要"索引"
在理解Elasticsearch的搜索原理之前,我们先回答一个基础问题:为什么需要搜索引擎?
传统的数据库查询(如MySQL的LIKE查询)属于顺序查找。当我们要在100万条文档中找到包含"历史"关键字的记录时,数据库需要逐行遍历、逐字段扫描,时间复杂度为 O(n)。这种查找方式在数据量较小的时候表现尚可,但当文档数量达到百万、千万级别时,性能会急剧下降。
搜索引擎采用了完全不同的策略——索引查找。它的核心理念是:
顺序查找: 文档1 → 扫描全文 → 没有 → 文档2 → 扫描全文 → 没有 → ... → 文档N → 找到了 索引查找: 创建索引时:文档 → 分词 → 建立 "词→文档列表" 映射 搜索时:输入关键词 → 直接查找映射表 → 立即定位文档搜索引擎在写入数据时将非结构化内容中的信息提取出来,重新组织成结构化数据,这部分数据就叫作索引。这种"用空间换时间"的策略使得搜索过程从 O(n) 降低到 O(1) 级别,是搜索引擎高性能的根本保证。
二、文本分析:分词器三层处理架构
当一条文档被写入Elasticsearch时,它首先要经历**文本分析(Analysis)**过程。这是搜索引擎的第一道工序,也是最容易被忽视的一环——分词结果的好坏直接决定了后续搜索的召回率和准确率。
Elasticsearch的分析器由三个核心组件按顺序组成,形成三层流水线处理架构:
原始文本 "The iPhone 14 Pro Max costs $1099!" │ ▼ ┌─────────────────────────┐ │ 1. 字符过滤器 │ 过滤/替换无意义字符 │ (Character Filter) │ 如:HTML标签清理、表情符号替换 └────────────┬────────────┘ │ "The iPhone 14 Pro Max costs 1099!" ▼ ┌─────────────────────────┐ │ 2. 分词器 │ 将文本切分为词元(Term) │ (Tokenizer) │ 如:按空格/标点切分 └────────────┬────────────┘ │ ["The", "iPhone", "14", "Pro", "Max", "costs", "1099"] ▼ ┌─────────────────────────┐ │ 3. 词元过滤器 │ 对词元进行加工处理 │ (Token Filter) │ 如:转小写、去除停用词、同义词扩展 └────────────┬────────────┘ │ ["iphone", "14", "pro", "max", "cost", "1099"] ▼ 最终索引的Term列表2.1 字符过滤器(Character Filter)
字符过滤器工作在原始文本层面,在分词之前对字符流进行预处理。它主要完成三类任务:
- HTML Strip:去除HTML标签,将
<b>iPhone</b>处理为iPhone - Mapping:字符映射替换,如将
&替换为and,或将全角字符转为半角 - Pattern Replace:基于正则表达式的替换,如过滤掉敏感信息
2.2 分词器(Tokenizer)
分词器负责将字符流切分为独立的词元(Term)。这是分析器的核心组件,一个分析器有且仅有一个分词器。Elasticsearch内置了多种分词器:
- standard:默认分词器,按Unicode文本分段算法切分,去除标点并转小写
- whitespace:仅按空白字符切分,不做任何其他处理
- keyword:不分词,将整个字段值作为一个Token
- pattern:按正则表达式切分
- ik_smart/ik_max_word(IK分词器):专门针对中文的分词模式
2.3 词元过滤器(Token Filter)
词元过滤器对切分后的Token进行二次加工,可以有零个或多个。常见的Token Filter包括:
- lowercase:将所有Token转为小写
- stop:移除停用词(如"的"、“是”、“the”、“a”)
- synonym:同义词扩展(如"手机"→"移动电话,cellphone")
- stemmer:词干提取(如"running"→"run")
- shingle:生成N-gram词组
2.4 使用_analyzeAPI 测试分词效果
任何阶段的排查和调优都离不开_analyzeAPI。以下代码演示如何一步步测试分析效果:
// 1. 测试 standard 分析器的完整处理结果POST_analyze{"analyzer":"standard","text":"The iPhone 14 Pro Max costs $1099!"}// 返回结果(省略部分字段){"tokens":[{"token":"the","start_offset":0,"end_offset":3,"position":0},{"token":"iphone","start_offset":4,"end_offset":10,"position":1},{"token":"14","start_offset":11,"end_offset":13,"position":2},{"token":"pro","start_offset":14,"end_offset":17,"position":3},{"token":"max","start_offset":18,"end_offset":21,"position":4},{"token":"costs","start_offset":22,"end_offset":27,"position":5},{"token":"1099","start_offset":29,"end_offset":33,"position":6}]}// 2. 仅测试分词器(跳过字符过滤器和词元过滤器)POST_analyze{"tokenizer":"standard","text":"The iPhone 14 Pro Max costs $1099!"}// 3. 自定义分析器组合测试POST_analyze{"char_filter":["html_strip"],"tokenizer":"standard","filter":["lowercase","stop","stemmer"],"text":"The <b>iPhone</b> 14 Pro Max costs $1099!"}// 4. 测试中文分词(需安装IK分词器)POST_analyze{"tokenizer":"ik_max_word","text":"中华人民共和国国歌"}// 返回:["中华人民共和国", "中华人民", "中华", "华人", "人民共和国", "人民", "共和国", "共和", "国", "国歌"]最佳实践建议:每个索引的Mapping都应该显式指定分析器,而不是依赖默认值。分析器的选择直接影响搜索体验,应当先在测试环境通过_analyzeAPI 验证分词效果,再上线生产。
三、查询语法分析:构建查询树
当用户在搜索框中输入 “iPhone 14” 并按下回车键后,Elasticsearch并不会立即去倒排索引中查找Term,而是先进行查询语法分析。
3.1 查询语法分析的两大任务
查询语法分析需要完成两个关键任务:
确定各个Term的搜索字段:用户输入的关键词应该在哪个(或哪些)字段中搜索?是
title还是content?如果是multi_match查询,则需要确定权重分配。确定Term之间的逻辑组合关系:多个关键词之间是 AND 关系(同时匹配)还是 OR 关系(任一匹配)?用户是否指定了排序、分页等自定义条件?
3.2 查询树的构建过程
Elasticsearch的查询语法分析会将用户输入构建为一棵查询树(Query Tree)。查询树的结构决定了后续倒排索引搜索时的文档列表合并策略。
以搜索 “iPhone 14” 为例,构建查询树的过程如下:
用户输入: "iPhone 14" │ ▼ ┌─────────────────┐ │ 词法分析 │→ 切分为两个Term: "iphone", "14" │ (分词器处理) │ └────────┬─────────┘ │ ▼ ┌─────────────────┐ │ 语法分析 │→ 确定搜索字段:title, description │ (构建查询树) │→ 确定逻辑关系:should (OR) └────────┬─────────┘ │ ▼ Boolean Query (查询树根节点) │ ┌────┴────┐ │ │ TermQuery TermQuery "iphone" "14" (title) (title) (desc) (desc)AND 关系的查询树(match查询使用operator: “and”):
BooleanQuery (must) │ ┌────┴────┐ │ │ TermQuery TermQuery "iphone" "14"3.3 完整DSL示例
// 默认OR关系查询GET/products/_search{"query":{"match":{"title":{"query":"iPhone 14","operator":"or"}}}}// AND关系查询(要求两个词同时出现)GET/products/_search{"query":{"match":{"title":{"query":"iPhone 14","operator":"and"}}}}// 多字段查询(cross_fields类型将各字段视为一个大字段)GET/products/_search{"query":{"multi_match":{"query":"iPhone 14","fields":["title^3","description","brand"],"type":"cross_fields","operator":"and"}}}四、倒排索引搜索流程
查询树构建完毕后,就进入搜索引擎最核心的阶段——倒排索引搜索。这是一个三段式流水线:Term查找 → 文档列表合并 → 得分计算。
4.1 Term查找
倒排索引的本质是一个"词→文档ID列表"的映射表。当查询树中的每个TermQuery被执行时,Lucene直接去倒排索引中查找该Term对应的倒排列表(Posting List):
倒排索引结构(简化): Term → Posting List ───────────────────────────────────── "iphone" → [doc1, doc3, doc7, doc15, doc22, ...] "14" → [doc3, doc7, doc9, doc15, doc20, ...] "pro" → [doc1, doc15, doc18, doc27, ...] "max" → [doc1, doc15, doc22, doc30, ...]4.2 文档列表合并(AND/OR 操作)
获取到各Term的Posting List后,根据查询树的逻辑关系进行列表合并:
OR关系(should)—— 取并集:
Term "iphone" 的列表: [doc1, doc3, doc7, doc15, doc22] Term "14" 的列表: [doc3, doc7, doc9, doc15, doc20] ↓ OR(合并+去重) 结果列表: [doc1, doc3, doc7, doc9, doc15, doc20, doc22]AND关系(must)—— 取交集:
Term "iphone" 的列表: [doc1, doc3, doc7, doc15, doc22] Term "14" 的列表: [doc3, doc7, doc9, doc15, doc20] ↓ AND(双指针求交集) 结果列表: [doc3, doc7, doc15]Lucene内部使用**跳表(Skip List)**数据结构优化AND/OR操作,使得多列表合并的效率接近 O(min(len(A), len(B)))。
4.3 得分计算
文档列表合并完成后,对结果集中的每个文档计算相关性得分(Score)。得分计算的完整公式将在下一节详细介绍,这里先给出计算的三个核心维度:
- 词频(TF):查询词在文档中出现了多少次?出现次数越多,相关性越高。
- 逆文档频率(IDF):查询词在整个索引中出现在了多少文档中?出现越少(越稀有),相关性越高。
- 字段长度归一化:字段越长,每个词的重要性越低。
得分计算完成后,按Score降序排序,最终返回 Top N 结果给用户。
五、TF-IDF的直觉理解
在深入BM25算法之前,先理解TF-IDF的直觉是必要的。TF-IDF虽然已经不再是Elasticsearch的默认评分算法(从5.0开始默认使用BM25),但它的设计思想影响了所有现代评分模型。
5.1 TF(词频):词频越高越相关
直觉:如果搜索词"Java"在文档A中出现了100次,在文档B中只出现了1次,那么文档A显然与"Java"更相关。
TF(t, d) = 词t在文档d中出现的次数5.2 IDF(逆文档频率):文档越稀少越相关
直觉:如果搜索词"Elasticsearch"只出现在很少的文档中,而搜索词"的"出现在几乎全部文档中,那么匹配到"Elasticsearch"的文档比匹配到"的"的文档更有价值。
IDF(t) = log( 总文档数N / 包含词t的文档数n(t) )含义解读:当 n(t) 很小时,N/n(t) 很大,log值大,说明这个词很"珍贵";当 n(t) 接近N时,N/n(t)≈1,log值≈0,说明这个词几乎没有区分度。
5.3 TF-IDF的问题
尽管TF-IDF在搜索领域影响深远,但它有两个明显缺陷:
- TF的线性增长:词出现100次和出现101次对相关性的贡献应该是接近的,但TF-IDF中二者的得分差距很大——这在直觉上不合理。
- 不考虑字段长度:一篇500字的文章中出现5次"Java"和一篇50000字的文章中出现5次"Java",其重要性显然是不同的。
BM25正是针对这两个问题提出了改进方案。
六、BM25算法深度解析
BM25全称Best Matching 25,是BM25F的信息检索排序函数。从Elasticsearch 5.0开始,它取代了TF-IDF成为默认的相关性评分算法。
6.1 BM25的核心公式
BM25的得分公式如下:
score(D, Q) = Σ IDF(qi) × ( f(qi, D) × (k1 + 1) ) / ( f(qi, D) + k1 × (1 - b + b × |D| / avgdl) ) 其中: D = 文档(Document) Q = 查询语句(Query) qi = 查询Q分词后的第i个词元 f(qi, D) = 词元qi在文档D中出现的频次 |D| = 文档D的字段长度(词元数) avgdl = 索引中所有文档的平均字段长度 k1 = 可调参数,控制词频的饱和度(默认 1.2) b = 可调参数,控制字段长度归一化的程度(默认 0.75)6.2 BM25对TF-IDF的两大改进
改进一:TF饱和度控制(k1参数)
BM25对词频引入了非线性饱和度函数。随着词频增加,得分增长逐渐变缓,最终趋近于一个上限:
TF-IDF: score ∝ TF (线性增长,永无止境) BM25: score ∝ TF / (TF + k1) (非线性增长,趋于饱和)直观理解:
- 词频从0→1:得分大幅提升(从无到有的质变)
- 词频从1→2:得分小幅提升
- 词频从10→11:得分几乎没有变化(已经饱和)
k1的默认值为1.2。值越小,饱和度越明显;值越大,越接近线性增长。
改进二:字段长度归一化(b参数)
BM25通过b参数控制字段长度对得分的影响程度:
b = 1:完全归一化,长文档的每个词权重被完全稀释b = 0:完全不归一化,字段长度不影响得分b = 0.75(默认):折中方案
6.3 k1和b参数调优指南
| 参数 | 默认值 | 含义 | 调增方向 | 适用场景 |
|---|---|---|---|---|
| k1 | 1.2 | 控制词频对得分的影响 | 增大→词频影响更线性 | 短文档搜索(标题/标签) |
| 减小→词频饱和更快 | 长文档搜索(文章内容) | |||
| b | 0.75 | 控制字段长度归一化程度 | 增大→惩罚长文档更重 | 短文段与长文本混合搜索 |
| 减小→长度影响变小 | 字段长度相对均匀的场景 |
6.4 TF-IDF vs BM25 对比
| 对比维度 | TF-IDF | BM25 |
|---|---|---|
| 词频增长模型 | 线性增长,无上限 | 非线性增长,趋于饱和 |
| 字段长度影响 | 不考虑 | 通过b参数归一化 |
| 可调参数 | 无 | k1, b 两个调优参数 |
| 评分合理性 | 长文档易被"埋没" | 自动适配不同长度 |
| 边界情况 | 极端词频时不合理 | 饱和机制保证稳定 |
| Elasticsearch支持 | 已废弃,不再默认 | 5.0+默认评分算法 |
| 自定义参数DSL | "type": "tfidf" | 通过similarity配置 |
七、实战:搜索"iPhone 14"的完整执行过程
让我们通过一个具体案例,完整追踪一条搜索请求从发出到返回结果的全过程。
场景设定
假设我们有一个商品索引shop,包含以下文档:
// 索引MappingPUT/shop{"mappings":{"properties":{"title":{"type":"text","analyzer":"standard","similarity":"BM25"},"description":{"type":"text","analyzer":"standard"},"brand":{"type":"keyword"},"price":{"type":"double"}}}}// 插入测试数据POST/shop/_bulk{"index":{"_id":1}}{"title":"iPhone 14 Pro Max 256GB","description":"Apple最新旗舰手机 iPhone 14 Pro Max","brand":"Apple","price":8999}{"index":{"_id":2}}{"title":"iPhone 14 Plus 128GB","description":"大屏iPhone 14 Plus 超长续航","brand":"Apple","price":6999}{"index":{"_id":3}}{"title":"iPhone 14 标准版","description":"年度主打iPhone 14 超强性能","brand":"Apple","price":5999}{"index":{"_id":4}}{"title":"华为Mate 60 Pro","description":"华为旗舰拍照手机 卫星通信","brand":"Huawei","price":6999}{"index":{"_id":5}}{"title":"小米14 Ultra","description":"小米14旗舰 徕卡影像","brand":"Xiaomi","price":5999}执行搜索
GET/shop/_search{"query":{"multi_match":{"query":"iPhone 14","fields":["title^2","description"],"operator":"and"}},"explain":true}完整执行过程追踪
═══════════════════════════════════════════════════════ 搜索 "iPhone 14" 完整执行过程 ═══════════════════════════════════════════════════════ 第1步:接收请求(协调节点) ┌─────────────────────────────────────┐ │ 协调节点收到请求: "iPhone 14" │ │ - operator: AND │ │ - 字段权重: title^2, description^1 │ └─────────────────────────────────────┘ 第2步:查询分析(Query Parsing) ┌─────────────────────────────────────┐ │ 分词器处理查询字符串 "iPhone 14" │ │ → 得到两个Term: "iphone", "14" │ │ │ │ 语法分析构建查询树: │ │ BooleanQuery (must/AND) │ │ ├─ TermQuery("iphone") │ │ │ 在 title^2, description^1 │ │ └─ TermQuery("14") │ │ 在 title^2, description^1 │ └─────────────────────────────────────┘ 第3步:反向索引查找(Term Lookup) ┌─────────────────────────────────────┐ │ 倒排索引查找: │ │ Term "iphone" → Posting List: │ │ title字段: [doc1, doc2, doc3] │ │ desc字段: [doc1, doc2, doc3] │ │ │ │ Term "14" → Posting List: │ │ title字段: [doc1, doc2, doc3, doc5] │ │ desc字段: [doc2] │ └─────────────────────────────────────┘ 第4步:文档列表合并(AND操作) ┌─────────────────────────────────────┐ │ 多字段合并后的文档列表: │ │ "iphone" 匹配: [doc1, doc2, doc3] │ │ "14" 匹配: [doc1, doc2, doc3, │ │ doc5] │ │ AND 交集结果: [doc1, doc2, doc3] │ │ │ │ doc4: 未匹配 "iphone" → 淘汰 │ │ doc5: "14" 只在title出现,但desc没有 │ │ "iphone" → 淘汰(AND关系) │ └─────────────────────────────────────┘ 第5步:BM25评分计算 ┌─────────────────────────────────────┐ │ 对 doc1, doc2, doc3 逐一计算Score: │ │ │ │ doc1 ("iPhone 14 Pro Max 256GB"): │ │ IDF(iphone) × TF饱和 × 长度归一化 │ │ + IDF(14) × TF饱和 × 长度归一化 │ │ = 较高分(两个词都密集出现在短标题中) │ │ │ │ doc2 ("iPhone 14 Plus 128GB"): │ │ 同理计算 = 高分 │ │ │ │ doc3 ("iPhone 14 标准版"): │ │ 同理计算 = 高分 │ └─────────────────────────────────────┘ 第6步:排序与返回 ┌─────────────────────────────────────┐ │ 按Score降序排列: │ │ 1. doc1: score=2.85 (标题两次命中+权重) │ │ 2. doc2: score=2.63 │ │ 3. doc3: score=2.41 │ │ │ │ 返回Top N结果给客户端 │ └─────────────────────────────────────┘使用_explainAPI 查看评分细节
// 查看特定文档的评分明细GET/shop/_explain/1{"query":{"multi_match":{"query":"iPhone 14","fields":["title^2","description"],"operator":"and"}}}// 该接口返回详细的计算过程,包括每个Term的IDF值、TF值、// 字段长度、k1/b参数以及最终加权得分。八、总结与最佳实践
核心要点回顾
- 搜索引擎通过倒排索引实现O(1)级别的关键词查找,将非结构化文本转化为结构化的 “Term→文档列表” 映射。
- 文本分析是搜索质量的基础,字符过滤器→分词器→词元过滤器的三层架构决定了哪些Term能被搜索到,务必在写入前通过
_analyzeAPI 验证分词效果。 - 查询树结构决定了文档列表的合并策略,AND关系取交集(精确匹配),OR关系取并集(广泛召回),根据业务场景合理选择。
- BM25通过TF饱和度(k1)和字段长度归一化(b)两大改进,解决了TF-IDF的两个核心缺陷,是更合理、更通用的评分算法。
最佳实践清单
| 实践建议 | 详细说明 |
|---|---|
| 显式指定analyzer | 每个text字段在Mapping中明确指定analyzer,不依赖默认值 |
| 写入前验证分词 | 使用POST _analyze测试分词效果,确保与预期一致 |
| 搜索时使用相同分析器 | 搜索时用search_analyzer指定,确保搜索结果准确性 |
| 合理选择AND/OR | 精确匹配用AND,广泛召回用OR,高精度场景用minimum_should_match |
| 调优BM25参数 | 根据文档长度分布特征调整k1和b,长短文本混合场景适当降低k1 |
| 使用explain调试 | _explainAPI 可查看每篇文档的详细评分过程,是调优的利器 |
| 权重设置 | 对multi_match使用^符号为不同字段设置权重,如"title^3" |
上一篇【第42篇】Elasticsearch倒排索引原理——Lucene的核心数据结构
下一篇【第44篇】Elasticsearch分布式索引原理——分片路由与写入流程
