转自 ElasticSearch | Kyle’s Blog (cyborg2077.github.io)

初识ElasticSearch

了解ES

ElasticSearch的作用

ElasticSearch是一个分布式的搜索引擎

海量数据中快速寻找有相关关键字的条目

搜索引擎中常用

ELK技术栈(elastic stack)

  • ElasticSearch结合kibanaLogstashBeats,也就是elastic stack(ELK)。被广泛应用在日志数据分析实时监控等领域
    • 日志信息帮助快速排查错误
  • ElasticSearchelastic stack的核心,负责存储、搜索、分析数据
  • ElasticSearch底层又由Lucene实现

image-20230411172124334

ElasticSearch和Lucene

  • ElasticSearch底层是基于Lucene来实现的
  • Lucene是一个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发,官网地址:https://lucene.apache.org/
  • Lucene的优势
    • 易扩展
    • 高性能(基于倒排索引)
  • Lucene的缺点
    • 只限于Java语言开发
    • 学习曲线陡峭
    • 不支持水平扩展
  • 后来 ElasticSearch 重写了 Lucene
  • 相比于Lucene,ElasticSearch具备以下优势
    • 支持分布式,可水平扩展
    • 提供Restful接口(与语言无关),可以被任意语言调用

总结

  • 什么是ElasticSearch?
    • 一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能
  • 什么是Elastic Stack(ELK)?
    • 它是以ElasticSearch为核心的技术栈,包括beats、Logstash、kibana、elasticsearch
  • 什么是Lucene?
    • 是Apache的开源搜索引擎类库,提供了搜索引擎的核心API, 他可以说是ElasticSearch的底层实现

倒排索引

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

正向索引

什么是倒排索引?先看看什么是正向索引。

例如MySQL中有这样一个数据库表,一般以id作为主键建立聚簇索引,通过id 搜索数据会非常快这种索引就叫正向索引

id title price
1 小米手机 3499
2 华为手机 4999
3 华为小米充电器 49
4 小米手环 49

而当我们对title进行模糊匹配 like %小米% 即便字段有索引索引也会失效,他会采用逐条扫描的方式进行匹配,如果匹配上才会加入到结果集当中。性能会非常差

倒排索引

倒排索引的解决方案,

倒排索引(倒排数据库表)有两个重要的字段:文档(document) 词条 (term)

  1. 文档(Document):(对标于MySQL的一行数据)用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品,或理解为一个对象
  2. 词条(Term):(相当于文本的词汇)对文档中的数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。
词条(term) 文档id
小米 1,3,4
手机 1,2
华为 2,3
充电器 3
手环 4

倒排索引为词条再创建索引 ,将来查找的时候按照词条来查找,再来定位文档id 返回文档,这也是为什么叫倒排索引的原因(正向索引:根据文档找词条, 倒排索引:根据词条找文档)

正向和倒排

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

ES的一些概念

ElasticSearch中有很多独有的概念,与MySQL中略有差别,但也有相似之处

文档和字段

ElasticSearch是面向文档(Document)存储的,可以是数据库中的一行数据 ,如一条商品数据,一个订单信息。这一点和MySQL相似,因为你可以将文档理解为一行一行的数据,文档数据会被序列化为json格式后存储在ElasticSearch中,而Json文档中往往包含很多的字段(Field),类似于数据库中的

如下,

image-20230411201418570

索引和映射

  • 索引(Index),就是相同类型的文档的集合 : ES中的索引就相当于MySQL中的数据表,那么ES的索引库相当于MySQL的数据库
  • 映射(mapping) 是索引中文档的字段约束信息 , ES中的映射就 类似于表的结构约束

MySQL与ElasticSearch

  • 我们统一的把MySQL和ElasticSearch的概念做一下对比
MySQL Elasticsearch 说明
数据库 好像没有对应的只有一个库
Table(表) Index(索引/索引库) 索引(index),就是文档的集合,类似数据库的表(Table)
Schema(表结构) Mapping(映射) Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
Row(行) Document(文档) 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
Column(列) Field(字段) 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
SQL DSL DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD
  • 二者各有自己擅长之处
    • MySQL:产长事务类型操作,可以保证数据的安全和一致性
    • ElasticSearch:擅长海量数据的搜索、分析、计算
  • 因此在企业中,往往是这二者结合使用

    • 对安全性要求较高的写操作,使用MySQL实现

    • 对查询性能个较高的搜索需求,使用ElasticSearch实现

    • 二者再基于某种方式,实现数据的同步,保证一致性

