relevance score原理及排序规则优化
多shard场景下相关度分数(relevance score)不准确问题
在es中检索某个field中是否包含关键字,会使用到TF/IDF算法来计算相关度分数
计算相关度分数主要从以下三点考虑
- 在一个doc中field中关键字出现的次数(越大相关度越高)
- 在所有doc中field中关键字出现的次数(次数越大相关度越低)
- doc中field的长度
在计算相关度分数时,第二点很关键,因为es默认在一个shard中统计的,不是索引里所有的primary shard
可能会出现以下情况
有shard1(5000条数据),shard2(100条数据)两个分片,搜索吃鸡手机
字段,中文分词器会分成吃
,鸡
,手机
shard1有由一个词条满足吃鸡手机
,shard2中一条只满足手机
,但是在这种情况下,es可能会先返回shard2的数据,因为shard2的数据少,可能会导致虽然不是全部满足也会比shard1的分数要高
解决方案
-
生产环境下,数据量大,尽可能实现均匀分配,es在多个shard中均匀路由数据的,路由的时候根据_id,负载均衡
-
设置shard的大小相同
multi_match多字段搜索
multi_match
查询提供了一个简便的方法用来对多个字段执行相同的查询。有三种返回方式
- best_fields
- most_fields
- cross_fields
测试数据
#创建索引
PUT product
{
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_max_word"
},
"desc":{
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_max_word"
}
}
}
}
PUT /product/_doc/1
{
"name": "吃鸡手机,游戏神器,超级",
"desc": "基于TX深度定制,流畅游戏不发热,物理外挂,快充",
"price": 3999,
"createtime": "2020-05-20",
"collected_num": 99,
"tags": [
"性价比",
"发烧",
"不卡"
]
}
PUT /product/_doc/2
{
"name": "小米NFC手机",
"desc": "支持全功能NFC,专业吃鸡,快充",
"price": 4999,
"createtime": "2020-05-20",
"collected_num": 299,
"tags": [
"性价比",
"发烧",
"公交卡"
]
}
PUT /product/_doc/3
{
"name": "NFC手机,超级",
"desc": "手机中的轰炸机",
"price": 2999,
"createtime": "2020-05-20",
"collected_num": 1299,
"tags": [
"性价比",
"发烧",
"门禁卡"
]
}
PUT /product/_doc/4
{
"name": "小米耳机",
"desc": "耳机中的黄焖鸡",
"price": 999,
"createtime": "2020-05-20",
"collected_num": 9,
"tags": [
"低调",
"防水",
"音质好"
]
}
PUT /product/_doc/5
{
"name": "红米耳机",
"desc": "耳机中的肯德基",
"price": 399,
"createtime": "2020-05-20",
"collected_num": 0,
"tags": [
"牛逼",
"续航长",
"质量好"
]
}
best_fields
默认情况下是best_fields
:对于同一个查询语句,多个字段中,返回评分最高的作为分数,并以此作为排序依据。
GET product/_search
{
"query": {
"multi_match": {
"type": "best_fields",
"query": "吃鸡手机",
"fields": [
"name",
"desc"
]
}
}
}
#类似于
GET product/_search
{
"query": {
"dis_max": {
"queries": [
{
"match": {
"name": "吃鸡手机"
}
},
{
"match": {
"desc": "吃鸡手机"
}
}
]
}
}
}
返回数据
单节点在删除索引重新插入数据后,分数变化,经查询资料:当索引有update/delete一类操作的时候,旧文档不是马上被删除的,实际的删除操作发生在一些segment合并的阶段。 而主副分片的segment合并完全是各不想干独立进行的,所以还未删除的旧文档是不一致的。 而出于一些实际因素的考虑,还未物理删除的文档,也是shard统计信息的一部分,这样就会导致分片可能存在打分的差异。
查询条件\返回 分数 | doc1 | doc2 | doc3 |
---|---|---|---|
name | 1.565133 | 1.894927 | 2.055728 |
desc | 1.341788 | 1.502588 | |
name+desc | 1.565133 | 1.894927 | 2.055728 |
分析
搜索关键词:吃鸡手机(吃鸡加入热词库中,不会被分词)
结果分析:期望的匹配结果是doc1>doc2>doc3
TF/IDF:
TF: 关键词在每个doc中出现的次数
IDF: 关键词在整个索引中出现的次数
relevance score计算规则:每个query的分数,乘以matched query数量,除以总query数量
-
它会执行 should 语句中的两个查询。
-
加和两个查询的评分。
-
乘以匹配语句的总数。
-
除以所有语句总数
下面从宏观上来讲的简单公式:
score=best_field.scoreboost+other_fieldsboost.score*tie_breaker。
实际计算远比这个公式复杂得多,还要考虑分片因素、出现位置、文档长短等。
name字段 | 吃鸡 | 手机 | 计分 |
---|---|---|---|
doc1 | 1 | 1 | 2 |
doc2 | 0 | 1 | 1 |
doc3 | 0 | 1 | 1 |
des字段 | 吃鸡 | 手机 | 计分 |
---|---|---|---|
doc1 | 0 | 0 | 0 |
doc2 | 0 | 1 | 1 |
doc3 | 0 | 1 | 1 |
总分: (query1+query2)*matched query / total query
doc1 | doc2 | doc3 |
---|---|---|
2*1/2=1 | 2*2/2=2 | 2*2/2=2 |
实际情况: 由于3中字段长度较短,导致比2的分数要高,导致实际结果为 3>2>1
most_fields
- 含义:匹配多个字段,返回的综合评分(非最高分)
- 类似:bool + 多字段匹配。
GET product/_search
{
"query": {
"multi_match": {
"type": "most_fields",
"query": "吃鸡手机",
"fields": [
"name",
"desc"
]
}
}
}
#等价于
GET product/_search
{
"query": {
"bool": {
"should": [
{"match": {
"name": "吃鸡手机"
}},
{
"match": {
"desc": "吃鸡手机"
}
}
]
}
}
}
cross_fields
- 含义:跨字段匹配——待查询内容在多个字段中都显示。
#解释: 分词器会将此分为 吃鸡 手机 两个词
#此时是要求 吃鸡 在 name/desc 中必须出现 并且 手机 在name/desc必须出现
#不写默认 是 or
GET product/_search
{
"query": {
"multi_match": {
"type": "cross_fields",
"query": "吃鸡手机",
"fields": [
"name",
"desc"
]
, "operator": "and"
}
}
}
#解释: 分词器会将此分为 吃鸡 手机 两个词
#此时是要求 吃鸡 在 name/desc 中必须出现 或者 手机 在name/desc必须出现
GET product/_search
{
"query": {
"multi_match": {
"type": "cross_fields",
"query": "吃鸡手机",
"fields": [
"name",
"desc"
]
}
}
}
dix_max查询
将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回 ,类似 多字段搜索的best_fields
GET product/_search
{
"query": {
"dis_max": {
"queries": [
{
"match": {
"name": "吃鸡手机"
}
},
{
"match": {
"desc": "吃鸡手机"
}
}
]
}
}
}
tie_breaker
取值范围 [0,1],其中 0 代表使用 dis_max 最佳匹配语句的普通逻辑,1表示所有匹配语句同等重要。最佳的精确值需要根据数据与查询调试得出,但是合理值应该与零接近(处于 0.1 - 0.4 之间),这样就不会颠覆 dis_max 最佳匹配性质的根本
#此时设置为0.7 表示除了最佳匹配的字段权重为1以外, 其他的要乘以0.7的系数
GET product/_search
{
"query": {
"dis_max": {
"queries": [
{
"match": {
"name": "吃鸡手机"
}
},
{
"match": {
"desc": "吃鸡手机"
}
}
],
"tie_breaker": 0.7
}
}
}
function score query
ES 会为 query 的每个文档计算一个相关度得分 score ,并默认按照 score 从高到低的顺序返回搜索结果。
在很多场景下,我们不仅需要搜索到匹配的结果,还需要能够按照某种方式对搜索结果重新打分排序。例如:
- 搜索具有某个关键词的文档,同时考虑到文档的时效性进行综合排序。
- 搜索某个旅游景点附近的酒店,同时根据距离远近和价格等因素综合排序。
- 搜索标题包含 elasticsearch 的文章,同时根据浏览次数和点赞数进行综合排序。
Function score query 就可以让我们实现对最终 score 的自定义打分。
score 自定义打分过程
为了行文方便,本文把 ES 对 query
匹配的文档进行打分得到的 score 记为 query_score
,而最终搜索结果的 score 记为 result_score
,显然,一般情况下(也就是不使用自定义打分时),result_score
就是 query_score
。
那么当我们使用了自定义打分之后呢?最终结果的 score 即 result_score
的计算过程如下:
- 跟原来一样执行
query
并且得到原来的query_score
。 - 执行设置的自定义打分函数,并为每个文档得到一个新的分数,本文记为
func_score
。 - 最终结果的分数
result_score
等于query_score
与func_score
按某种方式计算的结果(默认是相乘)。
不使用自定义打分
GET /product/_search
{
"query": {
"match": {
"name":"吃鸡手机"
}
}
}
#或者
#默认是multiply
GET /product/_search
{
"query": {
"function_score": {
"query": {
"match": {
"name": "吃鸡手机"
}
}
, "boost_mode": "multiply" }
}
}
返回结果
{
"took" : 28,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 3,
"relation" : "eq"
},
"max_score" : 1.5651325,
"hits" : [
{
"_index" : "product",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.5651325,
"_source" : {
"name" : "吃鸡手机,游戏神器,超级",
"desc" : "基于TX深度定制,流畅游戏不发热,物理外挂,快充",
"price" : 3999,
"createtime" : "2020-05-20",
"collected_num" : 99,
"tags" : [
"性价比",
"发烧",
"不卡"
]
}
},
{
"_index" : "product",
"_type" : "_doc",
"_id" : "2",
"_score" : 0.55313927,
"_source" : {
"name" : "小米NFC手机",
"desc" : "支持全功能NFC,专业吃鸡,快充",
"price" : 4999,
"createtime" : "2020-05-20",
"collected_num" : 299,
"tags" : [
"性价比",
"发烧",
"公交卡"
]
}
},
{
"_index" : "product",
"_type" : "_doc",
"_id" : "3",
"_score" : 0.55313927,
"_source" : {
"name" : "NFC手机,超级",
"desc" : "手机中的轰炸机",
"price" : 2999,
"createtime" : "2020-05-20",
"collected_num" : 1299,
"tags" : [
"性价比",
"发烧",
"门禁卡"
]
}
}
]
}
}
最终搜索结果 score 的计算过程就是:
- 执行
query
得到原始的分数,与上文假设对应,即query_score
分别是0.3、0.2、0.1
。 - 执行自定义的打分函数,这一步会为每个文档得到一个新的分数,假设新的分数即
func_score
分别是1、3、5
。 - 最终结果的 score 分数即
result_score
=query_score
*func_score
,对应假设的三个搜索结果最终的 score 分别就是0.3 * 1 = 0.3
、0.2 * 3 = 0.6
、0.1 * 5 = 0.5
,至此我们完成了新的打分过程,而搜索结果也会按照最终的 score 降序排列。
最终的分数 result_score
是由 query_score
与 func_score
进行计算而来,计算方式由参数 boost_mode
定义:
multiply
: 相乘(默认),result_score = query_score * function_score
replace
: 替换,result_score = function_score
sum
: 相加,result_score = query_score + function_score
avg
: 取两者的平均值,result_score = Avg(query_score, function_score)
max
: 取两者之中的最大值,result_score = Max(query_score, function_score)
min
: 取两者之中的最小值,result_score = Min(query_score, function_score)
function_score 打分函数
function_score
提供了以下几种打分的函数:
-
weight
: 加权。 -
random_score
: 随机打分。 -
field_value_factor
: 使用字段的数值参与计算分数。-
field:要计算的字段
-
modifier:以何种运算方式计算,接受以下枚举
-
none:不处理
-
log:计算对数 log(factor * field_value)
-
log1p:先将字段值 +1,再计算对数 log(1 + factor * field_value)
-
log2p:先将字段值 +2,再计算对数 log(2 + factor * field_value)
-
ln:计算自然对数 ln(factor * field_value)
-
ln1p:先将字段值 +1,再计算自然对数 ln(1 + factor * field_value)
-
ln2p:先将字段值 +2,再计算自然对数 ln(2 + factor * field_value)
-
square:计算平方 (factor * field_value)^2
-
sqrt:计算平方根 sqrt(factor * field_value)
-
reciprocal:计算倒数 1/(factor * field_value)
-
-
factor:当前分数计算,对整个结果产生的权重比
-
-
decay_function
: 衰减函数 gauss, linear, exp 等。同样以某个字段的值为标准,距离某个值越近得分越高 -
script_score
: 自定义脚本。 -
boost_mode
:指定计算后的分数与原始的_score如何合并,有以下选项- multiply:查询分数和函数分数相乘
- sum:查询分数和函数分数相加
- avg:取平均值
- replace:替换原始分数
- min:取查询分数和函数分数的最小值
- max:取查询分数和函数分数的最大值
-
max_boost
:分数上限
GET product/_search
{
"query": {
"function_score": {
"query": {
"match_all": {}
},
"field_value_factor": {
"field": "collected_num",
"modifier": "log1p",
"factor": 0.9
},
"boost_mode": "multiply",
"max_boost": 3
}
}
}
GET product/_search
{
"query": {
"function_score": {
"query": {
"match_all": {}
},
"script_score": {
"script": {
"source": "Math.log(1 + doc['price'].value)"
}
}
}
}
}