Elasticsearch 高斯函数实战:搜索排序还能这么玩

491 阅读7分钟

引言

前段时间,我们平台的搜索功能被用户吐槽得挺严重。总结下来就是三个问题:

  1. 搜出来的东西不准,比如搜「手提斜挎包」,偶尔会跑出来一堆根本搭不上边的鞋和衣服;

  2. 排序也乱,一些挂了大半年没人管的老商品总在最前面;

  3. 新品曝光特别差,个别商家抱怨流量分发不公平。

最开始我们准备直接重构,做个完备的搜索引擎。但理想很丰满,现实很骨感:现有的系统是基于 EasyEs 封装的,而且搜索业务嵌了一堆筛选条件,短期内很难全盘推倒。再加上运营部门的期望也很直接:先看到效果,后面再慢慢优化

所以我们退了一步,去找有没有不大改架构但又能见效的办法。

经过一番研究,决定用 Elasticsearch 的 function_score 函数来做增量优化。其实主要是高斯衰减函数,对我们这种「按上架时间加权排序」的场景特别适合。

250.gif

正文

为什么商品排序不合理

在做之前,稍微花了点时间去复盘:

  • 文本相关性这块,用的是 multi_match,但只是把关键词在商品名和描述里匹配一下,没啥深度;

  • 上架时间这个字段,根本没参与排序。也就是说,一个一年前上架的老商品,只要名字匹配上了,它还是能排第一;

  • 而像一些新品,哪怕刚上架,哪怕是爆款,只要名字不贴关键词,基本就埋了。

所以不能光靠文本分数来决定排序,还得拉业务字段进来参与打分,做加权和降权的排序。

为什么选高斯函数

高斯函数是 function_score 里的一种“加分机制”。可以理解为,它会对某个字段的值做一个“离中心越远,得分越低”的打分逻辑。

越新的商品得分越高,越旧的就让它自然排后面。

完全符合我们的业务需求,而且不影响其他筛选条件,适配现有系统也相对简单。

实现思路拆一下

大致做了以下几步:

1. 基础查询还是 multi_match,不动

"multi_match": {
  "query": "手提斜挎包",
  "fields": ["goodsName^3.0", "description^1.0"],
  "type": "cross_fields",
  "operator": "or",
  "minimum_should_match": "50%"
}

这里没有动,是因为大部分商品本身就是靠名称匹配的,不需要引入复杂的 embedding。

2. function_score 加入高斯函数权重

"functions": [
  {
    "gauss": {
      "putawayAt": {
        "origin": "now",
        "scale": "30d",
        "offset": "7d",
        "decay": 0.5
      }
    }
  }
]

解释一下这几个参数的实际含义:

  • origin: now,意思是以现在时间作为中心;

  • offset: 7d,7天内视为“非常新”的商品,得满分;

  • scale: 30d,30天后开始快速衰减;

  • decay: 0.5,37天以后得分降到 50%。

可以理解为我们在算法上给新商品加了一个“新鲜度分数”,这个分数会和基础相关性乘起来形成最终得分。

完整的 Query DSL

如下:

