ElasticSearch学习笔记

简介

相比较myslq而言,在搜索方面的局限性:

  • 性能低(在没有索引、模糊查询时)
  • 没有排名能力
  • 无法全文检索
  • 所搜不准确,没有分词

全文检索

一般数据分为两种:结构化数据和非结构化数据。

  • 结构化数据:具有固定长度或者有限长度的数据,如数据库、元数据等。
  • 非结构化数据:指不定长或无固定格式的数据,如邮件,word文档等。

非结构化数据有也叫做全文数据。

按照数据的分类,搜索也分为两种:

  • 对结构化数据的搜索:例如对数据库的搜索,用SQL语句,再如对元数据的搜索,例如用windows搜索对文件名、类型、修改时间进行搜索等。
  • 对非结构化数据的搜索:利用windows的搜索搜索文件内容,Linux下的grep命令,或者用搜索引擎搜索大量内容数据。

非结构化数据的搜索,即全文数据的搜索主要由两种方法:

  • 顺序扫描法:比如找内容包含一个字符串的文件,就是一个文档一个文档的看,对于每一个文档,从头看到为,进行匹配,直到扫描完所有的文件。这种方式在文件量大,数据量大时,比较慢。

  • 全文检索:将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对此有一定结构的数据进行搜索,从而达到搜索相对较快的目的。这部分从非结构化数据中提取出的然后重新组织的信息,我们称之索引

    例如:字典。字典的拼音表和部首检字表就相当于字典的索引,对每一个字的解释是非结构化的,如果字典没有音节表和部首检字表,在茫茫辞海中找一个字只能顺序扫描。然而字的某些信息可以提取出来进行结构化处理,比如读音,就比较结构化,分声母和韵母,分别只有几种可以一一列举,于是将读音拿出来按一定的顺序排列,每一项读音都指向此字的详细解释的页数。我们搜索时按结构化的拼音搜到读音,然后按其指向的页数,便可找到我们的非结构化数据——也即对字的解释。

这种先建立索引,再对索引进行搜索的过程就叫全文检索(Full-text Search)。

虽然创建索引的过程也是非常耗时的,但是索引一旦创建就可以多次使用,全文检索主要处理的是查询,所以耗时间创建索引是值得的。(在少些多读的场景下非常具有优势)

Lucene

提到全文检索,不得不提到的一个技术就是Lucene,Lucene是apache下的一个开放源代码的全文检索引擎工具包。提供了完整的查询引擎和索引引擎,部分文本分析引擎。我们所熟知的全文检索引擎Solr和ES都是基于Lucene的。

img

  1. 绿色表示索引过程,对要搜索的原始内容进行索引构建一个索引库,索引过程包括:

    确定原始内容即要搜索的内容->采集文档->创建文档->分析文档->索引文档

  2. 红色表示搜索过程,从索引库中搜索内容,搜索过程包括:

    用户通过搜索界面->创建查询->执行搜索,从索引库搜索->渲染搜索结果

创建索引

也就是对文档索引的过程,将用户要搜索的文档内容进行索引,索引存储在索引库(index)中。

比如刚才的这些文档:

img

我们要分析其中所有的单词,将单词、文档名建立映射关系。

(对于单词的切分包括了对原始文档提取单词、去除停用词等过程,这个过程被称为分词)

我们分析其中的一篇文档Lucene.txt

原文档内容:

1
Lucene is a Java full-text search engine.  Lucene is not a complete application, but rather a code library and API that can easily be used to add search capabilities to applications.

我们可以分析后得到语汇单元:

1
lucene、java、full、search、engine。。。。

另一个文档flink.txt加入几个单词:

1
java flink kakfa

我们也可以得到语汇单元:

1
java flink kakfa

这样我们就建立了映射关系,lucenejavafullsearchLucene.txt中,而flink不在Lucene.txt中,但是在flink.txt中。java即在Lucene.txt中,也在flink.txt中。

img

那当我们查找lucene这个词,就在Lucene.txt中,但是查找java时可以获悉其在这两个文件中。

创建索引是对语汇单元索引,通过词语找文档,这种索引的结构就叫做叫倒排索引结构。

传统方法是根据文件找到该文件的内容,在文件内容中匹配搜索关键字,这种方法是顺序扫描方法,数据量大、搜索慢。

倒排索引结构是根据内容(词语)找文档,如下图:

img

倒排索引结构也叫反向索引结构,包括索引和文档两部分,索引即词汇表,它的规模较小,而文档集合较大。

有倒排索引,对应肯定,有正向索引。 正向索引其实就是顺序扫描所有文件,这样本身效率是极低的。

查询索引

查询索引也是搜索的过程。搜索就是用户输入关键字,从索引(index)中进行搜索的过程。根据关键字搜索索引,根据索引找到对应的文档,从而找到要搜索的内容(这里指磁盘上的文件)。

