ElasticSearch权威指南:深入搜索(中)

2021-10-27 09:20:29 浏览数 (1)

三、 多字段搜索

查询很少是简单一句话的 match 匹配查询。通常我们需要用相同或不同的字符串查询一个或多个字段,也就是说,需要对多个查询语句以及它们相关度评分进行合理的合并。

有时候或许我们正查找作者 Leo Tolstoy 写的一本名为 _War and Peace_(战争与和平)的书。或许我们正用 “minimum should match” (最少应该匹配)的方式在文档中对标题或页面内容进行搜索,或许我们正在搜索所有名字为 John Smith 的用户。

在本章,我们会介绍构造多语句搜索的工具及在特定场景下应该采用的解决方案。

1.多字符串查询

最简单的多字段查询可以将搜索项映射到具体的字段。 如果我们知道 War and Peace 是标题,Leo Tolstoy 是作者,很容易就能把两个条件用 match 语句表示, 并将它们用 bool 查询 组合起来:

代码语言:javascript复制
GET /_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "title":  "War and Peace" }},
        { "match": { "author": "Leo Tolstoy"   }}
      ]
    }
  }
}

bool 查询采取 more-matches-is-better 匹配越多越好的方式,所以每条 match 语句的评分结果会被加在一起,从而为每个文档提供最终的分数 _score 。能与两条语句同时匹配的文档比只与一条语句匹配的文档得分要高。

当然,并不是只能使用 match 语句:可以用 bool 查询来包裹组合任意其他类型的查询, 甚至包括其他的 bool 查询。我们可以在上面的示例中添加一条语句来指定译者版本的偏好:

代码语言:javascript复制
GET /_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "title":  "War and Peace" }},
        { "match": { "author": "Leo Tolstoy"   }},
        { "bool":  {
          "should": [
            { "match": { "translator": "Constance Garnett" }},
            { "match": { "translator": "Louise Maude"      }}
          ]
        }}
      ]
    }
  }
}

为什么将译者条件语句放入另一个独立的 bool 查询中呢?所有的四个 match 查询都是 should 语句,所以为什么不将 translator 语句与其他如 title 、 author 这样的语句放在同一层呢?

答案在于评分的计算方式。 bool 查询运行每个 match 查询,再把评分加在一起,然后将结果与所有匹配的语句数量相乘,最后除以所有的语句数量。处于同一层的每条语句具有相同的权重。在前面这个例子中,包含 translator 语句的 bool 查询,只占总评分的三分之一。如果将 translator 语句与 title 和 author 两条语句放入同一层,那么 title 和 author 语句只贡献四分之一评分。

1. 语句的优先级

前例中每条语句贡献三分之一评分的这种方式可能并不是我们想要的, 我们可能对 title 和 author 两条语句更感兴趣,这样就需要调整查询,使 title 和 author 语句相对来说更重要。

在武器库中,最容易使用的就是 boost 参数。为了提升 titleauthor 字段的权重, 为它们分配的 boost 值大于 1

代码语言:javascript复制
GET /_search
{
  "query": {
    "bool": {
      "should": [
        { "match": {   #title 和 author 语句的 boost 值为 2 
            "title":  {
              "query": "War and Peace",
              "boost": 2
        }}},
        { "match": {   #title 和 author 语句的 boost 值为 2 
            "author":  {
              "query": "Leo Tolstoy",
              "boost": 2
        }}},
        { "bool":  {   #嵌套 bool 语句默认的 boost 值为 1 。 
            "should": [
              { "match": { "translator": "Constance Garnett" }},
              { "match": { "translator": "Louise Maude"      }}
            ]
        }}
      ]
    }
  }
}

要获取 boost 参数 “最佳” 值,较为简单的方式就是不断试错:设定 boost 值,运行测试查询,如此反复。 boost 值比较合理的区间处于 110 之间,当然也有可能是 15 。如果为 boost 指定比这更高的值,将不会对最终的评分结果产生更大影响,因为评分是被 归一化的(normalized)

2.单字符串查询

bool 查询是多语句查询的主干。 它的适用场景很多,特别是当需要将不同查询字符串映射到不同字段的时候。问题在于,目前有些用户期望将所有的搜索项堆积到单个字段中,并期望应用程序能为他们提供正确的结果。有意思的是多字段搜索的表单通常被称为 高级查询 (Advanced Search) —— 只是因为它对用户而言是高级的,而多字段搜索的实现却非常简单。

对于多词(multiword)、多字段(multifield)查询来说,不存在简单的 万能 方案。为了获得最好结果,需要 了解我们的数据 ,并了解如何使用合适的工具。

1. 了解我们的数据

当用户输入了单个字符串查询的时候,通常会遇到以下三种情形:

  • 最佳字段:当搜索词语具体概念的时候,比如 “brown fox” ,词组比各自独立的单词更有意义。像 titlebody 这样的字段,尽管它们之间是相关的,但同时又彼此相互竞争。文档在 相同字段 中包含的词越多越好,评分也来自于 最匹配字段
  • 多数字段:为了对相关度进行微调,常用的一个技术就是将相同的数据索引到不同的字段,它们各自具有独立的分析链。

主字段可能包括它们的词源、同义词以及 变音词 或口音词,被用来匹配尽可能多的文档。相同的文本被索引到其他字段,以提供更精确的匹配。一个字段可以包括未经词干提取过的原词,另一个字段包括其他词源、口音,还有一个字段可以提供 词语相似性 信息的瓦片词(shingles)。其他字段是作为匹配每个文档时提高相关度评分的 信号匹配字段越多 则越好。

  • 混合字段:对于某些实体,我们需要在多个字段中确定其信息,单个字段都只能作为整体的一部分:
代码语言:javascript复制
Person: first_name 和 last_name (人:名和姓)
Book: title 、 author 和 description (书:标题、作者、描述)
Address: street 、 city 、 country 和 postcode (地址:街道、市、国家和邮政编码)

在这种情况下,我们希望在 任何 这些列出的字段中找到尽可能多的词,这有如在一个大字段中进行搜索,这个大字段包括了所有列出的字段。

上述所有都是多词、多字段查询,但每个具体查询都要求使用不同策略。本章后面的部分,我们会依次介绍每个策略。

3.最佳字段

假设有个网站允许用户搜索博客的内容, 以下面两篇博客内容文档为例:

代码语言:javascript复制
PUT /my_index/my_type/1
{
    "title": "Quick brown rabbits",
    "body":  "Brown rabbits are commonly seen."
}

PUT /my_index/my_type/2
{
    "title": "Keeping pets healthy",
    "body":  "My quick brown fox eats rabbits on a regular basis."
}

用户输入词组 “Brown fox” 然后点击搜索按钮。事先,我们并不知道用户的搜索项是会在 title 还是在 body 字段中被找到,但是,用户很有可能是想搜索相关的词组。用肉眼判断,文档 2 的匹配度更高,因为它同时包括要查找的两个词:

现在运行以下 bool 查询:

代码语言:javascript复制
{
    "query": {
        "bool": {
            "should": [
                { "match": { "title": "Brown fox" }},
                { "match": { "body":  "Brown fox" }}
            ]
        }
    }
}

但是我们发现查询的结果是文档 1 的评分更高:

代码语言:javascript复制
{
  "hits": [
     {
        "_id":      "1",
        "_score":   0.14809652,
        "_source": {
           "title": "Quick brown rabbits",
           "body":  "Brown rabbits are commonly seen."
        }
     },
     {
        "_id":      "2",
        "_score":   0.09256032,
        "_source": {
           "title": "Keeping pets healthy",
           "body":  "My quick brown fox eats rabbits on a regular basis."
        }
     }
  ]
}

