Featured image of post Elasticsearch 基础

Elasticsearch 基础

Elasticsearch 分布式搜索

开源分布式搜索引擎。用于日志数据分析、实时监控

ELK: Elastic stack

  • Elasticsearch 存储,计算,搜索数据
  • logstash, beats 数据抓取
  • kibana 数据可视化

倒排索引

正向: 比如给id创建索引,形成一个b+树。

如果搜索title而不是id

select * from tb_goods where title like '%手机%'

逐条扫描判断是否包含"手机"

  • 是: 存入结果集
  • 否: 丢弃

倒排:

  • 文档 documetn:每条数据就是一个文档
  • 词条 term: 文档按照语义分成词语

存储时先把文档中的词语分成词条。再存储对应的id。基于词条创建索引

词条 文档id

查找过程:

  1. 对搜索词条分词

  2. 去词条列表查询文档id - 第一次检索

  3. 基于id查询文档 - 第二次检索

  4. 放到结果集

文档

elasticsearch是面向文档存储的。文档可以是数据库中一条商品数据,一个订单信息。

文档数据会被序列化为json格式后存储再存入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: 擅长海量的数据、分析、计算

ES部署

docker network create es-net #创建网络

运行

docker run -d \
  --name es \
  -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
  -e "discovery.type=single-node" \
  -v es-data:/usr/share/elasticsearch/data \
  -v es-plugins:/usr/share/elasticsearch/plugins \
  --privileged \
  --network es-net \
  -p 9200:9200 \
  -p 9300:9300 \
  elasticsearch:7.12.1

部署Kibana

docker run -d \
	--name kibana \
	-e ELASTICSEARCH_HOSTS=http://es:9200 \
	--network=es-net \
	-p 5601:5601 \
	kibana:7.12.1

运行

docker run -d \
	--name es \
	-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
	-e "discovery.type=single-node" \
	-v es-data:/usr/share/elasticsearch/data \
	-v es-plugins:/usr/share/elasticsearch/plugins \
	--privileged \
	--network es-net \
	-p 9200:9200 \
	-p 9300:9300 \
	elasticsearch:7.12.1

分词器

默认的分词器对中文处理不太好

安装ik分词器

# 进入容器内部
docker exec -it elasticsearch /bin/bash

# 在容器内安装插件
/bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip

# 退出
exit

# 重启容器
docker restart elasticsearch

粒度:

粗 ik_smart

细 ik_max_word

分词器原理

依赖字典

个性化设置字典: ikAnalyzer.cfg.xml

<properties>
	<entry key="ext_dict">ext.dic</entry> #扩展词典
    <entry key="ext_stopwords">stopword.dic</entry>
</properties>

ext.dic和stopword.dic是文件名,在当前文件所在目录创建,一行一个词

在什么时候用分词:

  1. 在创建倒排索引时对文档分词
  2. 用户搜索时对输入内容分词

Mapping 约束

type: 数据类型

  • 字符串: text (可分词的文本), keyword (精确值。拆分后没意义了 比如邮箱地址)
  • 数值: long, integer, short, byte, double, float
  • 布尔: boolean
  • 日期: date
  • 对象: object,嵌套的

没有数组,但是允许某个数据类型有多个值

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

  • 实际文档需要搜索;图片,邮箱等字段不需要搜索

analyzer: 分词器 iksmart, ikmax

properties: 该字段的子字段

DSL 索引库操作

ES中通过Restful请求操作索引库,文档。请求内容用DSL语句来表示

创建索引库和mapping的DSL:

PUT /索引名称
{
  "mappings": {
    "properties": {
      "字段名1": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2": {
        "type": "keyword",
        "index": "false"
      },
      "字段名3": {
        "properties": {
          "子字段名": {
            "type": "keyword"
          }
        }
      }
    }
  }
  // ...省略
}

查找:

GET /索引名称

删除

DELETE /索引名称

修改

不允许修改库

添加新字段

PUT /索引名称/_mapping
{
  "properties": {
    "新字段名": {
      "type": "xxx"
    }
  }
}

改旧字段名会报错

DSL 文档操作

POST /索引名称/_doc/文档id
{
  "字段1": "值1",
  "字段2": "值2",
  "字段3": {
    "子字段1": "值3",
    "子字段2": "值4"
  }
  // ...
}

不指定id会随机生成新的id,就相当于创建新文档了

GET /索引名称/_doc/文档id

DELETE /索引名称/_doc/文档id

方式1: 全量修改,会删除旧文档,添加新文档

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

