分布式搜索elasticsearch
elasticsearch是一款非常强大的开源搜索引擎,可以帮助我们从海量数据中快速找到需要的内容。
elasticsearch结合kibana、Logstash、Beats,也就是elastic stack (ELK)。被广泛应用在日志数据分析、实时监控等领域
elasticsearch是elastic stack的核心,负责存储、搜索、分析数据。
正向索引和倒排索引
传统数据库(如MySQL)采用正向索引,例如给下表(tb_goods)中的id创建索引:
elasticsearch采用倒排索引:
- 文档( document) :每条数据就是一个文档
- 词条(term) :文档按照语义分成的词语
文档
elasticsearch是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息。
文档数据会被序列化为json格式后存储在elasticsearch中。
索引
索引(index):相同类型的文档的集合
映射(mapping)︰索引中文档的字段约束信息,类似表的结构约束
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 |
分词器
es在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。我们在kibana的DevTools中测试:
# 测试分词器
POST /_analyze
{
"text": "黑马程序员学习java太棒了",
"analyzer": "english"
}
结果
{
"tokens" : [
{
"token" : "黑",
"start_offset" : 0,
"end_offset" : 1,
"type" : "<IDEOGRAPHIC>",
"position" : 0
},
{
"token" : "马",
"start_offset" : 1,
"end_offset" : 2,
"type" : "<IDEOGRAPHIC>",
"position" : 1
},
{
"token" : "程",
"start_offset" : 2,
"end_offset" : 3,
"type" : "<IDEOGRAPHIC>",
"position" : 2
},
{
"token" : "序",
"start_offset" : 3,
"end_offset" : 4,
"type" : "<IDEOGRAPHIC>",
"position" : 3
},
{
"token" : "员",
"start_offset" : 4,
"end_offset" : 5,
"type" : "<IDEOGRAPHIC>",
"position" : 4
},
{
"token" : "学",
"start_offset" : 5,
"end_offset" : 6,
"type" : "<IDEOGRAPHIC>",
"position" : 5
},
{
"token" : "习",
"start_offset" : 6,
"end_offset" : 7,
"type" : "<IDEOGRAPHIC>",
"position" : 6
},
{
"token" : "java",
"start_offset" : 7,
"end_offset" : 11,
"type" : "<ALPHANUM>",
"position" : 7
},
{
"token" : "太",
"start_offset" : 11,
"end_offset" : 12,
"type" : "<IDEOGRAPHIC>",
"position" : 8
},
{
"token" : "棒",
"start_offset" : 12,
"end_offset" : 13,
"type" : "<IDEOGRAPHIC>",
"position" : 9
},
{
"token" : "了",
"start_offset" : 13,
"end_offset" : 14,
"type" : "<IDEOGRAPHIC>",
"position" : 10
}
]
}
中文词汇没有正常分开
使用IK分词器
IK分词器包含两种模式:
-
ik_smart
:最少切分 -
ik_max_word
:最细切分
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "黑马程序员学习java太棒了"
}
结果
{
"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分词器-拓展词库
要拓展ik分词器的词库,只需要修改一个ik分词器目录中的config目录中的IkAnalyzer.cfg.xml文件:
<?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>
<!--用户可以在这里配置远程扩展字典 -->
<!-- <entry key="remote_ext_dict">words_location</entry> -->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>
加入的ext.dic和stopword.dic就是加入的扩展词文件和停止扩展词文件
在config目录新建ext.dic文件即可,stopword.dic已经有文件了
索引库操作
mapping属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
-
type:字段数据类型,常见的简单类型有:
- 字符串: text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
- 数值: long、integer、short、byte、double、float、
- 布尔: boolean
- 日期:date
- 对象:object
-
index:是否创建索引,默认为true
-
analyzer:使用哪种分词器
-
properties:该字段的子字段
创建索引库
ES中通过Restful请求操作索引库、文档。请求内容用DSL语句来表示。创建索引库和mapping的DSL语法如下:
查看、删除索引库
查看索引库语法:
- GET/索引库名
示例:
- GET /heima
删除索引库的语法:
- DELETE/索引库名
示例:
- DELETE /heima
修改索引库
索引库和mapping一旦创建无法修改,但是可以添加新的字段,语法如下:
文档操作
新增文档的DSL语法如下:
查看、删除文档
查看文档语法:
- GET /索引库名/_doc/文档id
示例
- GET /heima/ _doc/1
删除索引库的语法:
- DELETE /索引库名/_doc/文档id
示例:
- DELETE /heima/ _doc/1
修改文档
方式一:全量修改,会删除旧文档,添加新文档
方式二:增量修改,修改指定字段值
RestClient操作索引库
1.引入es的RestHighLevelclient依赖:
<!--elasticsearch-->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.12.1</version>
</dependency>
2.因为springboot默认是elasticsearch默认的版本是7.6.1,所以需要在properties标签中覆盖,父工程提供的默认版本
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
3.初始化RestHighLevelClient:
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.1.128:9200")
));
创建索引库
删除索引库
@Test
public void deleteHotelIndex() throws IOException {
//创建request对象
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
//发送请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
判断索引库是否存在
@Test
public void existHotelIndex() throws IOException {
//创建request对象
GetIndexRequest request = new GetIndexRequest("hotel");
//发送请求
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(exists);
}
索引库操作的基本步骤:
- 初始化RestHighLevelClient
- 创建XxxlndexRequest。XXX是CREATE、Get、Delete
- 准备DSL (CREATE时需要)
- 发送请求。调用RestHighLevelClient#indices().xxx()方法,XXX是create、exists、delete
RestClient操作文档
新增文档
@Test
public void addDocumentTest() throws IOException {
//根据id查询酒店数据
Hotel hotel = iHotelService.getById(61083L);
//转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
//准备Request对象
IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
//准备Json文档
request.source(JSON.toJSONString(hotelDoc),XContentType.JSON);
//发送请求
client.index(request,RequestOptions.DEFAULT);
}
查询文档
根据id查询到的文档数据是json,需要反序列化为java对象:
@Test
public void getDocumentByIdTest() throws IOException {
//准备Request对象
GetRequest request = new GetRequest("hotel","61083");
//发送请求
GetResponse response = client.get(request, RequestOptions.DEFAULT);
//解析响应
String json = response.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
更新文档
修改文档数据有两种方式:
方式一:全量更新。再次写入id一样的文档,就会删除旧文档,添加新文档
方式二:局部更新。只更新部分字段,我们演示方式二
@Test
public void updateDocumentByIdTest() throws IOException {
//准备Request对象
UpdateRequest request = new UpdateRequest("hotel","61083");
//准备参数,每两个参数为一对key value
request.doc(
"price","1000",
"score","45"
);
//发送请求
client.update(request, RequestOptions.DEFAULT);
}
删除文档
@Test
public void deleteDocumentByIdTest() throws IOException {
//准备Request对象
DeleteRequest request = new DeleteRequest("hotel","61083");
//发送请求
client.delete(request, RequestOptions.DEFAULT);
}
文档操作的基本步骤:
- 初始化RestHighLevelClient
- 创建XxxRequest。xxx是Index、Get、Update、Delete
- 准备参数(lndex和Update时需要)
- 发送请求。调用RestHighLevelClient#client.xxx()方法,xxx是index、get、update、delete
- 解析结果(Get时需要)
批量导入文档
@Test
public void bulkRequestTest() throws IOException {
//批量查询酒店数据
List<Hotel> hotelList = iHotelService.list();
//准备Request对象
BulkRequest request = new BulkRequest();
//准备参数,添加多个新增的Request
//转换为文档类型数据
for (Hotel hotel : hotelList) {
HotelDoc hotelDoc = new HotelDoc(hotel);
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc),XContentType.JSON));
}
//发送请求
client.bulk(request, RequestOptions.DEFAULT);
}
DSL查询语法
DSL Query的分类
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
-
查询的基本语法如下:
全文检索查询
全文检索查询,会对用户输入内容分词,常用于搜索框搜索:
match查询:全文检索查询的一种,会对用户输入内容分词,然后去倒排索引库检索,语法:
# match查询
GET /hotel/_search
{
"query": {
"match": {
"all": "外滩"
}
}
}
multi_match: 与match查询类似,只不过允许同时查询多个字段,语法:
# multi_match查询
GET /hotel/_search
{
"query": {
"multi_match": {
"query": "外滩如家",
"fields": ["name","business","brand"]
}
}
}
match和multi_match的区别
- match:根据一个字段查询
- multi_match:根据多个字段查询,参与查询字段越多,查询性能越差
建议利用copy to 把多个字段拷贝到一个字段中
精确查询
精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有:
- term:根据词条精确值查询
- range:根据值的范围查询
# term查询
GET /hotel/_search
{
"query": {
"term": {
"city": {
"value": "深圳"
}
}
}
}
# range查询
GET /hotel/_search
{
"query": {
"range": {
"price": {
"gte": 100,
"lte": 200
}
}
}
}
地理查询
根据经纬度查询。常见的使用场景包括:
-
携程:搜索我附近的酒店
-
滴滴:搜索我附近的出租车
-
微信:搜索我附近的人
根据经纬度查询,例如∶
geo_bounding_box:查询geo_point值落在某个矩形范围的所有文档
geo_distance:查询到指定中心点小于某个距离值的所有文档
# distance查询
GET /hotel/_search
{
"query": {
"geo_distance":{
"distance":"15km",
"location":"31.21,121.5"
}
}
}
复合查询
复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑,例如:
- fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名。例如百度竞价
当我们利用match查询时,文档结果会根据与搜索词条的关联度打分(_score),返回结果时按照分值降序排列。例如,我们搜索"虹桥如家",结果如下:
es5.0之后使用的是BM25算法
Function Score Query
使用function score query,可以修改文档的相关性算分(query score),根据新得到的算分排序。
案例
给“如家”这个品牌的酒店排名靠前一些
把这个问题翻译一下, function score需要的三要素:
1.哪些文档需要算分加权?
- 品牌为如家的酒店
2.算分函数是什么?
- weight就可以
3.加权模式是什么?
- 求和
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"term": {
"all": {
"value": "外滩"
}
}
},
"functions": [
{
"filter": {
"term": {
"brand": "如家"
}
},
"weight": 10
}
],
"boost_mode": "multiply"
}
}
}
复合查询
Boolean Query布尔查询是一个或多个查询子句的组合。
子查询的组合方式有:
- must:必须匹配每个子查询,类似“与”
- should:选择性匹配子查询,类似“或”
- must_not:必须不匹配,不参与算分,类似“非”.
- filter:必须匹配,不参与算分
案例
利用bool查询实现功能
需求:搜索名字包含“如家”,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。
搜索结果处理
排序
elasticsearch支持对搜索结果排序,默认是根据相关度算分(_score)来排序。可以排序字段类型有: keyword类型、数值类型、地理坐标类型、日期类型等。
案例
对酒店数据按照用户评价降序排序,评价相同的按照价格升序排序
评价是score字段,价格是price字段,按照顺序添加两个排序规则即可。
# 排序查询
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"score": {
"order": "desc"
},
"price": {
"order": "asc"
}
}
]
}
实现对酒店数据按照到你的位置坐标的距离升序排序
获取经纬度的方式: https://lbs.amap.com/demo/jsapi-v2/example/map/click-to-get-Inglat/
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance": {
"location": {
"lat": 31,
"lon": 121
},
"order": "asc",
"unit": "km"
}
}
]
}
分页
elasticsearch默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。
elasticsearch中通过修改from、size参数来控制要返回的分页结果:
ES是分布式的,所以会面临深度分页问题。例如按price排序后,获取from = 990,size =10的数据:
- 1.首先在每个数据分片上都排序并查询前1000条文档。
- 2.然后将所有节点的结果聚合,在内存中重新排序选出前1000条文档
- 3.最后从这1000条中,选取从990开始的10条文档
如果搜索页数过深,或者结果集(from + size)越大,对内存和CPU的消耗也越高。因此ES设定结果集查询的上限是10000
深度分页解决方案
针对深度分页,ES提供了两种解决方案
- search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
- scroll:原理将排序数据形成快照,保存在内存。官方已经不推荐使用。
总结
from + size:
- 优点:支持随机翻页
- 缺点:深度分页问题,默认查询上限( from + size)是10000
- 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索
after search:
- 优点:没有查询上限(单次查询的size不超过10000)
- 缺点:只能向后逐页查询,不支持随机翻页
- 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页
scroll:
- 优点:没有查询上限(单次查询的size不超过10000)
- 缺点:会有额外内存消耗,并且搜索结果是非实时的
- 场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议用aftersearch方案。
高亮
就是在搜索结果中把搜索关键字突出显示。
原理是这样的:
- 将搜索结果中的关键字用标签标记出来
- 在页面中给标签添加css样式
语法:
默认情况下,ES搜索字段必须与高亮字段一致
ES默认就是给结果加的<em></em>
标签,可以省略不写
GET /hotel/_search
{
"query": {
"match": {
"all":"如家"
}
},
"highlight": {
"fields": {
"name": {
"require_field_match": "false"
}
}
}
}
RestClient查询文档
match_all
我们通过match_all来演示下基本的API,先看请求DSL的组织:
对响应结果的解析
@Test
public void testMatchAll() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().query(QueryBuilders.matchAllQuery());
//发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析响应
SearchHits searchHits = response.getHits();
//获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到"+total+"条数据");
//获取文档数组
SearchHit[] hits = searchHits.getHits();
//遍历文档数组,就是每条数据
for (SearchHit hit : hits) {
//获取文档source
String json = hit.getSourceAsString();
//反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
}
全文检索查询
全文检索的match和multi_match查询与match_all的API基本一致。差别是查询条件,也就是query的部分。
@Test
public void testMatch() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().query(QueryBuilders.matchQuery("all","如家"));
//发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析响应
SearchHits searchHits = response.getHits();
//获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到"+total+"条数据");
//获取文档数组
SearchHit[] hits = searchHits.getHits();
//遍历文档数组,就是每条数据
for (SearchHit hit : hits) {
//获取文档source
String json = hit.getSourceAsString();
//反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
}
精确查询
精确查询常见的有term查询和range查询,同样利用QueryBuilders实现:
@Test
public void testTerm() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().query(QueryBuilders.termQuery("city","深圳"));
//发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析响应
responseHandle(response);
}
@Test
public void testRange() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().query(QueryBuilders.rangeQuery("price").lt(250));
//发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析响应
responseHandle(response);
}
复合查询-boolean query
精确查询常见的有term查询和range查询,同样利用QueryBuilders实现:
@Test
public void testBool() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.termQuery("city","上海"));
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(200));
request.source().query(boolQuery);
//发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析响应
responseHandle(response);
}
组合查询-function score
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-56BqKf2J-1687077051212)(…/…/…/AppData/Roaming/Typora/typora-user-images/image-20230611142703876.png)]
//算分控制
FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
//原始查询,相关性算分的查询
boolQuery
//function score的数组
, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
// 其中的一个function score元素
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
//过滤条件
QueryBuilders.termQuery("isAD",true)
//算法函数
, ScoreFunctionBuilders.weightFactorFunction(10)
)
});
排序和分页
搜索结果的排序和分页是与query同级的参数,对应的API如下:
@Test
public void testPageAndSort() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().query(QueryBuilders.matchAllQuery());
request.source().from(0).size(5);
request.source().sort("price", SortOrder.ASC);
//发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析响应
responseHandle(response);
}
高亮
高亮API包括请求DSL构建和结果解析两部分。我们先看请求的DSL构建:
@Test
public void testHighLight() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().query(QueryBuilders.matchQuery("city","深圳"));
request.source().highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false)
);
//发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析响应
responseHandle(response);
}
结果解析处理
private void responseHandle(SearchResponse response){
//解析响应
SearchHits searchHits = response.getHits();
//获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到"+total+"条数据");
//获取文档数组
SearchHit[] hits = searchHits.getHits();
//遍历文档数组,就是每条数据
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);
}
}