为了理解导致这样的原因, 需要回想一下 bool 是如何计算评分的:

  1. 它会执行 should 语句中的两个查询。
  2. 加和两个查询的评分。
  3. 乘以匹配语句的总数。
  4. 除以所有语句总数(这里为:2)。

文档 1 的两个字段都包含 brown 这个词,所以两个 match 语句都能成功匹配并且有一个评分。文档 2 的 body 字段同时包含 brownfox 这两个词,但 title 字段没有包含任何词。这样, body 查询结果中的高分,加上 title 查询中的 0 分,然后乘以二分之一,就得到比文档 1 更低的整体评分。

在本例中,titlebody 字段是相互竞争的关系,所以就需要找到单个 最佳匹配 的字段。

如果不是简单将每个字段的评分结果加在一起,而是将 最佳匹配 字段的评分作为查询的整体评分,结果会怎样?这样返回的结果可能是: 同时 包含 brownfox 的单个字段比反复出现相同词语的多个不同字段有更高的相关度。

代码语言:javascript复制
算一下doc1的分数:

{ "match": { "title": "Brown fox" }},针对doc1,是有一个分数的
{ "match": { " body":  "Brown fox" }},针对doc1,也是有一个分数的

所以是两个分数加起来,比如说,1.1   1.2 = 2.3
matched query数量 = 2
总query数量 = 2
总分数:2.3 * 2 / 2 = 2.3

算一下doc2的分数:

{ "match": { "title": " Brown fox " }},针对doc2,是没有分数的
{ "match": { " body":  " Brown fox " }},针对doc2,是有一个分数的

所以说,只有一个query是有分数的,比如2.3
matched query数量 = 1
总query数量 = 2
总分数:2.3 * 1 / 2 = 1.15

结论:doc5的分数 = 1.15 < doc4的分数 = 2.3

如果我们不是合并来自每个字段的分值,而是使用最佳匹配字段的分值作为整个查询的整体分值呢?这就会让包含有我们寻找的两个单词的字段有更高的权重,而不是在不同的字段中重复出现的相同单词。

1. dis_max查询

best fields策略,就是说,搜索到的结果,应该是某一个field中匹配到了尽可能多的关键词,被排在前面;而不是尽可能多的field匹配到了少数的关键词,排在了前面。简单的说: dis_max语法,直接取多个query中,分数最高的那一个query的分数即可。

不使用 bool 查询,可以使用 dis_max 即分离 最大化查询(Disjunction Max Query) 。分离(Disjunction)的意思是 或(or) ,这与可以把结合(conjunction)理解成 与(and) 相对应。

分离最大化查询(Disjunction Max Query)指的是:将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回

笔记:评分只返回所有match中最大的一个

代码语言:javascript复制
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Brown fox" }},
                { "match": { "body":  "Brown fox" }}
            ]
        }
    }
}

得到我们想要的结果为:

代码语言:javascript复制
{
  "hits": [
     {
        "_id":      "2",
        "_score":   0.21509302,
        "_source": {
           "title": "Keeping pets healthy",
           "body":  "My quick brown fox eats rabbits on a regular basis."
        }
     },
     {
        "_id":      "1",
        "_score":   0.12713557,
        "_source": {
           "title": "Quick brown rabbits",
           "body":  "Brown rabbits are commonly seen."
        }
     }
  ]
}
代码语言:javascript复制
{ "match": { "title": " Brown fox " }},针对doc1,是有一个分数的,1.1
{ "match": { " body":  " Brown fox " }},针对doc1,也是有一个分数的,1.2 

取最大分数,1.2

{ "match": { "title": " Brown fox " }},针对doc2,是没有分数的
{ "match": { " body":  " Brown fox " }},针对doc2,是有一个分数的,2.3

取最大分数,2.3

然后doc1的分数 = 1.2 < doc2的分数 = 2.3,所以doc2就可以排在更前面的地方,符合我们的需要
代码语言:javascript复制
GET /my_index/my_type/_search
{
   
   "query": {
     "dis_max": {
       "queries": [
           { "match": { "title": "Brown fox" }},
           { "match": { "body":  "Brown fox" }}
         ]
     }
   }
}
代码语言:javascript复制
Java代码:
SearchRequestBuilder srq =  client.prepareSearch("my_index").setTypes("my_type");  
srq.setSearchType(SearchType.DFS_QUERY_AND_FETCH);  
         
srq.setQuery(QueryBuilders.disMaxQuery().add(QueryBuilders.matchQuery("title", "Brown fox")).add(QueryBuilders.matchQuery("body", "Brown fox")));

4. 最佳字段查询调优

当用户搜索 “quick pets” 时会发生什么呢? 在前面的例子中,两个文档都包含词 quick ,但是只有文档 2 包含词 pets ,两个文档中都不具有同时包含 两个词相同字段

如下,一个简单的 dis_max 查询会采用单个最佳匹配字段, 而忽略其他的匹配:

代码语言:javascript复制
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Quick pets" }},
                { "match": { "body":  "Quick pets" }}
            ]
        }
    }
}
代码语言:javascript复制
{
  "hits": [
     {
        "_id": "1",
        "_score": 0.12713557,   #注意两个评分是完全相同的。 
        "_source": {
           "title": "Quick brown rabbits",
           "body": "Brown rabbits are commonly seen."
        }
     },
     {
        "_id": "2",
        "_score": 0.12713557, #注意两个评分是完全相同的。 
        "_source": {
           "title": "Keeping pets healthy",
           "body": "My quick brown fox eats rabbits on a regular basis."
        }
     }
   ]
}

我们可能期望同时匹配 titlebody 字段的文档比只与一个字段匹配的文档的相关度更高,但事实并非如此,因为 dis_max 查询只会简单地使用 单个 最佳匹配语句的评分 _score 作为整体评分。

1. tie_breaker参数

tie_breaker参数的意义,在于说,将其他query的分数,乘以tie_breaker,然后综合与最高分数的那个query的分数,综合在一起进行计算除了取最高分以外,还会考虑其他的query的分数  tie_breaker的值,在0~1之间,是个小数,就ok。

可以通过指定 tie_breaker 这个参数将其他匹配语句的评分也考虑其中:

代码语言:javascript复制
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Quick pets" }},
                { "match": { "body":  "Quick pets" }}
            ],
            "tie_breaker": 0.3
        }
    }
}
代码语言:javascript复制
Java代码:
srq.setQuery(QueryBuilders.disMaxQuery().add(QueryBuilders.matchQuery("title", "Quick pets")).add(QueryBuilders.matchQuery("body", "Quick pets")).tieBreaker(0.3f));

结果如下:

代码语言:javascript复制
{
  "hits": [
     {
        "_id": "2",
        "_score": 0.14757764,   #文档 2 的相关度比文档 1 略高。
        "_source": {
           "title": "Keeping pets healthy",
           "body": "My quick brown fox eats rabbits on a regular basis."
        }
     },
     {
        "_id": "1",
        "_score": 0.124275915,   #文档 2 的相关度比文档 1 略高。
        "_source": {
           "title": "Quick brown rabbits",
           "body": "Brown rabbits are commonly seen."
        }
     }
   ]
}

tie_breaker 参数提供了一种 dis_maxbool 之间的折中选择,它的评分方式如下:

  1. 获得最佳匹配语句的评分 _score
  2. 将其他匹配语句的评分结果与 tie_breaker 相乘。
  3. 对以上评分求和并规范化。

有了 tie_breaker ,会考虑所有匹配语句,但最佳匹配语句依然占最终结果里的很大一部分。

tie_breaker 可以是 01 之间的浮点数,其中 0 代表使用 dis_max 最佳匹配语句的普通逻辑, 1 表示所有匹配语句同等重要。最佳的精确值需要根据数据与查询调试得出,但是合理值应该与零接近(处于 0.1 - 0.4 之间),这样就不会颠覆 dis_max 最佳匹配性质的根本。