方式2: 局部修改

POST /索引库名/_update/文档id
{
	"doc": {
		“字段名": ”新的值"
	}
}

注意:

id用keyword记

地理位置有两种 geo_point和geo_sharpe

多个字段合并搜索: copy_to

  • "all": {
    	"type": "text",
        "analyzer": "ik_max_word"
    }
    "xxx": {
        "type": "keyword",
        "copy_to": "all"
    }
    

Java RestClient

组装DSL语句,通过http发送给ES

初始化客户端

  1. 引入es的RestHighLevelClient依赖:
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
  1. 配置SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:
<properties>
    <java.version>1.8</java.version>
    <elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
  1. 初始化RestHighLevelClient:
RestHighLevelClient client = new RestHighLevelClient(
    RestClient.builder(
        HttpHost.create("http://192.168.150.101:9200")
    )
);

操作索引库

@Test
void testCreateHotelIndex() throws IOException {
    // 1.创建Request对象
    CreateIndexRequest request = new CreateIndexRequest("hotel");
    // 2. 请求参数,MAPPING_TEMPLATE是映射模板字符串,由我们提前定义好的DSL语句
    request.source(MAPPING_TEMPLATE, XContentType.JSON);
    // 3. 客户端发送请求
    client.indices().create(request, RequestOptions.DEFAULT);
}

@Test
void testDeleteHotelInex throws IOException {
    // 1.创建Request对象
    CreateIndexRequest request = new CreateIndexRequest("hotel");
    // 2. 客户端发送请求
    client.indices().delete(request, RequestOptions.DEFAULT);
}

判断索引库是否存在

@Test
void testExistsHotelInex throws IOException {
    // 1.创建Request对象
    CreateIndexRequest request = new CreateIndexRequest("hotel");
    // 2. 客户端发送请求
    boolean exists = clinet.indices().exists(request, RequestOptions,DEFAULT);
}

操作文档

初始化JavaRestClient

public class ElasticsearchDocumentTest {
    // 客户端
    private RestHighLevelClient client;
    
    @BeforeEach
    void setUp() {
        client = new RestHighLevelClient(
            RestClient.builder(
                HttpHost.create("http://192.168.150.101:9200")
            )
        );
    }

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

: 添加数据到索引库

@Test
void testIndexDocument() throws IOException {
    // 1.创建request对象
    IndexRequest request = new IndexRequest("indexName").id("1");
    // 2. 准备JSON文档
    request.source("{\"name\": \"Jack\", \"age\": 21}", XContentType.JSON);
    // request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
    // 3. 客户端发送请求
    client.index(request, RequestOptions.DEFAULT);
}

相当于

POST /indexName/_doc/1
{
	"name": "Jack",
	"age": 21
}

处理地理位置的技巧:

比如原来的Hotel类 (数据库中存的):

public class Hotel {
	private String name;
    private String longitude;
    private String latitude;
}

新建一个HotelDoc类 用于es :

public class HotelDoc {
	private String name;
    private String location;
    
    public HotelDoc(Hotel hotel){
        this.name = hotel.getName();
        this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
    }
}

:

@Test
void testGetDocumentById() throws IOException {
    // 1.创建request对象
    GetRequest request = new GetRequest("indexName", "1");
    // 2. 发送请求,得到结果
    GetResponse response = client.get(request, RequestOptions.DEFAULT);
    // 3. 解析结果
    String json = response.getSourceAsString();
    System.out.println(json);
    // HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); // 反序列化
}

相当于

GET /indexName/_doc/1

:

也分全量更新和局部更新

局部更新:

@Test
void testUpdateDocumentById() throws IOException {
    // 1.创建request对象
    UpdateRequest request = new UpdateRequest("indexName", "1");
    // 2. 准备数据,键值对方式 => key value
    request.doc(
        "age", 18,
        "name", "Rose"
    );
    // 3. 客户端发送
    client.update(request, RequestOptions.DEFAULT);
}

相当于

POST /users/_update/1
{
	"doc": {
		"name": "Rose",
		"age": 18
	}
	
}

:

@Test
void TestDeleteDocument(){
    // 1.创建request对象
    DeleteRequest request = new DeleteRequest("indexName","1");
    client.delete(request, RequestOptions.DEFAULT);
}

相当于

DELETE /indexName/_doc/1

批量导入文档

Bulk 批处理

@Test
void testBulk() throws IOException {
    // 1. 创建Bulk请求
    BulkRequest request = new BulkRequest();
    // 2. 添加批量处理的请求;这里演示了两个索引文档的请求
    request.add(new IndexRequest("hotel")
        .id("101").source("json source1", XContentType.JSON));
    request.add(new IndexRequest("hotel")
        .id("102").source("json source2", XContentType.JSON));
    // 3. 客户端批量发送
    client.bulk(request, RequestOptions.DEFAULT);
}

多个Index请求合并到一起,一并提交

DSL

DSL Query的分类

