elasticsearch根据more_like实现用户新闻或视频推荐系统

首先来说 elasticsearch(文章里面我们简称 “ es ”) 我们在这一期项目中应用的比较简单,本来是要整合各种东西,最后由于时间原因,还只是做了一部最简单的推荐系统。

    只需要技术点: elasticsearch  + redis

在这里说一下,说道理谁都会,总有些人会夸夸而谈,但是你在一个真正的实践过的人眼前,感觉就是那种特别low,这我深有体会。 没做之前,我也感觉非常简单,几天就搞定,但是真的在做的过程中,遇到的各种坑,再加上我们的业务复杂,导致,在项目上线前一天,还在各种bug频繁的出现。

  先说一下主要 实现架构,算不上是大数据分析,说白了 就是用了  more_like的模糊搜索

  实现原理:根据用户搜索、点击视频播放的时候存下用户记录,去给推荐栏目里面 推荐用户喜好的文章或者视频,新闻有banner页,所以说 一边要推荐用户喜好的文章,还要推荐banner页,还有一点就是用户每次刷新一次,都要去给他更新数据,先用redis 记录上次更新时间,与当前时间进行搜索,如果说不满十条或者要求推荐的数,那就去取五天前的,如果说五天前的数据没有的话,那就有几天就推送几条。注意 :这里有一个去重问题,就是当前时间段,或者五天前的数据 会跟我们现在redis里面取出来的数据重复(我们这里不是直接从es里面取数据,而是先从es里面取出来,放到redis里面,这个时候redis充当了一个推荐池,可能有很多人有疑问,为什么不直接从es里面取,后面我会一一的介绍)去重问题我们也会在分解代码的时候提到,有一点是栏目下面,也要推荐,实现原理是跟推荐里面一样的,只不过多加了一个栏目id而已。

  

搭建基础设置(es 现在主要有两种请求方式 Rest Client 和 Transport client)这里我们用的是 Rest Client ,据说Transport client已经摒弃。

 依赖包:

        <dependency>
			<groupId>org.elasticsearch.client</groupId>
			<artifactId>elasticsearch-rest-client</artifactId>
			<version>6.3.1</version>
		</dependency>

		<dependency>
			<groupId>org.elasticsearch.client</groupId>
			<artifactId>elasticsearch-rest-client-sniffer</artifactId>
			<version>6.3.1</version>
		</dependency>
		<dependency>
			<groupId>org.elasticsearch.client</groupId>
			<artifactId>elasticsearch-rest-high-level-client</artifactId>
			<version>6.3.1</version>
		</dependency>

配置文件:(spring cloud)

elasticsearch.host=192.168.1.33
elasticsearch.port=9200
elasticsearch.schema=http
elasticsearch.cluster.isCluster=true
elasticsearch.cluster.name=ELK-cluster
elasticsearch.connectionTimeOut=1000
elasticsearch.socketTimeOut=30000
elasticsearch.connectionRequestTimeOut=500
elasticsearch.maxConnectNum=100
elasticsearch.maxConnectPerRoute=100
elasticsearch.uniqueConnectTimeConfig=false
elasticsearch.uniqueConnectNumConfig=true

贴上es工具类代码,根据我们的业务场景自己封装的。(注意:千万不要盲目复制哦。里面有我们的业务代码,要复制的话把我们的业务代码去掉即可)

 1、搜索代码,搜索直接从es里面取出直接展示出来,没有经过redis

   

