1.概述:

elasticsearch是一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能
比如:
elastic stack(ELK)是以elasticsearch为核心的技术栈,

包括beats、Logstash、kibana、elasticsearch,这是这个技术栈的基本架构,数据可视化,数据抓取的技术栈不一定要依靠这三个,但数据检索es是必不可少的。

2.倒排索引:

倒排索引的概念是基于MySQL这样的正向索引而言的。

2.1.正向索引

正向索引:例如给mysql表中的id创建索引,如果是根据id查询,那么直接走索引,查询速度非常快。但如果是基于title做模糊查询,索引会失效,只能是逐行扫描数据,流程如下:

逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。
2.2.倒排索引
倒排索引中有两个非常重要的概念:
- 文档(`Document`,类比于mysql中的一条数据):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
- 词条(`Term`):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条
创建倒排索引是对正向索引的一种特殊处理,流程如下:


- 将每一个文档的数据利用算法分词,得到一个个词条
- 创建表,每行数据包括词条、词条所在文档id、位置等信息
- 因为词条唯一性,可以给词条创建索引,例如hash表结构索引
  至此这就相当于创建了倒排索引
- 然后给文档id创建索引,方便拿着id去找文档
基于倒排索引的搜索流程如下(以搜索"华为手机"为例):


1)用户输入条件`"华为手机"`进行搜索。

2)对用户输入内容分词,得到词条:`华为`、`手机`。

3)拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3。

4)拿着文档id到正向索引中查找具体文档。

虽然要先查询词条索引,再查询id正向索引,但是无论是词条、还是文档id都建立了索引,查询速度非常快!无需全表扫描。
2.3对比:
为什么一个叫做正向索引,一个叫做倒排索引呢?
    正向索引是最传统的,根据id索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程。而倒排索引则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的id,然后根据id获取文档。是根据词条找文档的过程。
2.4优缺点:
正向索引:
- 优点:
  - 可以给多个字段创建索引
  - 根据索引字段搜索、排序速度非常快
- 缺点:
  - 根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。
倒排索引:
- 优点:
  - 根据词条搜索、模糊搜索时,速度非常快
- 缺点:
  - 只能给词条创建索引,而不是字段
  - 无法根据字段做排序 

3.es中的概念,对比mysql

3.1.文档和字段

elasticsearch是面向文档(Document)存储的,文档相当于mysql中的一行数据。文档数据会被序列化为json格式后存储在elasticsearch中:


一个文档中往往包含很多的字段(Field),类似于数据库中的列,比如这里的id,title等都是一个字段。
3.2.索引和映射

索引(Index),就是相同类型的文档的集合,类似mysql中的数据库。比如:


数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束(唯一约束,主键约束等)。
3.3互相补充
Mysql:擅长事务类型操作,可以确保数据的安全和一致性,Elasticsearch:擅长海量数据的搜索、分析、计算。因此在企业中,往往是两者结合使用:

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

4.安装

4.1安装es和kibana,ik分词器

看一下代码,

首先得新建一个网络,用于es软件的互相通信:

docker network creat es-net

然后运行的时候--network加入即可。

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

命令解释:
- `-e "ES_JAVA_OPTS=-Xms512m -Xmx512m"`:内存大小,指定最大运行内存
- `-e "discovery.type=single-node"`:非集群模式
- `-v es-data:/usr/share/elasticsearch/data`:挂载逻辑卷,绑定es的数据目录
- `-v es-logs:/usr/share/elasticsearch/logs`:挂载逻辑卷,绑定es的日志目录
- `-v es-plugins:/usr/share/elasticsearch/plugins`:挂载逻辑卷,绑定es的插件目录(分词器可以放在这里)
- `--privileged`:授予逻辑卷访问权
- `--network es-net` :加入一个名为es-net的网络中(在此网络中的容器可以以名字直接连接)
- `-p 9200:9200`:端口映射配置,供其他用户访问

