relevance score原理及排序规则优化

多shard场景下相关度分数(relevance score)不准确问题

在es中检索某个field中是否包含关键字,会使用到TF/IDF算法来计算相关度分数

计算相关度分数主要从以下三点考虑

  1. 在一个doc中field中关键字出现的次数(越大相关度越高)
  2. 在所有doc中field中关键字出现的次数(次数越大相关度越低)
  3. doc中field的长度

在计算相关度分数时,第二点很关键,因为es默认在一个shard中统计的,不是索引里所有的primary shard

可能会出现以下情况

有shard1(5000条数据),shard2(100条数据)两个分片,搜索吃鸡手机字段,中文分词器会分成手机shard1有由一个词条满足吃鸡手机,shard2中一条只满足手机,但是在这种情况下,es可能会先返回shard2的数据,因为shard2的数据少,可能会导致虽然不是全部满足也会比shard1的分数要高

image-20210601154627103

解决方案

  1. 生产环境下,数据量大,尽可能实现均匀分配,es在多个shard中均匀路由数据的,路由的时候根据_id,负载均衡

  2. 设置shard的大小相同

multi_match多字段搜索

multi_match查询提供了一个简便的方法用来对多个字段执行相同的查询。有三种返回方式

  1. best_fields
  2. most_fields
  3. 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统计信息的一部分,这样就会导致分片可能存在打分的差异。

es查询时,同一个文档,每次查询的得分不一样,总是在两个值之间来回切换

查询条件\返回 分数 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数量

  1. 它会执行 should 语句中的两个查询。

  2. 加和两个查询的评分。

  3. 乘以匹配语句的总数。

  4. 除以所有语句总数

    下面从宏观上来讲的简单公式:

    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 的计算过程如下:

  1. 跟原来一样执行 query 并且得到原来的 query_score
  2. 执行设置的自定义打分函数,并为每个文档得到一个新的分数,本文记为 func_score
  3. 最终结果的分数 result_score 等于 query_scorefunc_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 的计算过程就是:

  1. 执行 query 得到原始的分数,与上文假设对应,即 query_score 分别是 0.3、0.2、0.1
  2. 执行自定义的打分函数,这一步会为每个文档得到一个新的分数,假设新的分数即 func_score 分别是 1、3、5
  3. 最终结果的 score 分数即 result_score = query_score * func_score ,对应假设的三个搜索结果最终的 score 分别就是 0.3 * 1 = 0.30.2 * 3 = 0.60.1 * 5 = 0.5 ,至此我们完成了新的打分过程,而搜索结果也会按照最终的 score 降序排列。

最终的分数 result_score 是由 query_scorefunc_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 打分函数

ES 自定义打分 Function score query

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)"
        }
      }
    }
  }
}
posted @ 2021-06-08 16:04  知白守黑,和光同尘  阅读(607)  评论(0编辑  收藏  举报