十九种Elasticsearch字符串搜索方式终极介绍

前言

刚开始接触Elasticsearch的时候被Elasticsearch的搜索功能搞得晕头转向,每次想在Kibana里面查询某个字段的时候,查出来的结果经常不是自己想要的,然而又不知道问题出在了哪里。出现这个问题归根结底是因为对于Elasticsearch的底层索引原理以及各个查询搜索方式的不了解,在Elasticsearch中仅仅字符串相关的查询就有19个之多,如果不弄清楚查询语句的工作方式,应用可能就不会按照我们预想的方式运作。这篇文章就详细介绍了Elasticsearch的19种搜索方式及其原理,老板再也不用担心我用错搜索语句啦!

简介

Elasticsearch为所有类型的数据提供实时搜索和分析,不管数据是结构化文本还是非结构化文本、数字数据或地理空间数据,都能保证在支持快速搜索的前提下对数据进行高效的存储和索引。用户不仅可以进行简单的数据检索,还可以聚合信息来发现数据中的趋势和模式。
搜索是Elasticsearch系统中最重要的一个功能,它支持结构化查询、全文查询以及结合二者的复杂查询。结构化查询有点像SQL查询,可以对特定的字段进行筛选,然后按照特定的字段进行排序得到结果。全文查询会根据查询字符串寻找相关的文档,并且按照相关性排序。
Elasticsearch内包含很多种查询类型,下面介绍是其中最重要的19种。如果你的app想要添加一个搜索框,为用户提供搜索操作,并且数据量很大用MySQL会造成慢查询想改用Elasticsearch,那么我相信这篇文章会给你带来很大的帮助。

query和filter区别

在正式进入到搜索部分之前,我们需要区分 query
(查询)和 filter
(过滤)的区别。

在进行 query
的时候,除了完成匹配的过程,我们实际上在问“ 这个结果到底有多匹配我们的搜索关键词
”。在所有的返回结果的后面都会有一个 _score
字段表示这个结果的匹配程度,也就是 相关性
。相关性越高的结果就越排在前面,相关性越低就越靠后。当两个文档的相关性相同的时候,会根据lucene内部的 doc_id
字段来排序,这个字段对于用户是不可见的也不能控制。

而在进行 filter
的时候,仅仅是在问“ 这个文档符不符合要求
”,这仅仅是一个过滤的操作判断文档是否满足我们的筛选要求,不会计算任何的相关性。比如 timestamp
的范围是否在2019和2020之间, status
状态是否是1等等。

在一个查询语句里面可以同时存在 query
filter
,只不过只有 query
的查询字段会进行相关性 _score
的计算,而 filter
仅仅用来筛选。比如在下面的查询语句里面,只有 title
字段会进行相关性的计算,而下面的 status
只是为了筛选并不会计算相关性。

GET /_search
{
  "query": {
    "bool": {
      "must": [
        {"match": {"title": "Search"}}
      ],
      "filter": [
        {"term": {"state": 1}}
      ]
    }
  }
}

对于在实际应用中应该用 query
还是用 filter
需要根据实际的业务场景来看。如果你的产品的搜索只是需要筛选得到最后的搜索结果并不需要Elasticsearch的相关性排序(你可能自定义了其他的排序规则),那么使用 filter
就完全能够满足要求并且能够有更好的性能( filter
不需要计算相关性而且会缓存结果);如果需要考虑文档和搜索词的相关性,那么使用 query
就是最好的选择。

相关性

上面讲到了在使用 query
查询的时候会计算相关性并且进行排序,很多人都会好奇相关性是怎么计算的?

相关性的计算是比较复杂的,详细的文档可以看这两篇博客—— 什么是相关性
ElasticSearch 使用教程之_score(评分)介绍
,我这里只是做一个简单的介绍。