我们这里就是通过查询索引表,找到文档所在的位置,就完成了查询,但其他的场景可以灵活的把查询出来的结果展示出去,比如我们的百度搜索时,为我们展示的是相关网页。

img

Elasticsearch

Elasticsearch 是一个分布式可扩展实时搜索数据分析引擎。 它能从项目一开始就赋予你的数据以搜索、分析和探索的能力,这是通常没有预料到的。 它存在还因为原始数据如果只是躺在磁盘里面根本就毫无用处。

Elasticsearch 是一个开源的搜索引擎,建立在一个全文搜索引擎库 Apache Lucene™ 基础之上。 Lucene 可以说是当下最先进、高性能、全功能的搜索引擎库—无论是开源还是私有。

但是 Lucene 仅仅只是一个库。为了充分发挥其功能,你需要使用 Java 并将 Lucene 直接集成到应用程序中。 更糟糕的是,您可能需要获得信息检索学位才能了解其工作原理。Lucene 非常 复杂。

Elasticsearch 也是使用 Java 编写的,它的内部使用 Lucene 做索引与搜索,但是它的目的是使全文检索变得简单, 通过隐藏 Lucene 的复杂性,取而代之的提供一套简单一致的 RESTful API。

然而,Elasticsearch 不仅仅是 Lucene,并且也不仅仅只是一个全文搜索引擎。 它可以被下面这样准确的形容:

  • 一个分布式的实时文档存储,每个字段 可以被索引与搜索
  • 一个分布式实时分析搜索引擎
  • 能胜任上百个服务节点的扩展,并支持 PB 级别的结构化或者非结构化数据

Elasticsearch 将所有的功能打包成一个单独的服务,这样你可以通过程序与它提供的简单的 RESTful API 进行通信, 可以使用自己喜欢的编程语言充当 web 客户端,甚至可以使用命令行(去充当这个客户端)。

安装

使用docker最为方便

1
docker run -d --name elasticsearch -p 9200:9200 -p 9300:9300 -e ES_JAVA_OPS="-Xms256m -Xmx256m" -e "discovery.type=single-node" -e "xpack.security.enabled=false" -d elasticsearch:7.17.0

使用7.17.0版本,可以兼容更多操作系统和CPU

discovery.type=single-node 单机模式

xpack.security.enabled=false 关闭不安全的提示,以防后续交互出现提示 Elasticsearch built-in security features are not enabled

交互

与es的交互方式可以使用对应语言的sdk,或者通过RESTful

1
curl -X<VERB> '<PROTOCOL>://<HOST>:<PORT>/<PATH>?<QUERY_STRING>' -d '<BODY>'

< > 标记的部件:

VERB 适当的 HTTP 方法谓词 : GETPOSTPUTHEAD 或者 DELETE
PROTOCOL http 或者 https(如果你在 Elasticsearch 前面有一个 https 代理)
HOST Elasticsearch 集群中任意节点的主机名,或者用 localhost 代表本地机器上的节点。
PORT 运行 Elasticsearch HTTP 服务的端口号,默认是 9200
PATH API 的终端路径(例如 _count 将返回集群中文档数量)。Path 可能包含多个组件,例如:_cluster/stats_nodes/stats/jvm
QUERY_STRING 任意可选的查询字符串参数 (例如 ?pretty 将格式化地输出 JSON 返回值,使其更容易阅读)
BODY 一个 JSON 格式的请求体 (如果请求需要的话)

例如,计算集群中文档的数量,我们可以用这个:

1
2
3
4
5
6
7
curl -XGET 'http://localhost:9200/_count?pretty' -d '
{
"query": {
"match_all": {}
}
}
'

为了更好的学习,可以一并安装图形还操作界面kibana

1
docker run -d --name kibana -e ELASTICSEARCH_HOSTS="http://192.168.41.68:9200" -p 5601:5601 kibana:7.17.0

需要注意,kibana的版本需要与es版本一致,另外,需要指向能够访问到eshosts,这里使用docker,则使用主机。(不能使用容器的IP,这是因为容器IP不固定)

服务正常之后,可以通过http://127.0.0.1:5601打开页面,点击首页的Try sample data添加简单数据用于学习。

点击Dev Tools进行操作。

image-20221010172947826

概念

es 7版本之前

index:相当于mysql中的database

type:相当于mysql中的table

但是在 es 7及之后,type概念被弱化,固定为 _doc,因此,在 es 7中,index可以理解为mysql中的table

es 7中使用_doc时,会出现提示 #! [types removal] Specifying types in document index requests is deprecated, use the typeless endpoints instead (/{index}/_doc/{id}, /{index}/_doc, or /{index}/_create/{id}).

document:相当于mysql中的row,也就是单个数据

field:相当于mysql中的column,也就是一个字段

mapping:相当于mysql中的schema,也就是字段类型映射