- `-p 9300:9300:这是 Elasticsearch 集群内部节点之间进行通信的 tcp端

kibana可以给我们提供一个elasticsearch的可视化界面。安装:

运行docker命令,部署kibana
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601  \
kibana:7.12.1
--network es-net` :加入一个名为es-net的网络中,与elasticsearch在同一个网络中,能够直接访问
--e ELASTICSEARCH_HOSTS=http://es:9200":设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch
- `-p 5601:5601`:端口映射配置

现在直接访问宿主机ip加5601就可以访问kibana,从而直接可以写DSl语句了,然后把ik分词器放到配置的那个数据卷目录下,这就完成了。

4.2ik分词器

分词器的作用:创建倒排索引时对文档分词,用户搜索时,对输入的内容分词当然分词器有很多,后面我们会详细讲这里先用一种支持中文的分词器
IK分词器包含两种模式:
ik_smart:最少切分(最大粒度切分),ik_max_word:最细切分比如:

但是这样就存在一些问题,首先就是如何添加新词,比如现在的网络用语,我们需要添加进去,只需要修改ik分词器的文件,然后重启即可:

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

2)在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>
</properties>
<entry key="ext_dict">ext.dic</entry>这个标签就是指定那个文件是新增词。
3)新建一个 ext.dic,写上拓展词即可。

然后就是禁用词,和拓展词配置方法一样:

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

1)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>
</properties>
3)在 stopword.dic 添加停用词即可。

5.Kibana中利用DSL操作es(我们平常是利用Java操作的,所以这里只要会看就行,最好记住)

5.1操作索引库

索引库就类似数据库表,mapping映射就类似表的结构。我们要向es中存储数据,必须先创建“库”和“表,要创建库,就要类似于mysql知道约束,所以先看映射,常见的mapping属性包括:
- type:字段数据类型,常见的简单类型有:
  - 字符串:text(可分词的文本,要配合分词器)、keyword(精确值,例如:品牌、国家、ip地址)
  - 数值:long、integer、short、byte、double、float、
  - 布尔:boolean
  - 日期:date
  - 对象:object
- index:是否创建索引,默认为true
- analyzer:使用哪种分词器
- properties:该字段的子字段

现在来看对于索引库的CRUD:

C:基本语法: - 请求方式:PUT - 请求路径:/索引库名,可以自定义,里面都有引号,多个映射和字段逗号隔开.

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

R: 基本语法: - 请求方式:GET/索引库名 

U:es不支持映射的改动所以不能U,虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。

PUT /索引库名/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}

D:语法:格式: DELETE /索引库名

PUT /myes
{
  "mappings": {
    "properties": {
      "key1":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "key2":{
        "type": "keyword"
      }
      }
    }
  }

GET /myes

DELETE /myes

5.2操作文档(CRUD)

文档操作有哪些?

- 创建文档:POST /{索引库名}/_doc/文档id   { json文档 }
- 查询文档:GET /{索引库名}/_doc/文档id
- 删除文档:DELETE /{索引库名}/_doc/文档id
- 修改文档:
  - 全量修改:PUT /{索引库名}/_doc/文档id { json文档 }
  - 增量修改:POST /{索引库名}/_update/文档id { "doc": {字段}}

全量修改的原理就是先删除数据库,然后再创建一个新的,如果没有就直接创建一个新的,所以可以把创建也认为是全量修改,从而简化业务。

PUT /myes/_doc/1
{
  "key1":"我是第一个",
  "key2":"我是第二个"
}

GET /myes/_doc/1

DELETE /myes/_doc/1

5.3RestAPI

ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。其中的Java Rest Client又包括两种:

- Java Low Level Rest Client
- Java High Level Rest Client
这里讲的的是Java HighLevel Rest Client客户端API,看一个实战

关键在于映射应该怎么样设计,应该考虑的有几点:

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

其中:

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

插入的时候应该是这种格式的:

"location": "31.230416,121.473701"  // 纬度在前,经度在后

