全文检索
全文搜索
我们已经介绍了搜索结构化数据的简单应用示例,现在来探寻 全文搜索(full-text search) :怎样在全文字段中搜索到最相关的文档。
全文搜索两个最重要的方面是:
相关性(Relevance)
它是评价查询与其结果间的相关程度,并根据这种相关程度对结果排名的一种能力,这种计算方式可以是 TF/IDF 方法(参见 相关性的介绍)、地理位置邻近、模糊相似,或其他的某些算法。
分析(Analysis)
它是将文本块转换为有区别的、规范化的 token 的一个过程,(参见 分析的介绍) 目的是为了(a)创建倒排索引以及(b)查询倒排索引。
一旦谈论相关性或分析这两个方面的问题时,我们所处的语境是关于查询的而不是过滤。
基于词项与基于全文
所有查询会或多或少的执行相关度计算,但不是所有查询都有分析阶段。 和一些特殊的完全不会对文本进行操作的查询(如 bool
或 function_score
)不同,文本查询可以划分成两大家族:
基于词项的查询
如 term
或 fuzzy
这样的底层查询不需要分析阶段,它们对单个词项进行操作。用 term
查询词项 Foo
只要在倒排索引中查找 准确词项 ,并且用 TF/IDF 算法为每个包含该词项的文档计算相关度评分 _score
。
记住 term
查询只对倒排索引的词项精确匹配,这点很重要,它不会对词的多样性进行处理(如, foo
或 FOO
)。这里,无须考虑词项是如何存入索引的。如果是将 ["Foo","Bar"]
索引存入一个不分析的( not_analyzed
)包含精确值的字段,或者将 Foo Bar
索引到一个带有 whitespace
空格分析器的字段,两者的结果都会是在倒排索引中有 Foo
和 Bar
这两个词。
基于全文的查询
像 match
或 query_string
这样的查询是高层查询,它们了解字段映射的信息:
- 如果查询
日期(date)
或整数(integer)
字段,它们会将查询字符串分别作为日期或整数对待。 - 如果查询一个(
not_analyzed
)未分析的精确值字符串字段, 它们会将整个查询字符串作为单个词项对待。 - 但如果要查询一个(
analyzed
)已分析的全文字段, 它们会先将查询字符串传递到一个合适的分析器,然后生成一个供查询的词项列表。
一旦组成了词项列表,这个查询会对每个词项逐一执行底层的查询,再将结果合并,然后为每个文档生成一个最终的相关度评分。
我们将会在随后章节中详细讨论这个过程。
我们很少直接使用基于词项的搜索,通常情况下都是对全文进行查询,而非单个词项,这只需要简单的执行一个高层全文查询(进而在高层查询内部会以基于词项的底层查询完成搜索)。
当我们想要查询一个具有精确值的
not_analyzed
未分析字段之前, 需要考虑,是否真的采用评分查询,或者非评分查询会更好。单词项查询通常可以用是、非这种二元问题表示,所以更适合用过滤, 而且这样做可以有效利用缓存:
GET /_search
{
"query": {
"constant_score": {
"filter": {
"term": { "gender": "female" }
}
}
}
}
匹配查询
匹配查询 match
是个 核心 查询。无论需要查询什么字段, match
查询都应该会是首选的查询方式。 它是一个高级 全文查询 ,这表示它既能处理全文字段,又能处理精确字段。
这就是说, match
查询主要的应用场景就是进行全文搜索,我们以下面一个简单例子来说明全文搜索是如何工作的:
索引一些数据
首先,我们使用 bulk
API 创建一些新的文档和索引:
DELETE /my_index
PUT /my_index
{ "settings": { "number_of_shards": 1 }}
POST /my_index/my_type/_bulk
{ "index": { "_id": 1 }}
{ "title": "The quick brown fox" }
{ "index": { "_id": 2 }}
{ "title": "The quick brown fox jumps over the lazy dog" }
{ "index": { "_id": 3 }}
{ "title": "The quick brown fox jumps over the quick dog" }
{ "index": { "_id": 4 }}
{ "title": "Brown fox brown dog" }
删除已有的索引。
稍后,我们会在 被破坏的相关性! 中解释只为这个索引分配一个主分片的原因。
单个词查询
我们用第一个示例来解释使用 match
查询搜索全文字段中的单个词:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "QUICK!"
}
}
}
elasticsearch 执行上面这个 match
查询的步骤是:
-
检查字段类型 。
标题
title
字段是一个string
类型(analyzed
)已分析的全文字段,这意味着查询字符串本身也应该被分析。 -
分析查询字符串 。
将查询的字符串
QUICK!
传入标准分析器中,输出的结果是单个项quick
。因为只有一个单词项,所以match
查询执行的是单个底层term
查询。 -
查找匹配文档 。
用
term
查询在倒排索引中查找quick
然后获取一组包含该项的文档,本例的结果是文档:1、2 和 3 。 -
为每个文档评分 。
用
term
查询计算每个文档相关度评分_score
,这是种将 词频(term frequency,即词quick
在相关文档的title
字段中出现的频率)和反向文档频率(inverse document frequency,即词quick
在所有文档的title
字段中出现的频率),以及字段的长度(即字段越短相关度越高)相结合的计算方式。参见 相关性的介绍 。
这个过程给我们以下(经缩减)结果:
"hits": [
{
"_id": "1",
"_score": 0.5,
"_source": {
"title": "The quick brown fox"
}
},
{
"_id": "3",
"_score": 0.44194174,
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "2",
"_score": 0.3125,
"_source": {
"title": "The quick brown fox jumps over the lazy dog"
}
}
]
文档 1 最相关,因为它的 title 字段更短,即 quick 占据内容的一大部分。
文档 3 比 文档 2 更具相关性,因为在文档 2 中 quick 出现了两次。
多词查询
如果我们一次只能搜索一个词,那么全文搜索就会不太灵活,幸运的是 match
查询让多词查询变得简单:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "BROWN DOG!"
}
}
}
上面这个查询返回所有四个文档:
{
"hits": [
{
"_id": "4",
"_score": 0.73185337,
"_source": {
"title": "Brown fox brown dog"
}
},
{
"_id": "2",
"_score": 0.47486103,
"_source": {
"title": "The quick brown fox jumps over the lazy dog"
}
},
{
"_id": "3",
"_score": 0.47486103,
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "1",
"_score": 0.11914785,
"_source": {
"title": "The quick brown fox"
}
}
]
}
文档 4 最相关,因为它包含词 "brown" 两次以及 "dog" 一次。
文档 2、3 同时包含 brown 和 dog 各一次,而且它们 title 字段的长度相同,所以具有相同的评分。
文档 1 也能匹配,尽管它只有 brown 没有 dog 。
因为 match
查询必须查找两个词( ["brown","dog"]
),它在内部实际上先执行两次 term
查询,然后将两次查询的结果合并作为最终结果输出。为了做到这点,它将两个 term
查询包入一个 bool
查询中,详细信息见 布尔查询。
以上示例告诉我们一个重要信息:即任何文档只要 title
字段里包含 指定词项中的至少一个词 就能匹配,被匹配的词项越多,文档就越相关。
提高精度
用 任意 查询词项匹配文档可能会导致结果中出现不相关的长尾。 这是种散弹式搜索。可能我们只想搜索包含 所有 词项的文档,也就是说,不去匹配 brown OR dog
,而通过匹配 brown AND dog
找到所有文档。
match
查询还可以接受 operator
操作符作为输入参数,默认情况下该操作符是 or
。我们可以将它修改成 and
让所有指定词项都必须匹配:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "BROWN DOG!",
"operator": "and"
}
}
}
}
match
查询的结构需要做稍许调整才能使用operator
操作符参数。
这个查询可以把文档 1 排除在外,因为它只包含两个词项中的一个。
控制精度
在 所有 与 任意 间二选一有点过于非黑即白。 如果用户给定 5 个查询词项,想查找只包含其中 4 个的文档,该如何处理?将 operator
操作符参数设置成 and
只会将此文档排除。
有时候这正是我们期望的,但在全文搜索的大多数应用场景下,我们既想包含那些可能相关的文档,同时又排除那些不太相关的。换句话说,我们想要处于中间某种结果。
match
查询支持 Minimum_should_match
最小匹配参数, 这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "quick brown dog",
"minimum_should_match": "75%"
}
}
}
}
当给定百分比的时候, minimum_should_match
会做合适的事情:在之前三词项的示例中, 75%
会自动被截断成 66.6%
,即三个里面两个词。无论这个值设置成什么,至少包含一个词项的文档才会被认为是匹配的。
参数
minimum_should_match
的设置非常灵活,可以根据用户输入词项的数目应用不同的规则。完整的信息参考文档https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-minimum-should-match.html#query-dsl-minimum-should-match
为了完全理解 match
是如何处理多词查询的,我们就需要查看如何使用 bool
查询将多个查询条件组合在一起。
组合查询
在 组合过滤器 中,我们讨论过如何使用 bool
过滤器通过 and
、 or
和 not
逻辑组合将多个过滤器进行组合。在查询中, bool
查询有类似的功能,只有一个重要的区别。
过滤器做二元判断:文档是否应该出现在结果中?但查询更精妙,它除了决定一个文档是否应该被包括在结果中,还会计算文档的 相关程度 。
与过滤器一样, bool
查询也可以接受 must
、 must_not
和 should
参数下的多个查询语句。比如:
GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": { "match": { "title": "quick" }},
"must_not": { "match": { "title": "lazy" }},
"should": [
{ "match": { "title": "brown" }},
{ "match": { "title": "dog" }}
]
}
}
}
以上的查询结果返回 title
字段包含词项 quick
但不包含 lazy
的任意文档。目前为止,这与 bool
过滤器的工作方式非常相似。
区别就在于两个 should
语句,也就是说:一个文档不必包含 brown
或 dog
这两个词项,但如果一旦包含,我们就认为它们 更相关 :
{
"hits": [
{
"_id": "3",
"_score": 0.70134366,
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "1",
"_score": 0.3312608,
"_source": {
"title": "The quick brown fox"
}
}
]
}
文档 3 会比文档 1 有更高评分是因为它同时包含
brown
和dog
。
评分计算
bool
查询会为每个文档计算相关度评分 _score
, 再将所有匹配的 must
和 should
语句的分数 _score
求和,最后除以 must
和 should
语句的总数。
must_not
语句不会影响评分; 它的作用只是将不相关的文档排除。
控制精度
所有 must
语句必须匹配,所有 must_not
语句都必须不匹配,但有多少 should
语句应该匹配呢? 默认情况下,没有 should
语句是必须匹配的,只有一个例外:那就是当没有 must
语句的时候,至少有一个 should
语句必须匹配。
就像我们能控制 match
查询的精度 一样,我们可以通过 minimum_should_match
参数控制需要匹配的 should
语句的数量, 它既可以是一个绝对的数字,又可以是个百分比:
GET /my_index/my_type/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "brown" }},
{ "match": { "title": "fox" }},
{ "match": { "title": "dog" }}
],
"minimum_should_match": 2
}
}
}
这个查询结果会将所有满足以下条件的文档返回:
title
字段包含"brown" AND "fox"
、"brown" AND "dog"
或"fox" AND "dog"
。如果有文档包含所有三个条件,它会比只包含两个的文档更相关。
如何使用布尔匹配
目前为止,可能已经意识到多词 match
查询只是简单地将生成的 term
查询包裹 在一个 bool
查询中。如果使用默认的 or
操作符,每个 term
查询都被当作 should
语句,这样就要求必须至少匹配一条语句。以下两个查询是等价的:
{
"match": { "title": "brown fox"}
}
{
"bool": {
"should": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }}
]
}
}
如果使用 and
操作符,所有的 term
查询都被当作 must
语句,所以 所有(all) 语句都必须匹配。以下两个查询是等价的:
{
"match": {
"title": {
"query": "brown fox",
"operator": "and"
}
}
}
{
"bool": {
"must": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }}
]
}
}
如果指定参数 minimum_should_match
,它可以通过 bool
查询直接传递,使以下两个查询等价:
{
"match": {
"title": {
"query": "quick brown fox",
"minimum_should_match": "75%"
}
}
}
{
"bool": {
"should": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }},
{ "term": { "title": "quick" }}
],
"minimum_should_match": 2
}
}
因为只有三条语句,
match
查询的参数minimum_should_match
值 75% 会被截断成2
。即三条should
语句中至少有两条必须匹配。
当然,我们通常将这些查询用 match
查询来表示,但是如果了解 match
内部的工作原理,我们就能根据自己的需要来控制查询过程。有些时候单个 match
查询无法满足需求,比如为某些查询条件分配更高的权重。我们会在下一小节中看到这个例子。
ElasticSearch中query should与同于filter should
bool query should:一个文档不必包含 should 中的词项,但如果一旦包含,我们就认为它们 更相关 ;
bool filter should:返回的命中文档至少须匹配其中一个过滤器的条件。
bool query should 中的 minimum_should_match:1 使用效果,相当于bool filter中should;且只能紧跟bool query中的should后面使用,其它位置会报异常。
查询语句提升权重
当然 bool
查询不仅限于组合简单的单个词 match
查询, 它可以组合任意其他的查询,以及其他 bool
查询。 普遍的用法是通过汇总多个独立查询的分数,从而达到为每个文档微调其相关度评分 _score
的目的。
假设想要查询关于 “full-text search(全文搜索)” 的文档, 但我们希望为提及 “Elasticsearch” 或 “Lucene” 的文档给予更高的 权重 ,这里 更高权重 是指如果文档中出现 “Elasticsearch” 或 “Lucene” ,它们会比没有的出现这些词的文档获得更高的相关度评分 _score
,也就是说,它们会出现在结果集的更上面。
一个简单的 bool
查询 允许我们写出如下这种非常复杂的逻辑:
GET /_search
{
"query": {
"bool": {
"must": {
"match": {
"content": {
"query": "full text search",
"operator": "and"
}
}
},
"should": [
{ "match": { "content": "Elasticsearch" }},
{ "match": { "content": "Lucene" }}
]
}
}
}
content 字段必须包含 full 、 text 和 search 所有三个词。
如果 content 字段也包含 Elasticsearch 或 Lucene ,文档会获得更高的评分 _score 。
should
语句匹配得越多表示文档的相关度越高。目前为止还挺好。
但是如果我们想让包含 Lucene
的有更高的权重,并且包含 Elasticsearch
的语句比 Lucene
的权重更高,该如何处理?
我们可以通过指定 boost
来控制任何查询语句的相对的权重, boost
的默认值为 1
,大于 1
会提升一个语句的相对权重。所以下面重写之前的查询:
GET /_search
{
"query": {
"bool": {
"must": {
"match": {
"content": {
"query": "full text search",
"operator": "and"
}
}
},
"should": [
{ "match": {
"content": {
"query": "Elasticsearch",
"boost": 3
}
}},
{ "match": {
"content": {
"query": "Lucene",
"boost": 2
}
}}
]
}
}
}
这些语句使用默认的 boost 值 1 。
这条语句更为重要,因为它有最高的 boost 值。
这条语句比使用默认值的更重要,但它的重要性不及 Elasticsearch 语句。
boost
参数被用来提升一个语句的相对权重(boost
值大于1
)或降低相对权重(boost
值处于0
到1
之间),但是这种提升或降低并不是线性的,换句话说,如果一个boost
值为2
,并不能获得两倍的评分_score
。相反,新的评分
_score
会在应用权重提升之后被 归一化 ,每种类型的查询都有自己的归一算法,细节超出了本书的范围,所以不作介绍。简单的说,更高的boost
值为我们带来更高的评分_score
。如果不基于 TF/IDF 要实现自己的评分模型,我们就需要对权重提升的过程能有更多控制,可以使用
function_score
查询操纵一个文档的权重提升方式而跳过归一化这一步骤。
更多的组合查询方式会在下章多字段搜索中介绍,但在此之前,让我们先看另外一个重要的查询特性:文本分析(text analysis)。
控制分析
查询只能查找倒排索引表中真实存在的项, 所以保证文档在索引时与查询字符串在搜索时应用相同的分析过程非常重要,这样查询的项才能够匹配倒排索引中的项。
尽管是在说 文档 ,不过分析器可以由每个字段决定。 每个字段都可以有不同的分析器,既可以通过配置为字段指定分析器,也可以使用更高层的类型(type)、索引(index)或节点(node)的默认配置。在索引时,一个字段值是根据配置或默认分析器分析的。
例如为 my_index
新增一个字段:
PUT /my_index/_mAPPing/my_type
{
"my_type": {
"properties": {
"english_title": {
"type": "text",
"analyzer": "english"
}
}
}
}
现在我们就可以通过使用 analyze
API 来分析单词 Foxes
,进而比较 english_title
字段和 title
字段在索引时的分析结果:
GET/my_index/_analyze{
"field": "my_type.title",
"text": "Foxes",
"analyzer": "standard "
}
GET/my_index/_analyze{
"field": "my_type.english_title",
"text": "Foxes",
"analyzer": "english"
}
字段 title ,使用默认的 standard 标准分析器,返回词项 foxes
字段 english_title ,使用 english 英语分析器,返回词项 fox
这意味着,如果使用底层 term
查询精确项 fox
时, english_title
字段会匹配但 title
字段不会。
如同 match
查询这样的高层查询知道字段映射的关系,能为每个被查询的字段应用正确的分析器。 可以使用 validate-query
API 查看这个行为:
GET /my_index/my_type/_validate/query?explain
{
"query": {
"bool": {
"should": [
{ "match": { "title": "Foxes"}},
{ "match": { "english_title": "Foxes"}}
]
}
}
}
返回语句的 explanation
结果:
(title:foxes english_title:fox)
match
查询为每个字段使用合适的分析器,以保证它在寻找每个项时都为该字段使用正确的格式。
默认分析器
虽然我们可以在字段层级指定分析器, 但是如果该层级没有指定任何的分析器,那么我们如何能确定这个字段使用的是哪个分析器呢?
分析器可以从三个层面进行定义:按字段(per-field)、按索引(per-index)或全局缺省(global default)。Elasticsearch 会按照以下顺序依次处理,直到它找到能够使用的分析器。索引时的顺序如下:
- 字段映射里定义的
analyzer
,否则 - 索引设置中名为
default
的分析器,默认为 standard
标准分析器
在搜索时,顺序有些许不同:
- 查询自己定义的
analyzer
,否则 - 字段映射里定义的
analyzer
,否则 - 索引设置中名为
default
的分析器,默认为 standard
标准分析器
有时,在索引时和搜索时使用不同的分析器是合理的。 我们可能要想为同义词建索引(例如,所有 quick
出现的地方,同时也为 fast
、 rapid
和 speedy
创建索引)。但在搜索时,我们不需要搜索所有的同义词,取而代之的是寻找用户输入的单词是否是 quick
、 fast
、 rapid
或 speedy
。
为了区分,Elasticsearch 也支持一个可选的 search_analyzer
映射,它仅会应用于搜索时( analyzer
还用于索引时)。还有一个等价的 default_search
映射,用以指定索引层的默认配置。
如果考虑到这些额外参数,一个搜索时的 完整 顺序会是下面这样:
- 查询自己定义的
analyzer
,否则 - 字段映射里定义的
search_analyzer
,否则 - 字段映射里定义的
analyzer
,否则 - 索引设置中名为
default_search
的分析器,默认为 - 索引设置中名为
default
的分析器,默认为 standard
标准分析器
分析器配置实践
就可以配置分析器地方的数量而言是十分惊人的, 但是实际非常简单。
保持简单
多数情况下,会提前知道文档会包括哪些字段。最简单的途径就是在创建索引或者增加类型映射时,为每个全文字段设置分析器。这种方式尽管有点麻烦,但是它让我们可以清楚的看到每个字段每个分析器是如何设置的。
通常,多数字符串字段都是 not_analyzed
精确值字段,比如标签(tag)或枚举(enum),而且更多的全文字段会使用默认的 standard
分析器或 english
或其他某种语言的分析器。这样只需要为少数一两个字段指定自定义分析:或许标题 title
字段需要以支持 输入即查找(find-as-you-type) 的方式进行索引。
可以在索引级别设置中,为绝大部分的字段设置你想指定的 default
默认分析器。然后在字段级别设置中,对某一两个字段配置需要指定的分析器。
对于和时间相关的日志数据,通常的做法是每天自行创建索引,由于这种方式不是从头创建的索引,仍然可以用 索引模板(Index Template) 为新建的索引指定配置和映射。
被破坏的相关度!
在讨论更复杂的 多字段搜索 之前,让我们先快速解释一下为什么只在主分片上 创建测试索引 。
用户会时不时的抱怨无法按相关度排序并提供简短的重现步骤: 用户索引了一些文档,运行一个简单的查询,然后发现明显低相关度的结果出现在高相关度结果之上。
为了理解为什么会这样,可以设想,我们在两个主分片上创建了索引和总共 10 个文档,其中 6 个文档有单词 foo
。可能是分片 1 有其中 3 个 foo
文档,而分片 2 有其中另外 3 个文档,换句话说,所有文档是均匀分布存储的。
在 什么是相关度?中,我们描述了 Elasticsearch 默认使用的相似度算法,这个算法叫做 词频/逆向文档频率 或 TF/IDF 。词频是计算某个词在当前被查询文档里某个字段中出现的频率,出现的频率越高,文档越相关。 逆向文档频率 将 某个词在索引内所有文档出现的百分数 考虑在内,出现的频率越高,它的权重就越低。
但是由于性能原因, Elasticsearch 不会计算索引内所有文档的 IDF 。 相反,每个分片会根据 该分片 内的所有文档计算一个本地 IDF 。
因为文档是均匀分布存储的,两个分片的 IDF 是相同的。相反,设想如果有 5 个 foo
文档存于分片 1 ,而第 6 个文档存于分片 2 ,在这种场景下, foo
在一个分片里非常普通(所以不那么重要),但是在另一个分片里非常出现很少(所以会显得更重要)。这些 IDF 之间的差异会导致不正确的结果。
在实际应用中,这并不是一个问题,本地和全局的 IDF 的差异会随着索引里文档数的增多渐渐消失,在真实世界的数据量下,局部的 IDF 会被迅速均化,所以上述问题并不是相关度被破坏所导致的,而是由于数据太少。
为了测试,我们可以通过两种方式解决这个问题。第一种是只在主分片上创建索引,正如 match
查询 里介绍的那样,如果只有一个分片,那么本地的 IDF 就是 全局的 IDF。
第二个方式就是在搜索请求后添加 ?search_type=dfs_query_then_fetch
, dfs
是指 分布式频率搜索(Distributed Frequency Search) , 它告诉 Elasticsearch ,先分别获得每个分片本地的 IDF ,然后根据结果再计算整个索引的全局 IDF 。
不要在生产环境上使用
dfs_query_then_fetch
。完全没有必要。只要有足够的数据就能保证词频是均匀分布的。没有理由给每个查询额外加上 DFS 这步。
相关阅读
全文检索 一、 全文检索的引出 我们生活中的数据总体分为两种:结构化数据 和非结构化数据 。 结构化数据: 指具有固定格式或有
原文链接:点击打开 全文检索的基本原理 什么是全文检索? 我们生活中的数据总体分为两种:结构化数据和非结构化数据。 * 结构化数
题记:Elasticsearch研究有一段时间了,现特将Elasticsearch相关核心知识、原理从初学者认知、学习的角度,从以下9个方面进行详细梳理
Elasticsearch Aggregations 统计buckets中key的个数
统计访问超5000的ulr有多少个(sum的最大值会小于等于size,因此size的预估很重要)GET /logstash-2018.03.27/_search? { "size":