ES 使用

ES 安装

ElasticSearch安装

1
docker pull elasticsearch:7.7.1 # 安装es7.7.1版本

es容器内部需要挂载的文件如下所示

  • 存放配置相关的:/usr/share/elasticsearch/config
  • 存放数据相关的:/usr/share/elasticsearch/data
  • 存放插件相关的:/usr/share/elasticsearch/plugins

如果要部署kibana,则需要让es和kibana互联,需要利用docker创建一个网络docker network create es-net 详情可自己搜索

这里我们在宿主机创建相应的文件

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732876644345-12660c50-0234-47ad-a28c-900c4c3432b1.png

为所有文件进行权限赋予

title:赋予权限
1
2
3
chmod 777 /mydata/es/config
chmod 777 /mydata/es/data
chmod 777 /mydata/es/plugins

直接启动一个简单的容器

title:启动简单的ES容器
1
docker run -d --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:7.7.1

将config里面的内容拷贝到宿主机

title:文件拷贝
1
docker cp elasticsearch:/usr/share/elasticsearch/config /mydata/es

删除临时容器

1
2
docker stop elasticsearch
docker rm -f elasticsearch

修改 elasticsearch.yml文件

这里需要注意,elasticsearch 8.x版本采用了默认配置,配置文件中没有之前那么多内容,如果需要修改的话,直接自己添加相应的内容即可。例如es8默认开启了账号校验,如果需要关闭的话,输入下图所示的配置即可。

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732878270154-a6c0b3cd-d8b4-4e65-a6ea-3d5216cc7f26.png

修改后,重新启动容器(一定要给权限给挂载的文件夹!)

title:启动es容器
1
2
3
4
5
6
7
8
9
10
11
docker run \
--name es \
--privileged=true \
-p 9200:9200 \
-p 9300:9300 \
-v /mydata/es/config:/usr/share/elasticsearch/config \
-v /mydata/es/data:/usr/share/elasticsearch/data \
-v /mydata/es/plugins:/usr/share/elasticsearch/plugins \
-e ES_JAVA_OPTS="-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-d elasticsearch:8.2.0

之后可以进行页面的访问http://你的ip:9200

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732876949792-66b60242-0b7f-4f5c-bbe5-a66cef0c1df3.png

es的ik分词器安装

es自带的分词器无法解析中文内容,所以我们需要安装ik分词器用于解析中文内容

https://github.com/medcl/elasticsearch-analysis-ik

这里把8.2.0版本的上传上来📎elasticsearch-analysis-ik-8.2.0.zip

找到与你es版本相应的ik分词器,我这里是8.2.0版本的es

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732884562691-8099ced9-b72a-477a-a566-c12588849bcb.png

下载完成后进行解压,将解压后的文件传输到linux服务器中,目录和文件内容如下

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732884808410-e9bd0d72-25da-455e-ab72-876ee7d3f4d6.png

进入容器内部

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732884893480-9039100e-0dc8-4840-b896-6eeece39440a.png

可以看到,容器内没有任何插件,我们在启动的时候进行了文件挂载,可以直接在宿主机的plugins文件夹下操作,如果启动时没有挂载,可以利用docker cp的命令进行文件的传输

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732884949958-e5897fc6-3a8f-4f8d-8e36-30a2c398a905.png

这里我们直接在宿主机操作,把文件传输过来后,重启es

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732885097347-d2552a45-6494-443a-b937-41bd6c961909.png

重启成功之后,我们进行测试

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732885175892-60fb026f-0c8d-44c4-90b1-57abf3bb0fdf.png

上面采用原生分词器,枫叶被拆开了

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732885205012-738817dd-7c17-4b6e-a2a2-a7da51c8ed42.png

更换为ik分词器后,可以发现,枫叶没有被拆开

ES-head插件安装

es-head.zip

下载好后,直接拖入chrome,安装插件

连接好es后,可以看到如下界面

image.png

ES基础http调用

使用http的put方法,传一个参数,代表创建了一个subject_index的索引

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732880526093-4f3763b5-981f-4b83-bbaf-a281368999a9.png

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732880572832-fdf55452-4e82-4857-a510-f18dc77ff3c5.png

查看索引,查看所有索引的情况

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732880806714-af635b86-e88d-4e83-88a5-0dbcee3c3e8c.png

查看单个索引

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732880888703-16162d4d-960c-4464-9562-afc071782c32.png

删除单个索引

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732880935931-b03975ad-17d8-4a1a-8d73-9867e37404dd.png

创建文档

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732881245944-f1be22ae-8dc8-4e7f-bd93-db23f0b8df7d.png

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732881287059-ad23ba7e-6cc4-4d00-81a0-52fa8d537c62.png

查看文档,需要传入上图的_id的值

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732881357585-3055247d-c2fd-447f-a760-f23bc794e166.png

创建映射,为列设置属性

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732881992274-d911bf56-89e9-4009-b5e4-3fbbb0919cc3.png

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732882069639-5a3775fd-f707-4a74-a0a9-84505dd5b4ea.png

查看映射,将设置映射的方法改为get

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732882311670-00b42ad4-664c-42fd-a0d3-90ed00a69bb7.png

查看匹配文档

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732883103375-fc697437-e4e5-4caa-b3a8-2d2fe2a59b8f.png

查看特定字段匹配文档

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732883295309-c22929e7-a352-4f7e-abda-db845968adbd.png

原生分词器使用

https://cdn.nlark.com/yuque/0/2024/png/34408480/1732884119238-636a92da-ff75-4e0d-bfa6-25bfafd739d0.png

spring-data-es基础调用

首先需要引入es的maven包

image.png

注意这里的版本,与是springboot的版本要相对应,如果版本过高,可能会与springboot产生冲突,导致项目无法启动。同时2.4.2版本对应的es版本需要是7.x,如果es版本过高,例如8.x,在调用的时候会报错。

导入成功后,就可以使用springdataes中的接口了

title:SubjectEsRepo
1
2
3
4
@Component
public interface SubjectEsRepo extends ElasticsearchRepository<SubjectInfoES, Integer> {

}

创建一个接口,注入到spring容器内,继承了ElasticsearchRepository ,可以直接使用其中的方法。

image.png

主要使用到的就是ElasticsearchRepository 接口以及一个名叫ElasticsearchRestTemplate的类。

这里创建了一个自己定义的接口SubjectEsService,直接展示实现类SubjectEsServiceImpl的代码

fold title:SubjectEsServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Service
@Slf4j
public class SubjectEsServiceImpl implements SubjectEsService {

@Resource
private ElasticsearchRestTemplate elasticsearchRestTemplate;

@Resource
private SubjectEsRepo subjectEsRepo;

@Override
public void createIndex() {
IndexOperations indexOperations = elasticsearchRestTemplate.indexOps(SubjectInfoES.class);
indexOperations.create();
Document mapping = indexOperations.createMapping(SubjectInfoES.class);
indexOperations.putMapping(mapping);
}

@Override
public void getDocuments() {

}

@Override
public void addDocuments() {
List<SubjectInfoES> list = new ArrayList<>();
// 模拟数据
list.add(new SubjectInfoES(1, "redis是什么", "是一种缓存", "maple", new Date()));
list.add(new SubjectInfoES(2, "mysql是什么", "是一种数据库", "maple", new Date()));
subjectEsRepo.saveAll(list);
}

@Override
public void find() {
Iterable<SubjectInfoES> all = subjectEsRepo.findAll();
all.forEach(subjectInfoES -> {
log.info("find.subjectInfoES:{}", subjectInfoES);
});

}

@Override
public void search() {
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.matchQuery("subjectName", "redis"))
.build();
SearchHits<SubjectInfoES> searchHits = elasticsearchRestTemplate.search(query, SubjectInfoES.class);
List<SearchHit<SubjectInfoES>> hits = searchHits.getSearchHits();
for (SearchHit<SubjectInfoES> hit : hits) {
SubjectInfoES subjectInfoES = hit.getContent();
log.info("subjectInfoES:{}", subjectInfoES);
}
}
}

createIndex 方法用于在 Elasticsearch 中创建索引。具体步骤如下:

  1. 获取索引操作对象:通过 elasticsearchRestTemplate.indexOps(SubjectInfoES.class) 获取 IndexOperations 对象,用于执行索引操作。
  2. 创建索引:调用 indexOperations.create() 方法创建索引。
  3. 创建映射:通过 indexOperations.createMapping(SubjectInfoES.class) 创建索引的映射(mapping),并将其存储在 Document 对象中。
  4. 设置映射:调用 indexOperations.putMapping(mapping) 方法,将映射应用到索引中。

addDocuments方法确保了在 Elasticsearch 中为 SubjectInfoES 类创建相应的索引和映射。

addDocuments 方法用于向 Elasticsearch 中添加文档。具体步骤如下:

  1. 创建一个 List<SubjectInfoES> 对象 list,用于存储要添加的文档。
  2. list 中添加两个 SubjectInfoES 对象,分别包含模拟数据。
  3. 使用 subjectEsRepo.saveAll(list) 方法将 list 中的所有文档保存到 Elasticsearch 中。

这个方法的主要作用是将一些模拟数据批量添加到 Elasticsearch 索引中。

find 方法从 Elasticsearch 数据库中检索所有 SubjectInfoES 实体,并将每个实体的信息记录到日志中。

search 方法用于搜索文档,在http的调用中我们可以看到,需要封装的json类型非常的复杂繁琐,这里利用了querybuilder 直接创建了一个查询对象用于查询,就类似于封装了一个http的请求

image.png

自定义 ES 工具

首先导入如下三个包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.7.1</version>
</dependency>

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

<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>

封装集群 ES 连接,统一配置

新建一个包名为es ,将与es有关的类都放在该包下,新建如下几个类

image.png

  • EsSourceData.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * es数据源
    */
    @Data
    public class EsSourceData implements java.io.Serializable {

    private String docId;

    private Map<String, Object> data;
    }