然后就是all这个类型字段:组合字段,其目的是将多字段的值 利用copy_to合并,提供给用户搜索,因为当查询涉及到的索引很多时,肯定不如一个索引(类似于联合索引),所以就写了这个字段,然后如果那个字段要加入直接指定"copy_to"即可,一个反直觉的事情就是,你看一个字段是keyword类型,不能分词,但是copy到all里面,all是text类型,竟然可以分词了,其实这并不冲突:

. 各字段的角色分工

  • keyword 类型字段(如 brandcity

    • 不分词,作为一个完整的精确值存储和索引。
    • 适合做:精确过滤(term 查询)、聚合(aggregation)、排序(sort)。
    • 它们的 copy_to: "all" 只是把自己的原始值复制一份到 all 字段里,自己本身的类型和行为不受影响。
  • all 字段(text 类型)

    • 接收来自多个 keyword 字段的原始值。
    • 作为一个 text 字段,会用 ik_max_word 分词器对这些拼接起来的文本进行分词,生成倒排索引。
    • 适合做:全文检索(match 查询),让用户可以在品牌、城市等多个维度里同时搜索。

其实就是两个独立的东西,互不干扰

5.1使用RestAPI:

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

1)引入es的RestHighLevelClient依赖:
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
2)因为SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:
<properties>
    <java.version>1.8</java.version>
    <elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
3)初始化RestHighLevelClient:我们可以配置一个bean实现自动注入(当然可以读取一个配置类)
@Bean
    public RestHighLevelClient restHighLevelClient() {
        return new RestHighLevelClient(
                RestClient.builder(
                   HttpHost.create("http://192.168.150.101:9200")
                )
        );

然后就是学习一下利用API完成索引和文档的操作:

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

索引库操作的基本步骤:

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

这是一个整体框架,所有对于es的操作框架都是这三步,第一步创建请求对象,根据操作不同需要的对象不一样,可以先写第三步的代码,然后ctrl+p提示需要什么对象就创建什么对象,然后第二部是第一步对象,.source就是代表那个下面的大的json文档,有的请求有请求体就写,没有就不需要第二步,支持链式编程。

5.2操作索引库

第三步的注入对象.indices就是index的复数,就是索引库的意思,返回的对象可以操作索引库。

需要的参数是一个什么类型的请求,就new一个,比如:

    @Test
    void text01() throws IOException {
        //1.准备请求对象
        CreateIndexRequest hotel = new CreateIndexRequest("hotel");
        //2.准备请求体
        hotel.source(CreatIndex.MAPPING_TEMPLATE, XContentType.JSON);
        //3.发送请求
        client.indices().create(hotel, RequestOptions.DEFAULT);
    }

其他的API就是一样的:

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

判断索引库是不是存在,其实本质就是GET请求,但是分装的不是get:

@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 ? "索引库已经存在!" : "索引库不存在!");
}

5.3操作文档:

其实基本框架是一样的,只不过多了一步就是:解析结果罢了,其实也很简单,就是拿到响应结果,然后一步步的去拆解结果得到数据即可。而且是注入对象直接加操作了。

第一步当然就是创建一个实体类了,对应索引库的类型。值得注意的是,索引库要求的地理位置是"纬度,经度",这和数据库实体类的有一个差别就是数据库的是分开的两个属性,所以要进行以下转化,通过构造函数转化即可

@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;

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

this.location = hotel.getLatitude() + ", " + hotel.getLongitude();这个就是构造函数传入一个数据库实体类,再进行改编。

来看新增操作:这里不是create而是index这个比较特殊,其他都是见名知意。

@Test
void testAddDocument() throws IOException {
    // 1.根据id查询酒店数据
    Hotel hotel = hotelService.getById(61083L);
    // 2.转换为文档类型
    HotelDoc hotelDoc = new HotelDoc(hotel);
    // 3.将HotelDoc转json
    String json = JSON.toJSONString(hotelDoc);

    // 1.准备Request对象
    IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
    // 2.准备Json文档
    request.source(json, XContentType.JSON);
    // 3.发送请求
    client.index(request, RequestOptions.DEFAULT);
}