5.multi_match 查询

multi_match 查询为能在多个字段上反复执行相同查询提供了一种便捷方式。

multi_match多匹配查询的类型有多种,其中的三种恰巧与 了解我们的数据 中介绍的三个场景对应,即: best_fields、most_fields、cross_fields (最佳字段、多数字段、跨字段)。

默认情况下,查询的类型是 best_fields , 这表示它会为每个字段生成一个 match 查询,然后将它们组合到 dis_max 查询的内部,如下:

代码语言:javascript复制
{
  "dis_max": {
    "queries":  [
      {
        "match": {
          "title": {
            "query": "Quick brown fox",
            "minimum_should_match": "30%"
          }
        }
      },
      {
        "match": {
          "body": {
            "query": "Quick brown fox",
            "minimum_should_match": "30%"
          }
        }
      },
    ],
    "tie_breaker": 0.3
  }
}

上面这个查询用 multi_match 重写成更简洁的形式:

代码语言:javascript复制
{
    "multi_match": {
        "query":                "Quick brown fox",
        "type":                 "best_fields",   #best_fields 类型是默认值,可以不指定。
        "fields":               [ "title", "body" ],
        "tie_breaker":          0.3,
        "minimum_should_match": "30%"   #如 minimum_should_match 或 operator 这样的参数会被传递到生成的 match 查询中。 
    }
}

best-fields策略,主要是说将某一个field匹配尽可能多的关键词的doc优先返回

实战基于multi_match语法实现dis_max tie_breaker:

代码语言:javascript复制
GET /forum/article/_search
{
  "query": {
    "multi_match": {
        "query":                "java solution",
        "type":                 "best_fields", 
        "fields":               [ "title^2", "content" ],
        "tie_breaker":          0.3,
        "minimum_should_match": "50%" 
    }
  } 
}
 
GET /forum/article/_search
{
  "query": {
    "dis_max": {
      "queries":  [
        {
          "match": {
            "title": {
              "query": "java beginner",
              "minimum_should_match": "50%",
          "boost": 2
            }
          }
        },
        {
          "match": {
            "body": {
              "query": "java beginner",
              "minimum_should_match": "30%"
            }
          }
        }
      ],
      "tie_breaker": 0.3
    }
  } 
}

minimum_should_match,主要是用来去长尾long tail长尾,比如你搜索5个关键词,但是很多结果是只匹配1个关键词的,其实跟你想要的结果相差甚远,这些结果就是长尾。minimum_should_match,控制搜索结果的精准度,只有匹配一定数量的关键词的数据才能返回。

1. 查询字段模糊查询

字段名称可以用模糊匹配的方式给出:任何与模糊模式正则匹配的字段都会被包括在搜索条件中。例如可以使用以下方式同时匹配 book_titlechapter_titlesection_title (书名、章名、节名)这三个字段:

代码语言:javascript复制
{
    "multi_match": {
        "query":  "Quick brown fox",
        "fields": "*_title"
    }
}

2. 提升单个字段权重

可以使用 ^ 字符语法为单个字段提升权重,在字段名称的末尾添加 ^boost , 其中 boost 是一个浮点数:

代码语言:javascript复制
{
    "multi_match": {
        "query":  "Quick brown fox",
        "fields": [ "*_title", "chapter_title^2" ]   #chapter_title 这个字段的 boost 值为 2 ,而其他两个字段 book_title 和 section_title 字段的默认 boost 值为 1 。 
    }
}

6.多数字段

全文搜索被称作是 召回率(Recall)精确率(Precision) 的战场: 召回率 ——返回所有的相关文档; 精确率 ——不返回无关文档。目的是在结果的第一页中为用户呈现最为相关的文档。

为了提高召回率的效果,我们扩大搜索范围 ——不仅返回与用户搜索词精确匹配的文档,还会返回我们认为与查询相关的所有文档。如果一个用户搜索 “quick brown box” ,一个包含词语 fast foxes 的文档被认为是非常合理的返回结果。

如果包含词语 fast foxes 的文档是能找到的唯一相关文档,那么它会出现在结果列表的最上面,但是,如果有 100 个文档都出现了词语 quick brown fox ,那么这个包含词语 fast foxes 的文档当然会被认为是次相关的,它可能处于返回结果列表更下面的某个地方。当包含了很多潜在匹配之后,我们需要将最匹配的几个置于结果列表的顶部。

提高全文相关性精度的常用方式是为同一文本建立多种方式的索引, 每种方式都提供了一个不同的相关度信号 signal 。主字段会以尽可能多的形式的去匹配尽可能多的文档。举个例子,我们可以进行以下操作:

  • 使用词干提取来索引 jumpsjumpingjumped 样的词,将 jump 作为它们的词根形式。这样即使用户搜索 jumped ,也还是能找到包含 jumping 的匹配的文档。
  • 将同义词包括其中,如 jumpleaphop
  • 移除变音或口音词:如 éstaestáesta 都会以无变音形式 esta 来索引。

尽管如此,如果我们有两个文档,其中一个包含词 jumped ,另一个包含词 jumping ,用户很可能期望前者能排的更高,因为它正好与输入的搜索条件一致。

为了达到目的,我们可以将相同的文本索引到其他字段从而提供更为精确的匹配。一个字段可能是为词干未提取过的版本,另一个字段可能是变音过的原始词,第三个可能使用 shingles 提供 词语相似性 信息。这些附加的字段可以看成提高每个文档的相关度评分的信号 signals ,能匹配字段的越多越好。

一个文档如果与广度匹配的主字段相匹配,那么它会出现在结果列表中。如果文档同时又与 signal 信号字段匹配,那么它会获得额外加分,系统会提升它在结果列表中的位置。

我们会在本书稍后对同义词、词相似性、部分匹配以及其他潜在的信号进行讨论,但这里只使用词干已提取(stemmed)和未提取(unstemmed)的字段作为简单例子来说明这种技术。

1. 多字段映射

首先要做的事情就是对我们的字段索引两次: 一次使用词干模式以及一次非词干模式。为了做到这点,采用 multifields 来实现,已经在 multifields 有所介绍:

代码语言:javascript复制
DELETE /my_index

PUT /my_index
{
    "settings": { "number_of_shards": 1 },   #参考 被破坏的相关度. 
    "mappings": {
        "my_type": {
            "properties": {
                "title": { 
                    "type":     "string",
                    "analyzer": "english",  # title 字段使用 english 英语分析器来提取词干。 
                    "fields": {
                        "std":   { 
                            "type":     "string",
                            "analyzer": "standard"  #title.std 字段使用 standard 标准分析器,所以没有词干提取。 
                        }
                    }
                }
            }
        }
    }
}

接着索引一些文档:

代码语言:javascript复制
PUT /my_index/my_type/1
{ "title": "My rabbit jumps" }

PUT /my_index/my_type/2
{ "title": "Jumping jack rabbits" }

这里用一个简单 match 查询 title 标题字段是否包含 jumping rabbits (跳跃的兔子):

代码语言:javascript复制
GET /my_index/_search
{
   "query": {
        "match": {
            "title": "jumping rabbits"
        }
    }
}

因为有了 english 分析器,这个查询是在查找以 jumprabbit 这两个被提取词的文档。两个文档的 title 字段都同时包括这两个词,所以两个文档得到的评分也相同:

代码语言:javascript复制
{
  "hits": [
     {
        "_id": "1",
        "_score": 0.42039964,
        "_source": {
           "title": "My rabbit jumps"
        }
     },
     {
        "_id": "2",
        "_score": 0.42039964,
        "_source": {
           "title": "Jumping jack rabbits"
        }
     }
  ]
}

