干货 | ElasticSearch相关性打分机制

时间:2022-05-04
本文章向大家介绍干货 | ElasticSearch相关性打分机制,主要内容包括逆向文档频率(Inverse document frequency)、字段长度正则值(Field-length norm)、查询正则因子(Query Normalization Factor)、查询协调(Query Coordination)、constant_score 查询、function_score 查询(function_score Query)、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

作者简介

孙咸伟,后端开发一枚,在携程技术中心市场营销研发部负责“携程运动”项目的开发和维护。

携程运动是携程旗下新业务,主要给用户提供羽毛球、游泳等运动项目的场馆预定。最近我们在做场馆搜索的功能时,接触到elasticsearch(简称es)搜索引擎。

我们展示给用户的运动场馆,在匹配到用户关键词的情况下,还会综合考虑多种因素,比如价格,库存,评分,销量,经纬度等。

如果单纯按场馆距离、价格排序时,排序过于绝对,比如有时会想让库存数量多的场馆排名靠前,有时会想让评分过低的排名靠后。有时在有多家价格相同的场馆同时显示的情况下,想让距离用户近的场馆显示在前面,这时就可以通过es强大的评分功能来实现。

本文将分享es是如何对文档打分的,以及在搜索查询时遇到的一些常用场景,希望给接触搜索的同学一些帮助。

一、Lucene的计分函数(Lucene’s Practical Scoring Function)

对于多术语查询,Lucene采用布尔模型(Boolean model)、词频/逆向文档频率(TF/IDF)、以及向量空间模型(Vector Space Model),然后将他们合并到单个包中来收集匹配文档和分数计算。 只要一个文档与查询匹配,Lucene就会为查询计算分数,然后合并每个匹配术语的分数。这里使用的分数计算公式叫做 实用计分函数(practical scoring function)。

score(q,d)  =  #1
            queryNorm(q)  #2
          · coord(q,d)    #3
          · ∑ (           #4
                tf(t in d)   #5
              · idf(t)²      #6
              · t.getBoost() #7
              · norm(t,d)    #8
            ) (t in q)    #9
  • #1 score(q, d) 是文档 d 与 查询 q 的相关度分数
  • #2 queryNorm(q) 是查询正则因子(query normalization factor)
  • #3 coord(q, d) 是协调因子(coordination factor)
  • #4 #9 查询 q 中每个术语 t 对于文档 d 的权重和
  • #5 tf(t in d) 是术语 t 在文档 d 中的词频
  • #6 idf(t) 是术语 t 的逆向文档频次
  • #7 t.getBoost() 是查询中使用的 boost
  • #8 norm(t,d) 是字段长度正则值,与索引时字段级的boost的和(如果存在)
词频(Term frequency)

术语在文档中出现的频度是多少?频度越高,权重越大。一个5次提到同一术语的字段比一个只有1次提到的更相关。词频的计算方式如下:

tf(t in d) = √frequency #1
  • #1 术语 t 在文件 d 的词频(tf)是这个术语在文档中出现次数的平方根。

逆向文档频率(Inverse document frequency)

术语在集合所有文档里出现的频次。频次越高,权重越低。常用词如 and 或 the 对于相关度贡献非常低,因为他们在多数文档中都会出现,一些不常见术语如 elastic 或 lucene 可以帮助我们快速缩小范围找到感兴趣的文档。逆向文档频率的计算公式如下:

idf(t) = 1 + log ( numDocs / (docFreq + 1)) #1
  • #1 术语t的逆向文档频率(Inverse document frequency)是:索引中文档数量除以所有包含该术语文档数量后的对数值。

字段长度正则值(Field-length norm)

字段的长度是多少?字段越短,字段的权重越高。如果术语出现在类似标题 title 这样的字段,要比它出现在内容 body 这样的字段中的相关度更高。字段长度的正则值公式如下:

norm(d) = 1 / √numTerms #1
  • #1 字段长度正则值是字段中术语数平方根的倒数。

查询正则因子(Query Normalization Factor)

查询正则因子(queryNorm)试图将查询正则化,这样就能比较两个不同查询结果。尽管查询正则值的目的是为了使查询结果之间能够相互比较,但是它并不十分有效,因为相关度分数_score 的目的是为了将当前查询的结果进行排序,比较不同查询结果的相关度分数没有太大意义。

查询协调(Query Coordination)

协调因子(coord)可以为那些查询术语包含度高的文档提供“奖励”,文档里出现的查询术语越多,它越有机会成为一个好的匹配结果。

二、查询时权重提升(Query-Time Boosting)

