本文翻译自:https://www.elastic.co/cn/blog/understanding-query-then-fetch-vs-dfs-query-then-fetch

在前一篇文章(译注:这篇文章不用看了)中,我们遇到了一个查询结果得分异常的情况。作为回顾,这是之前的查询语言以及查询结果

$ curl -XGET localhost:9200/startswith/test/_search?pretty -d '{
        "query": {
            "match_phrase_prefix": {
                "title": {
                    "query": "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,而其它几个字段的得分则不为1.0。既然这几个字段都匹配了字母 d,那么为什么它们的查询得分却不一样呢?事实上它们的得分本来应该是完全一致的,但是因为一些原因而导致了它们得分的差异。

相关性打分

索引文档相关性得分的计算使用了Elasticsearch(内部是Lucene)的TF-IDF算法。

关于TF-IDF的文章已经很多了,需要知道其核心原理为“如果一个字段在一个文档中出现的频率越高,则该字段的相关性越高;同时如果一个字段在整个索引中出现的频率越高,则该字段的相关性越低”。

如果有少量的字段只存在于一部分文档中,那么任何对于这些字段的查询都与这些文档有着极强的相关性。相反的,常见的字段在哪里都是很容易见到的,所以它们查询时的相关性都很低。

ES在查询时面临着一个问题,即查询需要返回所有的相关数据,但是这些数据却是分布在集群中的多个分片之上的。每一个分片都是一个独立的Lucene索引,这意味着每个分片都有着其独立的TF和DF,一个分片只知道某个字段在当前分片的出现次数,而无法知道其在整个索引之中的出现次数。

那么问题来了,相关性的计算不应该是需要知道整个索引的TF和DF,仅仅使用单个分片的结果不会导致查询结果的错误吗?

默认搜索方式:Query Then Fetch

答案是是也不是。默认情况下ES使用一种叫做 Query Then Fetch 的搜索方式,它的工作过程如下

  1. 向每一个分片发送查询请求
  2. 在每一个分片上查询符合要求的数据,并且根据当前分片的TF和DF计算相关性得分
  3. 构建一个优先级队列存储查询结果(包含分页、排序,等等)
  4. 把查询结果的metadata返回给查询节点。注意,真正的文档此时还并没有返回,返回的只是得分数据
  5. 查询节点对从所有分片上返回的得分数据进行归并和排序,根据查询标准对得分数据进行选择
  6. 最终所有符合查询要求的文档被从其所在的分片上取回到查询节点
  7. 查询节点将数据返回给客户端

这个过程在大部分情况下都能良好工作。在大部分情况下,你的索引拥有足够的文档数量来降低TF和DF所产生的影响。所以尽管每个分片都不能够知道整个索引的文档分布情况,但是因为每个分片的TF和DF都不会存在非常巨大的差异,所以计算出来的结果也大体上也是相似的,此时得到的查询也大致上满足了我们的要求。

不过在上一篇文章中提到的那种查询情况,默认的查询方式就失败了。

DFS Query Then Fetch

在上一篇文章中,我们创建一个没有指定分片数量的索引,在ES中不指定分片数量则默认为5。之后我们向索引中仅仅插入了5条数据,并且要求ES帮助我们取回数据并且给与准确的得分结果,这其实有点不公平。

查询得分的差异是因为默认的 Query Then Fetch 搜索方式的原因。根据ES的数据分片hash算法,一共5条数据,则每个分片可能仅保存了1到2条的数据。当我们使用ES对数据进行查询的时候,每个分片上对于这5条数据都只能有一个极小的了解,所以计算的得分自然是不精确的。

不过还好ES给我们提供了解决方案。如果遇到了得分存在差异的问题,ES提供了一个叫做 DFS Query Then Fetch 的查询方式。它的查询方式和 Query Then Fetch 基本上一致,区别在于它增加了一个用于得到索引中所有文档频率的 pre-query 阶段。

  1. 预查询所有的分片,得到一个索引中全局的 Term 和 Document 的频率信息
  2. 向每一个分片发送查询请求
  3. 在每一个分片上查询符合要求的数据,并根据全局的 Term 和 Document 的频率信息计算相关性得分
  4. 构建一个优先级队列存储查询结果(包含分页、排序,等等)
  5. 把查询结果的metadata返回给查询节点。注意,真正的文档此时还并没有返回,返回的只是得分数据
  6. 查询节点对从所有分片上返回的得分数据进行归并和排序,根据查询标准对得分数据进行选择
  7. 最终所有符合查询要求的文档被从其所在的分片上取回到查询节点
  8. 查询节点将数据返回给客户端

如果使用新的查询方式对前面的数据进行查询,我们将会得到一致的得分

$ curl -XGET 'localhost:9200/startswith/test/_search?pretty=true&search_type=dfs_query_then_fetch' -d '{
        "query": {
            "match_phrase_prefix": {
                "title": {
                    "query": "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"}

结论

当然,更好的准确性并不是没有代价的。预查询会导致一个额外的分片间的数据传输,并且会因为当前的索引大小、分片数量、查询频率等等而影响到性能。更加关键的是,在大部分情况这种精确查询是完全没有必要的,有足够量的数据几乎已经可以帮你解决准确性的问题。

不过有些时候你会遇到一些奇怪的查询得分问题,在这种时候考虑使用 DFS Query then Fetch 可能会是非常有用的。