DSL :Descriptor Structure Language描述结构性语言,相当于mysql中的sql

倒排索引:先将存储的数据转换成小写并且分词(英文分词使用空格,中文分词需要使用分词器),将分词后的词语,作为索引,而文档id则作为该索引指向的内容。当一个文档中,该分词比较多,则该文档在这个索引中的得分更高。

查询时,会将查询的内容进行分词,然后将分词后的数据进行查询。

索引

​ 动词:插入一个新的数据

​ 名词:index,也就是mysql中的table

操作

查看索引

1
GET /_cat/indices?v

添加数据

提交以下索引请求,将单个日志条目添加到logs-my_app-default数据流。由于logs-my_app-default不存在,请求使用内置的logs-*.*索引模板自动创建它。

1
2
3
4
5
6
PUT /website/_doc/123?pretty // 使用put,则需要加id
{
"title": "My first blog entry",
"text": "Just trying this out...",
"date": "2014/01/01"
}

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "website", // 索引名称
"_type" : "_doc",
"_id" : "123", // 唯一id
"_version" : 1, // 是一个序列号,用于计算文档更新的次数,如果再次操作,版本号+1,也用于乐观锁控制
"result" : "created", // 操作是创建,如果再次操作,就是更新
"_shards" : { // 分片
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 2, // 是一个序号,用于计算索引上发生的操作次数,如果文档被再次操作,序列号+1
"_primary_term" : 1
}

如果你的数据没有自然的 ID, Elasticsearch 可以帮我们自动生成 ID 。 请求的结构调整为: 不再使用 PUT 谓词(“使用这个 URL 存储这个文档”), 而是使用 POST 谓词(“存储文档在这个 URL 命名空间下”)。

1
2
3
4
5
6
7
POST /logs-my_app-default/_doc?pretty 
{
"@timestamp": "2099-05-06T16:21:15.000Z",
"event": {
"original": "192.0.2.42 - - [06/May/2099:16:21:15 +0000] \"GET /images/bg.jpg HTTP/1.0\" 200 24736"
}
}

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : ".ds-logs-my_app-default-2022.10.10-000001", // 自动生成的索引名称
"_type" : "_doc",
"_id" : "ti9TwYMB9PxJNpeQJALk", // 唯一的文档id
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 0,
"_primary_term" : 1
}

PUT不允许不带ID

没有就创建,有就报错

1
2
3
4
5
6
PUT /website/_create/2?pretty
{
"title": "My first blog entry",
"text": "Just trying this out...",
"date": "2014/01/01"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"error" : {
"root_cause" : [
{
"type" : "version_conflict_engine_exception",
"reason" : "[2]: version conflict, document already exists (current version [1])",
"index_uuid" : "7t50kag9Q1ymSFZ2K-ZquQ",
"shard" : "0",
"index" : "website"
}
],
"type" : "version_conflict_engine_exception",
"reason" : "[2]: version conflict, document already exists (current version [1])",
"index_uuid" : "7t50kag9Q1ymSFZ2K-ZquQ",
"shard" : "0",
"index" : "website"
},
"status" : 409
}

获取数据

获取单条数据

1
GET website/_doc/1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index": "website",
"_type": "_doc",
"_id": "1",
"_version": 1,
"_seq_no": 25,
"_primary_term": 1,
"found": true, // 是否发现,如果文档没有,则为false,并且请求返回 404
"_source": {
"title": "My first blog entry",
"text": "Just trying this out...",
"date": "2014/01/01"
}
}

或者

1
GET website/_source/1
1
2
3
4
5
{
"title": "My first blog entry",
"text": "Just trying this out...",
"date": "2014/01/01"
}

查询数据

查询所有的索引

1
GET _search?q=body
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"took": 98, // 花费时间,单位ms
"timed_out": false,
"_shards": {
"total": 10,
"successful": 10,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 197, // 一共查询到的文档数
"relation": "eq" // 查询条件,等于
},
"max_score": 4.2396765, // 最大打分
"hits": [ // 查询到的文档数详情
...
]
}
}

或者使用body查询

1
2
3
4
5
6
GET _search
{
"query": {
"match_all": {}
}
}
  • 空搜索

更新数据

更新全部,更新全部会覆盖其他的字段

1
2
3
4
5
6
PUT /website/_doc/123  // 使用POST也可以
{
"title": "My first blog entry",
"text": "I am starting to get the hang of this...",
"date": "2014/01/02"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index": "website",
"_type": "blog",
"_id": "123",
"_version": 3, // 版本号
"result": "updated", // 更新
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 27,
"_primary_term": 1
}

更新部分,是替换或者新增的部分

1
2
3
4
5
6
7
POST /website/blog/1/_update
{
"doc" : { // 更新的是 document 中的数据
"tags" : [ "testing" ],
"views": 0
}
}

