ElasticSearch 2 (14) - 深入搜索系列之全文搜索

时间:2023-03-08 16:25:10

ElasticSearch 2 (14) - 深入搜索系列之全文搜索

摘要

在看过结构化搜索之后,我们看看怎样在全文字段中查找相关度最高的文档。

全文搜索两个最重要的方面是:

  • 相关(relevance)

    相关是将查询到相关的文档结果进行排名的一种能力,这种相关度可以是根据TF/IDF、地理位置相似性(geolocation)、模糊相似,或者其他的一些算法得出。

  • 分析(analysis)

    将一个文本块转换为唯一的、规范化的token的过程,目的是为了(a)创建反向索引以及(b)查询反向索引。

当我们提到相关与分析的时候,我们已经身处查询之中,而不是过滤。

版本

elasticsearch版本: elasticsearch-2.x

内容

基于术语的与基于全文的(Term-Based versue Full-Text)

所有查询都会或多或少的执行相关性计算,但不是所有查询都有分析阶段,与特殊查询 boolfunction_score 不同,我们不会操作文本,而是将文本查询拆分到两部分:

基于术语的查询(Term-based queries)

termfuzzy 这样低层次的查询不需要分析阶段。它们对单个 term 进行操作。一个 term 查询 一个术语(Foo)在反向索引中查找准确的术语,并且用 TF/IDF 算法为每个匹配的文档计算相关度 _score

需要记住 term 查询只对反向索引的术语进行精确匹配,它不会对多样性进行处理(即,同时匹配foo 或 FOO)。这里,无须考虑术语是如何进入索引的。如果打算将索引 ["Foo", "Bar"] 索引,无论是 not_analyzed 还是 Foo Bar 这样带有空格(whitespace)的字段,结果都是在反向索引中有 Foo 和 Bar 两个术语。

基于全文的查询(Full-text queries)

matchquery_string 这样的高层次查询,它知道一个字段的映射:

  • 如果我们用它来查询 时间(date)整数(integer),他们会将查询字符串用分别当作 时间整数
  • 如果查询一个准确的(未分析过的 not_analyzed)字符串字段,它会将整个查询字符串当成一个术语。
  • 但是如果要查询一个全文字段(分析过的 analyzed),它会讲查询字符串传入到一个合适的分析器,然后生成一个供查询的术语列表。

一旦查询组成了一个术语列表,它会对每个术语逐一执行低层次的查询,然后将结果合并,为每个文档生成一个最终的相关性分数。

我们很少直接使用基于术语的搜索,通常情况下都是对全文进行查询,这样只需要执行一个高层次的全文查询(high-level full-text queries),进而在这个查询内部以基于术语的查询结束。

注意:

当我们想要准确查询一个未分析过(not_analyzed)的字段之前,需要仔细想想,我们到底是想要一个查询还是一个过滤。

单术语查询通常可以用是非问题表示,所以更适合用过滤来表达,而且这样子可以有效利用过滤的缓存:

GET /_search
{
"query": {
"filtered": {
"filter": {
"term": { "gender": "female" }
}
}
}
}

匹配查询(The match Query)

匹配查询又称为 go-to 查询。当我们想要查询某个字段时需要使用的首个查询。它是一个高层次的全文查询,这表示它既能处理全文字段,也能处理精确字段。

match 最主要的应用场景就是进行全文搜索,我们以下面一个简单的例子来说明:

索引某些数据(Index Some Data)

首先我们用bulk API创建一些新的文档和索引:

DELETE /my_index #1

PUT /my_index
{ "settings": { "number_of_shards": 1 }} #2 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" }
  • #1 删除已有的索引
  • #2 稍后,我们会在Relevance Is Broken中解释只为这个索引分配在一个主分片(primary shard)中。

单个词查询

我们的第一个例子要解释使用 match 在全文字段中使用单个词进行查找:

GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "QUICK!"
}
}
}