作用是表示从 Elasticsearch 中获取的数据源。它包含两个字段:

  • docId:表示文档的唯一标识符。

  • data:表示文档的实际数据,以键值对的形式存储。

  • EsSearchRequest.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    @Data
    public class EsSearchRequest {

    /**
    * 查询条件
    */
    private BoolQueryBuilder bq;

    /**
    * 查询字段
    */
    private String[] fields;

    /**
    * 页数
    */
    private int from;

    /**
    * 条数
    */
    private int size;

    /**
    * 是否需要快照
    */
    private Boolean needScroll;

    /**
    * 快照时间
    */
    private Long minutes;
    /**
    * 排序字段
    */
    private String sortName;
    /**
    * 排序类型
    */
    private String sortOrder;

    /**
    * 高亮
    */
    private HighlightBuilder highlightBuilder;
    }

用于封装 Elasticsearch 查询请求的参数。它包含了查询条件、查询字段、分页信息、排序信息以及高亮设置等。通过这个类,可以方便地构建和传递 Elasticsearch 查询请求的各种参数。

  • EsIndexInfo.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Data
    public class EsIndexInfo implements Serializable {
    private static final long serialVersionUID = -6010237985622125807L;

    /**
    * 集群名称
    */
    private String clusterName;

    /**
    * 索引名称
    */
    private String indexName;
    }

表示 Elasticsearch 索引的信息。它包含了集群名称 (clusterName) 和索引名称 (indexName) 两个字段,用于存储和传递与 Elasticsearch 索引相关的基本信息。

  • EsClusterConfig.java

    title:EsClusterConfig.java
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
    * es集群配置
    */
    @Data
    public class EsClusterConfig {

    /**
    * 集群名称
    */
    private String name;
    /**
    * 集群节点
    */
    private String nodes;
    }

用于表示 Elasticsearch 集群的配置

  • EsConfigProperties

    unwrap title:EsConfigProperties
    1
    2
    3
    4
    5
    6
    7
    @Data
    @Component
    @ConfigurationProperties(prefix = "es.cluster")
    public class EsConfigProperties {
    private List<EsClusterConfig> esConfigs = new ArrayList<>();
    }

用于加载和存储 Elasticsearch 集群的配置信息。它通过 Spring Boot 的 @ConfigurationProperties 注解,将配置文件中的属性映射到类的字段中。

image.png

title:EsRestClient
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Component
@Slf4j
public class EsRestClient {

public static Map<String, RestHighLevelClient> clientMap = new HashMap<>();

@Resource
private EsConfigProperties esConfigProperties;

@PostConstruct
public void initialize() {
List<EsClusterConfig> esConfigs = esConfigProperties.getEsConfigs();
for(EsClusterConfig esClusterConfig : esConfigs){
log.info("es client init start, clusterName:{},node:{}", esClusterConfig.getName(), esClusterConfig.getNodes());
RestHighLevelClient client = initRestClient(esClusterConfig);
if(client != null){
clientMap.put(esClusterConfig.getName(), client);
log.info("es client init success, clusterName:{},node:{}", esClusterConfig.getName(), esClusterConfig.getNodes());
}else {
log.error("es client init fail, clusterName:{},node:{}", esClusterConfig.getName(), esClusterConfig.getNodes());
}
}
}

private RestHighLevelClient initRestClient(EsClusterConfig esClusterConfig) {
String[] ipPortArr = esClusterConfig.getNodes().split(",");
List<HttpHost> httpHosts = new ArrayList<>(ipPortArr.length);
for (String ipPort : ipPortArr) {
String[] ipPortSplit = ipPort.split(":");
if(ipPortSplit.length != 2){
log.error("es node config error, node:{}", ipPort);
continue;
}
String ip = ipPortSplit[0];
int port = Integer.parseInt(ipPortSplit[1]);
HttpHost httpHost = new HttpHost("http://" + ip + ":" + port);
httpHosts.add(httpHost);
}
HttpHost[] hosts = new HttpHost[httpHosts.size()];
httpHosts.toArray(hosts);
try {
RestClientBuilder builder = RestClient.builder(hosts);
return new RestHighLevelClient(builder);
} catch (Exception e) {
log.error("es client init fail, clusterName:{},node:{},error:{}", esClusterConfig.getName(), esClusterConfig.getNodes(), e.getMessage());
return null;
}

}

}

初始化和管理与 Elasticsearch 集群的连接。它通过读取配置文件中的集群信息,创建并维护 RestHighLevelClient 实例,以便与不同的 Elasticsearch 集群进行交互

主要功能:

  1. 读取配置:通过 EsConfigProperties 读取配置文件中的 Elasticsearch 集群信息。
  2. 初始化客户端:在 initialize 方法中,遍历所有配置的集群信息,调用 initRestClient 方法为每个集群创建 RestHighLevelClient 实例。
  3. 管理客户端:将创建的 RestHighLevelClient 实例存储在 clientMap 中,以便后续使用。

通过这些连接方法,实际上是为了模拟与 Elasticsearch (ES) 进行 HTTP 连接以及发送请求

@PostConstruct 是一个 Java 标准注解,用于标记一个方法在依赖注入完成后执行。这个方法会在所有依赖注入完成后、但在该 bean 被任何其他 bean 使用之前调用

初始化逻辑:在这个例子中,initialize 方法被标记为 @PostConstruct,表示该方法将在所有依赖注入完成后自动调用,用于初始化 Elasticsearch 客户端。

确保顺序:通过使用 @PostConstruct,可以确保在任何业务逻辑开始之前,Elasticsearch 客户端已经正确初始化。

ES 学习

来源 : [黑马Elasticsearch全套教程][https://www.bilibili.com/video/BV1b8411Z7w5]

1. 倒排索引

在 Elasticsearch(ES)中,倒排索引是一种高效的数据结构,主要用于快速全文搜索。它的名称来源于与传统的“正排索引”相比的数据组织方式。

1. 什么是倒排索引?

倒排索引是从文档到词的映射的反向过程。

  • 正排索引:记录每个文档包含哪些词。

    • 示例:

      1
      2
      3
      4
      5
      文档1: "我爱学习"
      文档2: "学习使我快乐"
      正排索引:
      文档1 -> 我, 爱, 学习
      文档2 -> 学习, 使, 我, 快乐
  • 倒排索引:记录每个词在哪些文档中出现。

    • 示例:

      1
      2
      3
      4
      5
      6
      倒排索引:
      我 -> 文档1, 文档2
      爱 -> 文档1
      学习 -> 文档1, 文档2
      使 -> 文档2
      快乐 -> 文档2

2. 为什么叫“倒排”索引?

  • 在传统的正排索引中,系统需要从文档逐一扫描内容(比如按文档 ID 的顺序存储每个文档的内容)。
  • 倒排索引将这种“文档到词”的关系反转为“词到文档”,在搜索时不需要逐个扫描文档,而是可以直接查找到相关文档。
    这种“词 -> 文档”的关系与传统正排的“文档 -> 词”相反,因此称为倒排索引

3. 倒排索引的结构

倒排索引由两部分组成:

  • 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
  1. 词典(Term Dictionary):包含所有词(去重后的关键词列表)。
    • 例如:["我", "学习", "快乐", "爱", "使"]
  2. 倒排表(Posting List):记录每个词在哪些文档中出现。
    • 例如:

      1
      2
      3
      4
      5
      我 -> [1, 2]
      学习 -> [1, 2]
      快乐 -> [2]
      爱 -> [1]
      使 -> [2]

扩展的倒排索引还可以存储以下信息:

  • 词频:词在某个文档中出现的次数。
  • 位置信息:词在文档中的具体位置(用于短语查询)。
  • 文档权重:用于排序的权重信息。

4. 为什么 Elasticsearch 使用倒排索引?

倒排索引是全文检索的基础数据结构,能显著提升搜索效率,原因如下:

  1. 快速定位文档:查询一个词时,直接通过倒排表找到相关文档 ID,无需逐一扫描所有文档。
  2. 高效支持多种查询:如关键词查询、短语查询、布尔查询等。
  3. 节省存储空间:通过分词、去重和压缩技术,倒排索引能大幅减少存储开销。
  4. 灵活的相关性计算:结合词频和文档评分机制,可以为搜索结果排序,提高相关性。

5. 对比正排索引的劣势

倒排索引虽然在搜索场景下性能优越,但它并不是万能的:

  • 实时性较差:新增文档需要更新倒排索引,而正排索引更适合快速写入和更新。
  • 结构复杂:实现倒排索引需要更多的内存和计算资源。
  • 索引限制:只能给词条创建索引,而不是字段
  • 无法字段排序:无法根据字段做排序

因此,在实际系统中,倒排索引和正排索引通常结合使用(例如:ES 使用倒排索引用于全文检索,结合其他数据结构处理聚合查询或排序)。

总结

倒排索引因其“词到文档”的映射特点得名,是全文检索的核心技术。在 Elasticsearch 中,倒排索引通过高效的分词、压缩和优化技术,为海量文本数据的快速检索提供了坚实基础。

2. ES 数据库基本概念

elasticsearch中有很多独有的概念,与mysql中略有差别,但也有相似之处。

  1. 文档和字段

    一个文档就像数据库里的一条数据,字段就像数据库里的列

    elasticsearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在elasticsearch中:

    image.png

    而Json文档中往往包含很多的字段(Field),类似于mysql数据库中的列

  2. 索引和映射

    索引就像数据库里的表,映射就像数据库中定义的表结构

    例如:

    • 所有用户文档,就可以组织在一起,称为用户的索引;
    • 所有商品的文档,可以组织在一起,称为商品的索引;
    • 所有订单的文档,可以组织在一起,称为订单的索引;

    image.png

    因此,我们可以把索引当做是数据库中的表。

    数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。

  3. MySQL与Elasticsearch

    各自长处:

    • MySQL:擅长事务类型操作,可以确保数据的安全和一致性
    • Elasticsearch:擅长海量数据的搜索、分析、计算

    我们统一的把mysql与elasticsearch的概念做一下对比

    MySQL Elasticsearch 说明
    Table Index 索引(index),就是文档的集合,类似数据库的表(table)
    Row Document 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
    Column Field 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
    Schema Mapping Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
    SQL DSL DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD

    在企业中,往往是两者结合使用:

    • 对安全性要求较高的写操作,使用mysql实现
    • 对查询性能要求较高的搜索需求,使用elasticsearch实现
    • 两者再基于某种方式,实现数据的同步,保证一致性

    image.png

3. IK分词器

分词器的作用是什么?

  • 创建倒排索引时对文档分词
  • 用户搜索时,对输入的内容分词

IK分词器有几种模式?

  • ik_smart:智能切分,粗粒度
  • ik_max_word:最细切分,细粒度

IK分词器如何拓展词条?如何停用词条?

  • 利用config目录的IkAnalyzer.cfg.xml文件添加拓展词典和停用词典
  • 在词典中添加拓展词条或者停用词条

IK分词器包含两种模式:

  • ik_smart:最少切分
  • ik_max_word:最细切分

在kibana的Dev tools中输入以下代码:

”analyzer“ 就是选择分词器模式

1
2
3
4
5
6
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "黑马程序员学习java太棒了"
}

  • 结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    {
    "tokens" : [
    {
    "token" : "黑马",
    "start_offset" : 0,
    "end_offset" : 2,
    "type" : "CN_WORD",
    "position" : 0
    },
    {
    "token" : "程序员",
    "start_offset" : 2,
    "end_offset" : 5,
    "type" : "CN_WORD",
    "position" : 1
    },
    {
    "token" : "程序",
    "start_offset" : 2,
    "end_offset" : 4,
    "type" : "CN_WORD",
    "position" : 2
    },
    {
    "token" : "员",
    "start_offset" : 4,
    "end_offset" : 5,
    "type" : "CN_CHAR",
    "position" : 3
    },
    {
    "token" : "学习",
    "start_offset" : 5,
    "end_offset" : 7,
    "type" : "CN_WORD",
    "position" : 4
    },
    {
    "token" : "java",
    "start_offset" : 7,
    "end_offset" : 11,
    "type" : "ENGLISH",
    "position" : 5
    },
    {
    "token" : "太棒了",
    "start_offset" : 11,
    "end_offset" : 14,
    "type" : "CN_WORD",
    "position" : 6
    },
    {
    "token" : "太棒",
    "start_offset" : 11,
    "end_offset" : 13,
    "type" : "CN_WORD",
    "position" : 7
    },
    {
    "token" : "了",
    "start_offset" : 13,
    "end_offset" : 14,
    "type" : "CN_CHAR",
    "position" : 8
    }
    ]
    }

