引言
前段时间,我们平台的搜索功能被用户吐槽得挺严重。总结下来就是三个问题:
-
搜出来的东西不准,比如搜「手提斜挎包」,偶尔会跑出来一堆根本搭不上边的鞋和衣服;
-
排序也乱,一些挂了大半年没人管的老商品总在最前面;
-
新品曝光特别差,个别商家抱怨流量分发不公平。
最开始我们准备直接重构,做个完备的搜索引擎。但理想很丰满,现实很骨感:现有的系统是基于 EasyEs 封装的,而且搜索业务嵌了一堆筛选条件,短期内很难全盘推倒。再加上运营部门的期望也很直接:先看到效果,后面再慢慢优化。
所以我们退了一步,去找有没有不大改架构但又能见效的办法。
经过一番研究,决定用 Elasticsearch 的 function_score 函数来做增量优化。其实主要是高斯衰减函数,对我们这种「按上架时间加权排序」的场景特别适合。
正文
为什么商品排序不合理
在做之前,稍微花了点时间去复盘:
-
文本相关性这块,用的是
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 遇到啥坑,欢迎交流。
我也还在摸索,后面可能还会把销量、热度等维度引进来一起做多因子排序,到时候再写一篇。