技术Code

基于es的商品搜索功能实现(上)

2021-03-02  本文已影响0人  湘西刺客王胡子

公司的电商app需要做搜索功能,分析整理后列出如下需求点:

  1. 商品搜索(标题、描述全文检索)
  2. 搜索词汉字拼音自适应
  3. 搜索词自动补全
  4. 搜索词无匹配结果时给出联想关键词
  5. 历史搜索记录
  6. 热门搜索展示
  7. 搜索发现(搜索推荐)

接下来就是愉悦(tongku)的设计和coding了


涉及技术框架: OpenJDK11 + elasticsearch 7.6.2 + Redis + MySQL + Sping-Boot 2.3.2 (包含es版本 7.6.2

商品搜索(标题、描述全文检索)

商品表已经在业务库中存在直接用,只需要设计出es索引就可以。es安装后顺带装上jk、pinyin插件,因为后续要用到拼音功能,不过在本阶段先不管拼音的事儿。

spring-data-elasticsearch提供了Java Domain到Es indices mapping的自动创建功能,只需配置继承了ElasticsearchRepository的Repository类,并在项目类配置@EnableElasticsearchRepositories即可,我们使用此种方式配置

产品表定义:

  此处只展示关键字段,其他字段可根据业务需求进行适当冗余,如果能直接从es取回所有需展示字段,就不用再去mysql中查询了,能大大提高效率,但是es索引文件的占用空间也会增加,需要权衡取舍。
  另外关于analyzer的选择,此处不展开讲,如有需要可另外开贴讨论

@Document(indexName = "demo-product", refreshInterval = "5s")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Product {
    @Id
    private Long id;

    //商品名
    @MultiField(mainField = @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart"), otherFields = @InnerField(type = FieldType.Keyword, suffix = "raw", ignoreAbove = 256))
    private String productName;

    //商品描述
    @Field(type = FieldType.Text, analyzer = "ik_smart")
    private String description;

    //数据更新时间
    @Field(type = FieldType.Date_Nanos, index = false)
    private Long utime;

    //品牌名称
    @Field(type = FieldType.Text, analyzer = "ik_smart")
    private String brandName;

    //分类名称
    @Field(type = FieldType.Text, analyzer = "ik_smart")
    private String categoryName;

    //预配置索引的_class字段
    @Field(name = "_class", type = FieldType.Keyword, index = false)
    @JsonIgnore
    @Getter(value = AccessLevel.NONE)
    @Setter(value = AccessLevel.NONE)
    private String clazz;
}

产品类对应Repository定义:

//此处只需要声明父类和试题类型class、id类型,即可使用esRepository提供的便利CRUD方法
public interface ProductRepository extends ElasticsearchRepository<Product, Long> {
}

maven-pom文件增加引用

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

application.yml文件添加es相关配置

spring:
  elasticsearch:
    rest:
      uris:
        //多个节点继续添加即可
        - localhost:9200

以上定义完成后,启动项目,成功后可以看到如下提示(debug级别下):

2021-03-01 15:03:38.480 DEBUG 9664 --- [           main] org.elasticsearch.client.RestClient      : request [HEAD http://localhost:9200/demo-product?ignore_throttled=false&ignore_unavailable=false&expand_wildcards=open%2Cclosed&allow_no_indices=false] returned [HTTP/1.1 404 Not Found]
2021-03-01 15:03:39.491 DEBUG 9664 --- [           main] org.elasticsearch.client.RestClient      : request [PUT http://localhost:9200/demo-product?master_timeout=30s&timeout=30s] returned [HTTP/1.1 200 OK]
2021-03-01 15:03:39.768 DEBUG 9664 --- [           main] org.elasticsearch.client.RestClient      : request [PUT http://localhost:9200/demo-product/_mapping?master_timeout=30s&timeout=30s] returned [HTTP/1.1 200 OK]

如果没有伴随出现报错信息(es索引配置有误,不能创建索引时会给出相应提示),此时es中的product索引应该已经成功创建了。
使用esGET /demo-product/_mapping命令可查看索引结构已经完整创建:

{
  "demo-product" : {
    "mappings" : {
      "properties" : {
        "_class" : {
          "type" : "keyword",
          "index" : false
        },
        "brandName" : {
          "type" : "text",
          "analyzer" : "ik_smart"
        },
        "categoryName" : {
          "type" : "text",
          "analyzer" : "ik_smart"
        },
        "description" : {
          "type" : "text",
          "analyzer" : "ik_smart"
        },
        "id" : {
          "type" : "long"
        },
        "productName" : {
          "type" : "text",
          "fields" : {
            "raw" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          },
          "analyzer" : "ik_max_word",
          "search_analyzer" : "ik_smart"
        },
        "utime" : {
          "type" : "date_nanos",
          "index" : false
        }
      }
    }
  }
}

索引表的插入,可使用spring-boot的@Schedule定时器,以指定间隔扫描产品表的新增数据,以es对应product表的字段封装后,插入es索引即可

      //部分代码
      //valid状态判断可自行去除
        List<Product> products = isNull(latestUtime) ? productMapper.getAllValidProducts() : productMapper.getModifiedProducts(latestUtime);
        if (isNotEmpty(products)) {
            Set<Product> validProducts = products.parallelStream().filter(p -> p.getStatus() == STATUS_VALID)
                    .collect(Collectors.toSet());
            if (isNotEmpty(validProducts)) productRepository.saveAll(validProducts);

            Set<Product> invalidProducts = products.parallelStream()
                    .filter(p -> p.getStatus() == STATUS_INVALID || p.getStatus() == STATUS_DELETED)
                    .collect(Collectors.toSet());
            if (isNotEmpty(invalidProducts)) productRepository.deleteAll(invalidProducts);
        }

此时索引表结构和数据已经准备就绪,可以开始写查询方法了!
我们先准备一些测试商品数据:

var product1 = new Product();
        product1.setProductName("2020新款中年妈妈秋装风衣");
        product1.setBrandName("胖织缘福");
        product1.setCategoryName("女式风衣");
        product1.setId(1l);
        product1.setDescription("胖织缘福是知名的时尚中老年女装品牌,注重个性文化又不过分张扬,推崇时尚简约、独特个性设计风格,通过胖织缘福把自信、魅力潇洒淋漓尽致的展现出来。");
        product1.setUtime(Instant.now().getEpochSecond());
        var product2 = new Product();
        product2.setProductName("戈美其2020春季新款漆皮水钻单鞋女低跟复古舒适休闲英伦风小皮鞋");
        product2.setBrandName("戈美其");
        product2.setCategoryName("女式单鞋");
        product2.setId(2l);
        product2.setDescription(
                "“戈美其”女鞋成立于1990年,以“追求卓越品质、缔造名优品牌”为宗旨理念。款式时尚,脚感舒适,品质优良\n" + "发货地:浙江温州  发货物流:截单后24小时内顺丰快递发货");
        product2.setUtime(Instant.now().getEpochSecond());
        var product3 = new Product();
        product3.setProductName("【疫情地区不发货】Conniekids【疯狂秒杀】【随机4条装】女童纯棉螺纹吊带");
        product3.setBrandName("Conniekids");
        product3.setCategoryName("女童背心");
        product3.setId(3l);
        product3.setDescription(
                "CONNIEKIDS品牌总部设在英国伦敦,专注研发设计适合各国成长期儿童的贴身内衣内裤家居服等。品牌创始人热爱环球旅行,关注儿童健康成长,成立慈善基金。品牌每卖出一件衣服就会捐出0.01英镑。");
        product3.setUtime(Instant.now().getEpochSecond());
        var product4 = new Product();
        product4.setProductName("隽达卷边V领针织打底毛衣【疫情地区不发货】");
        product4.setBrandName("隽达");
        product4.setCategoryName("女式毛衣");
        product4.setId(4l);
        product4.setDescription("本次活动以春装为主,产品以时尚化、舒适化、精致化的设计理念。\n" + "隽达专为25-45岁成熟、高贵、讲究品位的都市女性设计。\n" +
                "秉承“都市轻熟女之家”的品牌核心。产品选用中高端面料,手感舒适,质地柔软。");
        product4.setUtime(Instant.now().getEpochSecond());
        var product5 = new Product();
        product5.setProductName("吉俪 加厚保暖牛奶绒魔法绒毛毯珊瑚绒毯子空调午睡毯");
        product5.setBrandName("吉俪");
        product5.setCategoryName("毛毯");
        product5.setId(5l);
        product5.setDescription(
                "吉俪家纺对商品的挑选,检验标准均为最高标准,能保证产品质量,服务和信誉;在产品品类的开发上,加强新技术的应用;产品品质上关注每一个细节,做到精益求精; 致力于为您创造高品质、健康舒适的家居生活和睡眠环境。");
        product5.setUtime(Instant.now().getEpochSecond());
        productRepository.saveAll(Arrays.asList(product1, product2, product3, product4, product5));

  此处有一点需要注意,由于商品品牌名称大多为互相无关联意义的字组成,如果使用默认分词,会被分的很散,不符合要求,所以需要将品牌名中文部分列出来,加入到ik分词插件的扩展词典中去,此处不展开。
  分析一下搜索需求,当用户传入搜索关键词后,需要在商品名称、描述、类目名称和品牌名称四个字段中做match操作,暂定互相之间的权重比为0.5:0.1:0.2:1,可使用NativeSearchQuery创建查询方法:

    /**
     * 核心查询方法
     * @param queryStr
     * @param page     从1开始
     * @param size     最大100,最小1
     * @return
     */
    public Object search(String queryStr, int page, int size) {
        var query = new NativeSearchQuery(
                new MultiMatchQueryBuilder(queryStr).field("productName", 0.5f).field("description", 0.1f)
                        .field("brandName", 1).field("categoryName", 0.2f))
                .setPageable(PageRequest.of(page < 0 ? 0 : page - 1, max(1, min(100, size))));
        return restTemplate.search(query, Product.class);
    }

  //接口定义方法,@RequestMapping("product")
    @GetMapping("{q}/{page}/{size}")
    public Object search(@PathVariable("q") String queryStr, @PathVariable("page") int page,
                         @PathVariable("size") int size) {
        return productService.search(queryStr, page, size);
    }

  打开浏览器,输入/product/风衣%20毛衣/1/10,可以得到如下结果:

{
"totalHits": 2,
"totalHitsRelation": "EQUAL_TO",
"maxScore": 0.85801333,
"scrollId": null,
"searchHits": [
{
"id": "1",
"score": 0.85801333,
"sortValues": [],
"content": {
"id": 1,
"productName": "2020新款中年妈妈秋装风衣",
"description": "胖织缘福是知名的时尚中老年女装品牌,注重个性文化又不过分张扬,推崇时尚简约、独特个性设计风格,通过胖织缘福把自信、魅力潇洒淋漓尽致的展现出来。",
"utime": 1614666414,
"brandName": "胖织缘福",
"categoryName": "女式风衣"
},
"highlightFields": {}
},
{
"id": "4",
"score": 0.7199211,
"sortValues": [],
"content": {
"id": 4,
"productName": "隽达卷边V领针织打底毛衣【疫情地区不发货】",
"description": "本次活动以春装为主,产品以时尚化、舒适化、精致化的设计理念。\n隽达专为25-45岁成熟、高贵、讲究品位的都市女性设计。\n秉承“都市轻熟女之家”的品牌核心。产品选用中高端面料,手感舒适,质地柔软。",
"utime": 1614666414,
"brandName": "隽达",
"categoryName": "女式毛衣"
},
"highlightFields": {}
}
],
"aggregations": null,
"empty": false
}

至此,第一步工作算是暂时完成。

上一篇下一篇

猜你喜欢

热点阅读