如果只是查询 title.std 字段,那么只有文档 2 是匹配的。尽管如此,如果同时查询两个字段,然后使用 bool 查询将评分结果 合并 ,那么两个文档都是匹配的( title 字段的作用),而且文档 2 的相关度评分更高( title.std 字段的作用):

代码语言:javascript复制
GET /my_index/_search
{
   "query": {
        "multi_match": {
            "query":  "jumping rabbits",
            "type":   "most_fields", #我们希望将所有匹配字段的评分合并起来,所以使用most_fields类型。这让multi_match查询用bool查询将两个字段语句包在里面,而不是使用dis_max查询。 
            "fields": [ "title", "title.std" ]
        }
    }
}
代码语言:javascript复制
{
  "hits": [
     {
        "_id": "2",
        "_score": 0.8226396,  #文档 2 现在的评分要比文档 1 高。 
        "_source": {
           "title": "Jumping jack rabbits"
        }
     },
     {
        "_id": "1",
        "_score": 0.10741998,   #文档 2 现在的评分要比文档 1 高。 
        "_source": {
           "title": "My rabbit jumps"
        }
     }
  ]
}

用广度匹配字段 title 包括尽可能多的文档——以提升召回率——同时又使用字段 title.std 作为信号将相关度更高的文档置于结果顶部。

每个字段对于最终评分的贡献可以通过自定义值boost 来控制。比如,使 title 字段更为重要,这样同时也降低了其他信号字段的作用:

代码语言:javascript复制
GET /my_index/_search
{
   "query": {
        "multi_match": {
            "query":       "jumping rabbits",
            "type":        "most_fields",
            "fields":      [ "title^10", "title.std" ]   #title 字段的 boost 的值为 10 使它比 title.std 更重要。
        }
    }
}

2. best_fields

是对多个field进行搜索,挑选某个field匹配度最高的那个分数,同时在多个query最高分相同的情况下,在一定程度上考虑其他query的分数。简单来说,你对多个field进行搜索,就想搜索到某一个field尽可能包含更多关键字的数据

  • 优点:通过best_fields策略,以及综合考虑其他field,还有minimum_should_match支持,可以尽可能精准地将匹配的结果推送到最前面
  • 缺点:除了那些精准匹配的结果,其他差不多大的结果,排序结果不是太均匀,没有什么区分度

 实际的例子:百度之类的搜索引擎,最匹配的到最前面,但是其他的就没什么区分度了

3. most_fields

综合多个field一起进行搜索,尽可能多地让所有field的query参与到总分数的计算中来,此时就会是个大杂烩,出现类似best_fields案例最开始的那个结果,结果不一定精准,某一个document的一个field包含更多的关键字,但是因为其他document有更多field匹配到了,所以排在了前面;所以需要建立类似sub_title.std这样的field,尽可能让某一个field精准匹配query string,贡献更高的分数,将更精准匹配的数据排到前面

  • 优点:将尽可能匹配更多field的结果推送到最前面,整个排序结果是比较均匀的
  • 缺点:可能那些精准匹配的结果,无法推送到最前面

实际的例子:wiki,明显的most_fields策略,搜索结果比较均匀,但是的确要翻好几页才能找到最匹配的结果

7.跨字段实体搜索

现在讨论一种普遍的搜索模式:跨字段实体搜索(cross-fields entity search)。 在如 personproductaddress (人、产品或地址)这样的实体中,需要使用多个字段来唯一标识它的信息。

person 实体可能是这样索引的:

代码语言:javascript复制
{
    "firstname":  "Peter",
    "lastname":   "Smith"
}

或地址:

代码语言:javascript复制
{
    "street":   "5 Poland Street",
    "city":     "London",
    "country":  "United Kingdom",
    "postcode": "W1V 3DG"
}

这与之前描述的 多字符串查询 很像,但这存在着巨大的区别。在 多字符串查询 中,我们为每个字段使用不同的字符串,在本例中,我们想使用 单个 字符串在多个字段中进行搜索。

我们的用户可能想搜索 “Peter Smith” 这个人,或 “Poland Street W1V” 这个地址,这些词出现在不同的字段中,所以如果使用 dis_maxbest_fields 查询去查找 单个 最佳匹配字段显然是个错误的方式。

1. 简单方式

依次查询每个字段并将每个字段的匹配评分结果相加,听起来真像是 bool 查询:

代码语言:javascript复制
{
  "query": {
    "bool": {
      "should": [
        { "match": { "street":    "Poland Street W1V" }},
        { "match": { "city":      "Poland Street W1V" }},
        { "match": { "country":   "Poland Street W1V" }},
        { "match": { "postcode":  "Poland Street W1V" }}
      ]
    }
  }
}

为每个字段重复查询字符串会使查询瞬间变得冗长,可以采用 multi_match 查询, 将 type 设置成 most_fields 然后告诉 Elasticsearch 合并所有匹配字段的评分:

代码语言:javascript复制
{
  "query": {
    "multi_match": {
      "query":       "Poland Street W1V",
      "type":        "most_fields",
      "fields":      [ "street", "city", "country", "postcode" ]
    }
  }
}

2. most_fields方式的问题

most_fields 这种方式搜索也存在某些问题,这些问题并不会马上显现:

  • 它是为多数字段匹配 任意 词设计的,而不是在 所有字段 中找到最匹配的。
  • 它不能使用 operatorminimum_should_match 参数来降低次相关结果造成的长尾效应。
  • 词频对于每个字段是不一样的,而且它们之间的相互影响会导致不好的排序结果。

8.字段中心式查询

以上三个源于 most_fields 的问题都因为它是 字段中心式(field-centric) 而不是 词中心式(term-centric) 的:当真正感兴趣的是匹配词的时候,它为我们查找的是最匹配的 字段best_fields 类型也是字段中心式的, 它也存在类似的问题。

首先查看这些问题存在的原因,再想如何解决它们。

1. 问题一:在多个字段中匹配多个值

回想一下 most_fields 查询是如何执行的:Elasticsearch 为每个字段生成独立的 match 查询,再用 bool 查询将他们包起来。

可以通过 validate-query API 查看:

代码语言:javascript复制
GET /_validate/query?explain
{
  "query": {
    "multi_match": {
      "query":   "Poland Street W1V",
      "type":    "most_fields",
      "fields":  [ "street", "city", "country", "postcode" ]
    }
  }
}

生成 explanation 解释:

代码语言:javascript复制
(street:poland   street:street   street:w1v)
(city:poland     city:street     city:w1v)
(country:poland  country:street  country:w1v)
(postcode:poland postcode:street postcode:w1v)

可以发现, 两个 字段都与 poland 匹配的文档要比一个字段同时匹配 polandstreet 文档的评分高。

2. 问题二:减掉长尾

在 匹配精度 中,我们讨论过使用 and 操作符或设置 minimum_should_match 参数来消除结果中几乎不相关的长尾,或许可以尝试以下方式:

代码语言:javascript复制
{
    "query": {
        "multi_match": {
            "query":       "Poland Street W1V",
            "type":        "most_fields",
            "operator":    "and",   #所有词必须呈现。 
            "fields":      [ "street", "city", "country", "postcode" ]
        }
    }
}

但是对于 best_fieldsmost_fields 这些参数会在 match 查询生成时被传入,这个查询的 explanation 解释如下:

代码语言:javascript复制
( street:poland    street:street    street:w1v)
( city:poland      city:street      city:w1v)
( country:poland   country:street   country:w1v)
( postcode:poland  postcode:street  postcode:w1v)

换句话说,使用 and 操作符要求所有词都必须存在于 相同字段 ,这显然是不对的!可能就不存在能与这个查询匹配的文档。

3. 问题三:词频

在 什么是相关 中,我们解释过每个词默认使用 TF/IDF 相似度算法计算相关度评分:

  • 词频:一个词在单个文档的某个字段中出现的频率越高,这个文档的相关度就越高。
  • 逆向文档频率:一个词在所有文档某个字段索引中出现的频率越高,这个词的相关度就越低。