Elasticsearch的相似度计算主要是利用了全文检索领域的计算标准—— TF/IDF
(Term Frequency/Inverted Document Frequency)也就是 检索词频率
反向文档频率

  1. TF
    (检索词频率):检索词在这个字段里面出现的频率越高,相关性越高。比如搜索词出现5次肯定比出现1次的文档相关性更高。
  2. IDF
    (反向文档频率):包含检索词的文档的频率越高,这个检索词的相关性比重越低。如果一个检索词在所有的文档里面都出现了,比如中文的
    ,那么这个检索词肯定就不重要,相对应的根据这个检索词匹配的文档的相关性权重应该下降。
  3. 字段长度
    :注意这个字段是文档的里面被搜索的字段,不是检索词。如果这个字段的长度越长,相关性就越低。这个主要是因为这个检索词在字段内的重要性降低了,文档就相对来说不那么匹配了。

在复合查询里面,比如 bool
查询,每个子查询计算出来的评分会根据特定的公式合并到综合评分里面,最后根据这个综合评分来排序。当我们想要修改不同的查询语句的在综合评分里面的比重的时候,可以在查询字段里面添加 boost
参数,这个值是相对于 1
来说的。如果大于 1
则这个查询参数的权重会提高;如果小于 1
,权重就下降。
这个评分系统一般是系统默认的,我们可以根据需要定制化我们自己的相关性计算方法,比如通过脚本自定义评分。

分析器

分析器是针对 text
字段进行文本分析的工具。文本分析是把非结构化的数据(比如产品描述或者邮件内容)转化成结构化的格式从而提高搜索效率的过程,通常在搜索引擎里面应用的比较多。

text
格式的数据和 keyword
格式的数据在存储和索引的时候差别比较大。 keyword
会直接被当成整个字符串保存在文档里面,而 text
格式数据,需要经过分析器解析之后,转化成结构化的文档再保存起来。比如对于 the quick fox
字符串,如果使用 keyword
类型,保存直接就是 the quick fox
,使用 the quick fox
作为关键词可以直接匹配,但是使用 the
或者 quick
就不能匹配;但是如果使用 text
保存,那么分析器会把这句话解析成 the
quick
fox
三个token进行保存,使用 the quick fox
就无法匹配,但是单独用 the
quick
fox
三个字符串就可以匹配。所以对于 text
类型的数据的搜索需要格外注意,如果你的搜索词得不到想要的结果,很有可能是你的搜索语句有问题。
分析器的工作过程大概分成两步:

  1. 分词
    (Tokenization):根据 停止词
    把文本分割成很多的小的token,比如 the quick fox
    会被分成 the
    quick
    fox
    ,其中的停止词就是空格,还有很多其他的停止词比如 &
    或者 #
    ,大多数的标点符号都是停止词
  2. 归一化
    (Normalization):把分隔的token变成统一的形式方便匹配,比如下面几种

    • 把单词变成小写, Quick
      会变成 quick
    • 提取词干, foxes
      变成 fox
    • 合并同义词, jump
      leap
      是同义词,会被统一索引成 jump

Elasticsearch自带了一个分析器,是系统默认的标准分析器,使用 标准分词器
,大多数情况下都能够有不错的分析效果。用户也可以定义自己的分析器,用于满足不同的业务需求。
想要知道某个解析器的分析结果,可以直接在ES里面进行分析,执行下面的语句就行了:

POST /_analyze
{
  "analyzer": "standard",
  "text": "1 Fire's foxes"
}

返回的结果是:

{
  "tokens" : [
    {
      "token" : "1",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "",
      "position" : 0
    },
    {
      "token" : "fire's",
      "start_offset" : 2,
      "end_offset" : 8,
      "type" : "",
      "position" : 1
    },
    {
      "token" : "fox",
      "start_offset" : 9,
      "end_offset" : 12,
      "type" : "",
      "position" : 2
    }
  ]
}

返回的 tokens
内部就是所有的解析结果, token
表示解析的词语部分, start_offset
end_offset
分别表示token在原text内的起始和终止位置, type
表示类型, position
表示这个token在整个tokens列表里面的位置。
OK!有了上面的基础知识,就可以进行下面的搜索的介绍了。

term搜索

term搜索不仅仅可以对 keyword
类型的字段使用,也可以对 text
类型的数据使用,前提是使用的搜索词必须要预先处理一下——不包含停止词并且都是小写(标准解析器),因为文档里面保存的 text
字段分词后的结果,用term是可以匹配的。

exists

