触类旁通Elasticsearch:打分

2019-05-25 19:32:21 浏览数 (1)

《Elasticsearch In Action》学习笔记。

使得ES查询与select * from users where name like 'bob%'查询不同的是其为文档赋予相关性得分的能力。从这个得分,可以得知文档和原始的查询有多么相关。

一、ES打分机制

确定文档和查询有多么相关的过程被称为打分(scoring)。

1. TF-IDF

Lucene及其扩展ES默认使用TF-IDF算法计算文档得分。TF是词频,即term frequency,IDF是逆文档频率,即inverse document frequency。关于TF-IDF一个简短的解释是,一个词条出现在某个文档的次数越多,它就越相关。但是该词条出现在不同的文档的次数越多,它就越不相关。DF表示一个词条出现在多少篇文档中,而IDF则为1/DF。

Lucene默认的评分公式如下:

用自然语言描述该公式为:“给定查询 q 和文档 d,其得分是查询中每个词条 t 的得分总和。而每个词条的得分是该词条在文档 d 中的词频的平方根,乘以该词逆文档频率的平方和,乘以该文档字段的归一化因子,乘以该词条的提升权重。”

显然词条的词频越高,得分越高;相似地,索引中词条越罕见,逆文档频率越高。调和因子考虑了搜索过多少文档以及发现了多少词条。查询标准化是视图让不同查询的结果具有可比性。

2. 其它打分方法

ES支持的其它打分方法包括:

  • Okapi BM25
  • 随机性分歧(Divergence from randomness),即DFR相似度
  • 基于信息的(Information based),即IB相似度
  • LM Dirichlet相似度
  • LM Jelinek Mercer相似度

二、boosting

boosting是一个可以用来修改文档相关性的程序。用户可以在查询时使用boosting。需要注意的是,boost的数值并不是一个精确的乘数。这是指,在计算分数的时候boost数值是被标准化的。例如,如果为每个单独字段指定了10的boost,那么最终标准化后每个字段会获得1的值,也就意味着没有实施任何boost。应该考虑boost的相对数值,将name字段boost3倍意味着name字段的重要性大概是其它字段的3倍。

(1)查询期间的boosting 可以使用基本的match、multi_match、simple_query_string或query_string查询,基于每个词条或者某个字段来控制boost。对查询进行boost意味着在所有的配置查询字段中,每个被发现的词条都会获得boost。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "description": {
              "query": "elasticsearch big data",
              "boost": 2.5
            }
          }
        },
        {
          "match": {
            "name": {
              "query": "elasticsearch big data"
            }
          }
        }
      ]
    }
  }
}'

注意boost只是添加到第一个match查询。现在对于最终的得分而言,第一个match查询比第二个match查询拥有更大的影响力。当使用bool或and/or/not组合多个查询时,boost查询才有意义。

(2)跨越多个字段的查询 对于跨越多个字段的查询,如multi_match,也可以使用多个替换的方法。用户可以指定整个multi_match的boost。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "multi_match": {
      "query": "elasticsearch big data",
      "fields": [
        "name",
        "description"
      ],
      "boost": 2.5
    }
  }
}'

也可以只对特定字段指定一个boost。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "multi_match": {
      "query": "elasticsearch big data",
      "fields": [
        "name^3",                               # 使用^3后缀,name字段被boost了3倍
        "description"
      ]
    }
  }
}'

在query_string查询中,可以使用特殊的语法来boost单个词条。下面代码会搜索“elasticsearch”和“big data”,而“elasticsearch”被boost了3倍。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "query_string": {
      "query": "elasticsearch^3 AND "big data""
    }
  }
}'

三、explain

explain包含了对得分的解释,从而了解为什么一篇文档获得了特定的得分,为什么一篇文档无法和某个查询匹配。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "match": {
      "description": "elasticsearch"
    }
  },
  "explain": true
}'

下面来看看该请求返回的第一个结果:

代码语言:javascript复制
  "hits" : {
    "total" : 9,
    "max_score" : 1.1171769,
    "hits" : [
      {
        "_shard" : "[get-together][1]",
        "_node" : "yO9AEg-BTS20V9BhuEWeuA",
        "_index" : "get-together",
        "_type" : "_doc",
        "_id" : "112",
        "_score" : 1.1171769,
        "_routing" : "5",
        "_source" : {
          "relationship_type" : {
            "name" : "event",
            "parent" : "5"
          },
          "host" : "Dave Nolan",
          "title" : "real-time Elasticsearch",
          "description" : "We will discuss using Elasticsearch to index data in real time",
          "attendees" : [
            "Dave",
            "Shay",
            "John",
            "Harry"
          ],
          "date" : "2013-02-18T18:30",
          "location_event" : {
            "name" : "SkillsMatter Exchange",
            "geolocation" : "51.524806,-0.099095"
          },
          "reviews" : 3
        },
        "_explanation" : {
          "value" : 1.1171769,
          "description" : "weight(description:elasticsearch in 13) [PerFieldSimilarity], result of:",
          "details" : [
            {
              "value" : 1.1171769,
              "description" : "score(doc=13,freq=1.0 = termFreq=1.0n), product of:",
              "details" : [
                {
                  "value" : 0.9614112,
                  "description" : "idf, computed as log(1   (docCount - docFreq   0.5) / (docFreq   0.5)) from:",
                  "details" : [
                    {
                      "value" : 6.0,
                      "description" : "docFreq",
                      "details" : [ ]
                    },
                    {
                      "value" : 16.0,
                      "description" : "docCount",
                      "details" : [ ]
                    }
                  ]
                },
                {
                  "value" : 1.1620178,
                  "description" : "tfNorm, computed as (freq * (k1   1)) / (freq   k1 * (1 - b   b * fieldLength / avgFieldLength)) from:",
                  "details" : [
                    {
                      "value" : 1.0,
                      "description" : "termFreq=1.0",
                      "details" : [ ]
                    },
                    {
                      "value" : 1.2,
                      "description" : "parameter k1",
                      "details" : [ ]
                    },
                    {
                      "value" : 0.75,
                      "description" : "parameter b",
                      "details" : [ ]
                    },
                    {
                      "value" : 16.6875,
                      "description" : "avgFieldLength",
                      "details" : [ ]
                    },
                    {
                      "value" : 11.0,
                      "description" : "fieldLength",
                      "details" : [ ]
                    }
                  ]
                }
              ]
            }
          ]
        }
      }

_explanation部分包含了对于文档得分的解释。这篇文档的最后得分是1.1171769。IDF值为0.9614112,标准化后的TF值为1.1620178,1.1620178 * 0.9614112 = 1.1171769。

下面看一个不匹配的例子:

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_doc/4/_explain?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "match": {
      "description": "elasticsearch"
    }
  }
}'

结果返回:

代码语言:javascript复制
{
  "_index" : "get-together",
  "_type" : "_doc",
  "_id" : "4",
  "matched" : false,
  "explanation" : {
    "value" : 0.0,
    "description" : "no matching term",         
    "details" : [ ]
  }
}

词条“elasticsearch”没有出现在ID为4的文档的description字段中,得分为0,解释了为什么这篇文档和查询没有匹配成功。

四、再打分

在下列情况下,打分可能会变成资源密集型的操作:

  • 使用脚本的评分,运行了一个脚本来计算索引中每篇文档的得分。这类似于SQL查询中使用UDF,每行数据都要执行函数。
  • 进行phrase词组查询,搜索在一定距离内出现的单词,使用很大的slop值。

在这些情况下,可能希望减轻打分算法所产生的性能影响。为解决这个问题,ES有一个特性称为再打分。再打分(rescoring)是指初始的查询运行后,针对返回的结果集进行第二轮的得分计算。例如,对于可能非常消耗性能的脚本查询,可以先使用更为经济的match匹配查询进行搜索,然后只对前1000项检索到的命中执行该脚本查询。下面是一个再打分的例子。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "match": {                                  # 在所有文档上执行的初始查询
      "title": "elasticsearch"
    }
  },
  "rescore": {
    "window_size": 20,                          # 运行在再评分的数量
    "query": {
      "rescore_query": {
        "match_phrase": {                       # 在初始查询的前20项结果上运行的新查询
          "title": {
            "query": "elasticsearch hadoop",
            "slop": 5
          }
        }
      },
      "query_weight": 0.8,                      # 初始查询得分的权重
      "rescore_query_weight": 1.3               # 再评分查询得分的权重
    }
  }
}'