String json = JSON.toJSONString(hotelDoc);就是把一个对象转化为json对象,前面学过JsonUtils.toJsonString,都是可以的,还有对应的JSON.parseObject(json, 目标类.class),JsonUtils.toBean(json, 目标类.class)。

然后查看操作:

    @Test
    void test02() throws Exception {
        // 1.准备Request对象
        GetRequest request = new GetRequest("hotel").id(String.valueOf(61083L));
        // 2.发送请求61083L
        GetResponse response = client.get(request, RequestOptions.DEFAULT);
        //3.处理响应
        String json = response.getSourceAsString();
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        System.out.println(hotelDoc);
    }

这三部都一样,只不过多了解析,这里演示一下如何解析:这是dsl查询出来的结果:

而get得到的结果就是响应体,其实就是最外边的括号,所以String json = response.getSourceAsString();就是在得到真实的数据。然后转化为对象解析即可。

删就不看了很简单,看以下改,改的话分为全量和增量修改,全量修改和新增API完全一样,不看了,看一下增量修改:比如这个业务。

批量处理BulkRequest,其本质就是将多个普通的CRUD请求组合在一起发送。比如批量插入删除等:基本语法是这样:

框架还是一样,只不过第二步变成了批量加入(add),然后里面是以前的第一步和第二部框架,只不过利用了链式编程。所以批量导入hotel数据:

    @Test
    void test04() throws Exception {
        //查询数据库中数据
        List<Hotel> list = hotelService.listBy();
        // 1.准备Request对象
        BulkRequest bulkRequest = new BulkRequest();
        for (Hotel hotel : list) {
            HotelDoc hotelDoc = new HotelDoc(hotel);
            // 3.准备请求体
            IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
            request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
            bulkRequest.add(request);
        }
        // 2.发送请求61083L
        client.bulk(bulkRequest, RequestOptions.DEFAULT);
    }

6.Es的DSL数据搜索:

es的一般查询有:

查询所有:查询出所有数据例如:match_all

全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:
  - match(单字段)
  - multi_match(多字段)
精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如:
  - range
  - term
地理(geo)查询:根据经纬度查询。例如:
  - geo_distance(距离查询)
  - geo_bounding_box(矩形范围查询)
复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:
  - bool
  - function_score 
查询的语法基本是这样的:
GET /indexName/_search
{
  "query": {
    "查询类型": {
      "查询条件": "条件值"
    }
  }
}

比如查询所有:

GET /hotel/_search
{
  "query": {
    "match_all": {}
  }
}

6.1全文检索查询:

 6.1.1全文检索查询的基本流程如下:
- 对用户搜索的内容做分词,得到词条(因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的text类型的字段)
- 根据词条去倒排索引库中匹配,得到文档id
- 根据文档id找到文档,返回给用户
比较常用的场景包括:
- 商城的输入框搜索
- 百度输入框搜索

比如:以下代码就会先对外滩如家进行分词,然后去索引库中进行匹配,然后得到文档,这里是匹配copy到all的字段。

因为将brand、name、business值都利用copy_to复制到了all字段中。因此你根据三个字段搜索,和根据all字段搜索效果一样了。但是,搜索字段越多,对查询性能影响越大,因此建议采用copy_to,然后单字段查询的方式。

6.2精准查询

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

常见的有:
- term:根据词条精确值查询
- range:根据值的范围查询

加e就是表示多了等于

6.3地理位置查询:

所谓的地理坐标查询,其实就是根据经纬度查询,常见的使用场景包括:

- 携程:搜索我附近的酒店
- 滴滴:搜索我附近的出租车
- 微信:搜索我附近的人
- 黑马点评实现按距离排序
6.3.1.矩形范围查询

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