ElasticSearch执行上面这个 match 查询的步骤是:

  1. 检查字段类型(Check the field type)

    标题 title 字段是一个全文(analyzed) string 类型的字段,这意味着查询字符串本身也需要被分析(analyzed)。

  2. 分析查询字符串(Analyze the query string)

    将查询的字符串 QUICK! 传入标准的分析器中(standard analyzer),输出的结果是 quick。因为对于单个术语,match 查询处理的是低层次的术语查询。

  3. 查找匹配文档(Find matching docs)

    term 查询在反向索引中查找 quick 然后获取一组包含有术语的文档,在这个例子中为文档:1,2和3。

  4. 为每个文档计算分数(Score each doc)

    term 查询为每个文档计算相关度分数,将术语频率(term frequency),即quick词在相关文档title中出现的频率,和反向文档频率(inverse document frequency),即quick在所有文档title中出现的频率,以及字段的长度,即字段越短相关度越高。

这个过程给我们的结果为:

"hits": [
{
"_id": "1",
"_score": 0.5, #1
"_source": {
"title": "The quick brown fox"
}
},
{
"_id": "3",
"_score": 0.44194174, #2
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "2",
"_score": 0.3125, #3
"_source": {
"title": "The quick brown fox jumps over the lazy dog"
}
}
]
  • #1 文档1最相关,因为title更短,即quick占有内容的很大一部分。
  • #2、3 文档3 比 文档2 更相关,因为在文档2中quick出现了2次。

多词查询(Multiword Queries)

如果我们一次只能搜索一个词,那么全文搜索会非常不灵活,幸运的是,ElasticSearch的 match 查询让多词查询非常简单:

GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "BROWN DOG!"
}
}
}

上面这个查询返回了所有四个文档:

{
"hits": [
{
"_id": "4",
"_score": 0.73185337, #1
"_source": {
"title": "Brown fox brown dog"
}
},
{
"_id": "2",
"_score": 0.47486103, #2
"_source": {
"title": "The quick brown fox jumps over the lazy dog"
}
},
{
"_id": "3",
"_score": 0.47486103, #3
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "1",
"_score": 0.11914785, #4
"_source": {
"title": "The quick brown fox"
}
}
]
}
  • #1 文档4是最为相关的,因为它包含词“brown”两次以及“dog”一次。
  • #2#3 文档2、3同时包含brown 和 dog 各一次,而且他们title的长度相同,所以有相同的分数。
  • #4 文档1 也能匹配,尽管它只有 brown 而没有 dog。

因为 match 查询查找两个词 ["brown", "dog"],内部实际上它执行两次 term 查询,然后将两次查询的结果合并为最终结果输出。为了这么做,它将两个term查询合并到一个bool查询中。

这告诉我们一个重要的结果,即任何文档只要是title里面包含了任意一个词,那么就会匹配上,被匹配的词越多,最终的相关性越高。

提高精度(Improving Precision)

用任意查询术语匹配任意文档会导致一个不相关的长尾。这是散弹搜索法。我们可能只想搜索包含所有术语的文档。换句话说,不是查找 brown 或 dog,我们想要找到所有 brown 和 dog同时匹配的文档。

match 查询接受操作符作为输入参数,默认情况下操作符是 or。我们可以修改它,让所有词都必须匹配:

GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "BROWN DOG!",
"operator": "and"
}
}
}
}

这个查询可以把文档1排除在外,因为它只包含一个术语。

控制精度(Controlling Precision)

allany 间的选择太过于非黑即白了,如果用户给了5个查询术语(term),想要查找只包含其中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 的设置非常灵活,官方文档里会有详细介绍

为了完整的了解 match 是如何处理多词查询的,我们需要看看ElasticSearch是如何用bool查询将多个查询组合到一起的。

组合查询(Combining Queries)

在组合过滤器中,我们讨论了如何使用 bool 过滤器将多个过滤器通过 andornot 逻辑组合在一起的,在查询中,bool 查询的与之非常相似,只有一个重要的区别。

过滤器做二元判断:文档是否应该出现在结果集中?但是,对于查询来说,更为精妙。它除了决定一个文档是否应该被包括在结果集中,还会计算文档的相关程度。