当搜索多个字段时,TF/IDF 会带来某些令人意外的结果。

想想用字段 first_namelast_name 查询 “Peter Smith” 的例子, Peter 是个平常的名 Smith 也是平常的姓,这两者都具有较低的 IDF 值。但当索引中有另外一个人的名字是 “Smith Williams” 时, Smith 作为名来说很不平常,以致它有一个较高的 IDF 值!

下面这个简单的查询可能会在结果中将 “Smith Williams” 置于 “Peter Smith” 之上,尽管事实上是第二个人比第一个人更为匹配。

代码语言:javascript复制
{
    "query": {
        "multi_match": {
            "query":       "Peter Smith",
            "type":        "most_fields",
            "fields":      [ "*_name" ]
        }
    }
}

这里的问题是 smith 在名字段中具有高 IDF ,它会削弱 “Peter” 作为名和 “Smith” 作为姓时低 IDF 的所起作用。

4. 解决方案

存在这些问题仅仅是因为我们在处理着多个字段,如果将所有这些字段组合成单个字段,问题就会消失。可以为 person 文档添加 full_name 字段来解决这个问题:

代码语言:javascript复制
{
    "first_name":  "Peter",
    "last_name":   "Smith",
    "full_name":   "Peter Smith"
}

当查询 full_name 字段时:

  • 具有更多匹配词的文档会比只有一个重复匹配词的文档更重要。
  • minimum_should_matchoperator 参数会像期望那样工作。
  • 姓和名的逆向文档频率被合并,所以 Smith 到底是作为姓还是作为名出现,都会变得无关紧要。

这么做当然是可行的,但我们并不太喜欢存储冗余数据。取而代之的是 Elasticsearch 可以提供两个解决方案——一个在索引时,而另一个是在搜索时——随后会讨论它们。

9.自定义 _all 字段

在 all-field 字段中,我们解释过 _all 字段的索引方式是将所有其他字段的值作为一个大字符串索引的。 然而这么做并不十分灵活,为了灵活我们可以给人名添加一个自定义 _all 字段,再为地址添加另一个 _all 字段。

Elasticsearch 在字段映射中为我们提供 copy_to 参数来实现这个功能:

代码语言:javascript复制
PUT /my_index
{
    "mappings": {
        "person": {
            "properties": {
                "first_name": {
                    "type":     "string",
                    "copy_to":  "full_name"   #first_name 和 last_name 字段中的值会被复制到 full_name 字段。 
                },
                "last_name": {
                    "type":     "string",
                    "copy_to":  "full_name"   #first_name 和 last_name 字段中的值会被复制到 full_name 字段。 
                },
                "full_name": {
                    "type":     "string"
                }
            }
        }
    }
}

有了这个映射,我们可以用 first_name 来查询名,用 last_name 来查询姓,或者直接使用 full_name 查询整个姓名。

first_namelast_name 的映射并不影响 full_name 如何被索引, full_name 将两个字段的内容复制到本地,然后根据 full_name 的映射自行索引。

copy_to 设置对多字段无效。如果尝试这样配置映射,Elasticsearch 会抛异常。解释:多字段只是以不同方式简单索引“主”字段;它们没有自己的数据源。也就是说没有可供 copy_to 到另一字段的数据源。

只要对“主”字段 copy_to 就能轻而易举的达到相同的效果:

代码语言:javascript复制
PUT /my_index
{
    "mappings": {
        "person": {
            "properties": {
                "first_name": {
                    "type":     "string",
                    "copy_to":  "full_name",   #copy_to 是针对“主”字段,而不是多字段的
                    "fields": {
                        "raw": {
                            "type": "string",
                            "index": "not_analyzed"
                        }
                    }
                },
                "full_name": {
                    "type":     "string"
                }
            }
        }
    }
}

10.cross-fields 跨字段

自定义 _all 的方式是一个好的解决方案,只需在索引文档前为其设置好映射。 不过, Elasticsearch 还在搜索时提供了相应的解决方案:使用 cross_fields 类型进行 multi_match 查询。 cross_fields 使用词中心式(term-centric)的查询方式,这与 best_fieldsmost_fields 使用字段中心式(field-centric)的查询方式非常不同,它将所有字段当成一个大字段,并在 每个字段 中查找 每个词

为了说明字段中心式(field-centric)与词中心式(term-centric)这两种查询方式的不同, 先看看以下字段中心式的 most_fields 查询的 explanation 解释:

代码语言:javascript复制
GET /_validate/query?explain
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "most_fields",
            "operator":    "and",   #所有词都是必须的。 
            "fields":      [ "first_name", "last_name" ]
        }
    }
}

对于匹配的文档, petersmith 都必须同时出现在相同字段中,要么是 first_name 字段,要么 last_name 字段:

代码语言:javascript复制
( first_name:peter  first_name:smith)
( last_name:peter   last_name:smith)

词中心式 会使用以下逻辑:

代码语言:javascript复制
 (first_name:peter last_name:peter)
 (first_name:smith last_name:smith)

换句话说,词 petersmith 都必须出现,但是可以出现在任意字段中。

cross_fields 类型首先分析查询字符串并生成一个词列表,然后它从所有字段中依次搜索每个词。这种不同的搜索方式很自然的解决了 字段中心式 查询三个问题中的二个。剩下的问题是逆向文档频率不同。

幸运的是 cross_fields 类型也能解决这个问题,通过 validate-query 可以看到:

代码语言:javascript复制
GET /_validate/query?explain
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "cross_fields",   #用 cross_fields 词中心式匹配。
            "operator":    "and",
            "fields":      [ "first_name", "last_name" ]
        }
    }
}

它通过 混合 不同字段逆向索引文档频率的方式解决了词频的问题:

代码语言:javascript复制
 blended("peter", fields: [first_name, last_name])
 blended("smith", fields: [first_name, last_name])

换句话说,它会同时在 first_namelast_name 两个字段中查找 smith 的 IDF ,然后用两者的最小值作为两个字段的 IDF 。结果实际上就是 smith 会被认为既是个平常的姓,也是平常的名。

为了让 cross_fields 查询以最优方式工作,所有的字段都须使用相同的分析器, 具有相同分析器的字段会被分组在一起作为混合字段使用。如果包括了不同分析链的字段,它们会以 best_fields 的相同方式被加入到查询结果中。例如:我们将 title 字段加到之前的查询中(假设它们使用的是不同的分析器), explanation 的解释结果如下: ( title:peter title:smith) ( blended("peter", fields: [first_name, last_name]) blended("smith", fields: [first_name, last_name]) ) 当在使用 minimum_should_matchoperator 参数时,这点尤为重要。

1. 按字段提高权重

采用 cross_fields 查询与 自定义 _all 字段 相比,其中一个优势就是它可以在搜索时为单个字段提升权重。

这对像 first_namelast_name 具有相同值的字段并不是必须的,但如果要用 titledescription 字段搜索图书,可能希望为 title 分配更多的权重,这同样可以使用前面介绍过的 ^ 符号语法来实现:

代码语言:javascript复制
GET /books/_search
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "cross_fields",
            "fields":      [ "title^2", "description" ]   #title 字段的权重提升值为 2 , description 字段的权重提升值默认为 1 。 
        }
    }
}

自定义单字段查询是否能够优于多字段查询,取决于在多字段查询与单字段自定义 _all 之间代价的权衡,即哪种解决方案会带来更大的性能优化就选择哪一种。

11.Exact-Value 字段

在结束多字段查询这个话题之前,我们最后要讨论的是精确值 not_analyzed 未分析字段。 将 not_analyzed 字段与 multi_matchanalyzed 字段混在一起没有多大用处。

