1. 前言

在使用Elasticsearch的时候,有时会遇到这种评分问题: 为何文档“drunk”分数为1.0,而其余的分数是0.3?难道这些文档不应该是相同的分数么,因为他们都同等地匹配了“d”。答案是肯定的,但是这个分数本身也有比较合理的地方。

$ 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"}

相关性打分
每个shard是一个Lucene的索引,保存了自身的TF和DF统计信息。一个shard只知道在其自身中出现的次数,而非整个cluster。
但是相关算法使用了TF-IDF,它需要知道对于整个索引的而不是对每个shard的TF和DF么?

2. query then fetch

默认情形下,ES会使用一个称之为Query then fetch的搜索类型。它运作的方式如下:

  1. 发送查询到每个shard
  2. 找到所有匹配的文档,并使用本地的Term/Document Frequency信息进行打分
  3. 对结果构建一个优先队列(排序,标页等)
  4. 返回关于结果的元数据到请求节点。注意,实际文档还没有发送,只是分数
  5. 各分片只返回排序和排名相关的信息(注意,不包括文档document),然后按照各分片返回的分数进行重新排序和排名,取前size个文档。
  6. 最终,去相关的shard取document;
  7. 结果被返回给用户

这个系统一般是能够良好地运作的。大多数情形下,你的索引有足够的文档来平滑Term/Document frequency统计信息。因此,尽管每个shard不一定拥有完整的关于整个cluster的frequency信息,结果仍然足够好,因为fequency在每个地方基本上是类似的。

但是在我们开头提起的那个查询,默认搜索类型有时候会失败。

当前查询Elasticsearch,如果不指定查询类型,默认使用查询类型是:query then fetch,执行流程如下:

  1. 发送查询到每个shard,在每个shard中进行如下处理:找到所有匹配的文档,并使用本地的Term/Document Frequency信息进行打分,对结果构建一个优先级队列(排序);
  2. 各shard只返回排序和排名相关的信息(不包含document)到请求节点,在请求节点上按照各shard返回的的分数进行重新排序和排名,取前size各文档;
  3. 请求节点去相关的shard上取document返回给用户。

导致排名不准确的原因:
因为每个shard都是基于自己分片上的数据进行打分计算的,计算分值使用的词频和文档频率等信息都是基于自己分片的数据进行的,而ES进行整体排名时是基于每个分片计算后的分值进行排序的(相当于打分的依据就不一样,最终对这些数据的统一排名就不准确了),最终导致排名不准确的问题。

如果需要保证排名准确,可以 采用dfs query then fetch的查询方式,该方式可以保证查询返回的数据量和排名是准确的,但性能是ES查询类型中最差的。

3. dfs query then fetch

在上篇文章中,我们默认建立了一个索引,ES通常使用5个shard。接着插入了5个文档进入索引,向ES发送请求返回相关结果和准确的分数。其结果并不是很公平,对吧?

这是由于默认的搜索类型导致的,每个shard仅仅包含一个或者两个文档(ES使用hash确保随机分布)。当我们要求ES计算分数时候,每个shard仅仅拥有关于五个文档的一个很窄的视角。所以分数是不准确的。

幸运的是,ES并没有让你无所适从。如果你遇到了这样的打分偏离的情形,ES提供了一个称为“DFS Query Then Fetch”。这个过程基本和Query Then Fetch类型,除了它执行了一个预查询来计算整体文档的frequency。

  1. 预查询每个shard,询问Term和Document frequency;
  2. 发送查询到每隔shard;
  3. 找到所有匹配的文档,并使用全局的Term/Document Frequency信息进行打分;
  4. 对结果构建一个优先队列(排序,标页等);
  5. 返回关于结果的元数据到请求节点。注意,实际文档还没有发送,只是分数;
  6. 来自所有shard的分数合并起来,并在请求节点上进行排序,文档被按照查询要求进行选择
  7. 最终,实际文档从他们各自所在的独立的shard上检索出来
  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"}

4. 结论

当然,更好准确性不是免费的。预查询本身会有一个额外的在shard中的轮询,这个当然会有性能上的问题(跟索引的大小,shard的数量,查询的频率等)。在大多数情形下,是没有必要的,拥有足够的数据可以解决这样的问题。

但是有时候,你可能会遇到奇特的打分场景,在这些情况中,知道如何使用DFS query then fetch去进行搜索执行过程的微调还是有用的。