TOC
使用ES作为存储引擎,设计资源检索服务的索引优化过程
-
引言
- 简单介绍需求
公司内部服务资源的检索,目前的数据保存在mysql中,通过like命令简单检索,存在问题:
- 容错性太低,错一个字符就搜不出来
- like语句性能比较差
- 几乎每个服务都有检索需求,重复开发
- 各个服务平台彼此数据割裂,缺少统一的服务入口
- 简单介绍需求
公司内部服务资源的检索,目前的数据保存在mysql中,通过like命令简单检索,存在问题:
-
方案比较
- 常见的存储和检索方案
- 关系型数据库(如MySQL)
- NoSQL数据库(如MongoDB)
- 专用搜索引擎(如Elasticsearch)
- 为什么选择Elasticsearch(ES)
- 性能优势
- 可扩展性
- 丰富的查询功能
- 常见的存储和检索方案
-
Elasticsearch检索原理
- 倒排索引的基本概念
- 什么是倒排索引
- 倒排索引的工作原理
- ES如何利用倒排索引进行高效检索
- 文档和字段的索引过程
- 查询的执行过程
- 倒排索引的基本概念
-
优化过程
原始数据
POST all_key/_doc/1
{
"from": "platform-1",
"kind": "service",
"key": "my service1",
"value": "https://my.service.gogodjzhu.com",
"create_time": "2024-12-23 17:18:09",
"update_time": "2024-12-23 17:18:09"
}
POST all_key/_doc/2
{
"from": "platform-1",
"kind": "service",
"key": "my service2",
"value": "https://my.service.gogodjzhu.com",
"create_time": "2024-12-23 17:18:09",
"update_time": "2024-12-23 17:18:09"
}
由于ES的动态字段特性,会根据字段值推测相应的字段类型。可以通过_mapping
接口查看索引当前的mapping定义:
GET all_key/_mapping
{
"all_key" : {
"mappings" : {
"_doc" : {
"properties" : {
"key" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
}
}
}
}
}
}
目前比较简单,所有的字段都被推测为text类型,并附加了一个嵌套字段类型为keyword。
其中text
类型是ES中最常见的类型,会以倒排索引方式存储数据,许多ES的高级特性都是基于这种类型来实现的。由于倒排索引的结构,因此text
类型字段已经失去了原始值,索引存储的都是切分词(term),指向文档ID。
而keyword
类型存储数据的方式是结构化的,可以支持过滤(filtering)、聚合(aggregation)和排序(sorting)操作。
正则表达式/match检索
有了ES自动推测的索引类型,我们已经可以使用两种基础的检索方法。首先是正则搜索,
GET all_key/_search
{
"query": {
"regexp": {
"key": "my.*"
}
}
}
// 返回文档: [1, 2]
很简单对吧,返回key
命中正则表达式my.*
的文档[1, 2]
。接着修改表达式尝试匹配更长的字符串。
GET all_key/_search
{
"query": {
"regexp": {
"key": "my service.*"
}
}
}
// 返回文档: []
奇怪,竟然没有结果返回。这是因为key
字段的类型是text
在底层是以倒排索引的方式存储的,而regexp检索的对象是分词(term)而不是字段原文。比如my service1
已经被拆分为[my, service1]
两个term,都无法命中正则my service.*
,所以返回结果为空。
这时候保存了字段原文的keyword
字段才能使用正则检索。
GET all_key/_search
{
"query": {
"regexp": {
"key.keyword": "my service.*"
}
}
}
// 返回文档: [1, 2]
另外一种常见的检索方式是match
,他的查询语法如下:
GET all_key/_search
{
"query": {
"match": {
"key": "my service1"
}
}
}
// 返回文档: [1, 2]
竟然两个文档都命中了,docId=2的值不是my service2
么?为什么也被检索出来了?这是因为match
查询的查询流程是:
- 将查询条件
my service1
拆分为[my, service1]
- 分别用
[my, service1]
和所有文档的key字段的分词(term)做匹配,其中docId=1
有两个term[my, service1]
能匹配上,而docId=2
也能匹配上一个term[my]
。 - 默认情况下,
match
查询会返回命中任意term的文档。也可以通过operator=(or|and)
标记来控制是否所有查询term都命中才返回。甚至使用minimum_should_match
来精确控制最少需要命中的term数量。
GET all_key/_search
{
"query": {
"match": {
"key": {
"query": "my service1",
"operator": "and"
}
}
}
}
// 返回文档: [1]
case-senstive
再看一个例子,无论大小写字符串做match
检索的均能命中文档:
GET all_key/_search
{
"query": {
"match": {
"key": "My"
}
}
}
// 返回文档: [1, 2]
这是因为ES对text
类型字段做分词操作,是由分析器(analyzer)来实现的,默认使用的standared
分析器会将所有大写字符转化为小写。可以通过_analyze
验证分词效果:
POST _analyze
{
"analyzer": "standard",
"text": "My service1"
}
// 返回结果,可以发现`My service1`被切割为两个字符串处理为小写`[my, service1]`。
{
"tokens" : [
{
"token" : "my",
"start_offset" : 0,
"end_offset" : 2,
"type" : "<ALPHANUM>",
"position" : 0
},
{
"token" : "service1",
"start_offset" : 3,
"end_offset" : 11,
"type" : "<ALPHANUM>",
"position" : 1
}
]
}
除了默认的
standared
分析器之外,还内置有其他几个常用的分析器:
simple
:使用简单的分词器,按非字母字符分割文本。"Text-analysis 123!"
->["text", "analysis"]
whitespace
:按空格分割文本,保留原始字符,不进行小写转换。"The Quick Brown Fox!"
->["The", "Quick", "Brown", "Fox!"]
stop
:类似于simple
分析器,但会移除停用词。"The Quick Brown Fox!"
->["quick", "brown", "fox"]
pattern
:基于正则表达式的分词器,允许自定义分割规则。"2025-01-10,Elasticsearch-Tokenizer"
->["2025", "01", "10", "Elasticsearch", "Tokenizer"]
(默认正则匹配模式为\W+
)ngram
:基于固定长度切分字符串,下文会具体介绍。
因此为了保留大小写信息,我们需要重建索引,并指定使用大小写敏感的分析器,比如whitespace
:
// 重建索引指定使用whitespace分析器
PUT all_key
{
"mappings": {
"_doc": {
"properties": {
"key": {
"type": "text",
"analyzer": "whitespace"
}
}
}
}
}
// 写入文档
POST all_key/_doc/1
{
"key": "my service1"
}
POST all_key/_doc/2
{
"key": "my service2"
}
// 执行检索
GET all_key/_search
{
"query": {
"match": {
"key": "My"
}
}
}
// 返回文档: [], 大小写敏感生效
值得注意的是,分析器对字符串进行处理不仅发生在文档写入的时候,也发生在查询阶段。在上面的例子中,默认都使用了mapping中定义的分析器。事实上也可以配置在不同阶段使用不同的分析器,后文我们会继续介绍。
fuzziness模糊匹配
回到检索服务的实际场景,还有一个常见的容错需求。当我们错误输入servce1
的时候,我们希望也能检索出最相关的文档来。fuzziness是满足需求的一个特性。
(继续进行之前,请重建索引使用standared
分词器)
GET all_key/_search
{
"query": {
"match": {
"key": {
"query": "servce1",
"fuzziness": 1
}
}
}
}
// 返回文档: [1]
fuzziness
的原理是在倒排查询之前,先计算查询term
和倒排索引词典
中所有词的编辑距离d
,取其中的满足d<=fuzziness
的所有term组成一组should clause
的term query
。
以这里的查询为例,查询term为servce1
,和倒排索引词典
中所有词的编辑距离为:my(7), service1(1), service2(2)
,只有service1
满足编辑距离小于1的条件,因此将上述查询改写为类似下面这样的查询:
// fuzziness改写后的查询
GET all_key/_search
{
"query": {
"bool": {
"should": [
{
"term": {
"key": {
"value": "service1"
}
}
}
]
}
}
}
// 返回文档: [1]
由于根据编辑距离生成term列表的结果可能非常大,所以ES(准确地说是Lucene)通过许多手段限制term列表无限扩张:
fuzziness
的可选值为[0,1,2],避免差异过大的词被选中prefix_length
控制最小匹配前缀长度——基于”大多数拼写错误不发生在字符串头部“的场景假设max_expansions
控制should
查询的子句数量,即控制候选term的数量。因为term过多,即便匹配上了准确率也不高,不如直接过滤掉一些
ngram/edge_ngram模糊匹配
与fuzzy类似,ngram也是模糊匹配场景下一个常见的手段。
**“gram”**的字面含义来源于希腊语 “γράμμα” (gramma),意思是“书写”或“字符”。在语言处理领域,gram表示构成文本的一组基本单位,通常是一个单词/词组。在ES中则相对简单一点,是指按照固定长度切割的一组字符串。n指的则是字符串的长度。
ES中提供的ngram是一个分词器(Tokenizer),可以通过_analyze
接口测试它的效果:
POST _analyze
{
"tokenizer": "ngram",
"text": "Hi U"
}
// 返回token列表: ['H', 'Hi', 'i', 'i ', ' ', ' U', 'U' ]
// 指定min/max gram来控制分词粒度
POST _analyze
{
"tokenizer": {
"type": "ngram",
"min_gram": 3,
"max_gram": 4
},
"text": "Hi U"
}
// 返回token列表: ['Hi ', 'Hi U', 'i U']
在ngram完成分词之后,term过滤和match检索流程是标准化的。下面是一个完整的例子:
// 重建索引
PUT all_key
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ngram"
}
}
}
},
"mappings": {
"_doc": {
"properties": {
"key": {
"type": "text",
"analyzer": "my_analyzer"
}
}
}
}
}
// 重新写入文档
POST all_key/_doc/1
{
"key": "my service1"
}
POST all_key/_doc/2
{
"key": "my service2"
}
// 测试
GET all_key/_search
{
"query": {
"match": {
"key": "my"
}
}
}
// 返回文档: [1, 2]
与ngram
类似的,还有另一个分词器edge_ngram,区别在于后者只会从边缘(edge)开始拆分n个gram,而且每次拆分的时候都从最边缘开始:
POST _analyze
{
"tokenizer": {
"type": "edge_ngram",
"min_gram": 5,
"max_gram": 8
},
"text": "hello world"
}
// 返回token列表['hello', 'hello ', 'hello w']
edge_ngram
的头部拆分特性导致其比较适合做完全前缀补全/匹配,当拼写错误发生在字符串中间的时候,便无法使用。
fuzzy和ngram的比较
两者都是模糊匹配场景中常用的检索方法,但是在思路上两者有一些差异。
fuzziness
是match查询的一个算法优化,核心是在所有term中过滤出最相似的(编辑距离最小的)term,不需要对原文档做专门的分词处理。缺点是性能跟数据量/查询条件有很大的关系,特别是大数据集上可能很慢。
ngram
是一个分词器,核心思路还是将字符串拆成倒排表的term,以空间换时间加速检索。会增加索引大小,但查询性能比较稳定。实际使用中都需要针对场景进行响应的参数调优,在准确率和召回率之间取得平衡。
ES检索原理概要
前面介绍了使用ES进行数据模糊匹配的常见方案,涉及到了ES中的几个非常核心的概念:token、term、filter、tokenizer、analyzer。每一个-er
组件又包含着诸多具体的实现。很头疼吧?别急,其实ES的检索流程很有章法,核心都是围绕着倒排表来进行的。
而倒排表的核心是回答一个问题:某个元素(即term,文本场景下则是分词)在哪些文档内存在。
为了快速回答这个问题,需要预先地创建一张很大的索引表,记下来每一个词指向了哪些文档。
Token与Term的比较
term
和token
是两个密切相关但有所区别的概念。token由tokenizer
(分词器) 和token_filter
(词元过滤器) 在分析器(analyzer
)的处理流程中生成。在分词(生成token
)之后,ES会将这些token
存入倒排索引,并将其作为term
存储。
对比项 Token(词元) Term(词项) 定义 分词后生成的文本最小单位 倒排索引中存储的实际单元 产生阶段 分词( analyzer
)阶段索引阶段 存储位置 临时生成,用于索引构建或查询时分析 永久存储在倒排索引中 是否处理大小写 可能会处理,如小写化 已经过滤器处理后存储 查询关系 查询文本通过分词器生成 tokens
,再与索引中的terms
匹配查询时直接与存储的 terms
匹配作用范围 索引和查询分析过程 查询匹配和倒排索引查询
Analyzer定义了处理字符串的一系列流程,
4.6 function_score + hit_count
_score标识关联度
match
检索的结果是bool,给定的文档要么满足条件,要么不命中。但不同的文档跟给定检索条件的匹配度是不同的,因此ES返回的结果中,还有一个_score
字段