img

安装ES、Kibana

部署单点ES

  • 因为我们还需要部署Kibana容器,因此需要让es和kibana容器互联,这里先创建一个网络(使用docker compose部署可以一键互联,那不需要这个步骤,但是将来有可能不需要kbiana,只需要es,所以先这里手动部署单点es)
docker network create es-net
  • 拉取镜像,这里采用的是ElasticSearch的7.12.1版本镜像
docker pull elasticsearch:7.12.1
  • 运行docker命令,部署单点ES
docker run -d \
    --name es \
    -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \  # 由于ES基于java写的 堆内存大小
    -e "discovery.type=single-node" \ # 单点模式启动
    -v es-data:/usr/share/elasticsearch/data \
    -v es-plugins:/usr/share/elasticsearch/plugins \
    --privileged \ # 授予逻辑卷访问权
    --network es-net \ # es容器加入到 这个网络当中
    -p 9200:9200 \  #HTTP 协议端口 用户用
    -p 9300:9300 \ # 	 集群模式下各个节点互联的接口
    elasticsearch:7.12.1

安装好之后,访问虚拟机ip的 9200端口 我的是 http://192.168.101.65:9200/

可以看到以下结果

image-20230411212416224

部署kibana

同样是先拉取镜像,注意版本需要与ES保持一致

docker pull kibana:7.12.1

运行docker命令,部署kibana

docker run -d \
    --name kibana \ # 让kibana加入es-net这个网络,与ES在同一个网络中
    -e ELASTICSEARCH_HOSTS=http://es:9200 \ # 设置ES的地址,因为kibana和ES在同一个网络,因此可以直接用容器名访问ES
    --network=es-net \ 
    -p 5601:5601 \ # 端口映射配置
    kibana:7.12.1

成功启动后,打开浏览器访问:http://192.168.101.65:5601/ ,即可以看到结果

kibana中提供了一个DevTools界面,在这个界面中我们可以编写DSL来操作ElasticSearch,并且有对DSL语句的自动补全功能

image-20230411215748765

image-20230411215925197

GET \等价于上面部署ES后直接访问http://192.168.101.65:9200/ 看,得到的结果是一样的

安装IK分词器

  • 默认的分词对中文的支持不是很好,所以这里我们需要安装IK插件
  • 在线安装IK插件
# 进入容器内部
docker exec -it es /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
# 如果在线下载慢,用离线方式: 仅需将离线安装包放到 上面设置好的es数据卷上即可
# 查看数据卷所在位置
docker volume inspect es-plugins 
# 如我的是 /var/lib/docker/volumes/es-plugins/_data

# 将课程资料 elasticsearch-analysis-ik-7.12.1 解压后放到查到的目录

#重启容器
docker restart es
  • IK分词器包含两种模式
    • ik_smart:最少切分
    • ik_max_word:最细切分

我们来对比一下

标准(默认分词器)

GET /_analyze
{
    "analyzer": "ik_smart",
    "text": "青春猪头G7人马文不会梦到JK黑丝兔女郎铁驭艾许"
}

搜索结果

发现中文支持不是很好,基本上是一个词一个词的分。。。

{
  "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" : "g7",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    。。。。}

搜索内容

GET /_analyze
{
    "analyzer": "ik_smart",
    "text": "青春猪头G7人马文不会梦到JK黑丝兔女郎铁驭艾许"
}

结果:

{
  "tokens" : [
    {
      "token" : "青春",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "猪头",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "G7",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "LETTER",
      "position" : 2
    },
    {
      "token" : "人",
      "start_offset" : 6,
      "end_offset" : 7,
      "type" : "COUNT",
      "position" : 3
    },
    ...}
GET /_analyze
{
    "analyzer": "ik_max_word",
    "text": "青春猪头G7人马文不会梦到JK黑丝兔女郎铁驭艾许"
}

结果

{
  "tokens" : [
    {
      "token" : "青春",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "猪头",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "G7",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "LETTER",
      "position" : 2
    },
    {
      "token" : "G",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "ENGLISH",
      "position" : 3
    },
    {
      "token" : "7",
      "start_offset" : 5,
      "end_offset" : 6,
      "type" : "ARABIC",
      "position" : 4
    },
    ...
    }

对比发现 ik_max_word 相比于 ik_smart划分力度更细一些,相对应的 这样 前者内存占用更高,但是更多的匹配项

随着互联网的发展,造词运动也愈发频繁。出现了许多新词汇,但是在原有的词汇表中并不存在,例如白给白嫖等,所以我们的词汇也需要不断的更新,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>
    	<!--用户可以在这里(当前文件夹中ext.dic)配置自己的扩展字典 -->
    	<entry key="ext_dict">ext.dic</entry>
        
        
        
    	 <!--用户可以在这里(当前文件夹中stopword.dic)配置自己的扩展停止词字典-->
    	<entry key="ext_stopwords">stopword.dic</entry>
    </properties>

3.在IKAnalyzer.cfg.xml同级目录下新建ext.dic和stopword.dic,添加扩展词和停止词

记得编码设置为utf-8编码

4.重启es

5.再次检索

会发现原本识别不了“黑丝”如果你添加了 则会加入 有“黑丝”这个词作为词条出现,不会再将其一字一分词,如果在停止词典中加了“的” “了”等词,则展示结果中不会再出现语气词

总结

  • 分词器的作用是什么?
    • 创建倒排索引时对文档分词
    • 用户搜索时,对输入的内容分词
  • IK分词有几种模式?
    • ik_smart:智能切分,粗粒度
    • ik_max_word:最细切分,细粒度
  • IK分词器如何拓展词条?如何停用词条?
    • 利用config目录的IKAnalyzer.cfg.xml文件添加拓展词典和停用词典
    • 在词典中添加拓展词条或者停用词条

索引库操作

  • 索引库就类似于数据库表,mapping映射就类似表的结构
  • 我们要向es中存储数据,必须先创建

索引库约束mapping

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

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

      1. 字符串:text(可分词文本)、keyword(精确值,例如:品牌、国家、ip地址;因为这些词,分词之后毫无意义)
      2. 数值:long、integer、short、byte、double、float
      3. 布尔:boolean
      4. 日期:date
      5. 对象:object
    • index:是否创建索引(是否参与搜索),默认为true,默认情况下会对所有字段创建倒排索引(如果是文档会建立先分词,后建立倒排索引,如果是其他数据类型也会建立倒排索引,但是是整体作为倒排索引的词条),即每个字段都可以被搜索。在创建字段映射时,一定要判断一下这个字段是否参与搜索,如果不参与搜索,则将其设置为false;

      此外对于字符串字段一些词只有作为整体才有意义比如品牌的名字,邮箱号等,不需要拆分 ,但如果你这时为text类型,它会默认先分词后建立倒排索引,但是我们不需要,可以将其设置为 这个字段的类型(type)不要设置为 text 而是 keyword

    • analyzer针对text类型的字段,如果建立了倒排索引,使用哪种分词器?中文的话,一定要设置自己的分词器,不然默认的分词器效果很差

    • properties:该字段的子字段表名

    • 特别要注意的是id是索引库中特殊的一个字段,他必须唯一而且是字符串类型可以认为是keyweord类型,一般会默认创建,不用自己单独创建,查询表结构的时候也也不会显示出来,但是当查询/增删改具体文档的时候必须要指定id,id也会显示出来

如如果要存储这样一个json文档,

{
    "age": 32,
    "weight": 48,
    "isMarried": false,
    "info": "次元游击兵--恶灵",
    "email": "wraith@Apex.net",
    "score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "雷尼",
        "lastName": "布莱希"
    }
}
  • 对应的每个字段映射(mapping):

    | 字段名 fieldname | 类型type | 是否建立索引index | 分词器analyzer |
    | :———————: | :———: | :———————-: | :——————: |
    | age | integer | true | null |
    | weight | float | true | null |
    | isMarried | boolean | true | null |
    | info | text | true | ik_smart |
    | email | keyword | false | null |
    | score | float | true | null |
    | name | object | | |
    | name.firstName | keyword | true | null |
    | name.lastName | keyword | true | null |

  • 其中score:虽然是数组,但是我们只看其中元素的类型,类型为float(ES不支持数组,但是允许该字段有多个该类型的参数);email不参与搜索,所以indexfalseinfo参与搜索,且需要分词,所以需要设置一下分词器

小结

  • mapping常见字段的属性(properties)有哪些?
    1. type:数据类型
    2. index:是否创建索引
    3. analyzer:选择分词器(针对于text类型)
    4. properties:子字段 (针对于object类型)
  • type常见的有哪些
    1. 字符串:text、keyword
    2. 数值:long、integer、short、byte、double、float
    3. 布尔:boolean
    4. 日期:date
    5. 对象:object (子字段)

索引库的CRUD

  • 这里是使用的Kibana提供的DevTools编写DSL语句

创建索引库和映射

  • 基本语法
    • 请求方式:PUT
    • 请求路径:/{索引库名},可以自定义
    • 请求参数:mapping映射
PUT /{索引库名}
{
  "mappings": {
    "properties": {
      "字段名1": {
        "type": "text ",
        "analyzer": "standard"
      },
      "字段名2": {
        "type": "text",
        "index": true
      },
      "字段名3": {
        "type": "object", // 这一句 不用写(因为下面有 properties,就表明这是一个对象了)
        "properties": {
          "子字段1": {
            "type": "keyword"
          },
          "子字段2": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

如针对下面的文档添加mapping映射

{
    "info": "次元游击兵--恶灵",
    "email": "wraith@Apex.net",
    "name": {
        "firstName": "雷尼",
        "lastName": "布莱希"
    }
}
PUT /test001
{
  "mappings": {
    "properties": {
      "info": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "email": {
        "type": "keyword",
        "index": false
      },
      "name": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "keyword"
          },
          "lastName": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

结果

{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "test001"
}

查询索引库

  • 基本语法
    • 请求方式:GET
    • 请求路径:/{索引库名}
    • 请求参数:
  • 格式:
GET /{索引库名}
  • 举例
GET /test001
  • 结果
{
  "test001" : {
    "aliases" : { },
    "mappings" : {
      "properties" : {
        "email" : {
          "type" : "keyword",
          "index" : false
        },
        "info" : {
          "type" : "text",
          "analyzer" : "ik_smart"
        },
        "name" : {
          "properties" : {
            "firstName" : {
              "type" : "keyword"
            },
            "lastName" : {
              "type" : "keyword"
            }
          }
        }
      }
    },
    "settings" : {
      "index" : {
        "routing" : {
          "allocation" : {
            "include" : {
              "_tier_preference" : "data_content"
            }
          }
        },
        "number_of_shards" : "1",
        "provided_name" : "test001",
        "creation_date" : "1681267481035",
        "number_of_replicas" : "1",
        "uuid" : "waQIx2FrQGKz79Ca2nD6sA",
        "version" : {
          "created" : "7120199"
        }
      }
    }
  }
}

修改索引库(无法真正修改只能添加属性。。。)

  • 基本语法
    • 请求方式:PUT
    • 请求路径:/{索引库名}/_mapping
    • 请求参数:mapping映射
PUT /{索引库名}/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}
  • 倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,就无法修改mapping
  • 虽然无法修改mapping中已有的字段,但是却允许添加新字段到mapping中,因为不会对倒排索引产生影响
  • 如果强行改,则会报错

删除索引库

  • 基本语法:
    • 请求方式:DELETE
    • 请求路径:/{索引库名}
    • 请求参数:无
DELETE /{索引库名}

总结

  • 索引库操作有哪些?
    1. 创建索引名:PUT /{索引库名}
    2. 查询索引库:GET /{索引库名}
    3. 删除索引库:DELETE /{索引库名}
    4. 添加字段:PUT /{索引库名}/_mapping

文档操作

文档有一个特殊字段是单独列出来的—— id

新增文档

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

示例

POST /test001/_doc/1
{
  "info": "次元游记兵--恶灵",
  "email": "wraith@Apex.net",
  "name": {
    "firstName": "雷尼",
    "lastName": "布莱希"
  }
}

img

查询文档

  • 根据rest风格,新增是post,查询应该是get,而且一般查询都需要条件,这里我们把文档id带上
  • 语法
GET /{索引库名}/_doc/{id}
  • 示例

    示例:根据id删除数据, 若删除的文档不存在, 则result为not found

GET /test001/_doc/1

img

删除文档

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

  • 语法

    DELETE /{索引库名}/_doc/{id}
  • 示例:根据id删除数据, 若删除的文档不存在, 则result为not found

    DELETE /test001/_doc/1

    img

修改文档

  • 修改有两种方式
    1. 全量修改:直接覆盖原来的文档
    2. 增量修改:修改文档中的部分字段

全量修改

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

    • 根据指定的id删除文档

    • 新增一个相同id的文档

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

  • 语法

PUT /{索引库名}/_doc/{文档id}
{
    "字段1": "值1",
    "字段2": "值2",
    // ... 略
}
  • 示例
PUT /test001/_doc/1
{
  "info": "爆破专家--暴雷",
  "email": "@Apex.net",
  "name": {
    "firstName": "沃尔特",
    "lastName": "菲茨罗伊"
  }
}

img

增量修改

  • 增量修改只修改指定id匹配文档中的部分字段
  • 语法
POST /{索引库名}/_update/{文档id}
{
    "doc": {
         "字段名": "新的值",
         ...
    }
}
  • 示例
POST /test001/_update/1
{
  "doc":{
    "email":"BestApex@Apex.net",
    "info":"恐怖G7人--马文"
  }
}

img

总结

  • 文档的操作有哪些?
    1. 创建文档:POST /{索引库名}/_doc/{id}
    2. 查询文档:GET /{索引库名}/_doc/{id}
    3. 删除文档:DELETE /{索引库名}/_doc/{id}
    4. 修改文档
      • 全量修改:PUT /{索引库名}/_doc/{id} —- 当id不存在时 等价于 创建文档
      • 增量修改:POST /{索引库名}/_update/{id}

DSL查询文档(非id查询)

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

DSL查询分类

  • ElasticSearch提供了基于DSL来定义查询。常见的查询类型包括

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

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

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

      注意虽然不会分词,但是也会建立倒排索引,只不过是整体作为倒排索引的 词条

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

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

      • bool
      • function_score

查询语法

  • 查询的语法基本一致
GET /indexname/_search
{
    "query": {
        "查询类型": { // match_all、match、multi_match等
            "查询条件": "条件值"
        }
    }
}

查询所有

这里以查询所有为例

  • 查询类型为match_all
  • 没有查询条件
GET /hotel/_search
{
    "query": {
        "match_all": { }
    }
}

// 这一句等价于
GET /hotel/_search
  • 其他的无非就是查询类型查询条件的变化

全文检索的查询

使用场景

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

基本语法

  • 常见的全文检索包括
    • match查询:单字段查询
    • multi_match查询:多字段查询,任意一个字段符合条件就算符合查询条件
  • match查询语法如下
GET /indexName/_search
{
    "query": {
        "match": {
            "FIELD": "TEXT"
        }
    }
}
  • multi_match语法如下
GET /indexName/_search
{
    "query": {
        "multi_match": {
            "fields": ["FIELD1", "FIELD2"]
        }
    }
}

示例

查询上海外滩的酒店数据

  • 以match查询示例,这里的all字段(虽然看不到,但是它真的存在于表结构中相当于复合索引)是之前由namecitybusiness这三个字段拷贝得来的,
  • 也就是 这要这三个字段出现 任意一个字段匹配上了 ”上海外滩“ 就能”命中”
GET /hotel/_search
{
    "query": {
        "match": {
            "all": "上海外滩"
        }
    }
}
  • 结果:
{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 82,
      "relation" : "eq"
    },
    "max_score" : 11.069907,
    "hits" : [****] // 我折叠了/其实也没有全部显示,为了避免差太多内存泄漏,只会显示部分
  }
}

命中了82条数据

  • 以multi_match查询示例
GET /hotel/_search
{
"query": {
    "multi_match": {
    "query": "上海外滩",
    "fields": ["brand", "city", "business"]
    }
}
}
  • 可以看到,这两种查询的结果是一样的,为什么?
    • 因为我们将namecitybusiness的值都利用copy_to复制到了all字段中,因此根据这三个字段搜索和根据all字段搜索的结果当然一样了
    • 但是搜索的字段越多,对查询性能影响就越大,因此建议采用copy_to,然后使用单字段查询的方式

小结

  • match和multi_match的区别是什么?
    • match:根据一个字段查询
    • multi_match:根据多个字段查询,参与查询的字段越多,查询性能就越差

精确查询

  • 精确查询一般是查找keyword、数值、日期、boolean等类型字段(品牌,城市等信息)。所以不会对搜索条件分词。常见的有
    • term:根据词条精确值查询
    • range:根据值的范围查询

term查询

  • 因为紧缺查询的字段是不分词的字段,因此查询的条件也必须是部分词的词条。查询时,用户输入的内容跟字段值完全匹配时才认为符合条件。如果用户输入的内容过多或过少,都会搜索不到数据

  • 语法说明

    GET /indexName/_search
    {
        "query": {
            "term": {
                "FIELD": {
                    "value": "VALUE"
                }
            }
        }
    }
  • 示例:查询北京的酒店数据

    GET /hotel/_search
    {
      "query": {
        "term": {
          "city": {
            "value": "北京"
          }
        }
      }
    }

但是当搜索的内容不是词条时,而是多个词语组成的短语时,反而搜索不到(不会自动分词,是作为一个整体查询)

range查询

  • 范围查询,一般应用在对数值类型做范围过滤的时候。例如做价格范围的过滤(数字,日期,keyword)
  • 基本语法
GET /hotel/_search
{
  "query": {
    "range": {
      "FIELD": {
        "gte": 10, //这里的gte表示大于等于,gt表示大于
        "lte": 20  //这里的let表示小于等于,lt表示小于
      }
    }
  }
}

示例:查询酒店价格在1000~3000的酒店

GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 1000,
        "lte": 3000
      }
    }
  }
}

小结

  • 精确查询常见的有哪些?
    1. term查询:根据词条精确匹配,一般搜索keyword类型、数值类型、布尔类型、日期类型字段
    2. range查询:根据数值范围查询,可以使数值、日期的范围

地理坐标查询

矩形范围查询 (geo_bounding_box)

  • 矩形范围查询,也就是geo_bounding_box查询,查询坐标落在某个矩形范围内的所有文档
  • 查询时。需指定矩形的左上、游戏啊两个点的坐标,然后画出一个矩形,落在该矩形范围内的坐标,都是符合条件的文档
  • 基本语法
GET /indexName/_search
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": {       // 左上点
          "lat": 31.1,      // lat: latitude 纬度 
          "lon": 121.5      // lon: longitude 经度
        },
        "bottom_right": {   // 右下点
          "lat": 30.9,      // lat: latitude 纬度 
          "lon": 121.7      // lon: longitude 经度
        }
      }
    }
  }
}