查询时,需要指定矩形的左上、右下两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": { // 左上点
          "lat": 31.1,
          "lon": 121.5
        },
        "bottom_right": { // 右下点
          "lat": 30.9,
          "lon": 121.7
        }
      }
    }
  }
}
6.3.2.附近查询

附近查询,也叫做距离查询(geo_distance):查询到指定中心点小于某个距离值的所有文档。

换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件,这才是业务中经常用到的:
GET /hotel/_search
{
  "query": {
    "geo_distance":{
      "distance":"15km",
      "location":"31.21,121.5"
    }
  }
}

第一个distance是表示距离,第二个"Field":坐标表示是在说明中心。

6.4复合查询

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

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

当match查询时,文档结果会根据与搜索词条的关联度打分(_score),返回结果时按照分值降序排列。

例如,我们搜索 "虹桥如家",结果如下:

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

谁分高谁就是在前边。

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

在后来的5.1版本升级中,elasticsearch将算法改进为BM25算法,公式如下:

TF-IDF算法有一各缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更加平滑:

但是实际开发中肯定要自己控制打分吧,谁给米了谁就考前,所以要控制打分机制,就需要利用elasticsearch中的function score 查询了。

6.4.2fuction score查询

基本语法:太多了还没有提示,看懂就行了

这就是复合查询,就是在先查询出query的情况下,利用functions定义过滤条件(里面又是一个查询)和得到一个新的算分函数,这个会对过滤之后的数据进行重新算分,然后可以指定算分的方法。看一个复杂一点的:

6.4.3布尔查询

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

- must:必须匹配每个子查询,类似“与”
- should:选择性匹配子查询,类似“或”
- must_not:必须不匹配,**不参与算分**,类似“非”
- filter:必须匹配,**不参与算分*

注意如果不需要参与算分的业务应该使用后两者,因为算分要消耗性能。比如一个业务:

这种多条件查询时,建议这样做:

- 搜索框的关键字搜索,是全文检索查询,使用must查询,参与算分
- 其它过滤条件,采用filter查询。不参与算分
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city": "上海" }}
      ],
      "should": [
        {"term": {"brand": "皇冠假日" }},
        {"term": {"brand": "华美达" }}
      ],
      "must_not": [
        { "range": { "price": { "lte": 500 } }}
      ],
      "filter": [
        { "range": {"score": { "gte": 45 } }},
        {"geo_distance":{
            "distance":"5km",    
            "location":"31.25,125.1"    
        }}
      ]
    }
  }
}

每个都是一个列表,列表里面是子查询。

6.5对查询结果的处理:

6.5.1排序

排序大体可以分为两种:普通字段排序和地理位置排序(看见了吧哈哈哈,终于可以解决点评中地理位置排序了)

elasticsearch默认是根据相关度算分来排序,但是也支持自定义方式对搜索[结果排序],就相当于mysql中的orderby排在查询结果后面,可以排序字段类型有:keyword类型、数值类型、地理坐标类型、日期类型等。

普通字段排序:keyword、数值、日期类型排序的语法基本一致。
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "FIELD": "desc"  // 排序字段、排序方式ASC、DESC
    }
  ]
}

排序条件是一个数组,也就是可以写多个排序条件。按照声明的顺序,当第一个条件相等时,再按照第二个条件排序,以此类推,不写了很简单,注意是和query平级。

地理坐标排序!!!重要

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance" : {
          "FIELD" : "纬度,经度", // 文档中geo_point类型的字段名、目标坐标点
          "order" : "asc", // 排序方式
          "unit" : "km" // 排序的距离单位
      }
    }
  ]
}

里面location换了一种写法,其实写成"location":"31,121"也可以。

这里写一个小的知识点,也是课程中没有涉及到的(可以作为项目的一个亮点我感觉,我太牛逼了):排序的话很明显算分就没有用了,那么如果我还是想让排序完,某些酒店置顶呢?就是在查询里面pinned中的ids数组里写上要置顶的顺序,其他的数据放在organic里,organic里写法和之前一样。