/**
         * 搜索条件 more_like 搜索 分页
         */
        public  NewsOrVideos searchOrRecommendPage(SearchAllDto searchDto){

            //设置索引
            SearchRequest searchRequest = new SearchRequest(searchDto.getEsIndex());
            //设置类型
            searchRequest.types(searchDto.getEsType());
            SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
            BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
            MoreLikeThisQueryBuilder moreLikeThisQueryBuilder = null;
            //如果说 length 等于0 说明需要把所有的数据都查询出来
            if (searchDto.getTitle() != null && searchDto.getTitle().length!=0){
                moreLikeThisQueryBuilder = QueryBuilders.moreLikeThisQuery(searchDto.getSourceFields(), searchDto.getTitle(), null);
                moreLikeThisQueryBuilder.minTermFreq(0);
                moreLikeThisQueryBuilder.minDocFreq(0);
            }
            TermQueryBuilder termQueryBuilder = null;
            if (StringUtils.isNotBlank(searchDto.getColumnId())){
                termQueryBuilder = QueryBuilders.termQuery(searchDto.getSourceCom(), searchDto.getColumnId());
            }
            if (StringUtils.isNotBlank(searchDto.getIsTop()) && !searchDto.getIsTop().equals("isTop")){
                termQueryBuilder = QueryBuilders.termQuery("isTop", searchDto.getIsTop());
            }
            if (searchDto.getStartTime()!=null && searchDto.getEndTime()!=null){
                boolQueryBuilder.filter(QueryBuilders.rangeQuery("onlineTime").gte(searchDto.getStartTime().getTime()).lte(searchDto.getEndTime().getTime()));
                searchSourceBuilder.query(boolQueryBuilder).sort("onlineTime",SortOrder.ASC);
            }
            //分页
            int page = searchDto.getPage();
            int size = searchDto.getSize();
            if(page<=0){page= 1;}
            int start = (page-1)*size;
            searchSourceBuilder.from(start);
            searchSourceBuilder.size(size);
            //请求搜索
            if (moreLikeThisQueryBuilder!=null){
                boolQueryBuilder.must(moreLikeThisQueryBuilder);
            }

            if (termQueryBuilder!=null){
                boolQueryBuilder.must(termQueryBuilder);
            }
            searchSourceBuilder.query(boolQueryBuilder);
            //
            searchRequest.source(searchSourceBuilder);
            SearchResponse searchResponse = null;
            try {
                searchResponse = restHighLevelClient.search(searchRequest);
            } catch (IOException e) {
                e.printStackTrace();
            }catch (Exception e){

            }
            //结果集处理
            SearchHits hits = searchResponse.getHits();
            SearchHit[] searchHits = hits.getHits();
            //记录总数
            long totalHits = hits.getTotalHits();
            //数据列表
            NewsOrVideos newsOrVideos = new NewsOrVideos();
            //不管当前还是视频 统一用这个返回   调用一下方法的时候 添加对应字段
            if (StringUtils.isNotBlank(searchDto.getContentType()) && searchDto.getContentType().equals(Constant.ContentType.VIDEO)){
                //查询视频
                List<VideoListVo> list = getVideosList(searchHits);
                if (list.size()>0){
                    newsOrVideos.setVideoEditVoList(list);
                }
            }else{
                //新闻
                List<NewsIndexPageVo> newsList = getNewsList(searchHits);
                if (newsList.size()>0) {
                    newsOrVideos.setNewsList(newsList);
                }

            }
            //总记录数
            newsOrVideos.setTotalCount(totalHits);
            return newsOrVideos;
        }

搜索比较简单,直接调用这个上面的方法即可,封装好指定的VO   ,直接传给前端,但是需要注意一些细节,在传入参数的时候,尽量直接传入一个数组,封装到一个dto里面。 

   这里的搜索使用的more_like 注意的一点事

moreLikeThisQueryBuilder.minTermFreq(0);
moreLikeThisQueryBuilder.minDocFreq(0);

这段代码必须要有!必须有!

min_term_freq:一篇文档中一个词语至少出现次数,小于这个值的词将被忽略,默认是2

min_doc_freq:一个词语最少在多少篇文档中出现,小于这个值的词会将被忽略,默认是无限制

这两个缺一个都会导致查不出来!

moreLikeThisQueryBuilder = QueryBuilders.moreLikeThisQuery(searchDto.getSourceFields(), searchDto.getTitle(), null);

 第一个参数是 要搜索的字段,第二个参数是 要搜索的内容数组,第三个设置为null 即可

 分页的话 大家应该都知道吧,