这个例子搜索了所有标题中含有“elasticsearch”关键词的文档,然后对获取的前20项结果重新计算得分,它使用了高slop值的phrase查询。尽管高slop值的phrase查询是很耗性能的,但这个查询只会在前20篇文档上执行。用户可以使用query_weight和rescore_query_weight参数来权衡不同查询的重要性,这取决于希望最终的得分多少是由初始查询决定,多少是由再评分查询决定。用户可以按序使用多个rescore再评分查询,每个查询使用前面的结果作为输入。

五、function_score

function_score查询允许用户指定任何数量的任意函数,让它们作用于匹配了初始查询的文档,修改其得分,从而达到精细化控制结果相关性的目的。下面是一个尚未进行任何额外评分的例子:

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": []
    }
  }
}'

(1)weight函数 weight函数将得分乘以一个常数。注意,普通的boost字段按照标准化来增加分数,而weight是真正将得分乘以确定的数值。下面的代码在初始查询得到的结果中,将description字段中包含“hadoop”的文档得分提升1.5倍。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [
        {
          "weight": 1.5,
          "filter": {
            "term": {
              "description": "hadoop"
            }
          }
        }
      ]
    }
  }
}'

可以增加多个函数:

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [
        {
          "weight": 2,
          "filter": {
            "term": {
              "description": "hadoop"
            }
          }
        },
        {
          "weight": 3,
          "filter": {
            "term": {
              "description": "logstash"
            }
          }
        }
      ]
    }
  }
}'

(2)得分合并 得分合并有以下两种情况:

  • 从每个单独的函数而来的得分是如何合并的,这被称为score_mode。
  • 从函数而来的得分是如何同原始查询得分合并的,这被称为boost_mode。

第一种情况处理不同函数得分如何合并。前面例子中有两个函数,一个权重为2,另一个权重是3。用户可以设置score_mode参数为multiply、sum、avg、first、max和min。如果没有特别指明,每个函数的得分是相乘的。

如果指定了first,只会考虑第一个拥有匹配过滤器的函数的分数。例如,如果将score_mode设置为first,并且有一篇文档的描述中有“hadoop”和“logstash”关键词,那么只会实施值为2的boost因子,因为这是第一个匹配文档的函数。

第二种得分合并的设置控制了原始查询的得分和函数得分是如何合并的。如果没有指定,新的得分是原始得分和函数得分相乘。用户可以将其设置为sum、avg、max、min或replace。设置为replace,意味着原有的查询得分将会被函数得分所替换。

(3)field_value_factor函数 field_value_factor函数将包含数值的字段名称作为输入,选择性地将其值乘以常数,然后最终对其运用数学函数,如取数值的对数。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "reviews",                 # 用作数值的字段
            "factor": 2.5,                      # 评论字段将要乘以的因子
            "modifier": "ln",                   # 可选择的修饰符,用于计算得分
            "missing": 0.000001                 # 缺少评论字段时的缺省值
          }
        }
      ]
    }
  }
}'

除ln外,其它修改函数包括:none(默认)、log、log1p、log2p、square、sqrt和reciprocal。field_value_factor将所有用户指定的字段值加载到内存中,因此可以很快计算出得分。这是字段数据的一部分。(4)脚本 脚本评分可以让用户完全控制如何修改评分,用户可以在脚本中进行任何的排序。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_mapping/_doc?pretty" -H 'Content-Type: application/json' -d'
{
  "properties": {
    "attendees": {
      "type": "text",
      "fielddata": "true"
    }
  }
}'

curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [
        {
          "script_score": {
            "script": {                         # 将在初始查询返回的每篇文档上运行的脚本
              "source": "if (doc[u0027attendeesu0027].value != null) { Math.log(doc[u0027attendeesu0027].values.size() * params.myweight)} else _score",
              "params": {
                "myweight": 3
              }
            }
          }
        }
      ],
      "boost_mode": "replace"                   # 初始的文档得分将会被脚本产生的得分所代替
    }
  }
}'