在搜索时使用权重提升参数让一个查询语句比其他语句更重要。查询时的权重提升是我们可以用来影响相关度的主要工具,任意一种类型的查询都能接受权重提升(boost)参数。将权重提升值设置为2,并不代表最终的分数会是原值的2倍;权重提升值会经过正则化和一些其他内部优化过程。尽管如此,它确实想要表明一个提升值为2的句子的重要性是提升值为1句子的2倍。

三、忽略TF/IDF(Ignoring TF/IDF)

有些时候我们不关心 TF/IDF,我们只想知道一个词是否在某个字段中出现过,不关心它在文档中出现是否频繁。

constant_score 查询

constant_score 查询中,它可以包含一个查询或一个过滤,为任意一个匹配的文档指定分数,忽略TF/IDF信息。

function_score 查询(function_score Query)

es进行全文搜索时,搜索结果默认会以文档的相关度进行排序,如果想要改变默认的排序规则,也可以通过sort指定一个或多个排序字段。但是使用sort排序过于绝对,它会直接忽略掉文档本身的相关度。

在很多时候这样做的效果并不好,这时候就需要对多个字段进行综合评估,得出一个最终的排序。这时就需要用到function_score 查询(function_score query) ,它允许我们为每个与主查询匹配的文档应用一个函数,以达到改变甚至完全替换原始分数的目的。 ElasticSearch预定义了一些函数:

  • weight 为每个文档应用一个简单的而不被正则化的权重提升值:当 weight 为 2 时,最终结果为 2 * _score
  • field_value_factor 使用这个值来修改 _score,如将流行度或评分作为考虑因素。
  • random_score 为每个用户都使用一个不同的随机分数来对结果排序,但对某一具体用户来说,看到的顺序始终是一致的。
  • Decay functions — linear, exp, gauss 以某个字段的值为标准,距离某个值越近得分越高。
  • script_score 如果需求超出以上范围时,用自定义脚本完全控制分数计算的逻辑。 它还有一个属性boost_mode可以指定计算后的分数与原始的_score如何合并,有以下选项:
  • multiply 将分数与函数值相乘(默认)
  • sum 将分数与函数值相加
  • min 分数与函数值的较小值
  • max 分数与函数值的较大值
  • replace 函数值替代分数
field_value_factor

field_value_factor的目的是通过文档中某个字段的值计算出一个分数,它有以下属性:

  • field:指定字段名
  • factor:对字段值进行预处理,乘以指定的数值(默认为1)
  • modifier将字段值进行加工,有以下的几个选项:
    • none:不处理
    • log:计算对数
    • log1p:先将字段值+1,再计算对数
    • log2p:先将字段值+2,再计算对数
    • ln:计算自然对数
    • ln1p:先将字段值+1,再计算自然对数
    • ln2p:先将字段值+2,再计算自然对数
    • square:计算平方
    • sqrt:计算平方根
    • reciprocal:计算倒数

假设有一个场馆索引,搜索时希望在相关度排序的基础上,评分(comment_score)更高的场馆能排在靠前的位置,那么这条查询DSL可以是这样的:

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "游泳馆"
        }      },
      "field_value_factor": {
        "field":    "comment_score",
        "modifier": "log1p",
        "factor":   0.1
      },
      "boost_mode": "sum"
    }  }}

这条查询会将名称中带有游泳的场馆检索出来,然后对这些文档计算一个与评分(comment_score)相关的分数,并与之前相关度的分数相加,对应的公式为:

_score = _score + log(1 + 0.1 * comment_score)
随机计分(random_score)

这个函数的使用相当简单,只需要调用一下就可以返回一个0到1的分数。

它有一个非常有用的特性是可以通过seed属性设置一个随机种子,该函数保证在随机种子相同时返回值也相同,这点使得它可以轻松地实现对于用户的个性化推荐。

衰减函数(Decay functions)

衰减函数(Decay Function)提供了一个更为复杂的公式,它描述了这样一种情况:对于一个字段,它有一个理想的值,而字段实际的值越偏离这个理想值(无论是增大还是减小),就越不符合期望。 有三种衰减函数——线性(linear)、指数(exp)和高斯(gauss)函数,它们可以操作数值、时间以及 经纬度地理坐标点这样的字段。三个都能接受以下参数:

  • origin 代表中心点(central point)或字段可能的最佳值,落在原点(origin)上的文档分数为满分 1.0。
  • scale 代表衰减率,即一个文档从原点(origin)下落时,分数改变的速度。
  • decay 从原点(origin)衰减到 scale 所得到的分数,默认值为 0.5。
  • offset 以原点(origin)为中心点,为其设置一个非零的偏移量(offset)覆盖一个范围,而不只是原点(origin)这单个点。在此范围内(-offset <= origin <= +offset)的所有值的分数都是 1.0。