与filter对应,bool查询接受多个查询语句,mustmust_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有更高的分数。

分数计算(Score Calculation)

bool查询为每个文档计算相关分数 _score 然后,它将所有匹配的 mustshould 语句的分数求和,然后除以 mustshould 的总数。

must_not语句不影响分数;它的作用只是将不相关的文档排除。

控制精度(Controlling Precision)

所有的must语句必须匹配,所有must_not语句都必须不能匹配,但是有多少 should 语句应该匹配呢?默认情况下,没有 should 语句是需要匹配的,只有一个例外:当没有 must 语句的时候,至少有一个should语句需要匹配。

就像我们能控制 匹配查询的精度 一样,我们可以通过 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
}
}
}

这个查询结果会将所有满足一下条件的文档返回:标题包括 "brown" AND "fox""brown" AND "dog""fox" AND "dog"。如果有文档包含他们三个,它比只包含两个的文档更为相关。

如何使用bool匹配(How match Uses bool)

目前为止,可能已经知道如何对多个词进行查询,我们需要做的只是要把多个语句放入bool查询中,因为默认的操作符是 or,每个 term 查询都会被当作 should 语句进行处理,所以至少有一个语句需要匹配,下面的两个查询是等价的:

{
"match": { "title": "brown fox"}
}

{
"bool": {
"should": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }}
]
}
}

如果使用 and 操作符,那么下面两个语句也是等价的:

{
"match": {
"title": {
"query": "brown fox",
"operator": "and"
}
}
}

{
"bool": {
"must": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }}
]
}
}

如果按照下面这样给定参数 minimum_should_match,那么下面两个查询也是等价的:

{
"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 查询来表示,但是如果了解match内部的工作原理,我们就能对查询过程按照我们的需要进行控制,有些时候单个match查询无法满足需求,比如我们要为一些查询条件分配更多的权重。在下一部分中,我们会介绍这个例子。

使用查询语句(Boosting Query Clause)

当然 bool 查询不仅限于组合简单的单个词的 match 查询,它可以组合任意其他的查询,甚至其他 bool 查询。通常的使用方法是为每个不同查询的文档结果优化相关性分数计算,然后将不同的分数汇总。

想象我们想要查询关于“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" }}
]
}
}
}

should 匹配的更多相关性越高。

但是如果我们想为“ElasticSearch”和“Lucene”分配不同的权重,即“ElasticSearch”比“Lucene”更相关,该怎么办?

我们可以通过 boost 来给出相对的权重,默认情况下,boost的值为1,大于1会增加一个语句的权重,重写上面的查询:

GET /_search
{
"query": {
"bool": {
"must": {
"match": { #1
"content": {
"query": "full text search",
"operator": "and"
}
}
},
"should": [
{ "match": {
"content": {
"query": "Elasticsearch",
"boost": 3 #2
}
}},
{ "match": {
"content": {
"query": "Lucene",
"boost": 2 #3
}
}}
]
}
}
}
  • #1 这些语句使用默认的boost值1。
  • #2 这句更重要,因为它有更高的boost值。
  • #3 这句比默认语句更重要,但是不及ElasticSearch语句。

注意:

boost参数是用来增加一个语句的相对权重的(boost值大于1)或降低相对权重(boost值处于0到1之间),但是这种增加和降低并不是线性的,换句话说,如果一个boost值为2,并不代表着最终的分数 _score 会是原来值的两倍。

相反,新的分数_score会在使用boost之后重新规范化,每种类型的查询都有自己的规范算法,详细的情况不在此介绍,简单的说,一个更高的boost值会有一个更高的分数_score

更多的组合查询(combining queries)会在多字段查询(multifield search)中介绍,在此之前,我们需要介绍另一个重要的查询特性:文本分析(text analysis)。

控制分析(Controlling Analysis)

查询只能查找反向索引表中存在的术语,所以要保证文档在索引时与查询字符串在搜索时使用的是同一分析过程。这样才能使查询的术语与反向索引中的术语能够匹配。