  • 查询所有: match_all
  • 全文检索: 利用分词器对用户输入内容分词,然后去倒排索引库中匹配
    • match_query
    • multi_match_query
  • 精确查询: 根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段
    • ids 基于id
    • range 数值范围
    • term 精确查找
  • 地理查询: 根据经纬度查询
    • geo_distance
    • geo_bounding_box
  • 复核查询: 上述各种条件查询组合起来
    • bool
    • function_score

DSL Query的基本语法

GET /indexName/_search
{
  "query": {
    "查询类型": {
      "查询字段": "搜索条件"
    }
  }
}

如果是查询所有: match_all: {}

DSL 查询语法

全文检索查询

match查询: 全文检索查询的一种,会对用户输入进行分词,然后去倒排索引库检索

GET /indexName/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  }
}

这里FIELD是all (copy_to合并后的) 的话是查询所有。TEXT是用户输入

multi_match查询: 与match查询类似,只不过允许同时查询多个字段

GET /indexName/_search
{
  "query": {
    "multi_match": {
      "query": "TEXT",
      "fields": ["FIELD1", "FIELD2"]
    }
  }
}

但是搜索字段越多,效率越低。推荐检索all

精确查询

精确查询一般是查找keyword、数值、日期、boolean等字段。不会对搜索条件进行分词

  • term: 根据词条精确值查询

  • GET /indexName/_search
    {
    	"query": {
            "term": {
                "FIELD": {
                    "value": "VALUE"
                }
            }
    	}
    }
    
  • range: 根据值的范围查询

  • GET /indexName/_search
    {
    	"query": {
            "range": {
                "FIELD": {
                    "gte": 10, // greater than equals
                    "lte": 20 // less than equals
                }
            }
    	}
    }
    

地理查询

  • geo_bounding_box: 一个矩形范围内

  • // 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 // 右下
            }
          }
        }
      }
    }
    
  • geo_distance: 指定中心点少于某个距离

  • // geo_distance 查询
    GET /indexName/_search
    {
      "query": {
        "geo_distance": {
          "distance": "15km",
          "FIELD": "31.21,121.5"
        }
      }
    }
    

复合查询

  • function score: 算分函数查询,可以控制文档相关性算分,控制文档排名

score算法:

TF算法

$$
TF(词频) = \frac{某个词出现次数}{文档中的总词数}
$$
TF-IDF算法

$$
IDF(逆文档频率) = \log\left(\frac{文档总数}{包含该词的文档数}\right)
$$

$$
\text{score} = \sum{TF(词频) \times IDF(逆文档频率)}
$$

所有文本都包含的某个词条的情况下,这个词条会不计入权重

比如3个文档,其中3个文档都包含词条 word。log 3/3 = 0,会不计入权重

BM25算法

$$
\text{Score}(q,d) = \sum_i{\log \left(1 + \frac{N - n_i + 0.5}{n_i + 0.5}\right) \cdot \frac{f_i \cdot (k_1 + 1)}{f_i + k_1 \cdot (1 - b + b \cdot \frac{d}{avgdl})}}
$$
TF-IDF受词频影响较大。得分会随着词频无限增长。但BF25算法不会

image-20240205003710307

function score query

控制得分

GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": { "match": { "all": "外滩" } }, // 原始查询条件,搜索文档并根据相关性打分 (query score)
      "functions": [
        {
          "filter": { "term": { "id": "1" }}, // 过滤条件,符合条件的才会被重新算分
          "weight": 10 // 算分函数,结果称为function score, 与query score运算得到新算分
        }
      ],
      "boost_mode": "multiply" // 加权模式。定义function score与query score的运算方式
    }
  }
}

算分函数:

  • weight: 给一个静态值,作为函数得分(function score)
  • field_value_factor: 用文档中的某个字段值来影响得分
  • random_score: 随机生成一个值,作为函数得分
  • script_score: 自定义评分公式,公式结果作为函数得分

加权模式:

  • multiply: 两者相乘
  • replace: 用function score替换query score
  • 其他: sum、avg、max、min

Boolean Query