searchSourceBuilder.from(start);//这里的start  代表的是 从第几条开始查 ps:不是指从第几页!是从第几条
searchSourceBuilder.size(size); //这里size 值得是 查询多少条
searchResponse = restHighLevelClient.search(searchRequest);

这段代码 是请求搜索。可能很多人 在写restHighLevelClient 这个类的时候有问题 其实是要先注入的

@Autowired
private RestHighLevelClient restHighLevelClient;

  给大家看一下 我封装的dto  也就是 searchDto

@Data
public class SearchAllDto {
    private String esIndex;//索引名称
    private String esType; //索引类型
    private int page;//当天第几条
    private int size;//分页条数
    private String[] content;//分页内容
    private String contentType;// 1、新闻 4、视频
    private String[] title;//标题
    private String[] sourceFields;
    private String columnId;//栏目id
    private String tagLable;//标签id
    private String sourceCom;//栏目字段
    private String isTop;//是否置顶
    private String columnTopFlag; //栏目置顶
    private String orderNum; //置顶顺序
    @JSONField(format="yyyy-MM-dd HH:mm:ss")
    private Date startTime; //开始时间
    @JSONField(format="yyyy-MM-dd HH:mm:ss")
    private Date endTime;//结束时间
    private String id;//id
    private String currPageTrue;//判断是不是要更新时间 等于0的时候不更新时间
}

搜索总结:其实es 的搜索里面有一个搜索机制,它搜索的机制是根据搜索出来的结果打分,分数高的 就会排在最前面。

2、每次刷新更新指定条数的代码 这里我们是每次最多更新十条

 