示例

GET /hotel/_search
{
  "query": {
    "geo_bounding_box": {
      "location": {
        "top_left": {
          "lat": 31.1,
          "lon": 121.5
        },
        "bottom_right": {
          "lat": 30.9,
          "lon": 121.7
        }
      }
    }
  }
}

附近查询(geo_distance)

  • 附近查询,也叫做举例查询(geo_distance):查询到指定中心点的距离小于等于某个值的所有文档
  • 换句话说,也就是以指定中心点为圆心,指定距离为半径,画一个圆,落在圆内的坐标都算符合条件
  • 语法说明

示例:查询我附近3km内的酒店文档

GET /hotel/_search
{
  "query": {
    "geo_distance": {
      "distance": "3km",            // 半径
      "location": "39.9, 116.4"     // 圆心
    }
  }
}

复合查询

  • 复合(compound)查询:复合查询可以将其他简单查询组合起来,实现更复杂的搜索逻辑,常见的有两种
    1. function score:算分函数查询,可以控制文档相关性算分,控制文档排名(例如搜索引擎的排名,第一大部分都是广告)
    2. bool query:布尔查询,利用逻辑关系组合多个其他的查询,实现复杂搜索

相关性算分 (score)

  • 当我们利用match查询时,文档结果会根据搜索词条的关联度打分(_score),返回结果时按照分值降序排列
  • 例如我们搜索虹桥如家,结果如下