原因可以通过查看查询的 explanation 解释得到,设想将 title 字段设置成 not_analyzed

代码语言:javascript复制
GET /_validate/query?explain
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "cross_fields",
            "fields":      [ "title", "first_name", "last_name" ]
        }
    }
}

因为 title 字段是未分析过的,Elasticsearch 会将 “peter smith” 这个完整的字符串作为查询条件来搜索!

代码语言:javascript复制
title:peter smith
(
    blended("peter", fields: [first_name, last_name])
    blended("smith", fields: [first_name, last_name])
)

显然这个项不在 title 的倒排索引中,所以需要在 multi_match 查询中避免使用 not_analyzed 字段。

四、 近似匹配

使用 TF/IDF 的标准全文检索将文档或者文档中的字段作一大袋的词语处理。 match 查询可以告知我们这大袋子中是否包含查询的词条,但却无法告知词语之间的关系。

思考下面这几个句子的不同:

  • Sue ate the alligator.
  • The alligator ate Sue.
  • Sue never goes anywhere without her alligator-skin purse.

match 搜索 sue alligator 上面的三个文档都会得到匹配,但它却不能确定这两个词是否只来自于一种语境,甚至都不能确定是否来自于同一个段落。

理解分词之间的关系是一个复杂的难题,我们也无法通过换一种查询方式去解决。但我们至少可以通过出现在彼此附近或者仅仅是彼此相邻的分词来判断一些似乎相关的分词。

每个文档可能都比我们上面这个例子要长: Suealligator 这两个词可能会分散在其他的段落文字中,我们可能会希望得到尽可能包含这两个词的文档,但我们也同样需要这些文档与分词有很高的相关度。

这就是短语匹配或者近似匹配的所属领域。

1. 短语匹配

就像 match 查询对于标准全文检索是一种最常用的查询一样,当你想找到彼此邻近搜索词的查询方法时,就会想到 match_phrase 查询 。

代码语言:javascript复制
GET /my_index/my_type/_search
{
    "query": {
        "match_phrase": {
            "title": "quick brown fox"
        }
    }
}

类似 match 查询, match_phrase 查询首先将查询字符串解析成一个词项列表,然后对这些词项进行搜索,但只保留那些包含 全部 搜索词项,且 位置 与搜索词项相同的文档。 比如对于 quick fox 的短语搜索可能不会匹配到任何文档,因为没有文档包含的 quick 词之后紧跟着 fox

代码语言:javascript复制
match_phrase 查询同样可写成一种类型为 phrase 的 match 查询:Es低版本支持"match": {
    "title": {
        "query": "quick brown fox",
        "type":  "phrase"
    }
}

1. 词项的位置

当一个字符串被分词后,这个分析器不但会返回一个词项列表,而且还会返回各词项在原始字符串中的位置或者顺序关系:

代码语言:javascript复制
GET /_analyze?analyzer=standard
Quick brown fox

curl -X GET "localhost:9200/_analyze?analyzer=standard&pretty" -H 'Content-Type: application/json' -d'
Quick brown fox
'

返回信息如下:

代码语言:javascript复制
{
   "tokens": [
      {
         "token": "quick",
         "start_offset": 0,
         "end_offset": 5,
         "type": "<ALPHANUM>",
         "position": 1   #position 代表各词项在原始字符串中的位置。 
      },
      {
         "token": "brown",
         "start_offset": 6,
         "end_offset": 11,
         "type": "<ALPHANUM>",
         "position": 2   #position 代表各词项在原始字符串中的位置。 
      },
      {
         "token": "fox",
         "start_offset": 12,
         "end_offset": 15,
         "type": "<ALPHANUM>",
         "position": 3 
      }
   ]
}

位置信息可以被存储在倒排索引中,因此 match_phrase 查询这类对词语位置敏感的查询, 就可以利用位置信息去匹配包含所有查询词项,且各词项顺序也与我们搜索指定一致的文档,中间不夹杂其他词项。

2. 什么是短语

一个被认定为和短语 quick brown fox 匹配的文档,必须满足以下这些要求:

  • quickbrownfox 需要全部出现在域中。
  • brown 的位置应该比 quick 的位置大 1
  • fox 的位置应该比 quick 的位置大 2

本质上来讲,match_phrase 查询是利用一种低级别的 span 查询族(query family)去做词语位置敏感的匹配。 Span 查询是一种词项级别的查询,所以它们没有分词阶段;它们只对指定的词项进行精确搜索。值得庆幸的是,match_phrase查询已经足够优秀,大多数人是不会直接使用 span 查询。 然而,在一些专业领域,例如专利检索,还是会采用这种低级别查询去执行非常具体而又精心构造的位置搜索。

2.混合起来

精确短语匹配 或许是过于严格了。也许我们想要包含 “quick brown fox” 的文档也能够匹配 “quick fox,” , 尽管情形不完全相同。

我们能够通过使用 slop 参数将灵活度引入短语匹配中:

代码语言:javascript复制
GET /my_index/my_type/_search
{
    "query": {
        "match_phrase": {
            "title": {
                "query": "quick fox",
                "slop":  1
            }
        }
    }
}

slop 参数告诉 match_phrase 查询词条相隔多远时仍然能将文档视为匹配 。 相隔多远的意思是为了让查询和文档匹配你需要移动词条多少次。

我们以一个简单的例子开始吧。 为了让查询quick fox能匹配一个包含quick brown fox的文档, 我们需要 slop的值为1:

代码语言:javascript复制
Pos 1         Pos 2         Pos 3
-----------------------------------------------
Doc:        quick         brown         fox
-----------------------------------------------
Query:      quick         fox
Slop 1:     quick                 ↳     fox

尽管在使用了slop短语匹配中所有的单词都需要出现, 但是这些单词也不必为了匹配而按相同的序列排列。 有了足够大的slop值, 单词就能按照任意顺序排列了。

为了使查询 fox quick 匹配我们的文档, 我们需要 slop 的值为 3:

代码语言:javascript复制
Pos 1         Pos 2         Pos 3
-----------------------------------------------
Doc:        quick         brown         fox
-----------------------------------------------
Query:      fox           quick
Slop 1:     fox|quick  ↵   
Slop 2:     quick      ↳  fox
Slop 3:     quick                 ↳     fox

注意 foxquick 在这步中占据同样的位置。 因此将 fox quick 转换顺序成 quick fox 需要两步, 或者值为 2slop

1. slop关键字

解释:要经过几次移动才能与一个document匹配,这个移动的次数;

举例:短语:"我爱北京天安门",搜索"我北" slop=1 搜索结果一样可以查询到;

代码语言:javascript复制
GET /forum/article/_search
{
    "query": {
        "match_phrase": {
            "title": {
                "query": "我北",
                "slop":  1
            }
        }
    }
}

3.多值字段

对多值字段使用短语匹配时会发生奇怪的事。 想象一下你索引这个文档:

代码语言:javascript复制
PUT /my_index/groups/1
{
    "names": [ "John Abraham", "Lincoln Smith"]
}

然后运行一个对 Abraham Lincoln 的短语查询:

代码语言:javascript复制
GET /my_index/groups/_search
{
    "query": {
        "match_phrase": {
            "names": "Abraham Lincoln"
        }
    }
}

令人惊讶的是, 即使 AbrahamLincolnnames 数组里属于两个不同的人名, 我们的文档也匹配了查询。 这一切的原因在Elasticsearch数组的索引方式。

在分析 John Abraham 的时候, 产生了如下信息:

  • Position 1: john
  • Position 2: abraham

然后在分析 Lincoln Smith 的时候, 产生了:

  • Position 3: lincoln
  • Position 4: smith