/**
         * 推荐十条
         */
        public  NewsOrVideos searchOrRecommendFromTime(SearchAllDto searchDto){
            //设置索引
            SearchRequest searchRequest = new SearchRequest(searchDto.getEsIndex());
            //设置类型
            searchRequest.types(searchDto.getEsType());
            SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
            BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//        MoreLikeThisQueryBuilder moreLikeThisQueryBuilder = null;
            //如果说 length 等于0 说明需要把所有的数据都查询出来
//        if (searchDto.getTitle() != null && searchDto.getTitle().length!=0){
//            moreLikeThisQueryBuilder = QueryBuilders.moreLikeThisQuery(searchDto.getSourceFields(), searchDto.getTitle(), null);
//            moreLikeThisQueryBuilder.minTermFreq(0);
//            moreLikeThisQueryBuilder.minDocFreq(0);
//        }
            TermQueryBuilder termQueryBuilder = null;
            TermQueryBuilder termQueryBuilder1 = null;
            if (searchDto.getContentType().equals("4")){
                //当前为视频
                if (StringUtils.isNotBlank(searchDto.getColumnId())){
                    termQueryBuilder = QueryBuilders.termQuery(searchDto.getSourceCom(), searchDto.getColumnId());
                }
            }else{
                if (StringUtils.isNotBlank(searchDto.getColumnId())){
                    termQueryBuilder = QueryBuilders.termQuery(searchDto.getSourceCom(), searchDto.getColumnId());
                    if (StringUtils.isNotBlank(searchDto.getColumnTopFlag()) && !searchDto.getColumnTopFlag().equals("columnTopFlag")) {
                        termQueryBuilder1 = QueryBuilders.termQuery("columnTopFlag", searchDto.getColumnTopFlag());
                    }
                } else {
                    if (StringUtils.isNotBlank(searchDto.getIsTop()) && !searchDto.getIsTop().equals("isTop")){
                        termQueryBuilder = QueryBuilders.termQuery("isTop", searchDto.getIsTop());
                    }
                }
            }
            if (searchDto.getStartTime()!=null && searchDto.getEndTime()!=null){
                boolQueryBuilder.filter(QueryBuilders.rangeQuery("onlineTime").gte(searchDto.getStartTime().getTime()).lte(searchDto.getEndTime().getTime()));
                searchSourceBuilder.query(boolQueryBuilder).sort("onlineTime",SortOrder.ASC);
                //分页
                searchSourceBuilder.from(0);
                searchSourceBuilder.size(10);
            }
            //请求搜索
//        if (moreLikeThisQueryBuilder!=null){
//            boolQueryBuilder.must(moreLikeThisQueryBuilder);
//        }

            if (termQueryBuilder!=null){
                boolQueryBuilder.must(termQueryBuilder);
            }
            if (termQueryBuilder1 != null) {
                boolQueryBuilder.must(termQueryBuilder1);
            }

            if (StringUtils.isNotBlank(searchDto.getColumnId())){
                if (StringUtils.isNotBlank(searchDto.getColumnTopFlag()) && searchDto.getColumnTopFlag().equals("columnTopFlag")) {
//                searchSourceBuilder.sort("columnTopFlag",SortOrder.DESC);
                }
            } else {
                if (StringUtils.isNotBlank(searchDto.getIsTop()) && searchDto.getIsTop().equals("isTop")){
//                searchSourceBuilder.sort("isTop",SortOrder.DESC);
                }
            }


            //
            searchRequest.source(searchSourceBuilder);
            SearchResponse searchResponse = null;
            try {
                searchResponse = restHighLevelClient.search(searchRequest);
            } catch (IOException e) {
                e.printStackTrace();
            }catch (Exception e){

            }
            //结果集处理
            SearchHits hits = searchResponse.getHits();
            SearchHit[] searchHits = hits.getHits();
            //记录总数
            long totalHits = hits.getTotalHits();
            //数据列表
            NewsOrVideos newsOrVideos = new NewsOrVideos();
            //不管当前还是视频 统一用这个返回   调用一下方法的时候 添加对应字段
            if (StringUtils.isNotBlank(searchDto.getContentType()) && searchDto.getContentType().equals(Constant.ContentType.VIDEO)){
                //查询视频
                List<VideoListVo> list = getVideosList(searchHits);
                if (list.size()>0){
                    newsOrVideos.setVideoEditVoList(list);
                    String formatStart = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(list.get(list.size()-1).getOnlineTime());
//                if (!searchDto.getCurrPageTrue().equals("0")){
//                    //这个时候可以更新
//                    if (list.size()>10){
//                        newsOrVideos.setNewTime(formatStart);
//                    }
//                }else{
                    newsOrVideos.setNewTime(formatStart);
//                }

                }
            }else{
                //新闻
                List<NewsIndexPageVo> newsList = getNewsList(searchHits);
                if (newsList.size()>0) {
                    newsOrVideos.setNewsList(newsList);
                }

            }
            //总记录数
            newsOrVideos.setTotalCount(totalHits);
            return newsOrVideos;
        }

   推荐是相对来说比较麻烦的,为什么这么说呢,因为他还要整合redis,还要去重,而且每个用户对应着一组redis,

   每个用户userID 对应的redis如下:

   key1: 推荐id+ userId

   key2: 栏目id+userId

  key3:  时间标识id+ userId (这个键 就是存放推荐的上次刷新时间)

  key4:  栏目标识id+userId (这个键是存放 栏目下的刷新时间)

  key5:  用户搜索标识+userId

大家感觉是不是感觉比较乱,但是我的设计如此,如果有更好的方法欢迎留言,我会虚心接受,谢谢。

  我前面说了,推荐要放到redis 里面 ,大致的意思就是,一个新用户进来以后,我默认的从es 里面按照时间降序取200条放到推荐池里面,注意这200条数据并不是我前面所说的,每次刷新十条从这里面取,对,他跟着200条没有关系,只不过是保证用户进来不刷新的情况下有数据,能分页(我这里是用户下拉刷新 才会有推荐的数据,前提基于用户的喜好),这是用户下拉刷洗以后就会推荐十条。

   这十条数据会先去查询用户是不是有搜索记录,如果有直接到es里面使用模糊查询(more_like),若是没有,直接用当前的时间到上次刷新的时间去取这个时间段里面的数据,这里可能会有人产生疑问,那么用户第一次进入的时候我们要怎么存这个时间,我这里是用了 redis的set方法,存了一个时间,第一次进来 默认给他一个当前时间 new Date()。

