• 从零搭建 ES 搜索服务(六)相关性排序优化


    一、前言

    上篇介绍了搜索结果高亮的实现方法,本篇主要介绍搜索结果相关性排序优化。


    二、相关概念

    2.1 排序

    默认情况下,返回结果是按照「相关性」进行排序的——最相关的文档排在最前。

    2.1.1 相关性排序(默认)

    在 ES 中相关性评分 由一个浮点数表示,并在搜索结果中通过「 _score 」参数返回,默认是按照 _score 降序排列。

    2.1.2 按照字段值排序

    使用「 sort 」参数实现,可指定一个或多个字段。然而使用 sort 排序过于绝对,它会直接忽略文档本身的相关度,因此仅适合在某些特殊场景使用。

    注:如果以字符串字段进行排序,除了索引一份用于全文查询的数据,还需要索引一份原始的未经分析器处理(即 not_analyzed )的数据。这就需要使用「 fields 」参数实现同一个字段多种索引方式,这里的「索引」是动词相当于「存储」的概念。

    2.2 相关性算法

    ES 5.X 版本将相关性算法由之前的「 TF/IDF 」算法改为了更先进的「 BM25 」算法。

    2.2.1 TF/IDF 评分算法

    ES版本 < 5 的评分算法,即词频/逆向文档频率。

    ① 词频( Term frequency )

    搜索词在文档中出现的频率,频率越高,相关度越高。计算公式如下:
    $$tf(t in d) = sqrt{frequency}$$
    搜索词「 t 」在文档「 d 」的词频「 tf 」是该词在文档中出现次数的平方根。

    ② 逆向文档频率( Inverse document frequency )

    搜索词在索引(单个分片)所有文档里出现的频率,频率越高,相关度越低。用人话描述就是「物以稀为贵」,计算公式如下:
    $$idf(t) = 1 + log frac{docCount}{docFreq + 1}$$
    搜索词「 t 」的逆向文档频率「 idf 」是索引中的文档总数除以所有包含该词的文档数,然后求其对数。

    ③ 字段长度归一值( Field length norm )

    字段的长度,字段越短,相关度越高。计算公式如下:
    $$norm(d) = frac{1}{sqrt{numTerms}}$$
    字段长度归一值「 norm 」是字段中词数平方根的倒数。

    注:前面公式中提到的「文档」实际上是指文档里的某个字段

    2.2.2 BM25 评分算法

    ES版本 >= 5 的评分算法;BM25 的 BM 是缩写自 Best Match, 25 貌似是经过 25 次迭代调整之后得出的算法。它也是基于 TF / IDF 算法进化来的。

    对于给定查询语句「Q」,其中包含关键词「$q_{1}$,...$q_{n}$」,那么文档「D」的 BM25 评分计算公式如下:
    $$score(D,Q) = sum_{i=1}^NIDF(q_{i}) · frac{f(q_{i},D) · (k_{1}+1)}{f(q_{i},D)+k_{1} · (1-b+b · frac{|D|}{avgdl})}$$
    这个公式看起来很唬人,尤其是那个求和符号,不过分解开来还是比较好理解的。

    总体而言,主要还是分三部分,TF - IDF - Document Length

    • IDF 的计算公式调整为如下所示,其中N 为文档总数, $n(q_{i})$ 为包含搜索词 $q_{i}$ 的文档数。
      $$IDF(q_{i}) = 1 + logfrac{N-n(q_{i})+0.5}{n(q_{i})+0.5}$$
    • $f(q_{i},D)$ 为搜索词 $q_{i}$ 在文档 D 中的「 TF 」,| D | 是文档的长度,avgdl 是平均文档长度。
      先不看 IDF 和 Document Length 的部分, 则公式变为 TF * ($k_{1}$ + 1) / (TF + $k_{1}$),
      相比传统的 TF/IDF 而言,BM25 抑制了 TF 对整体评分的影响程度,虽然同样都是增函数,但是 BM25 中,TF 越大,带来的影响无限趋近于 ($k_{1}$ + 1),这里 $k_{1}$ 值通常取 [1.2, 2.0],而传统的 TF/IDF 则会没有临界点的无限增长。
    • 至于文档长度 | D | 的影响,可以看到在命中搜索词的情况下,文档越短,相关性越高,具体影响程度又可以由公式中的 b 来调整,当设值为 0 的时候,就跟将 norms 设置为 false 一样,忽略文档长度的影响。
    • 最后再对所有搜索词的计算结果求和,就是 ES5 中一般查询的得分了。

    三、实际案例

    3.1 现实需求

    要求搜索文章时,搜索词出现在标题时的权重要比出现在内容中高,同时要考虑「引用次数」对最终排序的影响。

    3.2 实现方法

    3.2.1 调整搜索字段权重

    通过调整字段的 boost 参数实现自定义权重,此处将标题的权重调整为内容的两倍。

    private SearchQuery getKnowledgeSearchQuery(KnowledgeSearchParam param) {
        ...省略其余部分...
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        boolQuery.must(QueryBuilders.termQuery("isDeleted", IsDeletedEnum.NO.getKey()));
        boolQuery.should(QueryBuilders.matchQuery(knowledgeTitleFieldName, param.getKeyword()).boost(2.0f));
        boolQuery.should(QueryBuilders.matchQuery(knowledgeContentFieldName, param.getKeyword()));
        return new NativeSearchQueryBuilder()
                .withPageable(pageable)
                .withQuery(boolQuery)
                .withHighlightFields(knowledgeTitleField, knowledgeContentField)
                .build();
    }
    
    3.2.2 按引用次数提升权重

    这里通过 function score 实现重打分操作。根据上面的需求,我们将使用 field value factor 函数指定「 referenceCount 」字段计算分数并与 _score 相加作为最终评分进行排序。

    private SearchQuery getKnowledgeSearchQuery(KnowledgeSearchParam param) {
        ...省略其余部分...
        // 引用次数更多的知识点排在靠前的位置
        // 对应的公式为:_score = _score + log (1 + 0.1 * referenceCount)
        ScoreFunctionBuilder scoreFunctionBuilder = ScoreFunctionBuilders
                .fieldValueFactorFunction("referenceCount")
                .modifier(FieldValueFactorFunction.Modifier.LN1P)
                .factor(0.1f);
        FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders
                .functionScoreQuery(boolQuery, scoreFunctionBuilder)
                .boostMode(CombineFunction.SUM);
        return new NativeSearchQueryBuilder()
                .withPageable(pageable)
                .withQuery(functionScoreQuery)
                .withHighlightFields(knowledgeTitleField, knowledgeContentField)
                .build();
    }
    

    上述的 function score 是 ES 用于处理文档分值的 DSL(领域专用语言),它预定义了一些计算分值的函数:

    ① weight

    为每个文档应用一个简单的权重提升值:当 weight 为 2 时,最终结果为 2 * _score

    ② field_value_factor

    通过文档中某个字段的值计算出一个分数且使用该值修改 _score,具有以下属性:

    属性 描述
    field 指定字段名
    factor 对字段值进行预处理,乘以指定的数值,默认为 1
    modifier 将字段值进行加工,默认为 none
    boost_mode 控制函数与 _score 合并的结果,默认为 multiply
    ③ random_score

    为每个用户都使用一个随机评分对结果排序,可以实现对于用户的个性化推荐。

    ④ 衰减函数

    提供一个更复杂的公式,描述了这样一种情况:对于一个字段,它有一个理想值,而字段实际的值越偏离这个理想值就越不符合期望。具有以下属性:

    属性 描述
    origin(原点) 该字段的理想值,满分 1.0
    offset(偏移量) 与原点相差在偏移量之内的值也可以得到满分
    scale(衰减规模) 当值超出原点到偏移量这段范围,它所得的分数就开始衰减,衰减规模决定了分数衰减速度的快慢
    decay(衰减值) 该字段可以被接受的值,默认为 0.5
    ⑤ script_score

    支持自定义脚本完全控制评分计算

    3.2.3 理解评分标准

    通过JAVA API 实现相关功能后,输出评分说明可以帮助我们更好的理解评分过程以及后续调整算法参数。

    ① 首先定义一个打印搜索结果的方法,设置 explain = true 即可输出 explanation 。

    public void debugSearchQuery(SearchQuery searchQuery, String indexName) {
        SearchRequestBuilder searchRequestBuilder = elasticsearchTemplate.getClient().prepareSearch(indexName).setTypes(indexName);
        searchRequestBuilder.setSearchType(SearchType.DFS_QUERY_THEN_FETCH);
        searchRequestBuilder.setFrom(0).setSize(10);
        searchRequestBuilder.setExplain(true);
        searchRequestBuilder.setQuery(searchQuery.getQuery());
        SearchResponse searchResponse;
        try {
            searchResponse = searchRequestBuilder.execute().get();
            long totalCount = searchResponse.getHits().getTotalHits();
            log.info("总条数 totalCount:" + totalCount);
            //遍历结果数据
            SearchHit[] hitList = searchResponse.getHits().getHits();
            for (SearchHit hit : hitList) {
                log.info("SearchHit hit explanation:{}
    source:{}", hit.getExplanation().toString(), hit.getSourceAsString());
            }
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
    

    ② 之后调用接口,其 explanation 结果展示如下:

    19.491358 = sum of
      19.309036 = sum of:
        19.309036 = sum of:
          19.309036 = weight(knowledgeTitle.pinyin:test in 181) [PerFieldSimilarity], result of:
            19.309036 = score(doc=181,freq=1.0 = termFreq=1.0
    ), product of:
              2.0 = boost
              6.2461066 = idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from:
                2.0 = docFreq
                1289.0 = docCount
              1.5456858 = tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from:
                1.0 = termFreq=1.0
                1.2 = parameter k1
                0.75 = parameter b
                29.193172 = avgFieldLength
                4.0 = fieldLength
        0.0 = match on required clause, product of:
          0.0 = # clause
          1.0 = isDeleted:[0 TO 0], product of:
            1.0 = boost
            1.0 = queryNorm
      0.18232156 = min of:
        0.18232156 = field value function: ln1p(doc['referenceCount'].value * factor=0.1)
        3.4028235E38 = maxBoost
    

    其中 idf = 6.2461066,tfNorm = 1.5456858,boost = 2.0,由于此时只有一个搜索字段,因此 score = idf * tfNorm * boost = 19.309036;与此同时 field value function = 0.18232156;最终得分 sum = 19.309036 + 0.18232156 = 19.491358 。


    四、结语

    至此一个简单需求的相关性排序优化已经实现完毕,由于业务的关系暂时未涉及其他复杂的场景,所以此篇仅仅作为一个入门介绍。


    五、参考博文

  • 相关阅读:
    什么是函数式编程
    红包算法
    laravel中查看执行的SQL语句
    身份证号信息后台匹配
    在函数内部访问外部的变量
    设计模式-观察者模式
    laravel查询构造器操作数据库
    linux根目录文件夹的作用
    关于laravel连接数据库报错
    设定起始日期,遍历到今天的日期
  • 原文地址:https://www.cnblogs.com/orzlin/p/10496869.html
Copyright © 2020-2023  润新知