换句话说, Elasticsearch对以上数组分析生成了与分析单个字符串 John Abraham Lincoln Smith 一样几乎完全相同的语汇单元。 我们的查询示例寻找相邻的 lincolnabraham , 而且这两个词条确实存在,并且它们俩正好相邻, 所以这个查询匹配了。

幸运的是, 在这样的情况下有一种叫做 position_increment_gap 的简单的解决方案, 它在字段映射中配置 。

代码语言:javascript复制
DELETE /my_index/groups/   #首先删除映射 groups 以及这个类型内的所有文档。
  
PUT /my_index/_mapping/groups 
{
    "properties": {
        "names": {
            "type":                "string",
            "position_increment_gap": 100  #然后创建一个有正确值的新的映射 groups 。 
        }
    }
}

position_increment_gap 设置告诉 Elasticsearch 应该为数组中每个新元素增加当前词条 position 的指定值。 所以现在当我们再索引 names 数组时,会产生如下的结果:

  • Position 1: john
  • Position 2: abraham
  • Position 103: lincoln
  • Position 104: smith

现在我们的短语查询可能无法匹配该文档因为 abrahamlincoln 之间的距离为 100 。 为了匹配这个文档你必须添加值为 100 的 slop

4.越近越好

鉴于一个短语查询仅仅排除了不包含确切查询短语的文档, 而 邻近查询 — 一个 slop 大于 0— 的短语查询将查询词条的邻近度考虑到最终相关度 _score 中。 通过设置一个像 50 或者 100 这样的高 slop 值, 你能够排除单词距离太远的文档, 但是也给予了那些单词临近的的文档更高的分数。

下列对 quick dog 的邻近查询匹配了同时包含 quickdog 的文档, 但是也给了与 quick 和 dog 更加临近的文档更高的分数 :

代码语言:javascript复制
POST /my_index/my_type/_search
{
   "query": {
      "match_phrase": {
         "title": {
            "query": "quick dog",
            "slop":  50   #注意高 slop 值。 
         }
      }
   }
}
代码语言:javascript复制
{
  "hits": [
     {
        "_id":      "3",
        "_score":   0.75,   #分数较高因为 quick 和 dog 很接近 
        "_source": {
           "title": "The quick brown fox jumps over the quick dog"
        }
     },
     {
        "_id":      "2",
        "_score":   0.28347334,   #分数较低因为 quick 和 dog 分开较远
        "_source": {
           "title": "The quick brown fox jumps over the lazy dog"
        }
     }
  ]
}

5. 使用邻近度提高相关度

虽然邻近查询很有用, 但是所有词条都出现在文档的要求过于严格了。 我们讨论 全文搜索 一章的 控制精度 也是同样的问题: 如果七个词条中有六个匹配, 那么这个文档对用户而言就已经足够相关了, 但是 match_phrase 查询可能会将它排除在外。

相比将使用邻近匹配作为绝对要求, 我们可以将它作为 信号— 使用, 作为许多潜在查询中的一个, 会对每个文档的最终分值做出贡献 (参考 多数字段)。

实际上我们想将多个查询的分数累计起来意味着我们应该用 bool 查询将它们合并。

我们可以将一个简单的 match 查询作为一个 must 子句。 这个查询将决定哪些文档需要被包含到结果集中。 我们可以用 minimum_should_match 参数去除长尾。 然后我们可以以 should 子句的形式添加更多特定查询。 每一个匹配成功的都会增加匹配文档的相关度。

代码语言:javascript复制
GET /my_index/my_type/_search
{
  "query": {
    "bool": {
      "must": {
        "match": {   #must 子句从结果集中包含或者排除文档
          "title": {
            "query":                "quick brown fox",
            "minimum_should_match": "30%"
          }
        }
      },
      "should": {
        "match_phrase": {   #should 子句增加了匹配到文档的相关度评分。
          "title": {
            "query": "quick brown fox",
            "slop":  50
          }
        }
      }
    }
  }
}

我们当然可以在 should 子句里面添加其它的查询, 其中每一个查询只针对某一特定方面的相关度。

6. 性能优化

短语查询和邻近查询都比简单的 query 查询代价更高 。 一个 match 查询仅仅是看词条是否存在于倒排索引中,而一个 match_phrase 查询是必须计算并比较多个可能重复词项的位置。

Lucene nightly benchmarks 表明一个简单的 term 查询比一个短语查询大约快 10 倍,比邻近查询(有 slop 的短语 查询)大约快 20 倍。当然,这个代价指的是在搜索时而不是索引时。

通常,短语查询的额外成本并不像这些数字所暗示的那么吓人。事实上,性能上的差距只是证明一个简单的 term 查询有多快。标准全文数据的短语查询通常在几毫秒内完成,因此实际上都是完全可用,即使是在一个繁忙的集群上。在某些特定病理案例下,短语查询可能成本太高了,但比较少见。一个典型例子就是DNA序列,在序列里很多同样的词项在很多位置重复出现。在这里使用高 slop 值会到导致位置计算大量增加。

那么我们应该如何限制短语查询和邻近近查询的性能消耗呢?一种有用的方法是减少需要通过短语查询检查的文档总数。

1. 结果集重新评分

在先前的章节中 ,我们讨论了而使用邻近查询来调整相关度,而不是使用它将文档从结果列表中添加或者排除。 一个查询可能会匹配成千上万的结果,但我们的用户很可能只对结果的前几页感兴趣。

一个简单的 match 查询已经通过排序把包含所有含有搜索词条的文档放在结果列表的前面了。事实上,我们只想对这些 顶部文档 重新排序,来给同时匹配了短语查询的文档一个额外的相关度升级。

search API 通过 重新评分 明确支持该功能。重新评分阶段支持一个代价更高的评分算法--比如 phrase 查询--只是为了从每个分片中获得前 K 个结果。 然后会根据它们的最新评分 重新排序。

该请求如下所示:

代码语言:javascript复制
GET /my_index/my_type/_search
{
    "query": {
        "match": {   #match 查询决定哪些文档将包含在最终结果集中,并通过 TF/IDF 排序。 
            "title": {
                "query":                "quick brown fox",
                "minimum_should_match": "30%"
            }
        }
    },
    "rescore": {
        "window_size": 50,   #window_size 是每一分片进行重新评分的顶部文档数量。
        "query": {      #目前唯一支持的重新打分算法就是另一个查询,但是以后会有计划增加更多的算法。      
            "rescore_query": {
                "match_phrase": {
                    "title": {
                        "query": "quick brown fox",
                        "slop":  50
                    }
                }
            }
        }
    }
}

重打分机制:
match:1000个doc,其实这时候每个doc都有一个分数了;
match_phrase,前50个doc,进行rescore,重打分,即可; 让前50个doc,term举例越近的,排在越前面

默认情况下,match也许匹配了1000个doc,match_phrase 全都需要对每个doc进行一遍运算,判断能否slop移动匹配上,然后去贡献自己的分数。但是很多情况下,match出来也许1000个doc,其实用户大部分情况下是分页查询的,所以可能最多只会看前几页,比如一页是10条,最多也许就看5页,就是50条。match_phrase只要对前50个doc进行slop移动去匹配,去贡献自己的分数即可,不需要对全部1000个doc都去进行计算和贡献分数

7. 寻找相关词

短语查询和邻近查询都很好用,但仍有一个缺点。它们过于严格了:为了匹配短语查询,所有词项都必须存在,即使使用了slop

slop 得到的单词顺序的灵活性也需要付出代价,因为失去了单词对之间的联系。即使可以识别 suealligatorate 相邻出现的文档,但无法分辨是 Sue ate 还是 alligator ate

当单词相互结合使用的时候,表达的含义比单独使用更丰富。两个子句 I’m not happy I’m workingI’m happy I’m not working 包含相同 的单词,也拥有相同的邻近度,但含义截然不同。

