Elasticsearch探索:相关性打分机制 API

2021-01-18 18:01:17 浏览数 (1)

简介

官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html

Lucene的计分函数

Lucene’s Practical Scoring Function

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

代码语言:javascript复制
score(q,d)  =  // score(q, d) 是文档 d 与 查询 q 的相关度分数
            queryNorm(q)  // queryNorm(q) 是查询正则因子(query normalization factor)
          · coord(q,d)    // coord(q, d) 是协调因子(coordination factor)
          · ∑ (           // 查询 q 中每个术语 t 对于文档 d 的权重和
                tf(t in d)   // tf(t in d) 是术语 t 在文档 d 中的词频
              · idf(t)²      // idf(t) 是术语 t 的逆向文档频次
              · t.getBoost() // t.getBoost() 是查询中使用的 boost
              · norm(t,d)    // norm(t,d) 是字段长度正则值,与索引时字段级的boost的和(如果存在)
            ) (t in q)    // 查询 q 中每个术语 t 对于文档 d 的权重和

词频

Term frequency

术语在文档中出现的频度是多少?频度越高,权重越大。词频的计算方式如下:

代码语言:javascript复制
tf(t in d) = √frequency // 术语 t 在文件 d 的词频(tf)是这个术语在文档中出现次数的平方根。

逆向文档频率

Inverse document frequency

术语在集合所有文档里出现的频次。频次越高,权重越低。逆向文档频率的计算公式如下:

代码语言:javascript复制
idf(t) = 1   log ( numDocs / (docFreq   1)) // 术语t的逆向文档频率是索引中文档数量除以所有包含该术语文档数量后的对数值。
字段长度正则值

Field-length norm

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

代码语言:javascript复制
norm(d) = 1 / √numTerms  // 字段长度正则值是字段中术语数平方根的倒数。

查询正则因子

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 查询以非评分模式来执行 term 查询并以一作为统一评分。constant_score 查询中,它可以包含一个查询或一个过滤,为任意一个匹配的文档指定分数,忽略TF/IDF信息。

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

代码语言:javascript复制
{
  "query": {
    "constant_score": {
      "filter": {
        "term": {
          "drugname": "阿莫西林"
        }
      }
    }
  }
}

Java代码:
ConstantScoreQueryBuilder constantScoreQueryBuilder = new ConstantScoreQueryBuilder(QueryBuilders.termsQuery("drugname", q));
builder.query(constantScoreQueryBuilder);

function_score 查询

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

在很多时候这样做的效果并不好,这时候就需要对多个字段进行综合评估,得出一个最终的排序。这时就需要用到function_score 查询(function_score query) ,在 Elasticsearch 中function_score是用于处理文档分值的 DSL,它会在查询结束后对每一个匹配的文档进行一系列的重打分操作,最后以生成的最终分数进行排序。它提供了几种默认的计算分值的函数:

  • weight:为每个文档应用一个简单的而不被正则化的权重提升值:当 weight 为 2 时,最终结果为 2 * _score
  • field_value_factor:将某个字段的值进行计算得出分数
  • random_score:随机得到 0 到 1 分数, 为每个用户都使用一个不同的随机分数来对结果排序,但对某一具体用户来说,看到的顺序始终是一致的
  • Decay functions — linear, exp, gauss:以某个字段的值为标准,距离某个值越近得分越高
  • script_score 如果需求超出以上范围时,用自定义脚本完全控制分数计算的逻辑。 它还有一个属性boost_mode可以指定计算后的分数与原始的_score如何合并,有以下选项:
    • multiply:将_score与函数值相乘(默认)
    • sum:将_score与函数值相加
    • min:取_score与函数值的较小值
    • max:取_score与函数值的较大值
    • replace:函数值替代_score
代码语言:javascript复制
GET /_search
{
  "query": {
    "function_score": {
      "query": { "match_all": {} },
      "boost": "5", 
      "functions": [
        {
          "filter": { "match": { "test": "bar" } },
          "random_score": {}, 
          "weight": 23
        },
        {
          "filter": { "match": { "test": "cat" } },
          "weight": 42
        }
      ],
      "max_boost": 42,
      "score_mode": "max",
      "boost_mode": "multiply",
      "min_score": 42
    }
  }
}