更新整个文档 , 我们已经介绍过 更新一个文档的方法是检索并修改它,然后重新索引整个文档,这的确如此。然而,使用 update API 我们还可以部分更新文档,例如在某个请求时对计数器进行累加。

我们也介绍过文档是不可变的:他们不能被修改,只能被替换。 update API 必须遵循同样的规则。 从外部来看,我们在一个文档的某个位置进行部分更新。然而在内部, update API 简单使用与之前描述相同的 检索-修改-重建索引 的处理过程。 区别在于这个过程发生在分片内部,这样就避免了多次请求的网络开销。通过减少检索和重建索引步骤之间的时间,我们也减少了其他进程的变更带来冲突的可能性。

update 请求最简单的一种形式是接收文档的一部分作为 doc 的参数, 它只是与现有的文档进行合并。对象被合并到一起,覆盖现有的字段,增加新的字段。

部分更新下,如果更新部分没有更新,则不会执行更新操作。

1
2
3
4
5
6
7
POST /website/blog/1/_update
{
"doc" : {
"tags" : [ "testing" ],
"views": 123
}
}

多次更新结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index": "website",
"_type": "blog",
"_id": "1",
"_version": 4, // 版本号不更新
"result": "noop", // 结果没有变化
"_shards": {
"total": 0,
"successful": 0,
"failed": 0
},
"_seq_no": 36, // 序列号不更新
"_primary_term": 1
}

通过脚本更新

脚本可以在 update API中用来改变 _source 的字段内容, 它在更新脚本中称为 ctx._source 。 例如,我们可以使用脚本来增加博客文章中 views 的数量:

1
2
3
4
POST /website/blog/1/_update
{
"script" : "ctx._source.views+=1"
}

删除

1
DELETE /website/blog/1

批量操作

1
2
3
4
5
6
7
8
POST /_bulk
{"delete":{"_index":"website","_type":"blog","_id":"123"}} // 删除操作
{"create":{"_index":"website","_type":"blog","_id":"123"}} // 创建操作
{"title":"My first blog post"} // 创建操作的详细内容
{"index":{"_index":"website","_type":"blog"}} // 索引操作
{"title":"My second blog post"}
{"update":{"_index":"website","_type":"blog","_id":"123"}} // 更新操作,局部更新
{"doc":{"title":"My updated blog post"}} // 局部更新的内容

注意,不要格式化。

create

  • 如果待插入文档指定了文档_id,就检查文档_id是否存在,存在则插入失败。

index

  • 如果待插入文档指定了文档_id,就检查文档是否存在,不存在就插入,存在就检查_version。
  • 如果待插入文档没有指定了_version,文档的_version递增;如果待插入文档指定了_version,与原文档_version一致,覆盖成功,否者插入失败。

update

  • 每次update都会获取整个文档信息,然后对特定字段进行修改,这也导致会遍历一遍原始文档,性能会有很大的影响。

由于Lucene中的update其实就是覆盖替换,并不支持针对特定Field进行修改,Elasticsearch中的update为了实现针对特定字段修改,在Lucene的基础上做了一些改动。

这个 Elasticsearch 响应包含 items 数组,这个数组的内容是以请求的顺序列出来的每个请求的结果。

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
69
70
71
72
73
74
75
#! [types removal] Specifying types in bulk requests is deprecated.
{
"took" : 5,
"errors" : false,
"items" : [
{
"delete" : {
"_index" : "website",
"_type" : "blog",
"_id" : "123",
"_version" : 29,
"result" : "deleted",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 89,
"_primary_term" : 1,
"status" : 200
}
},
{
"create" : {
"_index" : "website",
"_type" : "blog",
"_id" : "123",
"_version" : 30,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 90,
"_primary_term" : 1,
"status" : 201
}
},
{
"index" : {
"_index" : "website",
"_type" : "blog",
"_id" : "xy_0wYMB9PxJNpeQ4QL9",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 91,
"_primary_term" : 1,
"status" : 201
}
},
{
"update" : {
"_index" : "website",
"_type" : "blog",
"_id" : "123",
"_version" : 31,
"result" : "updated",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 92,
"_primary_term" : 1,
"status" : 200
}
}
]
}

每个子请求都是独立执行,因此某个子请求的失败不会对其他子请求的成功与否造成影响。 如果其中任何子请求失败,最顶层的 error 标志被设置为 true ,并且在相应的请求报告出错误明细:

1
2
3
4
5
POST /_bulk
{ "create": { "_index": "website", "_type": "blog", "_id": "123" }}
{ "title": "Cannot create - it already exists" }
{ "index": { "_index": "website", "_type": "blog", "_id": "123" }}
{ "title": "But we can update it" }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"took": 3,
"errors": true,
"items": [
{ "create": {
"_index": "website",
"_type": "blog",
"_id": "123",
"status": 409,
"error": "DocumentAlreadyExistsException
[[website][4] [blog][123]:
document already exists]"
}},
{ "index": {
"_index": "website",
"_type": "blog",
"_id": "123",
"_version": 5,
"status": 200
}}
]
}