扩展词词典

随着互联网的发展,“造词运动”也越发的频繁。出现了很多新的词语,在原有的词汇列表中并不存在。比如:“奥力给”,“白嫖” 等。

所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。

1)打开IK分词器config目录:

https://img2023.cnblogs.com/blog/2729274/202302/2729274-20230205172005673-1129389907.png

2)在IKAnalyzer.cfg.xml配置文件内容添加:

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
<entry key="ext_dict">ext.dic</entry>
</properties>

3)新建一个 ext.dic,可以参考config目录下复制一个配置文件进行修改

1
2
白嫖
奥力给

4)重启elasticsearch

1
2
3
docker restart es
# 查看 日志
docker logs -f elasticsearch

https://img2023.cnblogs.com/blog/2729274/202302/2729274-20230205172011932-1622225700.png

日志中已经成功加载ext.dic配置文件

5)测试效果:

1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "传智播客Java就业超过90%,奥力给!"
}

注意当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑

停用词词典

在互联网项目中,在网络间传输的速度很快,所以很多语言是不允许在网络上传递的,如:关于宗教、政治等敏感词语,那么我们在搜索时也应该忽略当前词汇。

IK分词器也提供了强大的停用词功能,让我们在索引时就直接忽略当前的停用词汇表中的内容。

1)IKAnalyzer.cfg.xml配置文件内容添加:

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典-->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典 *** 添加停用词词典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>

3)在 stopword.dic 添加停用词

1
2
大帅逼

4)重启elasticsearch

1
2
3
4
5
6
7
# 重启服务
docker restart es
docker restart kibana

# 查看 日志
docker logs -f elasticsearch

日志中已经成功加载stopword.dic配置文件

5)测试效果:

1
2
3
4
5
6
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "我是真的会谢Java就业率超过95%,大帅逼都点赞白嫖,奥力给!"
}

注意当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑

4. 索引库操作

索引库就类似数据库表,mapping映射就类似表的结构。

我们要向es中存储数据,必须先创建“库”和“表”。

Mapping映射属性

mapping是对索引库中文档的约束,常见的mapping属性包括:

  • type:字段数据类型,常见的简单类型有:

    • 字符串:text(可分词的文本)keyword(精确值,例如:品牌、国家、ip地址)

      keyword类型只能整体搜索,不支持搜索部分内容

    • 数值:long、integer、short、byte、double、float、

    • 布尔:boolean

    • 日期:date

    • 对象:object

  • index:是否创建索引,默认为true

    实际上不是每个字段都需要创建倒排索引进行搜索,比如邮箱,商品图片的url地址类似字段不需要参与搜索,具体需要看业务需求,如果不参与,需要设置为false

  • analyzer:使用哪种分词器

  • properties:该字段的子字段

例如下面的json文档:

1
2
3
4
5
6
7
8
9
10
11
12
{
    "age": 21,
    "weight": 52.1,
    "isMarried": false,
    "info": "真相只有一个!",
"email": "zy@itcast.cn",
"score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "柯",
        "lastName": "南"
    }
}

对应的每个字段映射(mapping):

  • age:类型为 integer;参与搜索,因此需要index为true;无需分词器
  • weight:类型为float;参与搜索,因此需要index为true;无需分词器
  • isMarried:类型为boolean;参与搜索,因此需要index为true;无需分词器
  • info:类型为字符串,需要分词,因此是text;参与搜索,因此需要index为true;分词器可以用ik_smart
  • email:类型为字符串,但是不需要分词,因此是keyword;不参与搜索,因此需要index为false;无需分词器
  • score:虽然是数组,但是我们只看元素的类型,类型为float;参与搜索,因此需要index为true;无需分词器
  • name:类型为object,需要定义多个子属性
    • name.firstName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器
    • name.lastName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器

索引库 CRUD

CRUD简单描述:

  • 创建索引库:PUT /索引库名
  • 查询索引库:GET /索引库名
  • 删除索引库:DELETE /索引库名
  • 修改索引库(添加字段):PUT /索引库名/_mapping

这里统一使用Kibana编写DSL的方式来演示。

1. 创建索引库和映射

基本语法:

  • 请求方式:PUT
  • 请求路径:/索引库名,可以自定义
  • 请求参数:mapping映射

格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PUT /索引库名称
{
"mappings": {
"properties": {
"字段名":{
"type": "text",
"analyzer": "ik_smart"
},
"字段名2":{
"type": "keyword",
"index": "false"
},
"字段名3":{
"properties": {
"子字段": {
"type": "keyword"
}
}
},
// ...略
}
}
}

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
PUT /conan
{
"mappings": {
"properties": {
"column1":{
"type": "text",
"analyzer": "ik_smart"
},
"column2":{
"type": "keyword",
"index": "false"
},
"column3":{
"properties": {
"子字段1": {
"type": "keyword"
},
"子字段2": {
"type": "keyword"
}
}
},
// ...略
}
}
}

2. 查询索引库

基本语法

  • 请求方式:GET
  • 请求路径:/索引库名
  • 请求参数:无

格式

1
GET /索引库名

示例

image.png

3. 修改索引库

这里的修改是只能增加新的字段到mapping中

倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping

虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。

语法说明

1
2
3
4
5
6
7
8
PUT /索引库名/_mapping
{
"properties": {
"新字段名":{
"type": "integer"
}
}
}

示例

image.png

4. 删除索引库

语法:

  • 请求方式:DELETE
  • 请求路径:/索引库名
  • 请求参数:无

格式:

1
DELETE /索引库名

在kibana中测试:

image.png

5. 文档操作

1. 新增文档

1
2
3
4
5
6
7
8
9
10
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
// ...
}

示例:

1
2
3
4
5
6
7
8
9
10
copy
POST /heima/_doc/1
{
"info": "真相只有一个!",
"email": "zy@itcast.cn",
"name": {
"firstName": "柯",
"lastName": "南"
}
}

响应:

image.png

2. 查询文档

根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。

语法:

1
2
3
GET /{索引库名称}/_doc/{id}
//批量查询:查询该索引库下的全部文档
GET /{索引库名称}/_search

通过kibana查看数据:

1
GET /heima/_doc/1

查看结果:

image.png

3. 删除文档

删除使用DELETE请求,同样,需要根据id进行删除:

语法:

1
DELETE /{索引库名}/_doc/id值

示例:

1
2
# 根据id删除数据
DELETE /heima/_doc/1

结果:

image.png

4. 修改文档

修改有两种方式:

  • 全量修改:直接覆盖原来的文档
  • 增量修改:修改文档中的部分字段

4.1 全量修改

全量修改是覆盖原来的文档,其本质是:

跟新增类似,只不过POST改为了PUT

  • 根据指定的id删除文档
  • 新增一个相同id的文档

注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。

语法:

1
2
3
4
5
PUT /{索引库名}/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
// ... 略}

示例:

1
2
3
4
5
6
7
8
9
PUT /heima/_doc/1
{
"info": "黑马程序员高级Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName": "云",
"lastName": "赵"
}
}

4.2 增量修改

增量修改是只修改指定id匹配的文档中的部分字段

语法:

1
2
3
4
5
6
POST /{索引库名}/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}

示例:

1
2
3
4
5
6
7
copy
POST /heima/_update/1
{
"doc": {
"email": "ZhaoYun@itcast.cn"
}
}

6. RestClient 操作索引库

ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html