返回所有指定字段不为空的文档,比如这个字段对应的值是 null
或者 []
或者没有为这个字段建立索引。

GET /_search
{
  "query": {
    "exists": {
      "field": "user"
    }
  }
}

如果字段是空字符串 ""
或者包含 null
的数组 [null,"foo"]
,都会被当作字段存在。
这个方法可以用来搜索没有被索引的值或者不存在的值。

fuzzy

fuzzy查询是一种模糊查询,会根据检索词和检索字段的 编辑距离
(Levenshtein Distance)来判断是否匹配。一个编辑距离就是对单词进行一个字符的修改,这种修改可能是

  • 修改一个字符,比如 box
    fox
  • 删除一个字符,比如 black
    lack
  • 插入一个字符,比如 sic
    sick
  • 交换两个相邻的字符的位置,比如 act
    cat

在进行fuzzy搜索的时候,ES会生成一系列的在特定编辑距离内的变形,然后返回这些变形的准确匹配。默认情况下,当检索词的长度在 0..2
中间时,必须准确匹配;长度在 3..5
之间的时候,编辑距离最大为 1
;长度大于 5
的时候,最多允许编辑距离为 2

可以通过配置 fuzziness
修改最大编辑距离, max_expansions
修改最多的变形的token的数量
比如搜索是以下条件的时候:

GET /_search
{
  "query": {
    "fuzzy": {
      "name": "Accha"
    }
  }
}

返回结果有 Iccha
AccHa
accha
还有 ccha

ids

根据文档的 _id
数组返回对应的文档信息

GET /_search
{
  "query": {
    "ids": {
      "values": ["1","4","100"]
    }
  }
}

prefix

返回所有包含以检索词为前缀的字段的文档。

GET /_search
{
  "query": {
    "prefix": {
      "name": "ac"
    }
  }
}

返回所有以 ac
开头的字段,比如 acchu
achu
achar
等等

在某些场景下面比如搜索框里面,需要用户在输入内容的同时也要实时展示与输入内容前缀匹配的搜索结果,就可以使用prefix查询。为了加速prefix查询,还可以在设置字段映射的时候,使用 index_prefixes
映射。ES会额外建立一个长度在2和5之间索引,在进行前缀匹配的时候效率会有很大的提高。

range

对字段进行范围的匹配。

GET /_search
{
  "query": {
    "range": {
      "age": {
        "gte": 10,
        "lte": 20
      }
    }
  }
}

搜索年龄在10(包含)和20(包含)之间的结果

regexp

正则表达式匹配。通过正则表达式来寻找匹配的字段, lucene
会在搜索的时候生成 有限状态机
,其中包含很多的 状态
,默认的最多状态数量是10000

GET /_search
{
  "query": {
    "regexp": {
      "name": "ac.*ha"
    }
  }
}

这个搜索会匹配 achha
achintha
还有 achutha

term

根据检索词来准确匹配字段。官方文档建议不要用term去搜索 text
类型的字段,因为分析器的原因很有可能不会出现你想要的结果。但是直接使用term去搜索 text
字段还是可以工作的,前提是明白为什么会返回这些数据。比如通过下面的搜索:

GET /_search
{
  "query": {
    "term": {
      "name": {
        "value": "accha"
      }
    }
  }
}

如果 name
字段是 keyword
类型的,没有进行解析,那么只会匹配所有 name
accha
的文档。

如果 name
字段是 text
类型的,原字段经过分词、小写化处理之后,只能匹配到解析之后的单独token,比如使用标准解析器,这个搜索会匹配 Accha Baccha
so cute accha baccha
或者 Accha Baccha Shivam
等字段。

terms

根据检索词列表来批量搜索文档,每个检索词在搜索的时候相当于 or
的关系,只要一个匹配就行了。Elasticsearch最多允许65,536个term同时查询。

GET /_search
{
  "query": {
    "terms": {
      "name": [
        "accha",
        "ghazali"
      ]
    }
  }
}

上面的查询会匹配 name
字段为 accha
ghazali
的文档。

除了直接指定查询的term列表,还可以使用 Terms lookUp
功能,也就是指定某一个存在的文档的某一个字段(可能是数字、字符串或者列表)来作为搜索条件,进行terms搜索。