批量获取

例如数据在多个index中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /_mget
{
"docs" : [
{
"_index" : "website",
"_type" : "blog",
"_id" : 2
},
{
"_index" : "website",
"_type" : "pageviews",
"_id" : 1,
"_source": "views"
}
]
}

结果

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
#! [types removal] Specifying types in multi get requests is deprecated.
{
"docs" : [
{
"_index" : "website",
"_type" : "blog",
"_id" : "2",
"_version" : 1,
"_seq_no" : 26,
"_primary_term" : 1,
"found" : true,
"_source" : {
"title" : "My first blog entry",
"text" : "Just trying this out...",
"date" : "2014/01/01"
}
},
{
"_index" : "website",
"_type" : "pageviews",
"_id" : "1",
"found" : false // 没有获取到
}
]
}

分页

1
GET /_search?size=5&from=10

考虑到分页过深以及一次请求太多结果的情况,结果集在返回之前先进行排序。 但请记住一个请求经常跨越多个分片,每个分片都产生自己的排序结果,这些结果需要进行集中排序以保证整体顺序是正确的。

在分布式系统中深度分页

理解为什么深度分页是有问题的,我们可以假设在一个有 5 个主分片的索引中搜索。 当我们请求结果的第一页(结果从 1 到 10 ),每一个分片产生前 10 的结果,并且返回给 协调节点 ,协调节点对 50 个结果排序得到全部结果的前 10 个。

现在假设我们请求第 1000 页—结果从 10001 到 10010 。所有都以相同的方式工作除了每个分片不得不产生前10010个结果以外。 然后协调节点对全部 50050 个结果排序最后丢弃掉这些结果中的 50040 个结果。

可以看到,在分布式系统中,对结果排序的成本随分页的深度成指数上升。这就是 web 搜索引擎对任何查询都不要返回超过 1000 个结果的原因。

query DSL 查询

elasticsearch的查询操作非常丰富,官方文档Query DSL

match 匹配查询

match的查询,是先将查询条件的内容进行分词,然后进行查询,将查询的结果通过分数排序。

match 全文查询

大小写不敏感,会将大写全部转换为小写

匹配查询 match 是个 核心 查询。无论需要查询什么字段, match 查询都应该会是首选的查询方式。它是一个高级 全文查询 ,这表示它既能处理全文字段,又能处理精确字段。

1
2
3
4
5
6
7
8
GET /kibana_sample_data_ecommerce/_search
{
"query": {
"match": {
"customer_full_name": "underwood"
}
}
}

查询到的内容例如:

1
"customer_full_name" : "Eddie Underwood",

Elasticsearch 执行上面这个 match 查询的步骤是:

  1. 检查字段类型

    标题 customer_full_name 字段是一个 string 类型( analyzed )已分析的全文字段,这意味着查询字符串本身也应该被分析。

  2. 分析查询字符串

    将查询的字符串 underwood 传入标准分析器中,输出的结果是单个项 underwood 。因为只有一个单词项,所以 match 查询执行的是单个底层 term 查询。

  3. 查找匹配文档

    term 查询在倒排索引中查找 underwood 然后获取一组包含该项的文档,本例的结果是文档:1、2 和 3 。

  4. 为每个文档评分

    term 查询计算每个文档相关度评分 _score ,这是种将词频(term frequency,即词 underwood 在相关文档的 title 字段中出现的频率)和反向文档频率(inverse document frequency,即词 underwood 在所有文档的 title 字段中出现的频率),以及字段的长度(即字段越短相关度越高)相结合的计算方式。参见 相关性的介绍

match_phrase 近似匹配

就像 match 查询对于标准全文检索是一种最常用的查询一样,当你想找到彼此邻近搜索词的查询方法时,就会想到 match_phrase 查询。

1
2
3
4
5
6
7
8
GET /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": "quick brown fox"
}
}
}

类似 match 查询, match_phrase 查询首先将查询字符串解析成一个词项列表,然后对这些词项进行搜索,但只保留那些包含 全部 搜索词项,且 位置 与搜索词项相同的文档。 比如对于 quick fox 的短语搜索可能不会匹配到任何文档,因为没有文档包含的 quick 词之后紧跟着 fox

multi_match 多字段查询

multi_match 查询为能在多个字段上反复执行相同查询提供了一种便捷方式。

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"multi_match" : {
"query": "this is a test",
"fields": [ "subject", "message" ]
}
}
}

可以使用 ^ 字符语法为单个字段提升权重,在字段名称的末尾添加 ^boost ,其中 boost 是一个浮点数:

1
2
3
4
5
6
{
"multi_match": {
"query": "Quick brown fox",
"fields": [ "*_title", "chapter_title^2" ] // 包含chapter_title 打分双倍
}
}
query_string 查询

