最新在工作中有遇到一个需求,业务方期望能自定义一些商品作为商品搜索的推荐展示,且推荐的商品的相关度与搜索条件相关度不丢失;
业务需求效果
其最终效果有点类似于淘宝的搜索商品展示列表,根据搜索条件在某些占位显示被加权的结果集,如下图
由于我们部门目前还没有专门的广告系统,也没有类似的业务场景,且实际业务量不算特别高,所以就考虑到使用Elsticsearch的pinned搜索置顶语法;使用pinned语法实现搜索记录的置顶效果;
关于pinned的详细介绍可参考博客:
https://elastic.blog.csdn.net/article/details/113978528
在Elasticsearch的官网中关于pinned语法(固定查询)的介绍如下:
GET /_search
{
"query": {
"pinned": {
"ids": [ "1", "4", "100" ],
"organic": {
"match": {
"description": "iphone"
}
}
}
}
}
复制
其中,参数ids为需要指定的置顶的记录id顺序,organic为查询条件;在ES检索时,会优先将当前DSL语法中的ids参数中的文档id的记录的评分计算为最大评分,即2的127次幂,即java基本数据类型中的浮点类型(float)的最大值;
因为ES也是用java语言实现的,且ES中的评分值也都是float类型。采用2的127次幂作为记录的得分可以保证当前记录得分最高,排名最靠前,因为正常的ES打分机制的得分是不会超过这个分值的;
基于这一点,我们可以根据实际业务来合理使用ES的pinned语法实现普通的搜索置顶效果;
ES的Pinned的DSL语法实现
比如我需要搜索某个关键字如:大米,我希望某些特定的商品优先展示,在ES中的DSL语法实现如下
GET kstore/_search
{
"query": {
"pinned": {
"ids": [
"10893"
],
"organic": {
"bool": {
"must": [
{
"multi_match": {
"query": "大米",
"fields": [
"goodsInfoName^1.5",
"goodsName^1.0"
]
}
}
]
}
}
}
}
}
复制
在上述DSL语句,其含义为在索引名为 kstore中搜索关键字 大米,需要被搜索的字段为 商品名称和货品名称,关键字在货品名称中的搜索权重为1.5,在商品名称中权重为1.0;同时,索引中文档id为 10893 的记录需要优先被展示(得分最高);其在我本地执行效果如下:
Pinned语法存在的缺陷
通过上述DSL语句可以看到,使用pinned语法可以实现我们想要的广告置顶效果;但是,在上述DSL语句中,ids是写死的,不够灵活,并且是指定的的置顶,如果我们换一个搜索关键字,再来看看效果,此时会发现pinned语法所存在的缺陷了;如下图
根据上图我们可以看到,pinned语法在指定了置顶记录后,被召回记录的结果出现与搜索条件不符的记录;即:pinned语法优先召回被指定的记录id,然后根据搜索条件进行数据召回;
那么面对这样的问题,我们可以如何解决呢?
解决Pinned语法的缺陷
根据上述例子我们看到pinned语法的缺陷,那么在实际业务场景中我们可以如何解决当前语法缺陷呢?
还是参考一下淘宝,依然搜索大米,可以看到在淘宝里面搜索大米时,第一页展示的数据中出现了一个广告位,在第二页中也出现了一个广告位,在第三页中还是出现了一个广告位。
那么,是否可以设计两个索引,一个存储广告数据信息,一个存储正常业务数据信息;当我们根据搜索条件进行搜索时,先从广告数据中获取相关度最高的一条数据(比如查询分页size等于1);此时拿到广告数据的唯一标示(其唯一标识是正常业务数据的文档id,比如货品id);
这样我们在执行pinned语法的时候,就可以动态的传递ids进来作为置顶广告数据了。同时也可以实现类似淘宝的搜索广告效果了;
PS: 淘宝的广告系统100%不是这么玩的,淘宝有自己的千人千面和离线算法系统;
通过上述的思路,从两个索引中分别查询数据,返回给前端进行展示;从而实现广告置顶的效果;整体思路上还是比较ok的;
java代码集成Pinned语法
思路有了,原生DSL语法也实现了,那么开始整合java代码;
在整合java代码时,遇到了很奇怪的问题,在整个elasticsearch相关依赖包中,都找不到关于pinned相关的api接口;不管是RestHighLevelClient还是SearchTemplate,都找不到有关于pinned相关的语法;
更神奇的是,网上关于ES的pinned语法的博客少之又少,而且也没有java代码的案例,导致我这向来百度copy代码的习惯被打破;
没有思路和眉目,只好在Java代码中使用原生DSL语法进行ES的查询了;ES中操控原生DSL语法的API是 WrapperQueryBuilder,其有参构造有一个字符串,传递的就是DSL语句的Json字符串;
public WrapperQueryBuilder(String source) {
if (Strings.isEmpty(source)) {
throw new IllegalArgumentException("query source string cannot be null or empty");
} else {
this.source = source.getBytes(StandardCharsets.UTF_8);
}
}
复制
需要注意的是,上述构造器传递的DSL的json串和我们在kibana中写的DSL有点不太一样,里面传递的数据不包含最外层的query关键字;
如果拿到一个完整的全面的DSL语句,我们可以看到,通常需要查询字段的条件语句,一般都处于DSL的query段落中,而关于分页、超时时间、排序、聚合、高亮 等语法都是和query平级的;
基于这个特性,我们就可以很容易的拿到 查询条件的DSL语句了,也就是关于 query 段落内部的这一部分json数据;伪代码如下:
在上述伪代码中,通过封装字段级的查询条件,从而得到正常业务查询的 DSL语句中的 query段落中的json串,用于构造WrapperQueryBuilder对象;并且在上述代码中可以从结果集中拿到需要被推荐的广告信息;将广告信息的唯一标识作为下一次pinned查询的ids(置顶信息)
这样,简易查询的DSL语句也获取到了,需要被推荐的货品id也获取到了;基于这两个条件,继续下一次的正常业务数据的查询;
到这里,关于pinned在java代码中的支持 已经实现了,启动项目后调用试试看;我们用postman来测试一下效果,查询关键字为:大米,看看能否和我们上面看到的效果是一样的,广告位中的货品id为10893的展示在最前面;
通过postman可以看到已经实现了效果,同时,切换一个搜索关键词,此时置顶的广告也会被替换(或者没有广告);也就实现了我们想要的效果;
我们通过日志查看一下当前请求所执行的DSL语句,一共有2条,第一条是查询广告信息的,第二条是执行的pinned语法的查询业务商品数据的;
第一条查询广告id的
# 查询广告信息的DSL
{
"query": {
"bool": {
"must": [{
"multi_match": {
"query": "大米",
"fields": ["goodsInfoName^1.0", "goodsName^1.0"]
}
}, {
"term": {
"slyTop": {
"value": 1
}
}
}]
}
},
"_source": {
"includes": ["_id"],
"excludes": []
}
}
复制
第二条查询业务数据的
# 查询业务数据的DSL
{
"from": 0,
"size": 10,
"timeout": "5s",
"query": {
"wrapper": {
"query": "eyJwaW5uZWQiOnsiaWRzIjpbIjEwODkzIl0sIm9yZ2FuaWMiOnsiYm9vbCI6eyJhZGp1c3RfcHVyZV9uZWdhdGl2ZSI6dHJ1ZSwibXVzdCI6W3sibXVsdGlfbWF0Y2giOnsiYXV0b19nZW5lcmF0ZV9zeW5vbnltc19waHJhc2VfcXVlcnkiOnRydWUsInF1ZXJ5Ijoi5aSn57GzIiwiemVyb190ZXJtc19xdWVyeSI6Ik5PTkUiLCJmdXp6eV90cmFuc3Bvc2l0aW9ucyI6dHJ1ZSwiYm9vc3QiOjEuMCwicHJlZml4X2xlbmd0aCI6MCwiZmllbGRzIjpbImdvb2RzSW5mb05hbWVeMS4wIiwiZ29vZHNOYW1lXjEuMCJdLCJ0eXBlIjoibW9zdF9maWVsZHMiLCJvcGVyYXRvciI6Ik9SIiwic2xvcCI6MCwibWF4X2V4cGFuc2lvbnMiOjUwfX0seyJ0ZXJtcyI6eyJnb29kc0lkIjpbNTI5MCw1MjkzLDUyOTQsNTI5NSw1Mjk4LDUyOTksNTMwMCw1NTg2LDU1ODMsNTczMSw1MzA1LDUzNDksNTU4NSw1NTg4XSwiYm9vc3QiOjEuMH19XSwiYm9vc3QiOjEuMH19fX0="
}
},
"sort": [{
"_score": {
"order": "desc"
}
}],
"highlight": {
"pre_tags": ["<span style='color:red'>"],
"post_tags": ["</span>"],
"require_field_match": false,
"fields": {
"goodsInfoName": {}
}
}
}
复制
可以看到在第二条DSL语句中,有出现一个 wrapper的键,其值是一个类似于加密串的数据;其原因是Wrapper查询构造器的toString的内容和常规的DSL语言不一致;不过并不影响ES的查询,并且这些DSL语句拿到kibana中都可以正常执行的;
至此,一个简单的广告置顶功能就实现了,虽然大厂里面应该不会这样做。