比如有一个文件 index
my_doc
id
10
name
字段是 term
并且值为 accha
,搜索可以这样写:

{
  "query": {
    "terms": {
      "name": {
        "index": "my_doc",
        "id": "10",
        "path": "name"
      }
    }
  }
}

这样就可以返回所有 name
字段值是 accha
的文档里,这个通常可以用来查询所有和某个文档某个字段重复的文档并且不需要提前知道这个字段的值是什么。

terms_set

terms_set和terms十分类似,只不过是多了一个最少需要匹配数量 minimum_should_match_field
参数。当进行匹配的时候,只有至少包含了这么多的terms中的term的时候,才会返回对应的结果。

GET /_search
{
  "query": {
    "terms_set": {
      "programming_languages": {
        "terms": ["c++","java","php"],
        "minimum_should_match_field": "required_match"
      }
    }
  }
}
{
    "name":"Jane Smith",
    "programming_languages":[
        "c++",
        "java"
    ],
    "required_matches":2
}

那么只有 programming_languages
列表里面至少包含 ["c++", "java", "php"]
其中的2项才能满足条件

还可以使用 minimum_should_match_script
脚本来配置动态查询

{
  "query": {
    "terms_set": {
      "programming_languages": {
        "terms": ["c++","java","php"],
        "minimum_should_match_script": {
          "source": "Math.min(params.num_terms, doc['required_matches'].value)"
        }
      }
    }
  }
}

其中 params.num_terms
是在 terms
字段中的元素的个数

wildcard

通配符匹配,返回匹配包含通配符的检索词的结果。
目前只支持两种通配符:

?
*

在进行wildcard搜索的时候最好避免在检索词的开头使用 *
或者 ?
,这会降低搜索性能。

GET /_search
{
  "query": {
    "wildcard": {
      "name": {
        "value": "acc*"
      }
    }
  }
}

这个搜索会匹配 acchu
acche
或者 accio父

text搜索

text
搜索实际上是针对被定义为 text
类型的字段的搜索,通常搜索的时候不能根据输入的字符串的整体来理解,而是要预先处理一下,把搜索词变成小的token,再来查看每个token的匹配。

interval

返回按照检索词的特定排列顺序排列的文档。这个查询比较复杂,这里只是简单的介绍,详细的介绍可以看 官方文档

比如我们想查询同时包含 raj
nayaka
的字段并且 ray
正好在 nayaka
前面,查询语句如下:

POST /_search
{
  "query": {
    "intervals": {
      "name": {
        "match": {
          "query": "raj nayaka",
          "max_gaps": 0,
          "ordered": true
        }
      }
    }
  }
}

上面的查询会匹配 Raj Nayaka Acchu Valmiki
Yateesh Raj Nayaka

如果把 ordered:true
去掉,就会匹配 nayaka raj

如果把 max_gaps:0
去掉,系统会用默认值 -1
也就是没有距离要求,就会匹配 Raj Raja nayaka
或者 Raj Kumar Nayaka

其中有两个关键词 ordered
max_gaps
分别用来控制这个筛选条件是否需要排序以及两个token之间的最大间隔

match

查找和检索词短语匹配的文档,这些检索词在进行搜索之前会先被分析器解析,检索词可以是文本、数字、日期或者布尔值。match检索也可以进行模糊匹配。

GET /_search
{
  "query": {
    "match": {
      "name": "nagesh acchu"
    }
  }
}

以上的查询会匹配 NaGesh Acchu
Acchu Acchu
acchu
。系统默认是在分词后匹配任何一个token都可以完成匹配,如果修改 operator
AND
,则会匹配同时包含 nagesh
acchu
的字段。

GET /_search
{
  "query": {
    "match": {
      "name": {
        "query": "nagesh acchu",
        "operator": "and"
      }
    }
  }
}

上面这个查询就只会返回 NaGesh Acchu

查询的时候也可以使用模糊查询,修改 fuzziness
参数

GET /_search
{
  "query": {
    "match": {
      "name": {
        "query": "nagesh acchu",
        "operator": "and",
        "fuzziness": 1
      }
    }
  }
}

