作者简介
孙咸伟,后端开发一枚,在携程技术中心市场营销研发部负责“携程运动”项目的开发和维护。
携程运动是携程旗下新业务,主要给用户提供羽毛球、游泳等运动项目的场馆预定。最近我们在做场馆搜索的功能时,接触到elasticsearch(简称es)搜索引擎。
我们展示给用户的运动场馆,在匹配到用户关键词的情况下,还会综合考虑多种因素,比如价格,库存,评分,销量,经纬度等。
如果单纯按场馆距离、价格排序时,排序过于绝对,比如有时会想让库存数量多的场馆排名靠前,有时会想让评分过低的排名靠后。有时在有多家价格相同的场馆同时显示的情况下,想让距离用户近的场馆显示在前面,这时就可以通过es强大的评分功能来实现。
本文将分享es是如何对文档打分的,以及在搜索查询时遇到的一些常用场景,希望给接触搜索的同学一些帮助。
一、Lucene的计分函数(Lucene’s Practical Scoring Function)
对于多术语查询,Lucene采用布尔模型(Boolean model)、词频/逆向文档频率(TF/IDF)、以及向量空间模型(Vector Space Model),然后将他们合并到单个包中来收集匹配文档和分数计算。 只要一个文档与查询匹配,Lucene就会为查询计算分数,然后合并每个匹配术语的分数。这里使用的分数计算公式叫做 实用计分函数(practical scoring function)。
代码语言:javascript复制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次提到的更相关。词频的计算方式如下:
代码语言:javascript复制tf(t in d) = √frequency #1
- #1 术语 t 在文件 d 的词频(tf)是这个术语在文档中出现次数的平方根。
逆向文档频率(Inverse document frequency)
术语在集合所有文档里出现的频次。频次越高,权重越低。常用词如 and 或 the 对于相关度贡献非常低,因为他们在多数文档中都会出现,一些不常见术语如 elastic 或 lucene 可以帮助我们快速缩小范围找到感兴趣的文档。逆向文档频率的计算公式如下:
代码语言:javascript复制idf(t) = 1 log ( numDocs / (docFreq 1)) #1
- #1 术语t的逆向文档频率(Inverse document frequency)是:索引中文档数量除以所有包含该术语文档数量后的对数值。
字段长度正则值(Field-length norm)
字段的长度是多少?字段越短,字段的权重越高。如果术语出现在类似标题 title 这样的字段,要比它出现在内容 body 这样的字段中的相关度更高。字段长度的正则值公式如下:
代码语言:javascript复制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可以是这样的:
代码语言:javascript复制{
"query": {
"function_score": {
"query": {
"match": {
"name": "游泳馆"
} },
"field_value_factor": {
"field": "comment_score",
"modifier": "log1p",
"factor": 0.1
},
"boost_mode": "sum"
} }}
这条查询会将名称中带有游泳的场馆检索出来,然后对这些文档计算一个与评分(comment_score)相关的分数,并与之前相关度的分数相加,对应的公式为:
代码语言:javascript复制_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表示就是:
代码语言:javascript复制{
"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和衰减函数已经可以解决大部分问题,但是也可以看出它们还有一定的局限性:
- 这两种方式都只能针对一个字段计算分值
- 这两种方式应用的字段类型有限,field_value_factor一般只用于数字类型,而衰减函数一般只用于数字、位置和时间类型
这时候就需要script_score了,它支持我们自己编写一个脚本运行,在该脚本中我们可以拿到当前文档的所有字段信息,并且只需要将计算的分数作为返回值传回Elasticsearch即可。
注:使用脚本需要首先在配置文件中打开相关功能:
代码语言:javascript复制script.groovy.sandbox.enabled: true
script.inline: on
script.indexed: on
script.search: on
script.engine.groovy.inline.aggs: on
现在正值炎热的夏天,游泳成为很多人喜爱的运动项目,在满足用户搜索条件的情况下,我们想把游泳分类的场馆排名提前。此时可以编写Groovy脚本(Elasticsearch的默认脚本语言)来提高游泳相关场馆的分数。
代码语言:javascript复制return doc['category'].value == '游泳' ? 1.5 : 1.0
接下来只要将这个脚本配置到查询语句:
代码语言:javascript复制{
"query": {
"function_score": {
"query": {
"match": {
"name": "运动"
} },
"script_score": {
"script": "return doc['category'].value == '游泳' ? 1.5 : 1.0"
} } }}
当然还可以通过params属性向脚本传值,让推荐更灵活。
代码语言:javascript复制{
"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分)越高越好,并且对不同用户最好展示不同的结果以增加随机性。
那么它的查询语句应该是这样的:
代码语言:javascript复制{
"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 模型不再满足评分需求(例如希望基于时间或距离来评分),则需要使用自定义脚本,灵活应用各种需求。