match类似,match需要指定字段名,query_string是所有字段中搜索,范围更广泛

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"query_string": {
"query": "(new york city) OR (big apple)", // 查询 (new york city) 或者 (big apple)
"default_field": "content" // 默认字段,默认为*,也就是所有字段
}
}
}
match_all 查询所有数据

最简单的查询,它匹配所有文档,将所有文档的_score设置为1.0。

1
2
3
4
5
6
GET /_search
{
"query": {
"match_all": {}
}
}

Term-level 精确查询

term级别查询是指不会分词的查询(查询内容部分词,同时也不会转换成小写),用法与mysql关系型数据库的用法很相似。

可以使用术语级查询根据结构化数据中的精确值查找文档。结构化数据的示例包括日期范围、IP地址、价格或产品ID

与全文查询不同,术语级查询不分析搜索术语。相反,术语级查询与字段中存储的确切术语相匹配。

term 精确查询
1
2
3
4
5
6
7
8
9
10
11
GET /_search
{
"query": {
"term": {
"user.id": {
"value": "kimchy",
"boost": 1.0 // 分数权重
}
}
}
}

例如

1
2
3
4
5
6
7
8
9
10
GET /kibana_sample_data_ecommerce/_search
{
"query": {
"term": {
"customer_full_name": {
"value": "eddie"
}
}
}
}

结果

1
"customer_full_name" : "Eddie Underwood",

而如果搜索内容改成

1
2
Eddie  // 无法查询到
Eddie Underwood // 无法查询到

这是由于文档存储时,做了分词并且转换成小写,因此只有eddie能搜索到。所以,term查询一般用于存储时,不进行分词的字段,例如上面说的日期范围、IP地址、价格或产品ID

range 范围查询
1
2
3
4
5
6
7
8
9
10
11
12
GET /_search
{
"query": {
"range": {
"age": { // 字段
"gte": 10, // 大于等于
"lte": 20, // 小于等于
"boost": 2.0 // 打分
}
}
}
}
exists 字段存在查询
1
2
3
4
5
6
7
8
GET /_search
{
"query": {
"exists": {
"field": "user" // 查询包含user字段的文档
}
}
}
fuzzy 模糊查询