{
  "query": {
    "function_score": {
      // 基础查询:先执行 multi_match 查询,计算出基础相关性分数
      "query": {
        "multi_match": {
          "query": "手提斜挎包",
          "fields": ["goodsName^3.0", "description^1.0"],
          "type": "cross_fields",
          "operator": "or",
          "minimum_should_match": "50%"
        }
      },
      // 增强函数:对基础分数进行调整
      "functions": [
        {
          // 使用高斯衰减函数,对 putawayAt 字段进行处理
          "gauss": {
            "putawayAt": {
              "origin": "now",    // 排序的中心点是“现在”
              "scale": "30d",     // 时间范围基准:30天。在这个点上,分数加成会衰减
              "offset": "7d",     // 偏移量:7天内上架的商品获得满分加成,之后才开始衰减
              "decay": 0.5        // 衰减率:在 scale+offset (即37天) 的时候,分数加成会衰减到原来的 0.5
            }
          }
        }
      ],
      // 分数合并方式:将原始查询分数与函数计算出的分数相乘,得到最终分数
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  },
  "sort": [
    // 最终排序:
    // 首先,根据上面 function_score 计算出的最终 _score 降序排
    {
      "_score": {
        "order": "desc"
      }
    },
    // 其次,如果 _score 完全相同(虽然概率很低),则按上架时间倒序作为第二排序标准
    {
      "putawayAt": {
        "order": "desc"
      }
    }
  ]
}

Java落地

符串拼接DSL

我们一开始其实是用字符串拼接DSL,简单粗暴,类似这样:

String dslTemplate = """
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "%s",
          "fields": ["goodsName^3.0", "description^1.0"],
          "type": "cross_fields",
          "operator": "or",
          "minimum_should_match": "50%%"
        }
      },
      "functions": [...]
    }
  }
}
""";

String finalDsl = String.format(dslTemplate, keyword);

这种写法有个问题:我们系统除了搜索关键词,还有一堆筛选条件(分类、品牌、价格区间、地区等等),字符串拼接根本搞不定这种复杂场景。

Map拼装

后来考虑了Map.of()拼装

Map<String, Object> multiMatchQuery = Map.of(
    "multi_match", Map.of(
        "query", dto.getKeyword(),
        "fields", List.of("goodsName^3.0", "description^1.0"),
        "type", "cross_fields",
        "operator", "or",
        "minimum_should_match", "50%"
    )
);

// 然后各种Map.of嵌套...

代码写得看不下去,特别是条件多的时候,一堆Map嵌套,嵌套地狱。

【推荐】Velocity模板

还是模板引擎最靠谱。

用模板之后,DSL 可以像 MyBatis 那样写变量、写判断,还能写注释,非常舒服。

相比 Map.of 硬编码组装各种复杂条件,用模板引擎更易于维护,拓展性也相对更强。

而Velocity轻量、语法简单,团队上手成本很低。

考虑到性能问题,Velocity默认对Template也做了缓存优化,打消了顾虑。

用的时候只要把 DSL 写到 .vm 模板文件,参数通过 context 注入进去,清晰又不容易出错。

新版的IDEA也支持 .vm 文件的语法高亮,体验还是相当可以的。

模板文件长这样:

{
  ## 设置分页参数
  "from": ${page.offset},
  "size": ${page.size},

  "query": {
    #if( $isRelevanceSort )
    ## =============================================
    ## 模式一: 推荐排序 (使用 function_score)
    ## =============================================
    "function_score": {
      "query": {
    #end
        ## 无论哪种模式,都需要 bool 查询作为基础
        "bool": {
          "must": [
            {
              "multi_match": {
                "query": "$!{esc.json($dto.keyword)}", ## 使用 $!dto.keyword 防止null,并用 esc.json 转义特殊字符
                "fields": ["goodsName^3.0", "description^1.0"],
                "type": "cross_fields",
                "operator": "or",
                "minimum_should_match": "50%"
              }
            }
          ],
          ## =============================================
          ## 动态过滤条件 (filter)
          ## =============================================
          "filter": [
            #if( $dto.ifStrict )
            { "term": { "ifStrict": $dto.ifStrict } },
            #end
            #if( $dto.orgClassifyIdList && !$dto.orgClassifyIdList.isEmpty() )
            { "terms": { "orgClassifyId": $json.toJson($dto.orgClassifyIdList) } },
            #end
            #if( $dto.orgBrandIdList && !$dto.orgBrandIdList.isEmpty() )
            { "terms": { "orgBrandId": $json.toJson($dto.orgBrandIdList) } },
            #end
            #if( $dto.qualityList && !$dto.qualityList.isEmpty() )
            { "terms": { "quality": $json.toJson($dto.qualityList) } },
            #end
            
            ## 价格区间
            #if( $dto.currentPriceStart || $dto.currentPriceEnd )
            {
              "range": {
                "currentPrice": {
                  #if($dto.currentPriceStart) "gte": $dto.currentPriceStart #end
                  #if($dto.currentPriceStart && $dto.currentPriceEnd),#end ## 处理中间的逗号
                  #if($dto.currentPriceEnd) "lte": $dto.currentPriceEnd #end
                }
              }
            },
            #end

            ## 地区筛选
            #if( $dto.addressCodePath && !$dto.addressCodePath.isEmpty() )
            { "prefix": { "addressCodePath": "$!{esc.json($dto.addressCodePath)}" } }, ## 使用prefix代替likeRight
            #end
            #if( $dto.addressNamePath && !$dto.addressNamePath.isEmpty() )
            { "term": { "city": "$!{esc.json($dto.addressNamePath)}" } }, ## 假设city是keyword类型
            #end

            ## 标签筛选 (or 逻辑)
            #if( $dto.labelIdList && !$dto.labelIdList.isEmpty() )
            {
              "bool": {
                "should": [
                #foreach( $labelId in $dto.labelIdList )
                  { "term": { "labelIdList": $labelId } }#if( $foreach.hasNext ),#end
                #end
                ],
                "minimum_should_match": 1
              }
            },
            #end
            ## 此处可以继续添加其他 #if 条件
            {} ## 这是一个小技巧,确保filter数组永远不为空且最后一个有效元素后没有逗号
          ]
        }
    #if( $isRelevanceSort )
      },
      ## function_score 的函数部分
      "functions": [
        {
          "gauss": {
            "putawayAt": {
              "origin": "now",
              "scale": "30d",
              "offset": "7d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
    #end
  },

  ## =============================================
  ## 动态排序 (sort)
  ## =============================================
  "sort": [
    #if( $isRelevanceSort )
    { "_score": { "order": "desc" } },
    { "putawayAt": { "order": "desc" } }
    #else
      #foreach( $order in $orders )
      { "$order.field": { "order": "$order.direction" } }#if( $foreach.hasNext ),#end
      #end
    #end
  ]
}

Java调用也很简单,这里提供伪代码。

实际项目里面,应该对模板文件做统一管理,而不应该粗暴的写个字符串常量。

public PageResult<GoodsEs> searchGoods(GoodsSearchDto dto, Page page) {
    VelocityContext context = new VelocityContext();
    context.put("dto", dto);
    context.put("page", page);
    context.put("isRelevanceSort", CollectionUtils.isEmpty(page.orders()));
    context.put("orders", page.orders());
    context.put("json", new JsonTool());
    context.put("esc", new EscapeTool());
    
    StringWriter writer = new StringWriter();
    velocityEngine.mergeTemplate("templates/goods-search.vm", "UTF-8", context, writer);
    String dsl = writer.toString();
    
    LambdaEsQueryWrapper<GoodsEs> wrapper = new LambdaEsQueryWrapper<>();
    wrapper.rawQuery(dsl);
    
    return goodsMapper.search(wrapper);
}

上线效果

优化之后其实效果很明显:

  • 热门商品的曝光排序确实更合理了;

  • 搜索点击率稍微高了点,虽然不是爆炸式增长,但很稳定;

  • 用户对于搜索的负面反馈明显减少。

其实更重要的是,我们没破坏原有结构的前提下,把一个非常具体的业务问题解决掉了。这点很重要,因为大改系统的机会并不多,但业务反馈不能拖。

当然整套方案下来也不是完美的,有些边界case还需要继续优化,比如季节性商品的处理,品牌商品的权重等等。

一点实践经验

这里再列一些我的实践经验,权当分享:

  • 如果你也在用 EasyEs 或者其他框架封装了搜索查询,想加个 function_score,其实不需要重写全部结构,只要把 query 封装得好,函数分数是可以外挂进去的

  • 高斯函数对新旧打分很有效,但别想着靠它搞所有排序,最终还是要跟业务需求绑在一起;

  • 模板方案是我个人最推荐的,清晰、好维护,也适合团队协作;

  • 参数设置不能凭感觉,可以用历史数据做些简单分析,比如新商品平均多久热度下降,再来定 scale 和 decay。

最后

这次优化没啥花哨的东西,但有个共识:把简单的事做对,把复杂的事情做简单

如果你也遇到了类似的搜索排序问题,或者在用 EasyEs 遇到啥坑,欢迎交流。

我也还在摸索,后面可能还会把销量、热度等维度引进来一起做多因子排序,到时候再写一篇。

418.gif