weight

The weight score allows you to multiply the score by the provided weight. This can sometimes be desired since boost value set on specific queries gets normalized, while for this score function it does not. The number value is of type float.

weight 的用法最为简单,只需要设置一个数字作为权重,文档的分数就会乘以该权重。

他最大的用途应该就是和过滤器一起使用了,因为过滤器只会筛选出符合标准的文档,而不会去详细的计算每个文档的具体得分,所以只要满足条件的文档的分数都是 1,而 weight 可以将其更换为你想要的数值。

代码语言:javascript复制
"weight" : number
代码语言:javascript复制
POST drug/_search
{
  "query": {
    "function_score": {
      "query": {
        "match_all": {}
      },
      "functions": [
        {
          "filter": {
            "match": {
              "commonname": "阿莫西林"
            }
          },
          "weight": 2
        }
      ]
    }
  }
}
备注:"_score" : 2.0

field_value_factor

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

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

举一个简单的例子,假设有一个商品索引,搜索时希望在相关度排序的基础上,销量(sales)更高的商品能排在靠前的位置,那么这条查询 DSL 可以是这样的:

代码语言:javascript复制
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "commonname": "阿莫西林"
        }
      },
      "field_value_factor": {
        "field": "sales",
        "modifier": "log1p",
        "factor": 0.1
      },
      "boost_mode": "sum"
    }
  }
}

这条查询会将药品名中带有阿莫西林的药品检索出来,然后对这些文档计算一个与库存相关的分数,并与之前相关度的分数相加,对应的公式为:

代码语言:javascript复制
_score = _score   log (1   0.1 * sales)

随机计分(random_score)

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

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

  • seed:指定随机的种子,相同的种子返回相同排序,每个种子会为每个文档生成一个0-1的随机数,改随机数就是random_score的返回值,可以和其他filter或者外部打分一起使用。
  • field:对于相同shard的相同field的值,产生的随机数一样,因此在使用的时候,尽量选择值不一样的field。
代码语言:javascript复制
POST drug/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "commonname": "阿莫西林"
        }
      },
      "random_score": {
        "seed": 1,
        "field": "id"
      }
    }
  }
}
备注:seed值不同时,排序会发生变化

衰减函数(Decay functions)

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

  • origin(原点):代表中心点(central point)或字段可能的最佳值,落在原点(origin)上的文档分数为满分 1.0。
  • offset(偏移量):以原点(origin)为中心点,为其设置一个非零的偏移量(offset)覆盖一个范围,而不只是原点(origin)这单个点。在此范围内(-offset <= origin <= offset)的所有值的分数都是 1.0。
  • scale(衰减规模):代表衰减率,当值超出了原点到偏移量这段范围,它所得的分数就开始进行衰减了,衰减规模决定了这个分数衰减速度的快慢
  • decay(衰减值):该字段可以被接受的值(默认为 0.5),相当于一个分界点,具体的效果与衰减的模式有关

衰减函数还可以指定三种不同的模式:线性函数(linear)、以 e 为底的指数函数(Exp)和高斯函数(gauss),它们拥有不同的衰减曲线:

代码语言:javascript复制
GET /_search
{
  "query": {
    "function_score": {
      "functions": [
        {
          "gauss": {
            "price": {
              "origin": "0",
              "scale": "20"
            }
          }
        },
        {
          "gauss": {
            "location": {
              "origin": "11, 12",
              "scale": "2km"
            }
          }
        }
      ],
      "query": {
        "match": {
          "properties": "balcony"
        }
      },
      "score_mode": "multiply"
    }
  }
}

例如我们想要买一样东西:

它的理想价格是 50 元,这个值为原点,但是我们不可能非 50 元就不买,而是会划定一个可接受的价格范围,例如 45-55 元,±5 就为偏移量。当价格超出了可接受的范围,就会让人觉得越来越不值。如果价格是 70 元,评价可能是不太想买,而如果价格是 200 元,评价则会是不可能会买,这就是由衰减规模和衰减值所组成的一条衰减曲线。

例如我们想租一套房:

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

代码语言:javascript复制
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "title": "公寓"
        }
      },
      "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即可。