上面的语句会匹配 NaGesh Acchu
还有 Nagesh Bacchu

match_bool_prefix

match_bool_prefix会解析检索词,然后生成一个bool复合检索语句。如果检索词由很多个token构成,除了最后一个会进行prefix匹配,其他的会进行term匹配。

比如使用 nagesh ac
进行match_bool_prefix搜索

GET /_search
{
  "query": {
    "match_bool_prefix": {
      "name": "nagesh ac"
    }
  }
}

上面的查询会匹配 Nagesh Nagesh
Rakshith Achar
或者 ACoco

实际查询等价于

GET /_search
{
  "query": {
    "bool": {
      "should": [
        {
          "term": {
            "name": {
              "value": "nagesh"
            }
          }
        },
        {
          "prefix": {
            "name": {
              "value": "ac"
            }
          }
        }
      ]
    }
  }
}

match_phrase

词组匹配会先解析检索词,并且标注出每个的token相对位置,搜索匹配的字段的必须包含所有的检索词的token,并且他们的相对位置也要和检索词里面相同。

GET /_search
{
  "query": {
    "match_phrase": {
      "name": "Bade Acche"
    }
  }
}

这个搜索会匹配 Bade Acche Lagte
,但是不会匹配 Acche Bade Lagte
或者 Bade Lagte Acche

如果我们不要求这两个单词相邻,希望放松一点条件,可以添加 slop
参数,比如设置成 1
,代表两个token之间相隔的最多的距离(最多需要移动多少次才能相邻)。下面的查询语句会匹配 Bade Lagte Acche

GET /_search
{
  "query": {
    "match_phrase": {
      "name": {
        "query": "Bade Acche",
        "slop": 1
      }
    }
  }
}

match_phrase_prefix

match_phrase_prefix相当于是结合了match_bool_prefix和match_phrase。ES会先解析检索词,分成很多个token,然后除去最后一个token,对其他的token进行match_phrase的匹配,即全部都要匹配并且相对位置相同;对于最后一个token,需要进行前缀匹配并且匹配的这个单词在前面的match_phrase匹配的结果的后面。

GET /_search
{
  "query": {
    "match_phrase_prefix": {
      "name": "acchu ac"
    }
  }
}

上面的查询能够匹配 Acchu Acchu1
Acchu Acchu Papu
,但是不能匹配 acc acchu
或者 acchu pa

multi_match

multi_match可以同时对多个字段进行查询匹配,ES支持很多种不同的查询类型比如 best_fields
(任何字段match检索词都表示匹配成功)、 phrase
(用 match_phrase
代替 match
)还有 cross_field
(交叉匹配,通常用在所有的token必须在至少一个字段中出现)等等

下面是普通的 best_fields
的匹配

GET /_search
{
  "query": {
    "multi_match": {
      "query": "acchu",
      "fields": [
        "name",
        "intro"
      ]
    }
  }
}

只要 name
或者 intro
字段任何一个包含 acchu
都会完成匹配。

如果使用 cross_fields
匹配如下

GET /_search
{
  "query": {
    "multi_match": {
      "query": "call acchu",
      "type": "cross_fields",
      "fields": [
        "name",
        "intro"
      ],
      "operator": "and"
    }
  }
}

上面的匹配需要同时满足下面两个条件:

  • name
    中出现 call
    intro
    中出现 call
  • name
    中出现 acchu
    intro
    中出现 acchu

所以这个查询能够匹配 name
包含 acchu
intro
包含 call
的文档,或者匹配 name
同时包含 call
acchu
的文档。

common

common查询会把查询语句分成两个部分,较为重要的分为一个部分(这个部分的token通常在文章中出现频率比较低),不那么重要的为一个部分(出现频率比较高,以前可能被当作停止词),然后分别用 low_freq_operator
high_freq_operator
以及 minimum_should_match
来控制这些语句的表现。

在进行查询之前需要指定一个区分高频和低频词的分界点,也就是 cutoff_frequency
,它既可以是小数比如 0.001
代表该字段所有的token的集合里面出现的频率也可以是大于 1
的整数代表这个词出现的次数。当token的频率高于这一个阈值的时候,他就会被当作高频词。