尽管我们说文档的分析器(analyzer)是根据字段不同而不同的。每个字段都可以有不同的分析器,可以为某以字段(Field)、类型(Type)、索引(Index)、或节点(Node)指定具体的分析器。在索引时,一个字段值是根据配置或默认的分析器分析的。

下面这个例子是为my_index加一个新的字段:

PUT /my_index/_mapping/my_type
{
"my_type": {
"properties": {
"english_title": {
"type": "string",
"analyzer": "english"
}
}
}
}

现在我们就可以比较 english_title 字段和 title 字段在索引时分析的结果:

GET /my_index/_analyze?field=my_type.title #1
Foxes GET /my_index/_analyze?field=my_type.english_title #2
Foxes
  • #1 字段 title,使用默认标准的分析器,返回 foxes。
  • #2 字段 english_title,使用 english 分析器,返回 fox。

这说明,当我们使用低层次术语查询fox时,english_title字段会匹配,但是 title 字段不会。

高层次的查询例如 match 查询知道字段映射的关系,可以使用正确的API进行查询:

GET /my_index/my_type/_validate/query?explain
{
"query": {
"bool": {
"should": [
{ "match": { "title": "Foxes"}},
{ "match": { "english_title": "Foxes"}}
]
}
}
}

返回结果:

(title:foxes english_title:fox)

match查询为每个字段使用合适的分析器,以保证它在查看每个术语时都为该字段使用了正确的格式。

默认分析器(Default Analyzers)

当我们在字段级别指定分析器时,如果该级别没有指定任何的分析器,我们是如何确定这个字段使用的是哪个分析器呢?

分析器可以在不同级别进行指定。

在索引时,ElasticSearch会按照以下顺序依次处理,直到它找到能用的分析器

  • analyzer 在字段映射中定义,否则
  • analyzer 在文档的 _analyzer 中指定,否则
  • type 使用的默认 analyzer,否则
  • 索引设置中名为 defaultanalyzer,否则
  • 节点设置中名为 defaultanalyzer,否则
  • 标准(standard)的 analyzer

在搜索时,顺序有些许不同:

  • 定义在查询里的 analyzer,否则
  • 定义在字段映射里的 analyzer,否则
  • type 的默认 analyzer,否则
  • 索引设置中名为 defaultanalyzer,否则
  • 节点设置中名为 defaultanalyzer,否则
  • 标准(standard)的 analyzer

注意:

多语言需要特殊处理Dealing with Human Language

有时,在索引时和搜索时使用不同的分析器是合理的。比如,在索引时我们希望索引到同义词(在quick出现的地方,我们希望同时索引fast,rapid和speedy)。但在搜索时,我们不需要搜索所有的同义词。我们只需要关注用户输入的单词,无论是quick,fast,rapid,还是speedy,输入什么就是什么。

为了区分,ElasticSearch支持参数 index_analyzersearch_analyzer,以及 default_indexdefault_search的分析器。

如果考虑到这些参数,一个完整的顺序是,

在索引时:

  • 在字段映射中定义的 index_analyzer,否则
  • 在字段映射中定义的 analyzer,否则
  • 在文档的 _analyzer 中指定的 analyzer,否则
  • 在type中指定的默认 index_analyzer,否则
  • 在type中指定的默认 analyzer,否则
  • 在index设置中指定的名为 default_indexanalyzer,否则
  • 在index设置中指定的名为 defaultanalyzer,否则
  • 在node中指定的名为 default_indexanalyzer,否则
  • 在node中指定的名为 defaultanalyzer,否则
  • 标准(standard)的 analyzer

在搜索时:

  • 在query中定义的 analyzer,否则
  • 在字段映射中定义的 search_analyzer,否则
  • 在字段映射中定义的 analyzer,否则
  • 在type中指定的默认 search_analyzer,否则
  • 在type中指定的默认 analyzer,否则
  • 在index设置中指定的名为 default_searchanalyzer,否则
  • 在index设置中指定的名为 defaultanalyzer,否则
  • 在node中指定的名为 default_searchanalyzer,否则
  • 在node中指定的名为 defaultanalyzer,否则
  • 标准(standard)的 analyzer