代码语言:javascript复制
GET /_search
{
  "query": {
    "function_score": {
      "query": {
        "match": { "message": "elasticsearch" }
      },
      "script_score": {
        "script": {
          "params": {
            "a": 5,
            "b": 1.2
          },
          "source": "params.a / Math.pow(params.b, doc['my-int'].value)"
        }
      }
    }
  }
}

举一个之前做不到的例子,假如我们有一个位置索引,它有一个分类(category)属性,该属性是字符串枚举类型,例如商场、电影院或者餐厅等。现在由于我们有一个电影相关的活动,所以需要将电影院在搜索列表中的排位相对靠前。

之前的两种方式都无法给字符串打分,但是如果我们自己写脚本的话却很简单,使用 Groovy(Elasticsearch 的默认脚本语言)也就是一行的事:

代码语言:javascript复制
return doc ['category'].value == '电影院' ? 1.1 : 1.0
接下来只要将这个脚本配置到查询语句中就可以了:
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "天安门"
        }
      },
      "script_score": {
        "script": "return doc ['category'].value == '电影院' ? 1.1 : 1.0"
      }
    }
  }
}

或是将脚本放在elasticsearch/config/scripts下,然后在查询语句中引用它:

代码语言:javascript复制
category-score.groovy:return doc ['category'].value == '电影院' ? 1.1 : 1.0
代码语言:javascript复制
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "天安门"
        }
      },
      "script_score": {
        "script": {
         "file": "category-score"
        }
      }
    }
  }
}

script中还可以通过params属性向脚本传值,所以为了解除耦合,上面的 DSL 还能接着改写为:

代码语言:javascript复制
category-score.groovy:return doc ['category'].value == recommend_category ? 1.1 : 1.0
代码语言:javascript复制
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "天安门"
        }
      },
      "script_score": {
        "script": {
         "file": "category-score",
         "params": {
            "recommend_category": "电影院"
         }
        }
      }
    }
  }
}

这样就可以在不更改大部分查询语句和脚本的基础上动态修改推荐的位置类别了。

同时使用多个函数

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

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

第一个例子是类似于大众点评的餐厅应用。该应用希望向用户推荐一些不错的餐馆,特征是:范围要在当前位置的 5km 以内,有停车位是最重要的,有 Wi-Fi 更好,餐厅的评分(1 分到 5 分)越高越好,并且对不同用户最好展示不同的结果以增加随机性。

代码语言:javascript复制
{
  "query": {
    "function_score": {
      "filter": {
        "geo_distance": {
          "distance": "5km",
          "location": {
            "lat": $lat,
            "lon": $lng
          }
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "features": "wifi"
            }
          },
          "weight": 1
        },
        {
          "filter": {
            "term": {
              "features": "停车位"
            }
          },
          "weight": 2
        },
        {
            "field_value_factor": {
               "field": "score",
               "factor": 1.2
             }
        },
        {
          "random_score": {
            "seed": "$id"
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}
备注:其中所有以$开头的都是变量。
这样一个饭馆的最高得分应该是 2 分(有停车位)  1 分(有 wifi)  6 分(评分 5 分 * 1.2)  1 分(随机评分)。

第二个例子是类似于新浪微博的社交网站。现在要优化搜索功能,使其以文本相关度排序为主,但是越新的微博会排在相对靠前的位置,点赞(忽略相同计算方式的转发和评论)数较高的微博也会排在较前面。如果这篇微博购买了推广并且是创建不到 24 小时(同时满足),它的位置会非常靠前。

代码语言:javascript复制
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "content": "$text"
        }
      },
      "functions": [
        {
          "gauss": {
            "createDate": {
              "origin": "$now",
              "scale": "6d",
              "offset": "1d"
            }
          }
        },
        {
          "field_value_factor": {
            "field": "like_count",
            "modifier": "log1p",
            "factor": 0.1
          }
        },
        {
          "script_score": {
            "script": "return doc ['is_recommend'].value && doc ['create_date'] > time ? 1.5 : 1.0",
            params: {
                "time": $time
            }
          }
        }
      ],
      "boost_mode": "multiply"
    }
  }
}
代码语言:javascript复制
它的公式为:
_score * gauss (create_date, $now, "1d", "6d") * log (1   0.1 * like_count) * is_recommend ? 1.5 : 1.0

0 人点赞