GET /hotel/_search
{
  "query": {
    "pinned": {
      "ids": [
        "hotel_003",  // 第1个置顶
        "hotel_001",  // 第2个置顶
        "hotel_005"   // 第3个置顶
      ],
      "organic": {
        "query": { "match_all": {} },
        "sort": [
          {
            "_geo_distance": {
              "location": {
                "lat": 31.034661,
                "lon": 121.612282
              },
              "order": "asc",
              "unit": "km"
            }
          }
        ]
      }
    }
  }
}

但还是有一个问题,这种写法支持:静态指定 ID + 控制 ID 顺序 + 其余文档自定义排序,但是 不支持:动态检索(如全文检索)、动态算分(如 function_score)、按字段规则筛选置顶文档,本质是为了性能和确定性:ID 是文档的唯一标识,ES 能直接通过倒排索引快速定位,置顶操作几乎无性能损耗;那如何完成动态置顶需求呢(就是在置顶的那些东西里面动态调整)?

这完全是我自己想出来的,我太开心啦,哈哈哈:

我的思路是,首先分开置顶和非置顶,那如何分开呢,当然就我们自定义分数的查询:function_score了。我们可以给非置顶的得分置0,这样一般情况下置顶得分都是高于0的,这样就分开了。。从而排序的话先根据分数排序,这样所有置顶的都排完了(这就是动态置顶排序),然后非置顶的是0都一样,再按照自己想排序的规则排序(比如距离呀,什么的)。是不是很esay,这里的核心就是得分置0。所以第一部分就是拆开:

GET /hotel/_search
{
  "query": {
    "bool": {
      "should": [
        // ========== 第一部分:动态置顶的酒店(得分>0) ==========
        {
          "function_score": {
            // 1. 筛选置顶候选:全文检索
            "query": {
              "match": {
                "name": {
                  "query": "皇冠假日 上海 豪华",  // 要置顶的检索关键词
                  "operator": "or"              // 包含任意关键词即可
                }
              }
            },
            // 2. 置顶酒店内部排序规则(按权重高低排)
            "functions": [
              // 规则1:品牌精准匹配,权重10(优先级最高)
              {
                "filter": { "term": { "brand": "皇冠假日" } },
                "weight": 10
              },
              // 规则2:评分≥4.8,权重5(优先级次之)
              {
                "filter": { "range": { "score": { "gte": 48 } } },
                "weight": 5
              },
              // 规则3:价格<1000,权重2(优先级再次之)
              {
                "filter": { "range": { "price": { "lt": 1000 } } },
                "weight": 2
              }
            ],
            // 3. 得分合并规则
            "score_mode": "sum"        // 多个函数权重求和
            "boost_mode": "multiply",  // 函数权重 × 基础检索得分

          }
        },
        // ========== 第二部分:非置顶酒店(得分固定为0) ==========
        {
          "constant_score": {
            "filter": {
              "bool": {
                "must_not": [
                  // 排除和置顶候选相同的文档(避免重复)
                  {
                    "match": {
                      "name": {
                        "query": "皇冠假日 上海 豪华",
                        "operator": "or"
                      }
                    }
                  }
                ]
              }
            },
            "boost": 0  // 非置顶酒店得分强制为0(核心配置)
          }
        }
      ]
    }
  }
}

细节1:以前写的应该是"name":"皇冠假日 上海 豪华",这其实是以下的简写,or表示的是有其中一个分词即可,and表示必须这些分词都包含,要想完成更复杂的业务逻辑就得详细写。

细节2:以前之所以只有boost_mode是因为以前只写了一个过滤条件,现在写了好几个条件,如果有一个酒店满足所有的条件,那么它的第二个得分就是score_mode控制的,boost_mode控制的是最终得分。

细节3:这是一个新的算分复合查询,顾名思义就是常数算分,直接给过滤条件的数据赋值为0。还有就是利用must_not里面写成和第一部分一样的来排除置顶。