随后去es里面查询,这里在啰嗦一下,尽量将es里面的date类型存成 时间戳类型 到时候 时间都转换成时间戳进行比较即可,用到的方法如下:

boolQueryBuilder.filter(QueryBuilders.rangeQuery("onlineTime").gte(searchDto.getStartTime().getTime()).lte(searchDto.getEndTime().getTime()));

getTime()   这个方法就是获取时间戳。

 最重要的一点来了,必须要啰嗦:

  如果说 我现在redis里面存的是10点,那么我现在去下拉刷新,当前new date()为12点,但恰好这个时间段内有200条数据,那我们200条数据只取十条未免是不是有点浪费,用户昨天登录APP,过了好几天又登录了一次,难道这几天的数据对用户的下拉推荐,再多也是十条? 浪费不?

   这个问题 解决方案如下:我前面已经说过 这个可以分页了对吧  所以说取十条没问题,假如说现在有200条数据,那么正序排列,是不是就能取到前十条,为了避免浪费数据,就取第十条的时间,当做该次刷新时间,之前是把当前的时间为刷新时间,这里不是了,注意哦!

   es排序正序方法:

     searchSourceBuilder.query(boolQueryBuilder).sort("onlineTime",SortOrder.ASC); //正序排列

如果当前这个时间段没有数据,就去取五天前的,获取五天前的时间代码:

/**
     * 获取过去几天时间
     * @param past
     * @return
     */
    public static Date getPastDate(int past) throws ParseException {
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.DAY_OF_YEAR, calendar.get(Calendar.DAY_OF_YEAR) - past);
        Date today = calendar.getTime();
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String result = format.format(today);
        Date parse = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(result);
        return parse;
    }

获取某一个时间段内的数据,就会出现一个去重的问题,那么redis如何去重呢?

   这里我们用的是redis的list类型,去重会比较麻烦,但是分页比较简单,速度快,这里的解决办法是,在添加一个list类型的时候,在同时加一个redis 的 hash类型,id作为hashKey  在每一次从es里面查出一个时间段里面的数据以后,要先获取这hashkey是否存在,如果存在就不要添加了,为null的时候就添加。 这就达到了去重的效果。

这两个方法里面都有一个 取出list的方法 其实就是直接将map强转为对象

 

/**
         * 取出videos_list
         */
        public List<VideoListVo> getVideosList(SearchHit[] searchHits){
            List<VideoListVo> list = new ArrayList<>();
            for (SearchHit hit : searchHits) {
                VideoListVo videoEditVo = new VideoListVo();
                //取出source
                Map<String, Object> sourceAsMap = hit.getSourceAsMap();
                try {
                    BeanUtils.populate(videoEditVo, sourceAsMap);
                }catch (Exception e){

                }
                //添加到list
                list.add(videoEditVo);
            }
            return list;
        }

    其实这都不算是这个项目的难点,最后遇到的难点,就是同步问题,这里可能对很多人来说 会存在一个误区,因为修改某一条数据,或者删除的时候,不仅仅要同步es,最主要的是同步redis,同步redis的 是每一个用户都会对应一个redis。

   解决办法: 建一个 专门同步es的redis库,这样每次就不用都修改用户对应的redis了,因为用户可能上千万,速度也跟不上啊 是不是,这就是很好的解决办法,个人的redis里面只需要存放 id就可以,每次去查询,要知道,redis的查询比mysql快很多。

    这种场景是很多的,比如说是用户评论了,点赞了,都是要记录的。用这个不错吧?当然 有人人一开始技术选型就很好,不需要这么麻烦了。

    说道这里基本上已经结束了!我想大多数人 都会有一个疑问,而且很大的疑问,是问,为什么不直接从es里面取,其实这也是我一开始的疑问,这里要知道我们面向的不是一个用户,我们不可能去给每一个人搭建一套es,而且,每次的推送也是一个很大的问题,最明显的一点就是,这样只能去推送最新的数据,相当于从头拿,不是说没有解决办法,有!我找到了一种,很麻烦,我想在下一篇博客里面主要的分析这个问题。

   

     技术问题 请加 技术群  :808249297

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值