一个或多个查询子句的组合

  • must: 必须匹配的一个查询,类似 “与”
  • should: 选择性匹配的查询,类似 “或”
  • must_not: 必须不匹配,不参与打分,类似 “非”
  • filter: 必须匹配,不参与打分

不算分有助于提升性能,提升查询效率

GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city": "上海"}} // 常用于用户输入的搜索,用于算分
      ],
      "should": [
        {"term": {"brand": "品牌A"}}, // 广告竞价会用到分数计算排序
        {"term": {"brand": "品牌B"}}
      ],
      "must_not": [
        {"range": {"price": {"lte": 500}}} // 不用参与算分的搜索
      ],
      "filter": [
        {"range": {"score": {"gte": 45}}}
      ]
    }
  }
}

搜索结果处理

排序:

可以排序的字段类型有keyword, 数值, 地理坐标, 日期。默认是基于_score排序

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "FIELD": "desc" // 排序字段和排序方式为ASC, DESC
    }
  ]
}

地理坐标:

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance": {
          "FIELD": "纬度,经度",
          // "location": {
          //	"lat": 31.034661,
          //	"lon": 121.612282
          // }
          "order": "asc",
          "unit": "km"
      }
    }
  ]
}

发生排序时 _score会变成null

分页:

ES默认只返回top10数据。查询更多数据需要修改分页参数

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 990, // 分页开始的位置, 默认为0
  "size": 10, // 每页要获取的文档数量
  "sort": [
    {"price": "asc"}
  ]
}

倒排索引不容易做分页。每次查询都要先排序,获取前xx条数据,再在截取。

ES是分布式的,这样做会面临深度分页问题。

获取from = 990, size = 10 的数据:

image-20240206220941502

  1. 查询在每个数据分片上按排名方式查询前1000条文档。
  2. 然后将所有分片的结果归并,在内存中重新排序并返回前1000条文档
  3. 最后从这1000条中,选取从990开始的10条文档

结果集 (from+size) 的上限是10000。一般业务层面会限制超过10000条结果

解决:

  • search after: 分页时需要排除掉,原理是从上一次的排序结果开始,查询下一批数据。它方便在排序的分页中。缺点: 不能查找之前的结果。
  • scroll: 保证搜索的数据状态不改变,保持在早期的。它方便在不排序的使用。缺点: 更新后缓存的仍然是旧数据,搜索结果非实时。

高亮:

搜索结果中把搜索关键词突出表示

服务端会把关键词加入标签,前端再添加css样式

GET /hotel/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  },
  "highlight": {
    "fields": {
      "FIELD": {
        "pre_tags": ["<em>"],
        "post_tags": ["</em>"],
      	// "required_field_match": false
      }
    }
  }
}

默认情况下ES搜索字段必须与高亮字段一致。修改 required_field_match 可以设置成不一致。

RestClient查询文档

match_all

@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);
    // ...后续的结果处理
}

基本api

@Test
void testMatchAll() throws IOException {
    // ... 省略
    // 4.解析结果
    SearchHits searchHits = response.getHits();
    // 4.1.获取总的记录数
    long total = searchHits.getTotalHits().value;
    // 4.2.获取的详细结果
    SearchHit[] hits = searchHits.getHits();
    for (SearchHit hit : hits) {
        // 4.3.获取source
        String json = hit.getSourceAsString();
        // 4.4.打印
        System.out.println(json);
    }
}

全文检索查询

使用不同的 QueryBuilders 提供的方法来构造 matchmulti_match

// 单字段查询
QueryBuilders.matchQuery("all", "酒店");

// 多字段查询
QueryBuilders.multiMatchQuery("酒店", "name", "business");

抽取代码用于响应结果

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);
    }
}

精确查询

使用不同的 QueryBuilders 提供的方法来构造 termrange

// 精确查询
QueryBuilders.termQuery("city", "南京");

// 范围查询
QueryBuilders.rangeQuery("price").gte(100).lte(150);

复合查询

// 组合查询
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 添加must条件
boolQuery.must(QueryBuilders.termQuery("city", "南京"));
// 添加filter条件
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));

排序和分页

// 查询
request.source().query(QueryBuilders.matchAllQuery());

// 分页
request.source().from(0).size(5);
// request.source().from((PAGE-1)* SIZE).size(SIZE) 

// 按价格升序
request.source().sort("price", SortOrder.ASC);

高亮

request.source().highlighter(new HighlightBuilder())
    .field("name")
    .requireFieldMatch(false)
    );