返回包含与搜索词类似的词的文档,这些词由Levenshtein编辑距离度量。(字符串编辑距离(Levenshtein距离)算法
编辑距离是将一个术语转换为另一个术语所需的字符更改数。这些变化包括:

1
2
3
4
5
6
7
8
9
10
GET /_search
{
"query": {
"fuzzy": {
"user.id": {
"value": "ki"
}
}
}
}

ps:在match查询中,也可以用模糊查询,原理是分词之后再进行模糊。

Compound 组合查询

bool 查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
POST _search
{
"query": {
"bool" : {
"must" : { // 必须匹配,查询上下文,加分
"term" : { "user.id" : "kimchy" }
},
"filter": { // 必须匹配,过滤上下文,过滤
"term" : { "tags" : "production" }
},
"must_not" : { // 必须不匹配,过滤上下文,过滤
"range" : {
"age" : { "gte" : 10, "lte" : 20 }
}
},
"should" : [ // 应该匹配,查询上下文,加分
{ "term" : { "tags" : "env1" } }, // 内部语句,代表两个条件
{ "term" : { "tags" : "deployed" } }
],
"minimum_should_match" : 1,
"boost" : 1.0
}
}
}

bool查询采用更多匹配项是更好的方法,因此每个匹配的mustshould子句的分数将相加,以提供每个文档的最终_score

Mapping 映射

映射是定义文档及其包含的字段如何存储和索引的过程。

Mapping

映射

其中,比较重要的是字符串中的text类型和keyword类型。

如果typekeyword,则数据存储时,不会进行分词,如果在text,则会进行分词存储。

例如

1
GET /kibana_sample_data_ecommerce
1
2
3
4
5
6
7
8
9
"customer_full_name" : {
"type" : "text", // text类型,会进行分词
"fields" : {
"keyword" : {
"type" : "keyword", // 内嵌一个类型,不分词
"ignore_above" : 256
}
}
},
1
2
3
4
5
// 当存储时
"customer_full_name" : "Eddie Underwood",
// 其实是
"customer_full_name": "eddie","underwood"
"customer_full_name.keyword": "Eddie Underwood"

那么,可以通过进行检索

1
2
3
4
5
6
7
8
GET /kibana_sample_data_ecommerce/_search
{
"query": {
"match": {
"customer_full_name.keyword": "Eddie Underwood"
}
}
}

手动创建映射

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT /kibana_sample_data_ecommerce_test
{
"mappings": {
"properties": {
"age": {
"type": "integer"
},
"address": {
"type": "text"
}
}
}
}

Analyzer 分析器

分析与分析器

Text analysis

分析 包含下面的过程:

  • 首先,将一块文本分成适合于倒排索引的独立的 词条
  • 之后,将这些词条统一化为标准格式以提高它们的“可搜索性”,或者 recall

分析器执行上面的工作。 分析器 实际上是将三个功能封装到了一个包里:

  • 字符过滤器

    首先,字符串按顺序通过每个 字符过滤器 。他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉HTML,或者将 & 转化成 and

  • 分词器

    其次,字符串被 分词器 分为单个的词条。一个简单的分词器遇到空格和标点的时候,可能会将文本拆分成词条。

  • Token 过滤器

    最后,词条按顺序通过每个 token 过滤器 。这个过程可能会改变词条(例如,小写化 Quick ),删除词条(例如, 像 aandthe 等无用词),或者增加词条(例如,像 jumpleap 这种同义词)。

Elasticsearch提供了开箱即用的字符过滤器、分词器和token 过滤器。 这些可以组合起来形成自定义的分析器以用于不同的目的。我们会在 自定义分析器 章节详细讨论。

测试分词器

1
2
3
4
5
GET /_analyze
{
"analyzer": "standard", // 分词器
"text": "Text to analyze"
}

结果中每个元素代表一个单独的词条:

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
{
"tokens" : [
{
"token" : "text",
"start_offset" : 0,
"end_offset" : 4,
"type" : "<ALPHANUM>",
"position" : 0
},
{
"token" : "to",
"start_offset" : 5,
"end_offset" : 7,
"type" : "<ALPHANUM>",
"position" : 1
},
{
"token" : "analyze",
"start_offset" : 8,
"end_offset" : 15,
"type" : "<ALPHANUM>",
"position" : 2
}
]
}

内置分词器:Built-in analyzer reference

如何确定分词器:How Elasticsearch determines the search analyzer

中文分词器

例如,使用标准分词器分词中文

1
2
3
4
5
GET /_analyze
{
"analyzer": "standard",
"text": "中国"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"tokens" : [
{
"token" : "中",
"start_offset" : 0,
"end_offset" : 1,
"type" : "<IDEOGRAPHIC>",
"position" : 0
},
{
"token" : "国",
"start_offset" : 1,
"end_offset" : 2,
"type" : "<IDEOGRAPHIC>",
"position" : 1
}
]
}

此时分词效果比较差,因此需要针对中文指定特定的分词器。

几种中文分词器:

  • Jieba
  • SnowNLP
  • PkuSeg
  • THULAC
  • pyhanlp
  • ik-analyzer

安装ik分词器

过程见官方文档:https://github.com/medcl/elasticsearch-analysis-ik

需要注意:下载版本需要与es版本一致;如果是手动下载和拷贝到容器中,拷贝到plugins目录下后,需要修改目录名称

1
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.17.0/elasticsearch-analysis-ik-7.17.0.zip

安装后,重启docker,重启后,可以看到插件

1
2
sh-5.0# ./bin/elasticsearch-plugin list
analysis-ik

注意:

  • 移除名为 ik 的analyzer和tokenizer,请分别使用 ik_smartik_max_word
1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_smart",
"text": "中华人民共和国"
}
1
2
3
4
5
6
7
8
9
10
11
{
"tokens" : [
{
"token" : "中华人民共和国",
"start_offset" : 0,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 0
}
]
}
1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "中华人民共和国"
}
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
{
"tokens" : [
{
"token" : "中华人民共和国",
"start_offset" : 0,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "中华人民",
"start_offset" : 0,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "中华",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "华人",
"start_offset" : 1,
"end_offset" : 3,
"type" : "CN_WORD",
"position" : 3
},
{
"token" : "人民共和国",
"start_offset" : 2,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 4
},
{
"token" : "人民",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 5
},
{
"token" : "共和国",
"start_offset" : 4,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 6
},
{
"token" : "共和",
"start_offset" : 4,
"end_offset" : 6,
"type" : "CN_WORD",
"position" : 7
},
{
"token" : "国",
"start_offset" : 6,
"end_offset" : 7,
"type" : "CN_CHAR",
"position" : 8
}
]
}

注意:mapping中设置好分词器,后续无法再修改

丰富词库

例如需要指定某个词语的分词方式,例如将小夜时雨设置不分词

1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_smart",
"text": "小夜时雨的"
}
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
{
"tokens" : [
{
"token" : "小",
"start_offset" : 0,
"end_offset" : 1,
"type" : "CN_CHAR",
"position" : 0
},
{
"token" : "夜",
"start_offset" : 1,
"end_offset" : 2,
"type" : "CN_CHAR",
"position" : 1
},
{
"token" : "时",
"start_offset" : 2,
"end_offset" : 3,
"type" : "CN_CHAR",
"position" : 2
},
{
"token" : "雨",
"start_offset" : 3,
"end_offset" : 4,
"type" : "CN_CHAR",
"position" : 3
},
{
"token" : "的",
"start_offset" : 4,
"end_offset" : 5,
"type" : "CN_CHAR",
"position" : 4
}
]
}