这三个函数的唯一区别就是它们衰减曲线的形状,用图来说明会更为直观 衰减函数曲线

如果我们想找一家游泳馆:

  • 它的理想位置是公司附近
  • 如果离公司在5km以内,是我们可以接受的范围,在这个范围内我们不去考虑距离,而是更偏向于其他信息
  • 当距离超过5km时,我们对这家场馆的兴趣就越来越低,直到超出某个范围就再也不会考虑了

将上面提到的用DSL表示就是:

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "游泳馆"
        }      },
      "gauss": {
        "location": {
          "origin": { "lat": 31.227817, "lon": 121.358775 },
          "offset": "5km",
          "scale":  "10km"
           }         },
         "boost_mode": "sum"
    }  }}

我们希望租房的位置在(31.227817, 121.358775)坐标附近,5km以内是满意的距离,15km以内是可以接受的距离。

script_score

虽然强大的field_value_factor和衰减函数已经可以解决大部分问题,但是也可以看出它们还有一定的局限性:

  1. 这两种方式都只能针对一个字段计算分值
  2. 这两种方式应用的字段类型有限,field_value_factor一般只用于数字类型,而衰减函数一般只用于数字、位置和时间类型

这时候就需要script_score了,它支持我们自己编写一个脚本运行,在该脚本中我们可以拿到当前文档的所有字段信息,并且只需要将计算的分数作为返回值传回Elasticsearch即可。

注:使用脚本需要首先在配置文件中打开相关功能:

script.groovy.sandbox.enabled: true
script.inline: on
script.indexed: on
script.search: on
script.engine.groovy.inline.aggs: on

现在正值炎热的夏天,游泳成为很多人喜爱的运动项目,在满足用户搜索条件的情况下,我们想把游泳分类的场馆排名提前。此时可以编写Groovy脚本(Elasticsearch的默认脚本语言)来提高游泳相关场馆的分数。

return doc['category'].value == '游泳' ? 1.5 : 1.0

接下来只要将这个脚本配置到查询语句:

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "运动"
        }      },
      "script_score": {
        "script": "return doc['category'].value == '游泳' ? 1.5 : 1.0"
      }    }  }}

当然还可以通过params属性向脚本传值,让推荐更灵活。

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "运动"
        }      },
      "script_score": {
        "params": {
            "recommend_category": "游泳"
        },        "script": "return doc['category'].value == recommend_category ? 1.5 : 1.0"
      }    }  }}

scirpt_score 函数提供了巨大的灵活性,我们可以通过脚本访问文档里的所有字段、当前评分甚至词频、逆向文档频率和字段长度正则值这样的信息。

同时使用多个函数

上面的例子都只是调用某一个函数并与查询得到的_score进行合并处理,而在实际应用中肯定会出现在多个点上计算分值并合并,虽然脚本也许可以解决这个问题,但是应该没人愿意维护一个复杂的脚本。

这时候通过多个函数将每个分值都计算出再合并才是更好的选择。 在function_score中可以使用functions属性指定多个函数。它是一个数组,所以原有函数不需要发生改动。同时还可以通过score_mode指定各个函数分值之间的合并处理,值跟最开始提到的boost_mode相同。

下面举个例子介绍多个函数混用的场景。我们会向用户推荐一些不错的场馆,特征是:范围要在当前位置的5km以内,有停车位很重要,场馆的评分(1分到5分)越高越好,并且对不同用户最好展示不同的结果以增加随机性。

那么它的查询语句应该是这样的:

{
  "query": {
    "function_score": {
      "filter": {
        "geo_distance": {
          "distance": "5km",
          "location": {
            "lat": $lat,
            "lon": $lng          }        }      },
      "functions": [
        {
          "filter": {
            "term": {
              "features": "停车位"
            }          },
          "weight": 2
        },
        {
            "field_value_factor": {
               "field": "comment_score",
               "factor": 1.5
             }        },
        {
          "random_score": {
            "seed": "$id"
          }        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }  }}

注:其中所有以$开头的都是变量。 这样一个场馆的最高得分应该是2分(有停车位)+ 7.5分(评分5分 * 1.5)+ 1分(随机评分)。

总结

本文主要介绍了 Lucene 是如何基于 TF/IDF 生成评分的,以及 function_score 的使用。实践中,简单的查询组合就能提供很好的搜索结果,但是为了获得具有成效的搜索结果,就必须反复推敲修改前面介绍的这些调试方法。

通常,经过对策略字段应用权重提升,或通过对查询语句结构的调整来强调某个句子的重要性这些方法,就足以获得良好的结果。有时,如果 Lucene 基于词的 TF/IDF 模型不再满足评分需求(例如希望基于时间或距离来评分),则需要使用自定义脚本,灵活应用各种需求。