干货 | ElasticSearch相关性打分机制

作者简介

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

携程运动是携程旗下新业务,主要给用户提供羽毛球、游泳等运动项目的场馆预定。最近我们在做场馆搜索的功能时,接触到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 模型不再满足评分需求(例如希望基于时间或距离来评分),则需要使用自定义脚本,灵活应用各种需求。

原文发布于微信公众号 - 携程技术中心(ctriptech)

原文发表时间:2017-06-29

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏iOSDevLog

Scikit-Learn教程:棒球分析 (一)

一个scikit-learn教程,通过将数据建模到KMeans聚类模型和线性回归模型来预测MLB每赛季的胜利。

24320
来自专栏媒矿工厂

高吞吐量JPEG 2000(HTJ2K):新的算法和机会

本文参考D. S. Taubman等人发表在SMPTE Motion Imaging Journal上的文章High Throughput JPEG 2000 ...

18520
来自专栏AI科技大本营的专栏

开启机器学习的第一课:用Pandas进行数据分析

翻译 | AI科技大本营 参与 | 林椿眄 本课程的目的并不是要开发一门全新的机器学习或数据分析的综合入门课程,也不是想借此来取代基础教育、在线/离线课程或一...

46650
来自专栏AI科技大本营的专栏

用JavaScript创建神经网络的有趣教程,一定要让你知道!

【导读】本文中作者为初学者解释了如何使用 JavaScript 来搭建一个神经网络。不用担心,这不是一份深入介绍隐藏输入层、激励函数或如何使用 TensorFl...

11320
来自专栏Android 开发者

Android Smart Linkify 支持机器学习

上半周,我们发布了 Android 9 Pie,这是 Android 的最新版本,它的机器学习应用使您的手机更简单易用。 Android 9 中有一项功能是 S...

15430
来自专栏鹅厂优文

游戏人工智能 读书笔记 (四) AI算法简介——Ad-Hoc 行为编程

本书英文版: Artificial Intelligence and Games - A Springer Textbook

293100
来自专栏数据魔术师

运筹学教学 | 十分钟快速掌握最大流算法(附C++代码及算例)

—“运筹教科书到底能给你啥?” —“算法和实现离教科书有多远?” —“问题解决能力到底从哪来?” 今天刚起床就接到了BOSS的 提·问·三·连 小编表示 收到直...

61350
来自专栏深度学习与数据挖掘实战

干货|社区发现算法FastUnfolding的GraphX实现

现实生活中存在各种各样的网络,诸如人际关系网、交易网、运输网等等。对这些网络进行社区发现具有极大的意义,如在人际关系网中,可以发现出具有不同兴趣、背景的社会团体...

35430
来自专栏人工智能头条

饿了么推荐系统:从0到1

31050
来自专栏CreateAMind

DeepMind可微分神经计算机-论文中文解读

又是一篇deepmind发表在nature上的文章,还记得前面2篇吗?一篇是DQN,一篇讲AlphaGo。发表在nature上的论文格式不太一样,正文只是简单描...

10420

扫码关注云+社区

领取腾讯云代金券