进入容器目录

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
docker exec -it elasticsearch bash
cd config/analysis-ik/
mdkir mine
cd mine
vi analyzer.dic
cat analyzer.dic
小夜时雨 // 键入分词
cat analyzer_extra.dic
的 // 键入忽略词语
cd ..
vi IKAnalyzer.cfg.xml
cat 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">mine/analyzer.dic</entry> // 配置路径
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords">mine/analyzer_extra.dic</entry> // 配置忽略词路径
<!--用户可以在这里配置远程扩展字典 -->
<!-- <entry key="remote_ext_dict">words_location</entry> -->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>
exit
// 重启容器
docker restart elasticsearch

重启后

1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_smart",
"text": "小夜时雨的"
}
1
2
3
4
5
6
7
8
9
10
11
{
"tokens" : [
{
"token" : "小夜时雨",
"start_offset" : 0,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 0
}
]
}

es还有很多能力,例如聚合、脚本、嵌套存储和检索等,可以查看官方文档。

使用Go交互

使用oliverr的包elastic

官方示例:olivere.github.io/elastic/

1
go get github.com/olivere/elastic/v7
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
package main

import (
"context"
"fmt"
"log"
"os"

"github.com/olivere/elastic/v7"
)

const (
ElasticHost = "127.0.0.1"
ElasticPort = 9200
)

func main() {
ctx := context.Background()

client := NewEsClient()
// 测试
info, code, err := client.Ping("http://127.0.0.1:9200").Do(ctx)
if err != nil {
// Handle error
panic(err)
}
log.Printf("Elasticsearch returned with code %d and version %s\n", code, info.Version.Number)
}

func NewEsClient() *elastic.Client {
url := fmt.Sprintf("http://%s:%d", ElasticHost, ElasticPort)
client, err := elastic.NewClient(
elastic.SetSniff(false), // 使用docker场景下,需要设置为false
//elastic 服务地址
elastic.SetURL(url),
// 跟踪日志
elastic.SetTraceLog(log.New(os.Stdout, "", log.LstdFlags)),
// 设置错误日志输出
elastic.SetErrorLog(log.New(os.Stderr, "ELASTIC ", log.LstdFlags)),
// 设置info日志输出
elastic.SetInfoLog(log.New(os.Stdout, "", log.LstdFlags)))
if err != nil {
log.Fatalln("Failed to create elastic client")
}
return client
}

检索

打印检索语句

1
2
3
4
5
6
7
8
9
10
11
query := elastic.NewMatchQuery("customer_full_name", "underwood")
source, err := query.Source()
if err != nil {
log.Fatal(err)
}
data, err := json.Marshal(source)
if err != nil {
log.Fatal(err)
}
// {"match":{"customer_full_name":{"query":"underwood"}}}
log.Printf("%s", data)

使用检索服务

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
type Source struct {
CustomerFullName string `json:"customer_full_name,omitempty"`
CustomerFirstName string `json:"customer_first_name,omitempty"`
CustomerGender string `json:"customer_gender,omitempty"`
}

query := elastic.NewMatchQuery("customer_full_name", "underwood")
res, err := client.Search().
Index("kibana_sample_data_ecommerce").
Query(query).
From(0).
Size(1).
Do(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("结果数量 %d\n", res.Hits.TotalHits.Value)
for _, hit := range res.Hits.Hits {
var source Source
err = json.Unmarshal(hit.Source, &source)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", source)
}

可以看到数据结构与结果一致。

1
2
结果数量 59
{CustomerFullName:Eddie Underwood CustomerFirstName:Eddie CustomerGender:MALE}

新增

1
2
3
4
5
6
7
8
9
10
11
12
13
14
user := Source{
CustomerFullName: "Eddie Underwood",
CustomerFirstName: "Eddie",
CustomerGender: "MALE",
}

result, err := client.Index().
Index("kibana_sample_data_ecommerce").
BodyJson(user).
Do(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("插入结果: %s", result.Result)

新建mapping

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
    const mapping = `{
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "ik_smart"
}
}
}
}`

// Use the IndexExists service to check if a specified index exists.
exists, err := client.IndexExists("twitter").Do(ctx)
if err != nil {
// Handle error
panic(err)
}
if !exists {
// Create a new index.
createIndex, err := client.CreateIndex("twitter").BodyString(mapping).Do(ctx)
if err != nil {
// Handle error
panic(err)
}
if !createIndex.Acknowledged {
// Not acknowledged
}
}

参考文档:

什么是全文检索

Elasticsearch: 权威指南

Kibana 用户手册

Elasticsearch Guide 7.17

版本号和序列号:Sequence IDs: Coming Soon to an Elasticsearch Cluster Near You

查询语法:Query DSL