[
  {
    "_score" : 17.850193,
    "_source" : {
      "name" : "虹桥如家酒店真不错",
    }
  },
  {
    "_score" : 12.259849,
    "_source" : {
      "name" : "外滩如家酒店真不错",
    }
  },
  {
    "_score" : 11.91091,
    "_source" : {
      "name" : "迪士尼如家酒店真不错",
    }
  }
]

“_score”即为相关度算分:他早期是根据TF(词条出现频率)计算的,后来发现这种算法效果很差,于是引入了 TF-IDF(逆文档频率)算法

img

以词条”如家“为例,包含”如家“的文档总数为3,假设总文档数也为3,那么IDF权重就是 log(3/3)=0;

以词条”虹桥“为例,包含”虹桥“的文档总数为1,假设总文档数也为3,那么IDF权重就是 log(3/1)=log3;

则词条虹桥的权重更高,出现虹桥这个词的词条的分数更高

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

img

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

img

  • 小结:ES会根据词条和文档的相关度做打分,算法有两种
    1. TF-IDF算法
    2. BM25算法, ES 5.1版本后采用的算法

算分函数查询 (function score query)

  • 根据相关度打分是比较合理的需求,但是合理的并不一定是产品经理需要的
  • 以某搜索引擎为例,你在搜索的结果中,并不是相关度越高就越靠前,而是谁掏的钱多就让谁的排名越靠前
  • 要想控制相关性算分,就需要利用ES中的function score查询了