如果索引单词对而不是索引独立的单词,就能对这些单词的上下文尽可能多的保留。

对句子 Sue ate the alligator ,不仅要将每一个单词(或者 unigram )作为词项索引

代码语言:javascript复制
["sue", "ate", "the", "alligator"]

也要将每个单词 以及它的邻近词 作为单个词项索引:

代码语言:javascript复制
["sue ate", "ate the", "the alligator"]

这些单词对(或者 bigrams )被称为 shingles

代码语言:javascript复制
Shingles 不限于单词对;你也可以索引三个单词( trigrams ):
["sue ate the", "ate the alligator"]

Trigrams 提供了更高的精度,但是也大大增加了索引中唯一词项的数量。在大多数情况下,Bigrams 就够了。

当然,只有当用户输入的查询内容和在原始文档中顺序相同时,shingles 才是有用的;对 sue alligator 的查询可能会匹配到单个单词,但是不会匹配任何 shingles 。

幸运的是,用户倾向于使用和搜索数据相似的构造来表达搜索意图。但这一点很重要:只是索引 bigrams 是不够的;我们仍然需要 unigrams ,但可以将匹配 bigrams 作为增加相关度评分的信号。

1. 生成Shingles

Shingles 需要在索引时作为分析过程的一部分被创建。 我们可以将 unigrams 和 bigrams 都索引到单个字段中, 但将它们分开保存在能被独立查询的字段会更清晰。unigrams 字段将构成我们搜索的基础部分,而 bigrams 字段用来提高相关度。

首先,我们需要在创建分析器时使用 shingle 语汇单元过滤器:

代码语言:javascript复制
DELETE /my_index

PUT /my_index
{
    "settings": {
        "number_of_shards": 1,    #参考 被破坏的相关度! 。
        "analysis": {
            "filter": {
                "my_shingle_filter": {
                    "type":             "shingle",
                    "min_shingle_size": 2   #默认最小/最大的 shingle 大小是 2 ,所以实际上不需要设置。 
                    "max_shingle_size": 2,  #默认最小/最大的 shingle 大小是 2 ,所以实际上不需要设置。 
                    "output_unigrams":  false     #shingle 语汇单元过滤器默认输出 unigrams ,但是我们想让 unigrams 和 bigrams 分开。 
                }
            },
            "analyzer": {
                "my_shingle_analyzer": {
                    "type":             "custom",
                    "tokenizer":        "standard",
                    "filter": [
                        "lowercase",
                        "my_shingle_filter"   #my_shingle_analyzer 使用我们常规的 my_shingles_filter 语汇单元过滤器。 
                    ]
                }
            }
        }
    }
}

首先,用 analyze API 测试下分析器:

代码语言:javascript复制
GET /my_index/_analyze?analyzer=my_shingle_analyzer
Sue ate the alligator

POST my_index/_analyze
{
  "analyzer": "my_shingle_analyzer",   
  "text": "Sue ate the alligator"
}
结果:
{
  "tokens" : [
    {
      "token" : "sue ate",
      "start_offset" : 0,
      "end_offset" : 7,
      "type" : "shingle",
      "position" : 0
    },
    {
      "token" : "ate the",
      "start_offset" : 4,
      "end_offset" : 11,
      "type" : "shingle",
      "position" : 1
    },
    {
      "token" : "the alligator",
      "start_offset" : 8,
      "end_offset" : 21,
      "type" : "shingle",
      "position" : 2
    }
  ]
}

果然, 我们得到了 3 个词项:

  • sue ate
  • ate the
  • the alligator

现在我们可以继续创建一个使用新的分析器的字段。

2. 多字段

我们曾谈到将 unigrams 和 bigrams 分开索引更清晰,所以 title 字段将创建成一个多字段(参考 字符串排序与多字段 ):

代码语言:javascript复制
PUT /my_index/_mapping/my_type
{
    "my_type": {
        "properties": {
            "title": {
                "type": "string",
                "fields": {
                    "shingles": {
                        "type":     "string",
                        "analyzer": "my_shingle_analyzer"
                    }
                }
            }
        }
    }
}

通过这个映射, JSON 文档中的 title 字段将会被以 unigrams (title)和 bigrams (title.shingles)被索引,这意味着可以独立地查询这些字段。

最后,我们可以索引以下示例文档:

代码语言:javascript复制
POST /my_index/my_type/_bulk
{ "index": { "_id": 1 }}
{ "title": "Sue ate the alligator" }
{ "index": { "_id": 2 }}
{ "title": "The alligator ate Sue" }
{ "index": { "_id": 3 }}
{ "title": "Sue never goes anywhere without her alligator skin purse" }

3. 搜索shingles

为了理解添加 shingles 字段的好处 ,让我们首先来看 The hungry alligator ate Sue 进行简单 match 查询的结果:

代码语言:javascript复制
GET /my_index/my_type/_search
{
   "query": {
        "match": {
           "title": "the hungry alligator ate sue"
        }
   }
}

这个查询返回了所有的三个文档, 但是注意文档 1 和 2 有相同的相关度评分因为他们包含了相同的单词:

代码语言:javascript复制
{
  "hits": [
     {
        "_id": "1",
        "_score": 0.44273707,   #两个文档都包含 the 、 alligator 和 ate ,所以获得相同的评分。 
        "_source": {
           "title": "Sue ate the alligator"  
        }
     },
     {
        "_id": "2",
        "_score": 0.44273707,   #两个文档都包含 the 、 alligator 和 ate ,所以获得相同的评分。 
        "_source": {
           "title": "The alligator ate Sue"  
        }
     },
     {
        "_id": "3",   #我们可以通过设置 minimum_should_match 参数排除文档 3 ,参考 控制精度 。 
        "_score": 0.046571054,
        "_source": {
           "title": "Sue never goes anywhere without her alligator skin purse"
        }
     }
  ]
}

现在在查询里添加 shingles 字段。不要忘了在 shingles 字段上的匹配是充当一 种信号--为了提高相关度评分--所以我们仍然需要将基本 title 字段包含到查询中:

代码语言:javascript复制
GET /my_index/my_type/_search
{
   "query": {
      "bool": {
         "must": {
            "match": {
               "title": "the hungry alligator ate sue"
            }
         },
         "should": {
            "match": {
               "title.shingles": "the hungry alligator ate sue"
            }
         }
      }
   }
}

仍然匹配到了所有的 3 个文档, 但是文档 2 现在排到了第一名因为它匹配了 shingled 词项 ate sue.

代码语言:javascript复制
{
  "hits": [
     {
        "_id": "2",
        "_score": 0.4883322,
        "_source": {
           "title": "The alligator ate Sue"
        }
     },
     {
        "_id": "1",
        "_score": 0.13422975,
        "_source": {
           "title": "Sue ate the alligator"
        }
     },
     {
        "_id": "3",
        "_score": 0.014119488,
        "_source": {
           "title": "Sue never goes anywhere without her alligator skin purse"
        }
     }
  ]
}

即使查询包含的单词 hungry 没有在任何文档中出现,我们仍然使用单词邻近度返回了最相关的文档。

4. performance性能

shingles 不仅比短语查询更灵活, 而且性能也更好。 shingles 查询跟一个简单的 match 查询一样高效,而不用每次搜索花费短语查询的代价。只是在索引期间因为更多词项需要被索引会付出一些小的代价, 这也意味着有 shingles 的字段会占用更多的磁盘空间。 然而,大多数应用写入一次而读取多次,所以在索引期间优化我们的查询速度是有意义的。

这是一个在 Elasticsearch 里会经常碰到的话题:不需要任何前期进行过多的设置,就能够在搜索的时候有很好的效果。 一旦更清晰的理解了自己的需求,就能在索引时通过正确的为你的数据建模获得更好结果和性能。

0 人点赞