其中的Java Rest Client又包括两种:

  • Java Low Level Rest Client
  • Java High Level Rest Client
    ![[Pasted image 20241223190523.png]]
    ![[Pasted image 20241223190602.png]]
    我们使用的是Java HighLevel Rest Client客户端API

API操作索引库

JavaRestClient操作elasticsearch的流程基本类似。核心是client.indices()方法来获取索引库的操作对象。

索引库操作的基本步骤:【可以根据发送请求那步的第一个参数,发过来判断需要创建什么XXXXRequest】

  • 初始化RestHighLevelClient
  • 创建XxxIndexRequest。XXX是Create、Get、Delete
  • 准备DSL( Create时需要,其它是无参)
  • 发送请求。调用RestHighLevelClient#indices().xxx()方法,xxx是create、exists、delete

1. mapping映射分析

根据MySQL数据库表结构(建表语句),去写索引库结构JSON。表和索引库一一对应

注意:地理坐标、组合字段。索引库里的地理坐标是一个字段:坐标:维度,精度 。copy_to组合字段作用是供用户查询(输入关键字可以查询多个字段)

创建索引库,最关键的是mapping映射,而mapping映射要考虑的信息包括:

  • 字段名
  • 字段数据类型
  • 是否参与搜索
  • 是否需要分词
  • 如果分词,分词器是什么?

其中:

  • 字段名、字段数据类型,可以参考数据表结构的名称和类型
  • 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
  • 是否分词呢要看内容,内容如果是一个整体就无需分词,反之则要分词
  • 分词器,我们可以统一使用ik_max_word

来看下酒店数据的索引库结构:

title:索引库结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
PUT /hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name":{
"type": "text",
"analyzer": "ik_max_word",
"copy_to": "all"
},
"address":{
"type": "keyword",
"index": false
},
"price":{
"type": "integer"
},
"score":{
"type": "integer"
},
"brand":{
"type": "keyword",
"copy_to": "all"
},
"city":{
"type": "keyword",
"copy_to": "all"
},
"starName":{
"type": "keyword"
},
"business":{
"type": "keyword"
},
"location":{
"type": "geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}

几个特殊字段说明:

  • location:地理坐标,里面包含精度、纬度
  • all:一个组合字段,其目的是将多字段的值 利用copy_to合并,提供给用户搜索
    地理坐标说明:
    ![[Pasted image 20241223194718.png]]
    copy_to说明:
    ![[Pasted image 20241223194725.png]]

在 Elasticsearch 中,copy_to 是一个有用的字段属性,用于将字段的内容复制到其他字段,通常是一个专门用于全文搜索的字段。它的主要作用包括:

copy_to 的作用仅仅是在索引过程中复制内容到目标字段用于倒排索引,而不会在存储过程中创建真实的字段值。

  • 聚合多个字段进行全文搜索
    • 如果你想对多个字段(如 namebrandcity)进行统一的全文搜索,而不想分别对每个字段查询,可以使用 copy_to 将这些字段的内容复制到一个单独的字段(如 all)。
    • 查询时只需要对 all 字段进行查询,简化了查询逻辑
  • 提高查询效率
    • 如果对多个字段进行 OR 查询,性能可能较低。
    • 将多个字段合并到 all 中后,只需对单个字段进行查询,可以显著提升查询效率。
  • 灵活性
    • 使用 copy_to,你仍然可以单独查询原始字段,同时还能使用聚合字段(如 all)进行更复杂的查询。
      注意事项
  1. copy_to 不会影响原字段的存储和索引
    • 原字段仍然可以正常索引和查询,copy_to 只是额外生成一个字段用于特定用途。
  2. 增加了索引存储成本
    • 被复制的内容会占用更多的存储空间,因为它在目标字段中会被重复存储。

2. 初始化RestClient

在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。
分为三步:

1)引入es的RestHighLevelClient依赖:

1
2
3
4
<dependency>     
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

2)因为SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:

1
2
3
4
<properties>     
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>

![[Pasted image 20241223200821.png]]
3)初始化RestHighLevelClient:这里一般在启动类或者配置类里注入该Bean,用于告诉Java 访问ES的ip地址
初始化的代码如下:

title:初始化RestHighLevelClient
1
2
3
4
@Bean 
public RestHighLevelClient client(){
return new RestHighLevelClient(RestClient.builder(HttpHost.create("http://192.168.150.101:9200")));
}

这里为了单元测试方便,我们创建一个测试类HotelIndexTest,然后将初始化的代码编写在@BeforeEach方法中:

title:HotelIndexTest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package cn.itcast.hotel;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;

public class HotelIndexTest {
private RestHighLevelClient client;

@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
}

@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}

  • **@BeforeEach**:为每个测试方法运行前的初始化提供支持。
  • **@AfterEach**:确保每个测试方法运行后正确清理资源。它们用于在测试方法之间初始化和关闭 Elasticsearch 客户端,保证连接资源的正确管理。

3. Client 索引库CRUD

索引库操作的基本步骤

  1. 初始化RestHighLevelclient
  2. 创建XxxIndexRequest。XXX是CREATE、Get、Delete
  3. 准备DSL(CREATE时需要)
  4. 发送请求。调用RestHighLevelClient#indices().xxx()方法 xxx是create、exists、delete
3.1 创建索引库

代码分为三步:

1)创建Request对象。因为是创建索引库的操作,因此Request是CreateIndexRequest。
2)添加请求参数,其实就是DSL的JSON参数部分。因为json字符串很长,这里是定义了静态字符串常量MAPPING_TEMPLATE,让代码看起来更加优雅。
3)发送请求,client.indices()方法的返回值是IndicesClient类型,封装了所有与索引库操作有关的方法。

创建索引库的API如下:
![[Pasted image 20241223200121.png]]
代码:

在hotel-demo的cn.itcast.hotel.constants包下,创建一个类,定义mapping映射的JSON字符串常量:

title:定义常量JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package cn.itcast.hotel.constants;

public class HotelConstants {
public static final String MAPPING_TEMPLATE = "{\n" +
" \"mappings\": {\n" +
" \"properties\": {\n" +
" \"id\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"name\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"address\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"price\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"score\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"brand\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"city\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"starName\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"business\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"location\":{\n" +
" \"type\": \"geo_point\"\n" +
" },\n" +
" \"pic\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"all\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}

在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现创建索引:

1
2
3
4
5
6
7
8
9
@Test
void createHotelIndex() throws IOException {
// 1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2.准备请求的参数:DSL语句
request.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3.发送请求
client.indices().create(request, RequestOptions.DEFAULT);
}
3.2 删除索引库

三步走:

  • 1)创建Request对象。这次是DeleteIndexRequest对象
  • 2)准备参数。这里是无参
  • 3)发送请求。改用delete方法
    删除索引库的DSL语句非常简单:
    1
    DELETE /hotel
    在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现删除索引:
    1
    2
    3
    4
    5
    6
    7
    @Test
    void testDeleteHotelIndex() throws IOException {
    // 1.创建Request对象
    DeleteIndexRequest request = new DeleteIndexRequest("hotel");
    // 2.发送请求
    client.indices().delete(request, RequestOptions.DEFAULT);
    }
3.3 查询索引库

三步走:

  • 1)创建Request对象。这次是GetIndexRequest对象
  • 2)准备参数。这里是无参
  • 3)发送请求。改用exists方法
    判断索引库是否存在,本质就是查询,对应的DSL是:
    1
    判断索引库是否存在,本质就是查询,对应的DSL是:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Test
    void testExistsHotelIndex() throws IOException {
    // 1.创建Request对象
    GetIndexRequest request = new GetIndexRequest("hotel");
    // 2.发送请求
    boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
    // 3.输出
    System.err.println(exists ? "索引库已经存在!" : "索引库不存在!");
    }

API操作文档

文档操作的基本步骤:

  1. 初始化RestHighLevelclient
  2. 创建XxxRequest。XXX是Index、Get、Update、Delete
  3. 准备参数(Index和Update时需要)
  4. 发送请求。调用RestHighLevelClient#.xxx()方法,xxx是index、get、update、delete
  5. 解析结果(Get时需要)

新增文档

![[Pasted image 20241223203317.png]]
这里操作文档,直接index()即可,前面索引库需要indices()

查询文档

![[Pasted image 20241223204633.png]]

修改文档

![[Pasted image 20241223205459.png]]

删除文档

![[Pasted image 20241223210308.png]]

批量导入文档

三步走:

  • 1)创建Request对象。这里是BulkRequest
  • 2)准备参数。批处理的参数,就是其它Request对象,这里就是多个IndexRequest
  • 3)发起请求。这里是批处理,调用的方法为client.bulk()方法

案例需求:利用BulkRequest批量将数据库数据导入到索引库中。
步骤如下:

  • 利用mybatis-plus查询酒店数据
  • 将查询到的酒店数据(Hotel)转换为文档类型数据(HotelDoc)
  • 利用JavaRestClient中的BulkRequest批处理,实现批量新增文档

语法说明:
批量处理BulkRequest,其本质就是将多个普通的CRUD请求组合在一起发送。
其中提供了一个add方法,用来添加其他请求:
![[Pasted image 20241223203057.png]]
可以看到,能添加的请求包括:

  • IndexRequest,也就是新增
  • UpdateRequest,也就是修改
  • DeleteRequest,也就是删除

因此Bulk中添加了多个IndexRequest,就是批量新增功能了。示例:
![[Pasted image 20241223212143.png]]
我们在导入酒店数据时,将上述代码改造成for循环处理即可。
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:

title:HotelDocumentTest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void testBulkRequest() throws IOException {
// 批量查询酒店数据
List<Hotel> hotels = hotelService.list();
// 1.创建Request
BulkRequest request = new BulkRequest();
// 2.准备参数,添加多个新增的Request
for (Hotel hotel : hotels) {
// 2.1.转换为文档类型HotelDoc
HotelDoc hotelDoc = new HotelDoc(hotel);
// 2.2.创建新增文档的Request对象
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc), XContentType.JSON));
}
// 3.发送请求
client.bulk(request, RequestOptions.DEFAULT);
}