GET /_search
{
  "query": {
    "common": {
      "body": {
        "query": "nelly the elephant as a cartoon",
        "cutoff_frequency": 0.001,
        "low_freq_operator": "and"
      }
    }
  }
}

其中高频词是 the
a
as
,低频词是 nelly
elephant
cartoon
,上面的搜索大致等价于下面的查询

GET /_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"body": "nelly"}},
        {"term": {"body": "elephant"}},
        {"term": {"body": "cartoon"}}
      ],
      "should": [
        {"term": {"body": "the"}},
        {"term": {"body": "as"}},
        {"term": {"body": "a"}}
      ]
    }
  }
}

但是第一个查询的效率要优于第二个,因为common语句有性能上的优化,只有重要的token匹配之后的文档,才会在不重要的文档的查询时候计算 _score
;不重要的token在查询的时候不会计算 _score

query_string

输入一个查询语句,返回和这个查询语句匹配的所有的文档。

这个查询语句不是简单的检索词,而是包含特定语法的的搜索语句,里面包含操作符比如 AND
OR
,在进行查询之前会被一个语法解析器解析,转化成可以执行的搜索语句进行搜索。用户可以生成一个特别复杂的查询语句,里面可能包含通配符、多字段匹配等等。在搜索之前ES会检查查询语句的语法,如果有语法错误会直接报错。

GET /_search
{
  "query": {
    "query_string": {
      "default_field": "name",
      "query": "acchu AND nagesh"
    }
  }
}

上面的查询会匹配所有的同时包含 acchu
nagesh
的结果。简化一下可以这样写:

GET /_search
{
  "query": {
    "query_string": {
      "query": "name: acchu AND nagesh"
    }
  }
}

query_string里面还支持更加复杂的写法:

  • name: acchu nagesh
    :查询 name
    包含 acchu
    nagesh
    其中的任意一个
  • book.\*:(quick OR brown)
    book
    的任何子字段比如 book.title
    book.content
    ,包含 quick
    或者 brown
  • _exists_: title
    title
    字段包含非 null
  • name: acch*
    :通配符,匹配任何 acch
    开头的字段
  • name:/joh?n(ath[oa]n)/
    :正则表达式,需要把内容放到两个斜杠 /
    中间
  • name: acch~
    :模糊匹配,默认编辑距离为2,不过80%的情况编辑距离为1就能解决问题 name: acch~1
  • count:[1 TO 5]
    :范围查询,或者 count: >10

下面的查询允许匹配多个字段,字段之间时 OR
的关系

GET /_search
{
  "query": {
    "query_string": {
      "fields": [
        "name",
        "intro"
      ],
      "query": "nagesh"
    }
  }
}

simple_query_string

和上面的query_string类似,但是使用了更加简单的语法。使用了下面的操作符:

  • +
    表示 AND
    操作
  • |
    表示 OR
    操作
  • -
    表示否定
  • "
    用于圈定一个短语
  • *
    放在token的后面表示前缀匹配
  • ()
    表示优先级
  • ~N
    放在token后面表示模糊查询的最大编辑距离 fuzziness
  • ~N
    放在phrase后面表示模糊匹配短语的 slop
GET /_search
{
  "query": {
    "simple_query_string": {
      "query": "acch* + foll~2 + -Karen",
      "fields": [
        "intro"
      ]
    }
  }
}

上面的搜索相当于搜索包含前缀为 acch
的、和 foll
编辑距离最大是2的并且不包含 Karen
的字段,这样的语句会匹配 call me acchu
或者 acchu follow me

总结

Elasticsearch提供了强大的搜索功能,使用query匹配可以进行相关性的计算排序但是filter可能更加适用于大多数的过滤查询的情况,如果用户对于标准解析器不太满意可以自定义解析器或者第三方解析器比如支持中文的 IK解析器

在进行搜索的时候一定要注意搜索 keyword
text
字段时候的区别,使用term相关的查询只能匹配单个的token但是使用text相关的搜索可以利用前面的term搜索进行组合查询,text搜索更加灵活强大,但是性能相对差一点。

参考