这个例子将使用参与者(attendees)列表的人数来影响得分,将其和权重相乘,并取对数。脚本比普通的评分操作要慢得多,原因是对于每篇匹配查询的文档而言,它们必须是动态执行的。

(5)随机 random_score函数给予用户为文档指定随机分数的能力。用户可以选择性地指定种子(seed),这是一个传递给查询的数值,用于产生随机数。这一点可以让用户一随机的方式来排列文档,但是使用相同的随机种子,再次执行相同的请求时,结果排序将总是一样的。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [
        {
          "random_score": {
            "seed": 1234
          }
        }
      ]
    }
  }
}'

(6)衰减函数 衰减函数允许用户根据某个字段,应用一个逐步衰减的文档得分。有3种类型的衰减函数,即linear、gauss和exp。对于衰减函数,有以下4种配置选项。

  • origin:中心点,在这里用户希望分数是最高的。
  • offset:分数开始衰减的位置,和原点之间的距离。
  • scale和decay:这两个选项是密切合作的。通过设置它们,可以让字段值为指定的scale时,其得分减少到指定的decay。

下面是一个使用高斯衰减函数的例子。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match_all": {}
      },
      "functions": [
        {
          "gauss": {
            "location_event.geolocation": {
              "origin": "40.018528,-105.275806",     # 距离原点100米之内的位置,分数保持不变
              "offset": "100m",                      # 衰减开始距离原点的位置
              "scale": "2km",                        # 距离原点两公里的地方,分数被降低一半
              "decay": 0.5
            }
          }
        }
      ]
    }
  }
}'

(7)综合示例 下面展示一个查询,其中:

  • 特定的词条权重更高。
  • 考虑了评论数据。
  • 活动参与人数越多,排名越高。
  • 靠近某个地理位置的文档得以boost加权。
代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "function_score": {
      "query": {
        "match_all": {}
      },
      "functions": [
        {
          "weight": 1.5,
          "filter": {
            "term": {
              "description": "hadoop"
            }
          }
        },
        {
          "field_value_factor": {
            "field": "reviews",
            "factor": 10.5,
            "modifier": "log1p",
            "missing": 0
          }
        },
        {
          "script_score": {
            "script": {
              "source": "if (doc[u0027attendeesu0027].value != null) { Math.log(doc[u0027attendeesu0027].values.size() * params.myweight)} else _score",
              "params": {
                "myweight": 3
              }
            }
          }
        },
        {
          "gauss": {
            "location_event.geolocation": {
              "origin": "40.018528,-105.275806",
              "offset": "100m",
              "scale": "2km",
              "decay": "0.5"
            }
          }
        }
      ],
      "score_mode":"sum",
      "boost_mode":"replace"
    }
  }
}'

在这个例子中包含了以下几点:

  • 由于使用了match_all查询,用户匹配了索引之中所有的文档。
  • 使用了weight函数,提升了描述中包含“hadoop”关键词的文档。
  • 通过field_value_factor函数,使用某个文档中的评论数量来修改得分。
  • 使用了script_score,将参与者的数量纳入考虑范围。
  • 使用了gausss衰减,对于离原点越来越远的点进行了分数的逐步衰减。

六、使用脚本排序

除了使用脚本来修改文档的得分,ES还允许使用脚本在文档返回前对其进行排序。当用户需要在某个不存在的文档字段上排序时,这一点非常有用。例如,在下面的例子中,搜索关于“elasticsearch”的文档,但想根据参与人数排序。

代码语言:javascript复制
curl -XPOST "172.16.1.127:9200/get-together/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "match": {
      "description": "elasticsearch"
    }
  },
  "sort": [
    {
      "_script": {
        "script": "doc[u0027attendeesu0027].values.size()",    # 使用参与者的数量作为排序的值
        "type": "number",                                        # 该排序是一个数值型类型
        "order": "desc"                                          # 降序
      }
    },
    "_score"                                                     # 如果参与者数量相同,使用_score作为第二排序标准
  ]
}'

0 人点赞