7. ES 搜索引擎

elasticsearch的查询依然是基于JSON风格的DSL来实现的。

7.1. DSL设置查询条件

7.1.1 DSL查询分类

Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。常见的查询类型包括:

  • 查询所有:查询出所有数据,_一般测试用_。例如:match_all

  • 全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:

    • match_query
    • multi_match_query
  • 精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如:

    • ids
    • range
    • term
  • 地理(geo)查询:根据经纬度查询。例如:

    • geo_distance
    • geo_bounding_box
  • 复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:

    • bool
    • function_score

查询的语法基本一致:

title:查询基本语法
1
2
3
4
5
6
7
8
9
GET /indexName/_search
{
  "query": {
    "查询类型": {
      "查询条件": "条件值"
    }
  }
}

我们以查询所有为例,其中:

  • 查询类型为match_all
  • 没有查询条件
    1
    2
    3
    4
    5
    6
    7
    8
    // 查询所有
    GET /indexName/_search
    {
      "query": {
        "match_all": {
    }
      }
    }
    ![[Pasted image 20241223213814.png]]

    查询了所有,返回了所有的数据,可以看见total里面的value的值,同时所有的数据都是存放在一个叫hitsjson数组中,代表命中的数据

其它查询无非就是查询类型查询条件的变化。

7.1.2 全文检索查询

match和multi_match的区别是什么?

match:根据一个字段查询【推荐:使用copy_to构造all字段】
multi_match:根据多个字段查询,参与查询字段越多,查询性能越差

注:搜索字段越多,对查询性能影响越大,因此建议采用copy_to,然后单字段查询的方式。

7.1.2.1 使用场景

全文检索查询的基本流程如下:

  • 对用户搜索的内容做分词,得到词条
  • 根据词条去倒排索引库中匹配,得到文档id
  • 根据文档id找到文档,返回给用户

比较常用的场景包括:

  • 商城的输入框搜索
  • 百度输入框搜索

例如京东:
![[Pasted image 20241223214146.png]]
因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的text类型的字段。
常见的全文检索查询包括:

  • match 查询:单字段查询
  • multi_match 查询:多字段查询,任意一个字段符合条件就算符合查询条件
7.1.2.2 match查询

match查询语法如下:

1
2
3
4
5
6
7
8
GET /indexName/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  }
}

这里使用的all虽然看不见,但是在之前创建的时候,对一些字段用了copy_to,所以all在倒排索
表中是存在的。

![[Pasted image 20241223214257.png]]
每条数据会有一个score,也就是匹配的分数,分数越高,越靠前

7.1.2.3 mulit_match查询

mulit_match语法如下:

1
2
3
4
5
6
7
8
9
GET /indexName/_search
{
  "query": {
    "multi_match": {
      "query": "TEXT",
      "fields": ["FIELD1", " FIELD12"]
    }
  }
}

multi_match查询示例:
![[Pasted image 20241223214821.png]]

7.1.3 精准查询

精准查询类型:

term查询:根据词条精确匹配,一般搜索keyword类型、数值类型、布尔类型、日期类型字段
range查询:根据数值范围查询,可以是数值、日期的范围

精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有:

  • term:根据词条精确值查询
  • range:根据值的范围查询
1.3.1 term查询

因为精确查询的字段搜时不分词的字段,因此查询的条件也必须不分词的词条。查询时,用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。

语法说明:

1
2
3
4
5
6
7
8
9
10
11
// term查询
GET /indexName/_search
{
  "query": {
    "term": {
      "FIELD": {
        "value": "VALUE"
      }
    }
  }
}

示例:

当我搜索的是精确词条时,能正确查询出结果:
![[Pasted image 20241223214924.png]]
但是,当我搜索的内容不是词条,而是多个词语形成的短语时,反而搜索不到:
![[Pasted image 20241223214931.png]]

1.3.2 range查询

范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。

基本语法:

1
2
3
4
5
6
7
8
9
10
11
12
// range查询
GET /indexName/_search
{
  "query": {
    "range": {
      "FIELD": {
        "gte": 10, // 这里的gte代表大于等于,gt则代表大于
        "lte": 20 // lte代表小于等于,lt则代表小于
      }
    }
  }
}

示例:
![[Pasted image 20241223215339.png]]

7.1.4 地理坐标查询

所谓的地理坐标查询,其实就是根据经纬度查询,官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html

常见的使用场景包括:

  • 携程:搜索我附近的酒店
  • 滴滴:搜索我附近的出租车
  • 微信:搜索我附近的人

附近的酒店:
![[Pasted image 20241223215623.png]]
附近的车:
![[Pasted image 20241223215633.png]]

1.4.1 矩形范围查询

很少有业务有这种需求

矩形范围查询,也就是geo_bounding_box查询,查询坐标落在某个矩形范围的所有文档:
image

查询时,需要指定矩形的左上右下两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。

语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// geo_bounding_box查询
GET /indexName/_search
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": { // 左上点
          "lat": 31.1,
          "lon": 121.5
        },
        "bottom_right": { // 右下点
          "lat": 30.9,
          "lon": 121.7
        }
      }
    }
  }
}
1.4.2 附近(圆形)查询

附近查询,也叫做距离查询(geo_distance):查询到指定中心点小于某个距离值的所有文档。
换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件:
![[Pasted image 20241223215847.png]]
语法说明:

1
2
3
4
5
6
7
8
9
10
// geo_distance 查询
GET /indexName/_search
{
  "query": {
    "geo_distance": {
      "distance": "15km", // 半径
      "FIELD": "31.21,121.5" // 圆心
    }
  }
}

示例:
我们先搜索陆家嘴附近15km的酒店:
![[Pasted image 20241223215926.png]]

7.1.5.复合查询

复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。常见的有两种:

  • fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名
  • bool query:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索
1.5.1.相关性算分

当我们利用match查询时,文档结果会根据与搜索词条的关联度打分(_score),返回结果时按照分值降序排列。
例如,我们搜索 “虹桥如家”,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

[
  {
    "_score" : 17.850193,
    "_source" : {
      "name" : "虹桥如家酒店真不错",
    }
  },
  {
    "_score" : 12.259849,
    "_source" : {
      "name" : "外滩如家酒店真不错",
    }
  },
  {
    "_score" : 11.91091,
    "_source" : {
      "name" : "迪士尼如家酒店真不错",
    }
  }
]

在elasticsearch中,早期使用的打分算法是TF-IDF算法,公式如下
image-20210721190152134

在后来的5.1版本升级中,elasticsearch将算法改进为BM25算法,公式如下:
image-20210721190416214
TF-IDF算法有一各缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更加平滑:
image-20210721190907320
小结:elasticsearch会根据词条和文档的相关度做打分,算法由两种:

  • TF-IDF算法
  • BM25算法,elasticsearch5.1版本后采用的算法
1.5.2.算分函数查询

根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。
以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱多排名就越靠前。如图:
image-20210721191144560

要想认为控制相关性算分,就需要利用elasticsearch中的function score 查询了。
1)语法说明
image-20210721191544750

function score 查询中包含四部分内容:

  • 原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分(query score)
  • 过滤条件:filter部分,符合该条件的文档才会重新算分
  • 算分函数:符合filter条件的文档要根据这个函数做运算,得到的函数算分(function score),有四种函数
      - weight:函数结果是常量
      - field_value_factor:以文档中的某个字段值作为函数结果
      - random_score:以随机数作为函数结果
      - script_score:自定义算分函数算法
  • 运算模式:算分函数的结果、原始查询的相关性算分,两者之间的运算方式,包括:
      - multiply:相乘
      - replace:用function score替换query score
      - 其它,例如:sum、avg、max、min

function score的运行流程如下:

  • 1)根据原始条件查询搜索文档,并且计算相关性算分,称为原始算分(query score)
  • 2)根据过滤条件,过滤文档
  • 3)符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)
  • 4)将原始算分(query score)和函数算分(function score)基于运算模式做运算,得到最终结果,作为相关性算分。

因此,其中的关键点是:

  • 过滤条件:决定哪些文档的算分被修改
  • 算分函数:决定函数算分的算法
  • 运算模式:决定最终算分结果

2)示例
需求:给“如家”这个品牌的酒店排名靠前一些
翻译一下这个需求,转换为之前说的四个要点:

  • 原始条件:不确定,可以任意变化
  • 过滤条件:brand = “如家”
  • 算分函数:可以简单粗暴,直接给固定的算分结果,weight
  • 运算模式:比如求和
    因此最终的DSL语句如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {  .... }, // 原始查询,可以是任意条件
      "functions": [ // 算分函数
        {
          "filter": { // 满足的条件,品牌必须是如家
            "term": {
              "brand": "如家"
            }
          },
          "weight": 2 // 算分权重为2
        }
      ],
      "boost_mode": "sum" // 加权模式,求和
    }
  }
}

测试,在未添加算分函数时,如家得分如下:
image-20210721193152520

添加了算分函数后,如家得分就提升了:
image-20210721193458182
3)小结

function score query定义的三要素是什么?

  • 过滤条件:哪些文档要加分
  • 算分函数:如何计算function score
  • 加权方式:function score 与 query score如何运算
1.5.3.布尔查询

布尔查询是一个或多个查询子句的组合,每一个子句就是一个子查询。子查询的组合方式有:

  • must:必须匹配每个子查询,类似“与”
  • should:选择性匹配子查询,类似“或”
  • must_not:必须不匹配,不参与算分,类似“非”
  • filter:必须匹配,不参与算分
    比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段做过滤:

image-20210721193822848
每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用bool查询了。
需要注意的是,搜索时,参与打分的字段越多,查询的性能也越差。因此这种多条件查询时,建议这样做:

  • 搜索框的关键字搜索,是全文检索查询,使用must查询,参与算分
  • 其它过滤条件,采用filter查询。不参与算分