语法说明

img

  • function score查询中包含四部分内容
    1. 原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分(query score)
    2. 过滤条件:filter部分,符合该条件的文档才会被重新算分
    3. 算分函数:符合filter条件的文档要根据这个函数做运算,得到函数算分(function score),有四种函数
      • weight:函数结果是常量
      • field_value_factor:以文档中的某个字段值作为函数结果
      • random_score:以随机数作为函数结果
      • script_score:自定义算分函数算法
    4. 运算模式:算分函数的结果、原始查询的相关性算分,二者之间的运算方式,包括
      • multiply:相乘
      • replace:用function score替换query score
      • 其他,例如:sum、avg、max、min
  • function score的运行流程如下
    1. 根据原始条件查询搜索文档,并且计算相关性算分,称为原始算法(query score)
    2. 根据过滤条件,过滤文档
    3. 符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)
    4. 原始算分(query score)和函数算分(function score)基于运算模式做运算,得到最终给结果,作为相关性算分
  • 因此,其中的关键点是:
    • 过滤条件:决定哪些文档的算分被修改
    • 算分函数:决定函数算分的算法
    • 运算模式:决定最终算分结果

案例

需求:给如家这个品牌的酒店排名靠前一点
思路:过滤条件为"brand": "如家",算分函数和运算模式我们可以暴力一点,固定算分结果相乘

