产品

了解“Query Then Fetch”与“DFS Query Then Fetch”的区别

在我们关于以开头短语匹配的上一篇文章中,我们运行到了返回分数可疑的情况。作为回顾,以下是相关的查询:

$ curl -XGET localhost:9200/startswith/test/_搜索?pretty -d '{
        "query": {
        "match_phrase_prefix": {
           "title": {
             "查询": "d",
             "max_expansions": 5
           }
         }
       }
     }' | grep title

      "_score" : 1.0, "_source" : {"title":"drunk"}
      "_score" : 0.30685282, "_source" : {"title":"dzone"}
      "_score" : 0.30685282, "_source" : {"title":"data"}
      "_score" : 0.30685282, "_source" : {"title":"drive"}

看到文档“drunk”如何获得 1.0 的评分,而其余文档的评分为 0.3 了吗?既然它们同样匹配“d”的查询,这些文档不应该具有相同的评分吗?答案是肯定的,但这种评分差异背后有一个非常好的理由。

相关性评分

Elasticsearch(以及底层的 Lucene)所使用的评分算法的一部分包括“词频 - 逆文档频率 (TF-IDF)”统计信息,以帮助计算索引中相关文档的相关性。

关于 TF-IDF 的主题已经有很多论述,但它基本上说明了“一个词在文档中出现的次数越多,该文档的相关性就越高。但相关性会受到该词在整个索引中出现频率的抑制”。

稀有词仅存在于少数文档中,这意味着任何匹配稀有词的查询都会变得高度相关。相反,常用词随处可见,因此它们与查询的相关性较低。

当您执行搜索时,Elasticsearch 面临一个有趣的困境。您的查询需要找到所有相关文档……但这些文档分散在集群中的任意数量的分片中。

每个分片基本上都是一个 Lucene 索引,它维护着自己的 TF 和 DF 统计信息。分片只知道“pineapple”在分片内出现了多少次,而不知道整个集群的情况。

但是相关性算法使用 TF-IDF……它难道不需要知道整个索引的 TF 和 DF,而不是每个分片的吗?

默认搜索类型:Query Then Fetch

答案是肯定的,也是否定的。默认情况下,Elasticsearch 将使用一种称为“Query Then Fetch”的搜索类型。其工作方式如下:

  1. 将查询发送到每个分片
  2. 查找所有匹配的文档,并使用本地词频/文档频率计算分数
  3. 构建结果优先级队列(排序、使用 from/to 进行分页等)
  4. 将有关结果的元数据返回给请求Node。请注意,此时尚未发送实际文档,仅发送了分数
  5. 来自所有分片的评分在请求Node上进行合并和排序,并根据查询条件选择文档
  6. 最后,实际文档会从它们所在的各个分片中检索出来。
  7. 结果返回至客户端

该系统通常运行良好。在大多数情况下,您的索引中拥有“足够”的文档来平滑词频/文档频率统计信息。因此,虽然每个分片可能无法完全掌握整个集群的频率信息,但由于频率在各处都相当相似,结果也“足够好”。

但在本文开头提到的查询案例中,默认的搜索类型有时会失败。

DFS Query Then Fetch

在上一篇文章中,我们构建了一个索引,但未指定分片数量——Elasticsearch 使用了默认的 5 个分片。随后,我们在索引中插入了区区五个文档,并要求 ES 返回相关的结果和准确的评分。这不太公平,不是吗?

评分差异是由 Query Then Fetch 搜索类型引起的。每个分片仅包含 1 或 2 个文档(ES 使用的哈希算法确保了相对随机的分布)。当我们要求 Elastic 计算评分时,每个分片对这五个文档的索引只有很小的视角……因此评分是不准确的。

幸运的是,Elasticsearch 不会让您陷入困境。如果您遇到的情况中这种评分差异是个问题,ES 提供了一种名为“DFS Query Then Fetch”的搜索类型。其过程与 Query Then Fetch 几乎相同,区别在于它执行预查询来计算全局文档频率。

  1. 预查询每个分片以询问词频和文档频率
  2. 将查询发送到每个分片
  3. 查找所有匹配的文档,并使用从预查询计算出的全局词项/文档频率来计算分数。
  4. 构建结果优先级队列(排序、使用 from/to 进行分页等)
  5. 将有关结果的元数据返回给请求Node。请注意,此时尚未发送实际文档,仅发送了分数
  6. 来自所有分片的评分在请求Node上进行合并和排序,并根据查询条件选择文档
  7. 最后,实际文档会从它们所在的各个分片中检索出来。
  8. 结果返回至客户端

如果我们对之前的查询应用这种新的搜索类型,我们将获得合理的评分结果(例如,它们完全相同):

$ curl -XGET 'localhost:9200/startswith/test/_search?pretty=true&search_type=dfs_query_then_fetch' -d '{
        "query": {
        "match_phrase_prefix": {
           "title": {
             "查询": "d",
             "max_expansions": 5
           }
         }
       }
     }' | grep title

      "_score" : 1.9162908, "_source" : {"title":"dzone"}
      "_score" : 1.9162908, "_source" : {"title":"data"}
      "_score" : 1.9162908, "_source" : {"title":"drunk"}
      "_score" : 1.9162908, "_source" : {"title":"drive"}

结论

当然,更高的准确性并非没有代价。预查询 (prequery) 会在分片之间导致额外的往返,这可能会根据索引大小、分片数量、查询速率等因素造成性能影响。在大多数情况下,这完全没有必要……拥有“足够”的数据就能为您解决问题。

但有时您会运行到奇怪的评分情况,在这种情况下,了解如何通过 DFS Query then Fetch 来调整搜索执行计划会很有用。