1)语法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city": "上海" }}
      ],
      "should": [
        {"term": {"brand": "皇冠假日" }},
        {"term": {"brand": "华美达" }}
      ],
      "must_not": [
        { "range": { "price": { "lte": 500 } }}
      ],
      "filter": [
        { "range": {"score": { "gte": 45 } }}
      ]
    }
  }
}

2)示例
题目需求:搜索名字包含“如家”,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。
分析:

  • 名称搜索,属于全文检索查询,应该参与算分。放到must中
  • 价格不高于400,用range查询,属于过滤条件,不参与算分。放到must_not中
  • 周围10km范围内,用geo_distance查询,属于过滤条件,不参与算分。放到filter中
    image-20210721194744183
    3)小结

    bool查询有几种逻辑关系?

    • must:必须匹配的条件,可以理解为“与”
    • should:选择性匹配的条件,可以理解为“或”
    • must_not:必须不匹配的条件,不参与打分
    • filter:必须匹配的条件,不参与打分

8.搜索结果处理

搜索的结果可以按照用户指定的方式去处理或展示。

8.1.排序

elasticsearch默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序。可以排序字段类型有:keyword类型、数值类型、地理坐标类型、日期类型等。

8.1.1.普通字段排序

keyword、数值、日期类型排序的语法基本一致。

语法

1
2
3
4
5
6
7
8
9
10
11
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "FIELD": "desc"  // 排序字段、排序方式ASC、DESC
    }
  ]
}

排序条件是一个数组,也就是可以写多个排序条件。按照声明的顺序,当第一个条件相等时,再按照第二个条件排序,以此类推

示例
需求描述:

酒店数据按照用户评价(score)降序排序,评价相同的按照价格(price)升序排序

image-20210721195728306

8.1.2.地理坐标排序

地理坐标排序略有不同。
语法说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance" : {
          "FIELD" : "纬度,经度", // 文档中geo_point类型的字段名、目标坐标点
          "order" : "asc", // 排序方式
          "unit" : "km" // 排序的距离单位
      }
    }
  ]
}

这个查询的含义是:

  • 指定一个坐标,作为目标点
  • 计算每一个文档中,指定字段(必须是geo_point类型)的坐标 到目标点的距离是多少
  • 根据距离排序

示例:
需求描述:实现对酒店数据按照到你的位置坐标的距离升序排序
提示:
获取你的位置的经纬度的方式:
https://lbs.amap.com/demo/jsapi-v2/example/map/click-to-get-lnglat/

假设我的位置是:31.034661,121.612282,寻找我周围距离最近的酒店。
image-20210721200214690
排序之后不打分,因为打分无意义

8.2.分页

elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。elasticsearch中通过修改from、size参数来控制要返回的分页结果:

  • from:从第几个文档开始
  • size:总共查询几个文档
    类似于mysql中的limit ?, ?
8.2.1.基本的分页

分页的基本语法如下:

1
2
3
4
5
6
7
8
9
10
11
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0, // 分页开始的位置,默认为0
  "size": 10, // 期望获取的文档总数
  "sort": [
    {"price": "asc"}
  ]
}
8.2.2.深度分页问题

现在,我要查询990~1000的数据,查询逻辑要这么写:

1
2
3
4
5
6
7
8
9
10
11
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 990, // 分页开始的位置,默认为0
  "size": 10, // 期望获取的文档总数
  "sort": [
    {"price": "asc"}
  ]
}

这里是查询990开始的数据,也就是 第990第1000条 数据。
不过,elasticsearch内部分页时,**必须先查询 0
1000条,然后截取其中的990 ~ 1000的这10条**:
image-20210721200643029

查询TOP1000,如果es是单点模式,这并无太大影响。
但是elasticsearch将来一定是集群,例如我集群有5个节点,我要查询TOP1000的数据,并不是每个节点查询200条就可以了。
因为节点A的TOP200,在另一个节点可能排到10000名以外了。
因此要想获取整个集群的TOP1000,必须先查询出每个节点的TOP1000,汇总结果后,重新排名,重新截取TOP1000。
image-20210721201003229

那如果我要查询9900~10000的数据呢?是不是要先查询 TOP 10000 呢?那每个节点都要查询10000条?汇总到内存中?
当查询分页深度较大时,汇总数据过多,对内存和CPU会产生非常大的压力,因此elasticsearch会禁止from+ size 超过10000的请求。
针对深度分页,ES提供了两种解决方案,官方文档

  • search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
  • scroll:原理将排序后的文档id形成快照,保存在内存。官方已经不推荐使用。
8.2.3.小结

分页查询的常见实现方案以及优缺点:

  • from + size
      - 优点:支持随机翻页
      - 缺点:深度分页问题默认查询上限(from + size)是10000
      - 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索
  • after search
      - 优点:没有查询上限(单次查询的size不超过10000)
      - 缺点:只能向后逐页查询不支持随机翻页
      - 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页
  • scroll
      - 优点:没有查询上限(单次查询的size不超过10000)
      - 缺点:会有额外内存消耗,并且搜索结果是非实时的
      - 场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议用 after search方案。

8.3.高亮

8.3.1.高亮原理

什么是高亮显示呢?
我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示:

image-20210721202705030
高亮显示的实现分为两步:

  • 1)给文档中的所有关键字都添加一个标签,例如<em>标签
  • 2)页面给<em>标签编写CSS样式
2.3.2.实现高亮

高亮的语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /hotel/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
    }
  },
  "highlight": {
    "fields": { // 指定要高亮的字段
      "FIELD": {
        "pre_tags": "<em>",  // 用来标记高亮字段的前置标签
        "post_tags": "</em>" // 用来标记高亮字段的后置标签
      }
    }
  }
}

注意:

  • 高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询。
  • 默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮
  • 如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false

示例
image-20210721203349633

8.4.总结

查询的DSL是一个大的JSON对象,包含下列属性:

  • query:查询条件

  • from和size:分页条件

  • sort:排序条件

  • highlight:高亮条件

示例:
image-20210721203657850

9.RestClient查询文档

文档的查询同样适用昨天学习的 RestHighLevelClient对象,基本步骤包括:

  • 1)准备Request对象
  • 2)准备请求参数
  • 3)发起请求
  • 4)解析响应

9.1.快速入门

我们以match_all查询为例

9.1.1.发起查询请求

image-20210721203950559

代码解读:

  1. 创建SearchRequest对象,指定索引库名
  2. 利用request.source()构建DSL,DSL中可以包含查询、分页、排序、高亮等
      - query():代表查询条件,利用QueryBuilders.matchAllQuery()构建一个match_all查询的DSL
  3. 利用client.search()发送请求,得到响应
    这里关键的API有两个,一个是request.source(),其中包含了查询、排序、分页、高亮等所有功能:
    image-20210721215640790
    另一个是QueryBuilders,其中包含match、term、function_score、bool等各种查询:
    image-20210721215729236
9.1.2.解析响应

响应结果的解析:
image-20210721214221057
elasticsearch返回的结果是一个JSON字符串,结构包含:

  • hits:命中的结果
      - total:总条数,其中的value是具体的总条数值
      - max_score:所有结果中得分最高的文档的相关性算分
      - hits:搜索结果的文档数组,其中的每个文档都是一个json对象
        - _source:文档中的原始数据,也是json对象

因此,我们解析响应结果,就是逐层解析JSON字符串,流程如下:

  • SearchHits:通过response.getHits()获取,就是JSON中的最外层的hits,代表命中的结果
      - SearchHits#getTotalHits().value:获取总条数信息
      - SearchHits#getHits():获取SearchHit数组,也就是文档数组
        - SearchHit#getSourceAsString():获取文档结果中的_source,也就是原始的json文档数据
9.1.3.完整代码

完整代码如下:

title:testMatchAll()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