对应的DSL语句如下,我们搜索外滩的酒店,对如家品牌过滤,最终的运算结果是10倍的原始算分

GET /hotel/_search
{
    "query": {
        "function_score": {
            "query": {
                "match": {
                    "all": "外滩"
                }
            },
            "functions": [
                {
                    "filter": {
                        "term": {
                            "brand": "如家"
                        }
                    },
                    "weight": 10
                }
            ],
            "boost_mode": "multiply"
        }
    }
}

可以看到,如家的算分达到了38,而第二名仅有6,成功将如家品牌的酒店提升到第一名

img

布尔查询 (bool query)

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

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

  • 每一个不同的字段,其查询条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就需要用到布尔查询了

需要注意的是,搜索时,参与打分的字段越多,查询的性能就越差,所以在多条件查询时

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

案例

需求:搜索名字中包含如家,价格不高于400,在坐标39.9, 116.4周围10km范围内的酒店
分析:

  • 名称搜索,属于全文检索查询,应该参与算分,放到must
  • 价格不高于400,用range查询,属于过滤条件,不参与算分,放到must_not
  • 周围10km范围内,用geo_distance查询,属于过滤条件,放到filter
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "name": "如家"
          }
        }
      ],
      "must_not": [
        {
          "range": {
            "price": {
              "gt": 400
            }
          }
        }
      ],
      "filter": [
          {
                "geo_distance": {
                "distance": "10km",
                "location": {
                    "lat": 31.21,
                    "lon": 121.5
                  }
              }
            
          }
      ]
    }
  }
}

案例

需求:搜索城市在上海,品牌为皇冠假日华美达,价格不低于500,且用户评分在45分以上的酒店

GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {
          "city": {
            "value": "上海"
          }
        }}
      ],
      "should": [
        {"term": {
          "brand": {
            "value": "皇冠假日"
          }
        }},
        {"term": {
          "brand": {
            "value": "华美达"
          }
        }}
      ],
      "must_not": [
        {"range": {
          "price": {
            "lte": 500
          }
        }}
      ], 
      "filter": [
        {"range": {
          "score": {
            "gte": 45
          }
        }}
      ]
    }
  }
}
  • 如果细心一点,就会发现这里的should有问题,must和should一起用的时候,should会不生效,结果中会查询到除了皇冠假日华美达之外的品牌。
  • 对于DSL语句的解决方案比较麻烦,需要在must里再套一个bool,里面再套should,但是对于Java代码来说比较容易修改
  • 小结:布尔查询有几种逻辑关系?
    1. must:必须匹配的条件,可以理解为
    2. should:选择性匹配的条件,可以理解为
    3. must_not:必须不匹配的条件,不参与打分,可以理解为
    4. filter:必须匹配的条件,不参与打分