配置分析器实践(Configuring Analyzers in Practice)

就可以配置分析器地方的数量来看是惊人的,但是在实际中,确非常简单。

使用索引设置而非配置文件(Use index settings, not config files)

需要记住的第一件事情是,尽管我们最开始使用ElasticSearch的目的非常简单,或者只是为了搜集单个应用的日志,在很大程度上,我们有机会将许多不同的应用跑在同一个ElasticSearch集群上,每个索引都必须独立,并且独立配置,我们不想为某一种应用场景设置默认配置,而在另一个场景下重写它。

所以我们不推荐在node基本配置分析器,而且如果要在node级别配置分析器,需要我们修改每个节点的配置文件,然后重启每个节点,这将是维护的噩梦。只通过API维护与管理ElasticSearch的设置是我们推荐的。

保持简单(Keep it simple)

大多数情况下,我们会知道我们文档中将会包括哪些字段,最简单的方式就是在创建索引(Index)或者增加类型映射(Type Mapping)的时候为每个全文字段设置分析器。这种方式尽管有点麻烦,但是它让我们可以清楚的看到每个字段的每个分析器是如何设置的。

通常,大多数字符串字段都是准确值(not_analyzed),比如标签(tag)或枚举(enum),而且更多的全文字段会使用默认的分析器如:standard 或 english或其他某种语言。这样我们只需要为少数一两个字段指定自定义分析:可能标题title字段需要以支持 find-as-you-type 的方式进行索引。

我们可以为几乎所有的全文字段指定默认的分析器,只为少数一两个字段进行特殊分析器的配置,如果在模型上,我们需要为不同类型type指定不同的默认分析器,只需要在type级别配置即可。

注意:

对于和时间相关的日志数据,它的索引是每天创建的,由于这种方式不是由我们自己从头创建索引的,我们仍然可以用索引模板(index templates)为新建的索引指定配置和映射。

被破坏的相关度(Relevance is Broken)

在我们讨论更复杂的多字段查询之前,这里先解释一下为什么我们将测试数据只创建在一个主分片上(primary shard)。

用户会时不时的抱怨遇到这样的问题:用户索引了一些文档,运行了一个简单的查询,然后明显发现一些低相关性的结果出现在高相关性结果之上。

为了理解为什么会这样,我们可以想象一下,我们在两个主分片上创建了索引,总共10个文档,其中6个文档有单词foo,可能是shard 1有其中3个文档,而shard 2有其中另外3个文档,换句话说,所有文档是均匀分布存储的。

在何为相关性中,我们描述了ElasticSearch默认的相似度算法,这个算法叫做 TF/IDF (term frequency/inverse document frequency)。详细参照之前提到的内容。

但是由于性能原因,ElasticSearch在计算IDF时,不会计算所有的文档。相反,每个分片会根据该分片的所有文档,计算一个本地的IDF。

因为我们的文档是均匀的分布存储的,每个shard的IDF是相同的。如果有5个文档存在于shard 1,而第6个文档存在于shard 2,在这种场景下,foo在一个shard里非常普通(所以不那么重要),但是在另个shard里非常少(所以会显得更重要)。这样局部IDF的差异会导致不正确的结果。

在实际中,这不是一个问题,本地和全局的IDF的差异随着索引里文档数的增多会渐渐消失,在真实世界的数据量下,局部的IDF会迅速被均化,所以这里的问题并不是相关性被破坏了,而是数据量太小了。

为了测试,我们可以通过两种方式解决这个问题。第一种是只在主分片上创建索引,就如我们例子里做的那样,如果只有一个shard,那么本地的IDF就是全局的IDF。

第二个方式就是在搜索请求后添加 ?search_type=dfs_query_then_fetchdfs 是指 分布式频率搜索(Distributed Frequency Search),它告诉ElasticSearch,先分别获得每个shard本地的IDF,然后根据结果再计算全局的IDF。

注意:

不要在产品上使用第二种方式,我们完全无须如此。

参考

elastic.co: Structured Search