这样就查到了两部分数据,一部分就是正分的置顶数据,而且按照我们的意思给分了,一部分就是全为0的非置顶数据。然后就很简单了,排序即可:

"sort": [
    // 维度1:优先按得分降序(置顶酒店>0,非置顶=0 → 置顶永远在前)
    { "_score": { "order": "desc" } },
    // 维度2:同得分的文档(仅非置顶),按距离由近到远排序
    {
      "_geo_distance": {
        "location": { "lat": 31.034661, "lon": 121.612282 },  // 目标坐标
        "order": "asc",
        "unit": "km"  // 距离单位:公里
      }
    }
  ]

细节:

这个_score就是代表按照得分排,这个字段里面会包含第一步查询出来的得分。这是一个新的语法,之前我们说的只能根据字段排,因为排序的本意不就是不按照它的得分排嘛,所以这个不常用。

总体就是:其他业务的时候直接复制这个进行,里面的细节你可以改一下。太牛逼了我

GET /hotel/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "function_score": {
            "query": {
              "match": {  
                "name": "云南"
              }
            },
            "functions": [
              {
                "filter": { "range": { "score": { "gte": 48 } } },  
                "weight": 5
              },
              {
                "filter": { "range": { "price": { "lt": 1000 } } },  
                "weight": 2
              }
            ],
            "score_mode": "sum",
            "boost_mode": "multiply"
          }
        },

        {
          "constant_score": {
            "filter": {
              "bool": {
                "must_not": [
                  {
                    "match": { 
                      "name": "云南"
                    }
                  }
                ]
              }
            },
            "boost": 0
          }
        }
      ]
    }
  },
  "sort": [
    { "_score": { "order": "desc" } },
    {
      "_geo_distance": {
        "location": { "lat": 31.034661, "lon": 121.612282 },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

这是结果,看见没sort列表里第一个得分是0,说明不属于置顶的,第二个得分就是地理位置远近。都符合

6.5.2分页:分页查询语法很简单,from和size就类似于limit的参数一样。

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0, // 分页开始的位置,默认为0
  "size": 10, // 期望获取的文档总数  
}

而如果是深度查询呢?

比如第990~第1000条数据:elasticsearch内部分页时,必须先查询 0~1000条,然后截取其中的990 ~ 1000的这10条:比如查询TOP1000,如果es是单点模式,这并无太大影响。但是elasticsearch将来一定是集群,例如我集群有5个节点,我要查询TOP1000的数据,并不是每个节点查询200条就可以了。因为节点A的TOP200,在另一个节点可能排到10000名以外了。因此要想获取整个集群的TOP1000,必须先查询出每个节点的TOP1000,汇总结果后,重新排名,,重新截取TOP1000,这样的查询量是很巨大的。当查询分页深度较大时,汇总数据过多,对内存和CPU会产生非常大的压力,因此elasticsearch会禁止from+ size 超过10000的请求。那如何深度分页呢?其实我们可以直接从源头上禁止,就不允许这样分页就行了,如果不得不分页呢?针对深度分页,ES提供了两种解决方案:
- search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
- scroll:原理将排序后的文档id形成快照,保存在内存。官方已经不推荐使用。

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

6.5.3高亮:

高亮是因为我们给前端返回的Java直接就带了<em>标签,如何前端写一个css样式即可,因为这样才能达到动态的效果。

样例:

GET /hotel/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
    }
  },
  "highlight": {
    "fields": { // 指定要高亮的字段
      "FIELD": {
        "pre_tags": "<em>",  // 用来标记高亮字段的前置标签
        "post_tags": "</em>" // 用来标记高亮字段的后置标签
      }
    }
  }
}

注意的点:

- 高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询,而且必须是全文检索查询。
- 默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮,如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false,比如下面的收缩字段是all,但是要对name字段进行高亮,所以不一致,所以要改属性。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