搜索结果处理

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

排序

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

普通字段排序

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

GET /hotel/_search
{
  "query": {
    "match_all": {
    }
  },
  "sort": [
    {
      "FIELD": {
        "order": "desc"
      },
      "FIELD": {
        "order": "asc"
      }
    }
  ]
}

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

案例

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

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "score": {
        "order": "desc"
      },
      "price": {
        "order": "asc"
      }
    }
  ]
}

img

地理坐标排序

语法说明

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance": {
        "FIELD": {
          "lat": 40,
          "lon": -70
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

这个查询的含义是

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

  • 根据距离排序

案例

需求:实现酒店数据按照到你位置坐标的距离升序排序

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 39.9,
          "lon": 116.4
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

从结果中可以看到,最近的是1.6km

img

分页

  • ES默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。
  • ES中通过修改from、size参数来控制要返回的分页结果
    • from:从第几个文档开始
    • size:总共查询几个文档
  • 类似于mysql中的limit ?, ? start , length

基本的分页

  • 分页的基本语法如下
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0,
  "size": 20
}

深度分页问题

现在,我要查询990~1000条数据

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 990,
  "size": 10,
  "sort": [
    {
      "price": {
        "order": "asc"
      }
    }
  ]
}
  • 这里是查询990开始的数据,也就是第991~第1000条数据
  • 不过,ES内部分页时,必须先查询0~1000条,然后截取其中990~1000的这10条

img

  • 查询TOP1000,如果ES是单点模式,那么并无太大影响
  • 但是ES将来一定是集群部署模式,例如我集群里有5个节点,我要查询TOP1000的数据,并不是每个节点查询TOP200就可以了。
  • 因为节点A的TOP200,可能在节点B排在10000名开外了
  • 因此想获取整个集群TOP1000,就必须先查询出每个节点TOP1000汇总结果后,重新排名,重新截取TOP1000

img

  • 那么如果要查询9900~10000的数据呢?是不是要先查询TOP10000,然后汇总每个节点的TOP10000,重新排名呢?
  • 当查询分页深度较大时,汇总数据过多时,会对内存和CPU产生非常大的压力,因此ES会禁止form + size > 10000的请求

img

小结

  • 分页查询的常见实现方案以及优缺点
    • from + size:
      • 优点:支持随机翻页
      • 缺点:深度分页问题,默认查询上限是10000(from + size)
      • 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索(百度现在支持翻页到75页,然后显示提示:限于网页篇幅,部分结果未予显示。)
    • after search:
      • 优点:没有查询上限(单词查询的size不超过10000)
      • 缺点:只能向后逐页查询,不支持随机翻页
      • 场景:没有随机翻页需求的搜索,例如手机的向下滚动翻页
    • scroll:
      • 优点:没有查询上限(单词查询的size不超过10000)
      • 缺点:会有额外内存消耗,并且搜索结果是非实时的(快照保存在内存中,不可能每搜索一次都更新一次快照)
      • 场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议使用after search方案

高亮

高亮原理

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

  • 高亮显示的实现分为两步

    1. 给文档中的所有关键字都添加一个标签,例如<em>标签
    2. 页面给<em>标签编写CSS样式
    3. 但默认情况下就是加的<em>标签,所以我们也可以省略

实现高亮

高亮的语法

GET /indexName/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  },
  "highlight": {
    "fields": {
      "FIELD": {
        "pre_tags": "<em>",
        "post_tags": "</em>"
      }
    }
  }
}

注意:

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

示例

GET /hotel/_search
{
  "query": {
    "match": {
      "all": "上海如家"
    }
  },
  "highlight": {
    "fields": {
      "name": {
        "pre_tags": "<em>",
        "post_tags": "</em>",
        "require_field_match": "false" // 因为这里name非搜索字段(虽然在all里面)
      }
    }
  }
}

小结

  • 查询的DSL是一个大的JSON对象,包含以下属性
    • query:查询条件
    • from和size:分页条件
    • sort:排序条件
    • highlight:高亮条件

img