@Test
void testMatchAll() throws IOException {
    // 1.准备Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备DSL
    request.source()
        .query(QueryBuilders.matchAllQuery());
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

private void handleResponse(SearchResponse response) {
    // 4.解析响应
    SearchHits searchHits = response.getHits();
    // 4.1.获取总条数
    long total = searchHits.getTotalHits().value;
    System.out.println("共搜索到" + total + "条数据");
    // 4.2.文档数组
    SearchHit[] hits = searchHits.getHits();
    // 4.3.遍历
    for (SearchHit hit : hits) {
        // 获取文档source
        String json = hit.getSourceAsString();
        // 反序列化
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        System.out.println("hotelDoc = " + hotelDoc);
    }
}
9.1.4.小结

查询的基本步骤是:

  1. 创建SearchRequest对象
  2. 准备Request.source(),也就是DSL。
       ① QueryBuilders来构建查询条件
       ② 传入Request.source() 的 query() 方法
  3. 发送请求,得到结果
  4. 解析结果(参考JSON结果,从外到内,逐层解析)

9.2.match查询

全文检索的match和multi_match查询与match_all的API基本一致。差别是查询条件,也就是query的部分。
image-20210721215923060

因此,Java代码上的差异主要是request.source().query()中的参数了。同样是利用QueryBuilders提供的方法:
image-20210721215843099
而结果解析代码则完全一致,可以抽取并共享。
完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
@Test
void testMatch() throws IOException {
    // 1.准备Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备DSL
    request.source().query(QueryBuilders.matchQuery("all", "如家"));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

9.3.精确查询

精确查询主要是两者:

  • term:词条精确匹配
  • range:范围查询
    与之前的查询相比,差异同样在查询条件,其它都一样。
    查询条件构造的API如下:
    image-20210721220305140

9.4.布尔查询

布尔查询是用must、must_not、filter等方式组合其它查询,代码示例如下:
image-20210721220927286

可以看到,API与其它查询的差别同样是在查询条件的构建,QueryBuilders,结果解析等其他代码完全不变。
完整代码如下:

title:bool查询构建
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
void testBool() throws IOException {
    // 1.准备Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备DSL
    // 2.1.准备BooleanQuery
    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
    // 2.2.添加term
    boolQuery.must(QueryBuilders.termQuery("city", "杭州"));
    // 2.3.添加range
    boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));
    request.source().query(boolQuery);
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

9.5.排序、分页

搜索结果的排序和分页是与query同级的参数,因此同样是使用request.source()来设置。
对应的API如下:
image-20210721221121266
完整代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void testPageAndSort() throws IOException {
    // 页码,每页大小
    int page = 1, size = 5;
    // 1.准备Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备DSL
    // 2.1.query
    request.source().query(QueryBuilders.matchAllQuery());
    // 2.2.排序 sort
    request.source().sort("price", SortOrder.ASC);
    // 2.3.分页 from、size
    request.source().from((page - 1) * size).size(5);
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

9.6.高亮

高亮的代码与之前代码差异较大,有两点:

  • 查询的DSL:其中除了查询条件,还需要添加高亮条件,同样是与query同级。
  • 结果解析:结果除了要解析_source文档数据,还要解析高亮结果
9.6.1.高亮请求构建

高亮请求的构建API如下:
image-20210721221744883

上述代码省略了查询条件部分,但是大家不要忘了:

高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字高亮。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
void testHighlight() throws IOException {
    // 1.准备Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备DSL
    // 2.1.query
    request.source().query(QueryBuilders.matchQuery("all", "如家"));
    // 2.2.高亮
    request.source()
    .highlighter(new HighlightBuilder()
    .field("name")
    .requireFieldMatch(false));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}
9.6.2.高亮结果解析

高亮的结果与查询的文档结果默认是分离的,并不在一起。
因此解析高亮的代码需要额外处理:
image-20210721222057212

代码解读:

  • 第一步:从结果中获取source。hit.getSourceAsString(),这部分是非高亮结果,json字符串。还需要反序列为HotelDoc对象
  • 第二步:获取高亮结果。hit.getHighlightFields(),返回值是一个Map,key是高亮字段名称,值是HighlightField对象,代表高亮值
  • 第三步:从map中根据高亮字段名称,获取高亮字段值对象HighlightField
  • 第四步:从HighlightField中获取Fragments,并且转为字符串。这部分就是真正的高亮字符串了
  • 第五步:用高亮的结果替换HotelDoc中的非高亮结果

完整代码如下:

title:解析高亮结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private void handleResponse(SearchResponse response) {
    // 4.解析响应
    SearchHits searchHits = response.getHits();
    // 4.1.获取总条数
    long total = searchHits.getTotalHits().value;
    System.out.println("共搜索到" + total + "条数据");
    // 4.2.文档数组
    SearchHit[] hits = searchHits.getHits();
    // 4.3.遍历
    for (SearchHit hit : hits) {
        // 获取文档source
        String json = hit.getSourceAsString();
        // 反序列化
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        // 获取高亮结果
        Map<String, HighlightField> highlightFields = hit.getHighlightFields();
        if (!CollectionUtils.isEmpty(highlightFields)) {
            // 根据字段名获取高亮结果
            HighlightField highlightField = highlightFields.get("name");
            if (highlightField != null) {
                // 获取高亮值
                String name = highlightField.getFragments()[0].string();
                // 覆盖非高亮结果
                hotelDoc.setName(name);
            }
        }
        System.out.println("hotelDoc = " + hotelDoc);
    }
}

10.旅游案例

下面,我们通过旅游的案例来实战演练下之前学习的知识。
我们实现四部分功能:

image-20210721223159598

10.1.酒店搜索和分页

案例需求:实现黑马旅游的酒店搜索功能,完成关键字搜索和分页

10.1.1.需求分析

在项目的首页,有一个大大的搜索框,还有分页按钮:

image-20210721223859419

点击搜索按钮,可以看到浏览器控制台发出了请求:

image-20210721224033789

请求参数如下:

image-20210721224112708

由此可以知道,我们这个请求的信息如下:

  • 请求方式:POST
  • 请求路径:/hotel/list
  • 请求参数:JSON对象,包含4个字段:
      - key:搜索关键字
      - page:页码
      - size:每页大小
      - sortBy:排序,目前暂不实现
  • 返回值:分页查询,需要返回分页结果PageResult,包含两个属性:
      - total:总条数
      - List<HotelDoc>:当前页的数据
    因此,我们实现业务的流程如下:
  • 步骤一:定义实体类,接收请求参数的JSON对象
  • 步骤二:编写controller,接收页面的请求
  • 步骤三:编写业务实现,利用RestHighLevelClient实现搜索、分页

10.1.2.定义实体类
实体类有两个,一个是前端的请求参数实体,一个是服务端应该返回的响应结果实体。
1)请求参数
前端请求的json结构如下:

1
2
3
4
5
6
{
    "key": "搜索关键字",
    "page": 1,
    "size": 3,
    "sortBy": "default"
}

因此,我们在cn.itcast.hotel.pojo包下定义一个实体类:

title:RequestParams
1
2
3
4
5
6
7
8
9
package cn.itcast.hotel.pojo;
import lombok.Data;
@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;
}

2)返回值
分页查询,需要返回分页结果PageResult,包含两个属性:

  • total:总条数
  • List<HotelDoc>:当前页的数据
    因此,我们在cn.itcast.hotel.pojo中定义返回结果:
    title:PageResult
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    package cn.itcast.hotel.pojo;
    import lombok.Data;
    import java.util.List;

    @Data
    public class PageResult {
        private Long total;
        private List<HotelDoc> hotels;
        public PageResult() {
        }

        public PageResult(Long total, List<HotelDoc> hotels) {
            this.total = total;
            this.hotels = hotels;
        }
    }

10.1.3.定义controller

定义一个HotelController,声明查询接口,满足下列要求:

  • 请求方式:Post
  • 请求路径:/hotel/list
  • 请求参数:对象,类型为RequestParam
  • 返回值:PageResult,包含两个属性
      - Long total:总条数
      - List<HotelDoc> hotels:酒店数据
    因此,我们在cn.itcast.hotel.web中定义HotelController:
1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/hotel")
public class HotelController {
    @Autowired
    private IHotelService hotelService;
    // 搜索酒店数据
    @PostMapping("/list")
    public PageResult search(@RequestBody RequestParams params){
      return hotelService.search(params);
    }
}

10.1.4.实现搜索业务
我们在controller调用了IHotelService,并没有实现该方法,因此下面我们就在IHotelService中定义方法,并且去实现业务逻辑。

1)在cn.itcast.hotel.service中的IHotelService接口中定义一个方法:

1
2
3
4
5
6
/**
 * 根据关键字搜索酒店信息
 * @param params 请求参数对象,包含用户输入的关键字
 * @return 酒店文档列表
 */
PageResult search(RequestParams params);

2)实现搜索业务,肯定离不开RestHighLevelClient,我们需要把它注册到Spring中作为一个Bean。在cn.itcast.hotel中的HotelDemoApplication中声明这个Bean:

1
2
3
4
5
6
7

@Bean
public RestHighLevelClient client(){
    return  new RestHighLevelClient(RestClient.builder(
        HttpHost.create("http://192.168.150.101:9200")
    ));
}

3)在cn.itcast.hotel.service.impl中的HotelService中实现search方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Override
public PageResult search(RequestParams params) {
    try {
        // 1.准备Request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备DSL
        // 2.1.query
        String key = params.getKey();
        if (key == null || "".equals(key)) {
            boolQuery.must(QueryBuilders.matchAllQuery());
        } else {
            boolQuery.must(QueryBuilders.matchQuery("all", key));
        }
        // 2.2.分页
        int page = params.getPage();
        int size = params.getSize();
        request.source().from((page - 1) * size).size(size);
        // 3.发送请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.解析响应
        return handleResponse(response);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}
// 结果解析
private PageResult handleResponse(SearchResponse response) {
    // 4.解析响应
    SearchHits searchHits = response.getHits();
    // 4.1.获取总条数
    long total = searchHits.getTotalHits().value;
    // 4.2.文档数组
    SearchHit[] hits = searchHits.getHits();
    // 4.3.遍历
    List<HotelDoc> hotels = new ArrayList<>();
    for (SearchHit hit : hits) {
        // 获取文档source
        String json = hit.getSourceAsString();
        // 反序列化
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        // 放入集合
        hotels.add(hotelDoc);
    }
    // 4.4.封装返回
    return new PageResult(total, hotels);
}

10.2.酒店结果过滤

需求:添加品牌、城市、星级、价格等过滤功能

10.2.1.需求分析

在页面搜索框下面,会有一些过滤项:

image-20210722091940726
传递的参数如图:
image-20210722092051994

包含的过滤条件有:

  • brand:品牌值
  • city:城市
  • minPrice~maxPrice:价格范围
  • starName:星级
    我们需要做两件事情:
  • 修改请求参数的对象RequestParams,接收上述参数
  • 修改业务逻辑,在搜索条件之外,添加一些过滤条件
10.2.2.修改实体类

修改在cn.itcast.hotel.pojo包下的实体类RequestParams:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;
    // 下面是新增的过滤条件参数
    private String city;
    private String brand;
    private String starName;
    private Integer minPrice;
    private Integer maxPrice;
}

10.2.3.修改搜索业务

在HotelService的search方法中,只有一个地方需要修改:requet.source().query( … )其中的查询条件。

在之前的业务中,只有match查询,根据关键字搜索,现在要添加条件过滤,包括:

  • 品牌过滤:是keyword类型,用term查询
  • 星级过滤:是keyword类型,用term查询
  • 价格过滤:是数值类型,用range查询
  • 城市过滤:是keyword类型,用term查询

多个查询条件组合,肯定是boolean查询来组合:

  • 关键字搜索放到must中,参与算分
  • 其它过滤条件放到filter中,不参与算分
    因为条件构建的逻辑比较复杂,这里先封装为一个函数:

image-20210722092935453

buildBasicQuery的代码如下:

title:buildBasicQuery
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private void buildBasicQuery(RequestParams params, SearchRequest request) {
    // 1.构建BooleanQuery
    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
    // 2.关键字搜索
    String key = params.getKey();
    if (key == null || "".equals(key)) {
        boolQuery.must(QueryBuilders.matchAllQuery());
    } else {
        boolQuery.must(QueryBuilders.matchQuery("all", key));
    }
    // 3.城市条件
    // filter 不参与算法,提高性能
    if (params.getCity() != null && !params.getCity().equals("")) {
        boolQuery.filter(QueryBuilders.termQuery("city", params.getCity()));
    }
    // 4.品牌条件
    if (params.getBrand() != null && !params.getBrand().equals("")) {
        boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand()));
    }
    // 5.星级条件
    if (params.getStarName() != null && !params.getStarName().equals("")) {
        boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName()));
    }
    // 6.价格
    if (params.getMinPrice() != null && params.getMaxPrice() != null) {
        boolQuery.filter(QueryBuilders
                         .rangeQuery("price")
                         .gte(params.getMinPrice())
                         .lte(params.getMaxPrice())
                        );
    }
    // 7.放入source
    request.source().query(boolQuery);
}

10.3.我周边的酒店

需求:我附近的酒店

10.3.1.需求分析

在酒店列表页的右侧,有一个小地图,点击地图的定位按钮,地图会找到你所在的位置:

image-20210722093414542
并且,在前端会发起查询请求,将你的坐标发送到服务端:
image-20210722093642382

我们要做的事情就是基于这个location坐标,然后按照距离对周围酒店排序。实现思路如下:

  • 修改RequestParams参数,接收location字段
  • 修改search方法业务逻辑,如果location有值,添加根据geo_distance排序的功能
10.3.2.修改实体类

修改在cn.itcast.hotel.pojo包下的实体类RequestParams:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package cn.itcast.hotel.pojo;
import lombok.Data;

@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;
    private String city;
    private String brand;
    private String starName;
    private Integer minPrice;
    private Integer maxPrice;
    // 我当前的地理坐标
    private String location;
}
10.3.3.距离排序API

我们以前学习过排序功能,包括两种:

  • 普通字段排序
  • 地理坐标排序
    我们只讲了普通字段排序对应的java写法。地理坐标排序只学过DSL语法,如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    GET /indexName/_search
    {
      "query": {
        "match_all": {}
      },
      "sort": [
        {
          "price": "asc"  
        },
        {
          "_geo_distance" : {
              "FIELD" : "纬度,经度",
              "order" : "asc",
              "unit" : "km"
          }
        }
      ]
    }

对应的java代码示例:
image-20210722095227059

10.3.4.添加距离排序

cn.itcast.hotel.service.implHotelServicesearch方法中,添加一个排序功能:
image-20210722095902314
完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
public PageResult search(RequestParams params) {
    try {
        // 1.准备Request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备DSL
        // 2.1.query
        buildBasicQuery(params, request);
        // 2.2.分页
        int page = params.getPage();
        int size = params.getSize();
        request.source().from((page - 1) * size).size(size);
        // 2.3.排序
        String location = params.getLocation();
        if (location != null && !location.equals("")) {
            request.source().sort(SortBuilders
                                  .geoDistanceSort("location", new GeoPoint(location))
                                  .order(SortOrder.ASC)
                                  .unit(DistanceUnit.KILOMETERS)
                                 );
        }
        // 3.发送请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.解析响应
        return handleResponse(response);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}
10.3.5.排序距离显示

重启服务后,测试我的酒店功能:
image-20210722100040674

发现确实可以实现对我附近酒店的排序,不过并没有看到酒店到底距离我多远,这该怎么办?
排序完成后,页面还要获取我附近每个酒店的具体距离值,这个值在响应结果中是独立的:

image-20210722095648542

因此,我们在结果解析阶段,除了解析source部分以外,还要得到sort部分,也就是排序的距离,然后放到响应结果中。
我们要做两件事:

  • 修改HotelDoc,添加排序距离字段,用于页面显示
  • 修改HotelService类中的handleResponse方法,添加对sort值的获取
    1)修改HotelDoc类,添加距离字段
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    package cn.itcast.hotel.pojo;
    import lombok.Data;
    import lombok.NoArgsConstructor;

    @Data
    @NoArgsConstructor
    public class HotelDoc {
        private Long id;
        private String name;
        private String address;
        private Integer price;
        private Integer score;
        private String brand;
        private String city;
        private String starName;
        private String business;
        private String location;
        private String pic;
        // 排序时的 距离值
        private Object distance;
        public HotelDoc(Hotel hotel) {
            this.id = hotel.getId();
            this.name = hotel.getName();
            this.address = hotel.getAddress();
            this.price = hotel.getPrice();
            this.score = hotel.getScore();
            this.brand = hotel.getBrand();
            this.city = hotel.getCity();
            this.starName = hotel.getStarName();
            this.business = hotel.getBusiness();
            this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
            this.pic = hotel.getPic();
        }
    }

2)修改HotelService中的handleResponse方法
image-20210722100613966

重启后测试,发现页面能成功显示距离了:
image-20210722100838604

10.4.酒店竞价排名

需求:让指定的酒店在搜索结果中排名置顶

10.4.1.需求分析

要让指定酒店在搜索结果中排名置顶,效果如图:

image-20210722100947292
页面会给指定的酒店添加广告标记。
那怎样才能让指定的酒店排名置顶呢?
我们之前学习过的function_score查询可以影响算分,算分高了,自然排名也就高了。而function_score包含3个要素:

  • 过滤条件:哪些文档要加分
  • 算分函数:如何计算function score
  • 加权方式:function score 与 query score如何运算
    这里的需求是:让指定酒店排名靠前。因此我们需要给这些酒店添加一个标记,这样在过滤条件中就可以根据这个标记来判断,是否要提高算分

比如,我们给酒店添加一个字段:isAD,Boolean类型:

  • true:是广告
  • false:不是广告
    这样function_score包含3个要素就很好确定了:
  • 过滤条件:判断isAD 是否为true
  • 算分函数:我们可以用最简单暴力的weight,固定加权值
  • 加权方式:可以用默认的相乘,大大提高算分
    因此,业务的实现步骤包括:
  1. 给HotelDoc类添加isAD字段,Boolean类型
  2. 挑选几个你喜欢的酒店,给它的文档数据添加isAD字段,值为true
  3. 修改search方法,添加function score功能,给isAD值为true的酒店增加权重
10.4.2.修改HotelDoc实体

cn.itcast.hotel.pojo包下的HotelDoc类添加isAD字段:
image-20210722101908062

10.4.3.添加广告标记

接下来,我们挑几个酒店,添加isAD字段,设置为true:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
POST /hotel/_update/1902197537
{
    "doc": {
        "isAD": true
    }
}
POST /hotel/_update/2056126831
{
    "doc": {
        "isAD": true
    }
}

POST /hotel/_update/1989806195
{
    "doc": {
        "isAD": true
    }
}

POST /hotel/_update/2056105938
{
    "doc": {
        "isAD": true
    }
}
10.4.4.添加算分函数查询

接下来我们就要修改查询条件了。之前是用的boolean 查询,现在要改成function_socre查询。
function_score查询结构如下:

image-20210721191544750
对应的JavaAPI如下:
image-20210722102850818

我们可以将之前写的boolean查询作为原始查询条件放到query中,接下来就是添加过滤条件算分函数加权模式了。所以原来的代码依然可以沿用。

在 Elasticsearch 中,function_score 查询是一个非常强大的工具,它允许在执行查询的同时,通过自定义的函数来修改相关文档的得分。它主要分为两大部分:

  1. Query (原始查询)
    这是 function_score 的核心查询部分,用于筛选出符合条件的文档。可以是任何标准的查询,比如 match, term, range, 或 bool 查询。这个部分决定了哪些文档会进入后续的打分阶段。

  2. Functions (函数部分)
    这是自定义的函数,用于基于某些文档字段的值或其他特性(如地理位置、随机值、时间衰减等)来调整文档的相关性分数。通过这些函数,可以对原始查询返回的文档进行进一步的打分优化或排序操作。函数的定义可以是以下几种类型:

    • field_value_factor: 根据文档字段的值调整分数,比如字段值的线性变换。
    • decay functions: 提供距离、时间等的衰减函数,包括 gauss, exp, linear
    • random_score: 用于生成随机排序。
    • script_score: 使用脚本灵活地定义计算逻辑。
    • weight: 为每个函数赋予一个权重。

修改cn.itcast.hotel.service.impl包下的HotelService类中的buildBasicQuery方法,添加算分函数查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private void buildBasicQuery(RequestParams params, SearchRequest request) {
    // 1.构建BooleanQuery
    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
    // 关键字搜索
    String key = params.getKey();
    if (key == null || "".equals(key)) {
        boolQuery.must(QueryBuilders.matchAllQuery());
    } else {
        boolQuery.must(QueryBuilders.matchQuery("all", key));
    }
    // 城市条件
    if (params.getCity() != null && !params.getCity().equals("")) {
        boolQuery.filter(QueryBuilders.termQuery("city", params.getCity()));
    }
    // 品牌条件
    if (params.getBrand() != null && !params.getBrand().equals("")) {
        boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand()));
    }
    // 星级条件
    if (params.getStarName() != null && !params.getStarName().equals("")) {
        boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName()));
    }
    // 价格
    if (params.getMinPrice() != null && params.getMaxPrice() != null) {
        boolQuery.filter(QueryBuilders
                         .rangeQuery("price")
                         .gte(params.getMinPrice())
                         .lte(params.getMaxPrice())
                        );
    }

    // 2.算分控制

    FunctionScoreQueryBuilder functionScoreQuery =
        QueryBuilders.functionScoreQuery(
        // 原始查询,相关性算分的查询
        boolQuery,
        // function score的数组
        new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
            // 其中的一个function score 元素
            new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                // 过滤条件
                QueryBuilders.termQuery("isAD", true),
                // 算分函数
                ScoreFunctionBuilders.weightFactorFunction(10)
            )
        });
    request.source().query(functionScoreQuery);
}