[读书笔记] ES权威指南

基础入门

轻量搜索

1
GET /megacorp/employee/_search?q=last_name:Smith

使用查询表达式搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /megacorp/employee/_search
{
"query" : {
"bool": {
"must": {
"match" : {
"last_name" : "smith"
}
},
"filter": {
"range" : {
"age" : { "gt" : 30 }
}
}
}
}
}

全文搜索

1
2
3
4
5
6
7
8
GET /megacorp/employee/_search
{
"query" : {
"match" : {
"about" : "rock climbing"
}
}
}

短语搜索

精确匹配一系列单词或者短语

1
2
3
4
5
6
7
8
GET /megacorp/employee/_search
{
"query" : {
"match_phrase" : {
"about" : "rock climbing"
}
}
}

高亮搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /megacorp/employee/_search
{
"query" : {
"match_phrase" : {
"about" : "rock climbing"
}
},
"highlight": {
"fields" : {
"about" : {}
}
}
}

聚合

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /megacorp/employee/_search
{
"aggs" : {
"all_interests" : {
"terms" : { "field" : "interests" },
"aggs" : {
"avg_age" : {
"avg" : { "field" : "age" }
}
}
}
}
}

分布式特性

Elasticsearch 尽可能地屏蔽了分布式系统的复杂性。这里列举了一些在后台自动执行的操作:

分配文档到不同的容器 或 分片 中,文档可以储存在一个或多个节点中
按集群节点来均衡分配这些分片,从而对索引和搜索过程进行负载均衡
复制每个分片以支持数据冗余,从而防止硬件故障导致的数据丢失
将集群中任一节点的请求路由到存有相关数据的节点
集群扩容时无缝整合新节点,重新分配分片以便从离群节点恢复

集群内的原理

主分片的数目在索引创建时 就已经确定了下来。实际上,这个数目定义了这个索引能够 存储 的最大数据量。(实际大小取决于你的数据、硬件和使用场景。) 但是,读操作——搜索和返回数据——可以同时被主分片 或 副本分片所处理,所以当你拥有越多的副本分片时,也将拥有越高的吞吐量。

在运行中的集群上是可以动态调整副本分片数目的 ,我们可以按需伸缩集群。

当然,如果只是在相同节点数目的集群上增加更多的副本分片并不能提高性能,因为每个分片从节点上获得的资源会变少。 你需要增加更多的硬件资源来提升吞吐量。

但是更多的副本分片数提高了数据冗余量:按照上面的节点配置,我们可以在失去2个节点的情况下不丢失任何数据。

如果我们重新启动 Node 1 ,集群可以将缺失的副本分片再次进行分配,那么集群的状态也将如图 5 “将参数 number_of_replicas 调大到 2”所示。 如果 Node 1 依然拥有着之前的分片,它将尝试去重用它们,同时仅从主分片复制发生了修改的数据文件。

数据输入和输出

一个 键 可以是一个字段或字段的名称,一个 值 可以是一个字符串,一个数字,一个布尔值, 另一个对象,一些数组值,或一些其它特殊类型诸如表示日期的字符串,或代表一个地理位置的对象:

文档元数据

一个文档不仅仅包含它的数据 ,也包含 元数据 —— 有关 文档的信息。 三个必须的元数据元素如下:

_index
文档在哪存放
_type
文档表示的对象类别
_id
文档唯一标识

_index
一个 索引 应该是因共同的特性被分组到一起的文档集合。 例如,你可能存储所有的产品在索引 products 中,而存储所有销售的交易到索引 sales 中。 虽然也允许存储不相关的数据到一个索引中,但这通常看作是一个反模式的做法。

提示
实际上,在 Elasticsearch 中,我们的数据是被存储和索引在 分片 中,而一个索引仅仅是逻辑上的命名空间, 这个命名空间由一个或者多个分片组合在一起。 然而,这是一个内部细节,我们的应用程序根本不应该关心分片,对于应用程序而言,只需知道文档位于一个 索引 内。 Elasticsearch 会处理所有的细节。

我们将在 索引管理 介绍如何自行创建和管理索引,但现在我们将让 Elasticsearch 帮我们创建索引。 所有需要我们做的就是选择一个索引名,这个名字必须小写,不能以下划线开头,不能包含逗号。我们用 website 作为索引名举例。

_type编辑
数据可能在索引中只是松散的组合在一起,但是通常明确定义一些数据中的子分区是很有用的。 例如,所有的产品都放在一个索引中,但是你有许多不同的产品类别,比如 “electronics” 、 “kitchen” 和 “lawn-care”。

这些文档共享一种相同的(或非常相似)的模式:他们有一个标题、描述、产品代码和价格。他们只是正好属于“产品”下的一些子类。

Elasticsearch 公开了一个称为 types (类型)的特性,它允许您在索引中对数据进行逻辑分区。不同 types 的文档可能有不同的字段,但最好能够非常相似。 我们将在 类型和映射 中更多的讨论关于 types 的一些应用和限制。

一个 _type 命名可以是大写或者小写,但是不能以下划线或者句号开头,不应该包含逗号, 并且长度限制为256个字符. 我们使用 blog 作为类型名举例。

_id编辑
ID 是一个字符串, 当它和 _index 以及 _type 组合就可以唯一确定 Elasticsearch 中的一个文档。 当你创建一个新的文档,要么提供自己的 _id ,要么让 Elasticsearch 帮你生成。

其他元数据编辑
还有一些其他的元数据元素,他们在 类型和映射 进行了介绍。通过前面已经列出的元数据元素, 我们已经能存储文档到 Elasticsearch 中并通过 ID 检索它–换句话说,使用 Elasticsearch 作为文档的存储介质。

取回一个文档

1
2
3
GET /website/blog/123?pretty #调用 Elasticsearch 的 pretty-print 功能,该功能 使得 JSON 响应体更加可读
GET /website/blog/123?_source=title,text #该 _source 字段现在包含的只是我们请求的那些字段
GET /website/blog/123/_source #只想得到 _source 字段,不需要任何元数据

检查文档是否存在

curl -i -XHEAD http://localhost:9200/website/blog/123

更新整个文档

1
2
3
4
5
6
PUT /website/blog/123
{
"title": "My first blog entry",
"text": "I am starting to get the hang of this...",
"date": "2014/01/02"
}

ps:不存在时,就是创建

仅创建文档

1
2
PUT /website/blog/123/_create
{ ... }

删除文档

1
DELETE /website/blog/123

冲突处理

悲观并发控制
这种方法被关系型数据库广泛使用,它假定有变更冲突可能发生,因此阻塞访问资源以防止冲突。 一个典型的例子是读取一行数据之前先将其锁住,确保只有放置锁的线程能够对这行数据进行修改。
乐观并发控制
Elasticsearch 中使用的这种方法假定冲突是不可能发生的,并且不会阻塞正在尝试的操作。 然而,如果源数据在读写当中被修改,更新将会失败。应用程序接下来将决定该如何解决冲突。 例如,可以重试更新、使用新的数据、或者将相关情况报告给用户。

乐观并发控制

我们可以利用 _version 号来确保 应用中相互冲突的变更不会导致数据丢失。我们通过指定想要修改文档的 version 号来达到这个目的。 如果该版本不是当前版本号,我们的请求将会失败。

1
2
3
4
5
PUT /website/blog/1?version=1
{
"title": "My first blog entry",
"text": "Starting to get the hang of this..."
}

我们想这个在我们索引中的文档只有现在的 _version 为 1 时,本次更新才能成功。

通过外部系统使用版本控制

如果你的主数据库已经有了版本号 — 或一个能作为版本号的字段值比如 timestamp — 那么你就可以在 Elasticsearch 中通过增加 version_type=external 到查询字符串的方式重用这些相同的版本号, 版本号必须是大于零的整数, 且小于 9.2E+18 — 一个 Java 中 long 类型的正值。

外部版本号的处理方式和我们之前讨论的内部版本号的处理方式有些不同, Elasticsearch 不是检查当前 _version 和请求中指定的版本号是否相同, 而是检查当前 _version 是否 小于 指定的版本号。 如果请求成功,外部的版本号作为文档的新 _version 进行存储。

文档是不可变的:他们不能被修改,只能被替换。

部分更新

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

对象被合并到一起,覆盖现有的字段,增加新的字段。

取回多个文档

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

GET /website/blog/_mget
{
"ids" : [ "2", "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", "_retry_on_conflict" : 3} }
{ "doc" : {"title" : "My updated blog post"} }

分布式文档存储

分片选择公式

shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。

相同分片的副本不会放在同一节点

我们可以发送请求到集群中的任一节点。 每个节点都有能力处理任意请求。 每个节点都知道集群中任一文档位置,所以可以直接将请求转发到需要的节点上。

新建、索引和删除 请求都是 写 操作, 必须在主分片上面完成之后才能被复制到相关的副本分片
在客户端收到成功响应时,文档变更已经在主分片和所有副本分片执行完成,变更是安全的。

有一些可选的请求参数允许您影响这个过程,可能以数据安全为代价提升性能。这些选项很少使用,因为Elasticsearch已经很快,但是为了完整起见,在这里阐述如下:https://www.elastic.co/guide/cn/elasticsearch/guide/current/distrib-write.html

在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。

搜索——最基本的工具

映射(Mapping)
描述数据在每个字段内如何存储
分析(Analysis)
全文是如何处理使之可以被搜索的
领域特定查询语言(Query DSL)
Elasticsearch 中强大灵活的查询语言

多索引,多类型

然而,经常的情况下,你 想在一个或多个特殊的索引并且在一个或者多个特殊的类型中进行搜索。我们可以通过在URL中指定特殊的索引和类型达到这种效果,如下所示:

/_search
在所有的索引中搜索所有的类型
/gb/_search
在 gb 索引中搜索所有的类型
/gb,us/_search
在 gb 和 us 索引中搜索所有的文档
/g*,u*/_search
在任何以 g 或者 u 开头的索引中搜索所有的类型
/gb/user/_search
在 gb 索引中搜索 user 类型
/gb,us/user,tweet/_search
在 gb 和 us 索引中搜索 user 和 tweet 类型
/_all/user,tweet/_search
在所有的索引中搜索 user 和 tweet 类型

分页

和 SQL 使用 LIMIT 关键字返回单个 page 结果的方法相同,Elasticsearch 接受 from 和 size 参数:

size
显示应该返回的结果数量,默认是 10
from
显示应该跳过的初始结果数量,默认是 0
如果每页展示 5 条结果,可以用下面方式请求得到 1 到 3 页的结果:

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

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

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

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

当索引一个文档的时候,Elasticsearch 取出所有字段的值拼接成一个大的字符串,作为 _all 字段进行索引。
除非设置特定字段,否则查询字符串就使用 _all 字段进行搜索。

在刚开始开发一个应用时,_all 字段是一个很实用的特性。之后,你会发现如果搜索时用指定字段来代替 _all 字段,将会更好控制搜索结果。当 _all 字段不再有用的时候,可以将它置为失效,正如在 元数据: _all 字段 中所解释的。

下面的查询针对tweents类型,并使用以下的条件:

name 字段中包含 mary 或者 john
date 值大于 2014-09-10
all 字段包含 aggregations 或者 geo
+name:(mary john) +date:>2014-09-10 +(aggregations geo)

不推荐直接向用户暴露查询字符串搜索功能,除非对于集群和数据来说非常信任他们。

相反,我们经常在生产环境中更多地使用功能全面的 request body 查询API,除了能完成以上所有功能,还有一些附加功能。

映射和分析

Elasticsearch 中的数据可以概括的分为两类:精确值和全文。

你只能搜索在索引中出现的词条,所以索引文本和查询字符串必须标准化为相同的格式。

分词和标准化的过程称为 分析(analyze)

分析器 实际上是将三个功能封装到了一个包里:

字符过滤器
首先,字符串按顺序通过每个 字符过滤器 。他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉HTML,或者将 & 转化成 and
分词器
其次,字符串被 分词器 分为单个的词条。一个简单的分词器遇到空格和标点的时候,可能会将文本拆分成词条。
Token 过滤器
最后,词条按顺序通过每个 token 过滤器 。这个过程可能会改变词条(例如,小写化 Quick ),删除词条(例如, 像 aandthe 等无用词),或者增加词条(例如,像 jump 和 leap 这种同义词)。

当你查询一个 全文 域时, 会对查询字符串应用相同的分析器,以产生正确的搜索词条列表。
当你查询一个 精确值 域时,不会分析查询字符串, 而是搜索你指定的精确值。

测试分析器,测试分词结果

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

当Elasticsearch在你的文档中检测到一个新的字符串域 ,它会自动设置其为一个全文 字符串 域,使用 标准 分析器对它进行分析。

Elasticsearch 支持 如下简单域类型:

字符串: string
整数 : byte, short, integer, long
浮点数: float, double
布尔型: boolean
日期: date

当你索引一个包含新域的文档–之前未曾出现– Elasticsearch 会使用 动态映射 ,通过JSON中基本数据类型,尝试猜测域类型,

查看映射
GET /gb/_mapping/tweet

域最重要的属性是 type 。对于不是 string 的域,你一般只需要设置 type :

默认, string 类型域会被认为包含全文。就是说,它们的值在索引前,会通过 一个分析器,针对于这个域的查询在搜索前也会经过一个分析器。

string 域映射的两个最重要 属性是 index 和 analyzer 。

index 属性控制怎样索引字符串。它可以是下面三个值:

analyzed
首先分析字符串,然后索引它。换句话说,以全文索引这个域。
not_analyzed
索引这个域,所以它能够被搜索,但索引的是精确值。不会对它进行分析。
no
不索引这个域。这个域不会被搜索到。

analyzer
对于 analyzed 字符串域,用 analyzer 属性指定在搜索和索引时使用的分析器。默认, Elasticsearch 使用 standard 分析器, 但你可以指定一个内置的分析器替代它,例如 whitespace 、 simple 和 english

当你首次 创建一个索引的时候,可以指定类型的映射。你也可以使用 /_mapping 为新类型(或者为存在的类型更新映射)增加映射。
不能 _修改 存在的域映射。

创建索引的时候指定映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PUT /gb
{
"mappings": {
"tweet" : {
"properties" : {
"tweet" : {
"type" : "string",
"analyzer": "english"
},
"date" : {
"type" : "date"
},
"name" : {
"type" : "string"
},
"user_id" : {
"type" : "long"
}
}
}
}
}

测试映射结果

1
2
3
4
5
GET /gb/_analyze
{
"field": "tweet",
"text": "Black-cats"
}

ps:仅支持fulltext字段

复杂核心域类型

事实上, type 映射只是一种特殊的 对象 映射,我们称之为 根对象 。除了它有一些文档元数据的特殊顶级域,例如 _source 和 _all 域,它和其他对象一样。

Lucene 不理解内部对象。 Lucene 文档是由一组键值对列表组成的。为了能让 Elasticsearch 有效地索引内部类,它把我们的文档转化成这样:

1
2
3
4
5
6
7
8
9
{
"tweet": [elasticsearch, flexible, very],
"user.id": [@johnsmith],
"user.gender": [male],
"user.age": [26],
"user.name.full": [john, smith],
"user.name.first": [john],
"user.name.last": [smith]
}

内部域 可以通过名称引用(例如, first )。为了区分同名的两个域,我们可以使用全 路径 (例如, user.name.first ) 或 type 名加路径( tweet.user.name.first )。

内部对象数组
最后,考虑包含 内部对象的数组是如何被索引的。 假设我们有个 followers 数组:

1
2
3
4
5
6
7
{
"followers": [
{ "age": 35, "name": "Mary White"},
{ "age": 26, "name": "Alex Jones"},
{ "age": 19, "name": "Lisa Smith"}
]
}

这个文档会像我们之前描述的那样被扁平化处理,结果如下所示:

1
2
3
4
{
"followers.age": [19, 26, 35],
"followers.name": [alex, jones, lisa, smith, mary, white]
}

{age: 35} 和 {name: Mary White} 之间的相关性已经丢失了,因为每个多值域只是一包无序的值,而不是有序数组。这足以让我们问,“有一个26岁的追随者?”

但是我们不能得到一个准确的答案:“是否有一个26岁 名字叫 Alex Jones 的追随者?”

相关内部对象被称为 nested 对象,可以回答上面的查询,我们稍后会在嵌套对象中介绍它。

请求体查询

对于一个查询请求,Elasticsearch 的工程师偏向于使用 GET 方式,因为他们觉得它比 POST 能更好的描述信息检索(retrieving information)的行为。然而,因为带请求体的 GET 请求并不被广泛支持,所以 search API 同时支持 POST 请求:

过滤情况(filtering context)和查询情况(query context)。
当使用于 过滤情况 时,查询被设置成一个“不评分”或者“过滤”查询。
当使用于 查询情况 时,查询就变成了一个“评分”的查询.
过滤查询(Filtering queries)只是简单的检查包含或者排除,这就使得计算起来非常快。
通常的规则是,使用 查询(query)语句来进行 全文 搜索或者其它任何需要影响 相关性得分 的搜索。除此以外的情况都使用过滤(filters)。

查询表达式(Query DSL)是一种非常灵活又富有表现力的 查询语言。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET /_search
{
"query": YOUR_QUERY_HERE
}

一个查询语句 的典型结构:

{
QUERY_NAME: {
ARGUMENT: VALUE,
ARGUMENT: VALUE,...
}
}
如果是针对某个字段,那么它的结构如下:

{
QUERY_NAME: {
FIELD_NAME: {
ARGUMENT: VALUE,
ARGUMENT: VALUE,...
}
}
}

无论你在任何字段上进行的是全文搜索还是精确查询,match 查询是你可用的标准查询。
如果你在一个全文字段上使用 match 查询,在执行查询前,它将用正确的分析器去分析查询字符串:
{ “match”: { “tweet”: “About Search” }}
如果在一个精确值的字段上使用它, 例如数字、日期、布尔或者一个 not_analyzed 字符串字段,那么它将会精确匹配给定的值:
{ “match”: { “age”: 26 }}

multi_match 查询可以在多个字段上执行相同的 match 查询:

1
2
3
4
5
6
{
"multi_match": {
"query": "full text search",
"fields": [ "title", "body" ]
}
}

range 查询找出那些落在指定区间内的数字或者时间:

1
2
3
4
5
6
7
8
{
"range": {
"age": {
"gte": 20,
"lt": 30
}
}
}

term 查询对于输入的文本不 分析 ,所以它将给定的值进行精确查询。

terms 查询和 term 查询一样,但它允许你指定多值进行匹配。如果这个字段包含了指定值中的任何一个值,那么这个文档满足条件:

{ “terms”: { “tag”: [ “search”, “full_text”, “nosql” ] }}

exists 查询和 missing 查询被用于查找那些指定字段中有值 (exists) 或无值 (missing) 的文档。这与SQL中的 IS_NULL (missing) 和 NOT IS_NULL (exists) 在本质上具有共性:

1
2
3
4
5
{
"exists": {
"field": "title"
}
}

你可以用 bool 查询来实现你的需求。这种查询将多查询组合在一起,成为用户自己想要的布尔查询。它接收以下参数:

must
文档 必须 匹配这些条件才能被包含进来。
must_not
文档 必须不 匹配这些条件才能被包含进来。
should
如果满足这些语句中的任意语句,将增加 _score ,否则,无任何影响。它们主要用于修正每个文档的相关性得分。
filter
必须 匹配,但它以不评分、过滤模式来进行。这些语句对评分没有贡献,只是根据过滤标准来排除或包含文档。
由于这是我们看到的第一个包含多个查询的查询,所以有必要讨论一下相关性得分是如何组合的。每一个子查询都独自地计算文档的相关性得分。一旦他们的得分被计算出来, bool 查询就将这些得分进行合并并且返回一个代表整个布尔操作的得分。

下面的查询用于查找 title 字段匹配 how to make millions 并且不被标识为 spam 的文档。那些被标识为 starred 或在2014之后的文档,将比另外那些文档拥有更高的排名。如果 两者 都满足,那么它排名将更高:

1
2
3
4
5
6
7
8
9
10
{
"bool": {
"must": { "match": { "title": "how to make millions" }},
"must_not": { "match": { "tag": "spam" }},
"should": [
{ "match": { "tag": "starred" }},
{ "range": { "date": { "gte": "2014-01-01" }}}
]
}
}

如果没有 must 语句,那么至少需要能够匹配其中的一条 should 语句。但,如果存在至少一条 must 语句,则对 should 语句的匹配没有要求。

1
2
3
4
5
6
7
8
9
10
11
12
{
"bool": {
"must": { "match": { "title": "how to make millions" }},
"must_not": { "match": { "tag": "spam" }},
"should": [
{ "match": { "tag": "starred" }}
],
"filter": {
"range": { "date": { "gte": "2014-01-01" }}
}
}
}

过将 range 查询移到 filter 语句中,我们将它转成不评分的查询,将不再影响文档的相关性排名。由于它现在是一个不评分的查询,可以使用各种对 filter 查询有效的优化手段来提升性能。

对于合法查询,使用 explain 参数将返回可读的描述,这对准确理解 Elasticsearch 是如何解析你的 query 是非常有用的:

1
2
3
4
5
6
7
8
GET /_validate/query?explain
{
"query": {
"match" : {
"tweet" : "really powerful"
}
}
}

排序与相关性

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /_search
{
"query" : {
"bool" : {
"must": { "match": { "tweet": "manage text search" }},
"filter" : { "term" : { "user_id" : 2 }}
}
},
"sort": [
{ "date": { "order": "desc" }},
{ "_score": { "order": "desc" }}
]
}

对于数字或日期,你可以将多值字段减为单值,这可以通过使用 min 、 max 、 avg 或是 sum 排序模式 。 例如你可以按照每个 date 字段中的最早日期进行排序,通过以下方法:

1
2
3
4
5
6
"sort": {
"dates": {
"order": "asc",
"mode": "min"
}
}

所有的 _core_field 类型 (strings, numbers, Booleans, dates) 接收一个 fields 参数

该参数允许你转化一个简单的映射如:

1
2
3
4
"tweet": {
"type": "string",
"analyzer": "english"
}

为一个多字段映射如:

1
2
3
4
5
6
7
8
9
10
"tweet": {
"type": "string",
"analyzer": "english",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
}
}

现在,至少只要我们重新索引了我们的数据,使用 tweet 字段用于搜索,tweet.raw 字段用于排序:

1
2
3
4
5
6
7
8
9
GET /_search
{
"query": {
"match": {
"tweet": "elasticsearch"
}
},
"sort": "tweet.raw"
}

以全文 analyzed 字段排序会消耗大量的内存

相关性

查询语句会为每个文档生成一个 _score 字段。评分的计算方式取决于查询类型 不同的查询语句用于不同的目的: fuzzy 查询会计算与关键词的拼写相似程度,terms 查询会计算 找到的内容与关键词组成部分匹配的百分比,但是通常我们说的 relevance 是我们用来计算全文本字段的值相对于全文本检索词相似程度的算法。

Elasticsearch 的相似度算法 被定义为检索词频率/反向文档频率, TF/IDF.

解释为什么匹配或不匹配

1
2
3
4
5
6
7
8
9
GET /us/tweet/12/_explain
{
"query" : {
"bool" : {
"filter" : { "term" : { "user_id" : 2 }},
"must" : { "match" : { "tweet" : "honeymoon" }}
}
}
}

在 Elasticsearch 中,doc values 就是一种列式存储结构,默认情况下每个字段的 doc values 都是激活的,doc values 是在索引时创建的,当字段索引时,Elasticsearch 为了能够快速检索,会把字段的值加入倒排索引中,同时它也会存储该字段的 doc values。

执行分布式检索

搜索被执行成一个两阶段过程,我们称之为 query then fetch
ps:查询阶段+取回阶段

查询阶段
查询阶段包含以下三个步骤:

1.客户端发送一个 search 请求到 Node 3 , Node 3 会创建一个大小为 from + size 的空优先队列。
2.Node 3 将查询请求转发到索引的每个主分片或副本分片中。每个分片在本地执行查询并添加结果到大小为 from + size 的本地有序优先队列中。
3.每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,也就是 Node 3 ,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。

每个分片在本地执行查询请求并且创建一个长度为 from + size 的优先队列—也就是说,每个分片创建的结果集足够大,均可以满足全局的搜索请求。 分片返回一个轻量级的结果列表到协调节点,它仅包含文档 ID 集合以及任何排序需要用到的值,例如 _score 。

协调节点将这些分片级的结果合并到自己的有序优先队列里,它代表了全局排序结果集合。至此查询过程结束。
ps:from 90 size 10 ,需要100长度的队列。

取回阶段
分布式阶段由以下步骤构成:

1.协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。
2.每个分片加载并 丰富 文档,如果有需要的话,接着返回文档给协调节点。
3.一旦所有的文档都被取回了,协调节点返回结果给客户端。

深分页(Deep Pagination)

给 10,000 到 50,000 的结果文档深分页( 1,000 到 5,000 页)是完全可行的。但是使用足够大的 from 值,排序过程可能会变得非常沉重,使用大量的CPU、内存和带宽。因为这个原因,我们强烈建议你不要使用深分页。

实际上, “深分页” 很少符合人的行为。当2到3页过去以后,人会停止翻页,并且改变搜索标准。会不知疲倦地一页一页的获取网页直到你的服务崩溃的罪魁祸首一般是机器人或者web spider。

如果你 确实 需要从你的集群取回大量的文档,你可以通过用 scroll 查询禁用排序使这个取回行为更有效率,我们会在 later in this chapter 进行讨论。

超时问题
通常分片处理完它所有的数据后再把结果返回给协同节点,协同节点把收到的所有结果合并为最终结果。

这意味着花费的时间是最慢分片的处理时间加结果合并的时间。如果有一个节点有问题,就会导致所有的响应缓慢。

参数 timeout 告诉 分片允许处理数据的最大时间。如果没有足够的时间处理所有数据,这个分片的结果可以是部分的,甚至是空数据。

搜索的返回结果会用属性 timed_out 标明分片是否返回的是部分结果:

...
"timed_out":     true,
...

游标查询 Scroll
scroll 查询 可以用来对 Elasticsearch 有效地执行大批量的文档查询,而又不用付出深度分页那种代价。

游标查询允许我们 先做查询初始化,然后再批量地拉取结果。 这有点儿像传统数据库中的 cursor 。

1
2
3
4
5
6
GET /old_index/_search?scroll=1m
{
"query": { "match_all": {}},
"sort" : ["_doc"],
"size": 1000
}

保持游标查询窗口一分钟。

关键字 _doc 是最有效的排序顺序。

这个查询的返回结果包括一个字段 _scroll_id, 它是一个base64编码的长字符串 ((("scroll_id"))) 。 现在我们能传递字段 _scroll_id 到 _search/scroll 查询接口获取下一批结果:

1
2
3
4
5
GET /_search/scroll
{
"scroll": "1m",
"scroll_id" : "cXVlcnlUaGVuRmV0Y2g7NTsxMDk5NDpkUmpiR2FjOFNhNnlCM1ZDMWpWYnRROzEwOTk1OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MTA5OTM6ZFJqYkdhYzhTYTZ5QjNWQzFqVmJ0UTsxMTE5MDpBVUtwN2lxc1FLZV8yRGVjWlI2QUVBOzEwOTk2OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MDs="
}

注意再次设置游标查询过期时间为一分钟。

这个游标查询返回的下一批结果。 尽管我们指定字段 size 的值为1000,我们有可能取到超过这个值数量的文档。 当查询的时候, 字段 size 作用于单个分片,所以每个批次实际返回的文档数量最大为 size * number_of_primary_shards 。

索引管理

创建索引

1
2
3
4
5
6
7
8
9
PUT /my_index
{
"settings": { ... any settings ... },
"mappings": {
"type_one": { ... any mappings ... },
"type_two": { ... any mappings ... },
...
}
}

删除索引
用以下的请求来 删除索引:

DELETE /my_index
你也可以这样删除多个索引:

DELETE /index_one,index_two
DELETE /index_*
你甚至可以这样删除 全部 索引:

DELETE /_all
DELETE /*

设置索引

下面是两个 最重要的设置:

number_of_shards
每个索引的主分片数,默认值是 5 。这个配置在索引创建后不能修改。
number_of_replicas
每个主分片的副本数,默认值是 1 。对于活动的索引库,这个配置可以随时修改。

可以用 update-index-settings API 动态修改副本数:

1
2
3
4
PUT /my_temp_index/_settings
{
"number_of_replicas": 1
}

创建自定义分析器

1
2
3
4
5
6
7
8
9
10
11
PUT /my_index
{
"settings": {
"analysis": {
"char_filter": { ... custom character filters ... },
"tokenizer": { ... custom tokenizers ... },
"filter": { ... custom token filters ... },
"analyzer": { ... custom analyzers ... }
}
}
}

分析器应用在一个 string 字段上:

1
2
3
4
5
6
7
8
9
PUT /my_index/_mapping/my_type
{
"properties": {
"title": {
"type": "string",
"analyzer": "my_analyzer"
}
}
}

类型和映射

如果有两个不同的类型,每个类型都有同名的字段,但映射不同(例如:一个是字符串一个是数字),将会出现什么情况?

简单回答是,Elasticsearch 不会允许你定义这个映射。当你配置这个映射时,将会出现异常。

Lucene 没有文档类型的概念,每个文档的类型名被存储在一个叫 _type 的元数据字段上。 当我们要检索某个类型的文档时, Elasticsearch 通过在 _type 字段上使用过滤器限制只返回这个类型的文档。

Lucene 也没有映射的概念。 映射是 Elasticsearch 将复杂 JSON 文档 映射 成 Lucene 需要的扁平化数据的方式。

重要的一点是: 类型可以很好的区分同一个集合中的不同细分。在不同的细分中数据的整体模式是相同的(或相似的)。

类型不适合 完全不同类型的数据 。如果两个类型的字段集是互不相同的,这就意味着索引中将有一半的数据是空的(字段将是 稀疏的 ),最终将导致性能问题。在这种情况下,最好是使用两个单独的索引。

根对象

_source 字段在被写入磁盘之前先会被压缩。
这个字段的存储几乎总是我们想要的,因为它意味着下面的这些:

  • 搜索结果包括了整个可用的文档——不需要额外的从另一个的数据仓库来取文档。
  • 如果没有 _source 字段,部分 update 请求不会生效。
  • 当你的映射改变时,你需要重新索引你的数据,有了_source字段你可以直接从Elasticsearch这样做,而不必从另一个(通常是速度更慢的)数据仓库取回你的所有文档。
  • 当你不需要看到整个文档时,单个字段可以从 _source 字段提取和通过 get 或者 search 请求返回。
  • 调试查询语句更加简单,因为你可以直接看到每个文档包括什么,而不是从一列id猜测它们的内容。
    然而,存储 _source 字段的确要使用磁盘空间。如果上面的原因对你来说没有一个是重要的,你可以用下面的映射禁用 _source 字段:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    PUT /my_index
    {
    "mappings": {
    "my_type": {
    "_source": {
    "enabled": false
    }
    }
    }
    }
    在一个搜索请求里,你可以通过在请求体中指定 _source 参数,来达到只获取特定的字段的效果:
    1
    2
    3
    4
    5
    GET /_search
    {
    "query": { "match_all": {}},
    "_source": [ "title", "created" ]
    }

_all 字段:
一个把其它字段值 当作一个大字符串来索引的特殊字段。 query_string 查询子句(搜索 ?q=john )在没有指定字段时默认使用 _all 字段。

relevance algorithm 考虑的一个最重要的原则是字段的长度:字段越短越重要。 在较短的 title 字段中出现的短语可能比在较长的 content 字段中出现的短语更加重要。字段长度的区别在 _all 字段中不会出现。

如果你不再需要 _all 字段,你可以通过下面的映射来禁用:

1
2
3
4
5
6
PUT /my_index/_mapping/my_type
{
"my_type": {
"_all": { "enabled": false }
}
}

你可以为所有字段默认禁用 include_in_all 选项,仅在你选择的字段上启用:

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT /my_index/my_type/_mapping
{
"my_type": {
"include_in_all": false,
"properties": {
"title": {
"type": "string",
"include_in_all": true
},
...
}
}
}

记住,_all 字段仅仅是一个 经过分词的 string 字段。它使用默认分词器来分析它的值,不管这个值原本所在字段指定的分词器。就像所有 string 字段,你可以配置 _all 字段使用的分词器:

1
2
3
4
5
6
PUT /my_index/my_type/_mapping
{
"my_type": {
"_all": { "analyzer": "whitespace" }
}
}

文档标识与四个元数据字段 相关:

  • _id
    文档的 ID 字符串
  • _type
    文档的类型名
  • _index
    文档所在的索引
  • _uid
    _type 和 _id 连接在一起构造成 type#id

默认情况下, _uid 字段是被存储(可取回)和索引(可搜索)的。 _type 字段被索引但是没有存储, _id 和 _index 字段则既没有被索引也没有被存储,这意味着它们并不是真实存在的。

动态映射

幸运的是可以用 dynamic 配置来控制这种行为 ,可接受的选项如下:

true
动态添加新的字段–缺省
false
忽略新的字段
strict
如果遇到新字段抛出异常

把 dynamic 设置为 false 一点儿也不会改变 _source 的字段内容。 _source 仍然包含被索引的整个JSON文档。只是新的字段不会被加到映射中也不可搜索。

日期检测可以通过在根对象上设置 date_detection 为 false 来关闭:

PUT /my_index
{
“mappings”: {
“my_type”: {
“date_detection”: false
}
}
}

使用 dynamic_templates ,你可以完全控制 新检测生成字段的映射。你甚至可以通过字段名称或数据类型来应用不同的映射。

一个索引中的所有类型共享相同的字段和设置。 default 映射更加方便地指定通用设置,而不是每次创建新类型时都要重复设置。 default 映射是新类型的模板。
我们可以使用 default 映射为所有的类型禁用 _all 字段, 而只在 blog 类型启用:

PUT /my_index
{
“mappings”: {
default“: {
“_all”: { “enabled”: false }
},
“blog”: {
“_all”: { “enabled”: true }
}
}
}

重新索引数据
从旧索引复制到新索引

1
2
3
4
5
6
7
8
9
POST _reindex
{
"source": {
"index": "twitter"
},
"dest": {
"index": "new_twitter"
}
}

ps:需要先准备好目标索引
ps:https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html

索引 别名 就像一个快捷方式或软连接,可以指向一个或多个索引,也可以给任何一个需要索引名的API来使用。别名 带给我们极大的灵活性,允许我们做下面这些:

在运行的集群中可以无缝的从一个索引切换到另一个索引
给多个索引分组 (例如, last_three_months)
给索引的一个子集创建 视图

首先,创建索引 my_index_v1 ,然后将别名 my_index 指向它:

PUT /my_index_v1
PUT /my_index_v1/_alias/my_index

你可以检测这个别名指向哪一个索引:

GET /*/_alias/my_index
或哪些别名指向这个索引:

GET /my_index_v1/_alias/*

在你的应用中使用别名而不是索引名。然后你就可以在任何时候重建索引。别名的开销很小,应该广泛使用。

分片内部原理

倒排索引被写入磁盘后是 不可改变 的:它永远不会修改。

文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。

在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 refresh 。 默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch 是 近 实时搜索: 文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。

不要在生产环境下每次索引一个文档都去手动刷新。 相反,你的应用需要意识到 Elasticsearch 的近实时的性质,并接受它的不足。

在生产环境中,当你正在建立一个大的新索引时,可以先关闭自动刷新,待开始使用该索引时,再把它们调回来:

PUT /my_logs/_settings
{ “refresh_interval”: -1 }

PUT /my_logs/_settings
{ “refresh_interval”: “1s” }

refresh_interval 需要一个 持续时间 值, 例如 1s (1 秒) 或 2m (2 分钟)。 一个绝对值 1 表示的是 1毫秒 –无疑会使你的集群陷入瘫痪。

这个执行一个提交并且截断 translog 的行为在 Elasticsearch 被称作一次 flush 。 分片每30分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。

在文件被 fsync 到磁盘前,被写入的文件在重启之后就会丢失。默认 translog 是每 5 秒被 fsync 刷新到硬盘, 或者在每次写请求完成之后执行(e.g. index, delete, update, bulk)。

段合并的时候会将那些旧的已删除文档 从文件系统中清除。 被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。

optimize API大可看做是 强制合并 API 。它会将一个分片强制合并到 max_num_segments 参数指定大小的段数目。 这样做的意图是减少段的数量(通常减少到一个),来提升搜索性能。

使用optimize优化老的索引,将每一个分片合并为一个单独的段就很有用了;这样既可以节省资源,也可以使搜索更加快速:

POST /logstash-2014-10/_optimize?max_num_segments=1

请注意,使用 optimize API 触发段合并的操作一点也不会受到任何资源上的限制。这可能会消耗掉你节点上全部的I/O资源, 使其没有余裕来处理搜索请求,从而有可能使集群失去响应。 如果你想要对索引执行 optimize,你需要先使用分片分配(查看 迁移旧索引)把索引移到一个安全的节点,再执行。

深入搜索

结构化搜索

通常当查找一个精确值的时候,我们不希望对查询进行评分计算。只希望对文档进行包括或排除的计算,所以我们会使用 constant_score 查询以非评分模式来执行 term 查询并以一作为统一评分。

最终组合的结果是一个 constant_score 查询,它包含一个 term 查询:

GET /my_store/products/_search
{
“query” : {
“constant_score” : {
“filter” : {
“term” : {
“price” : 20
}
}
}
}
}
从概念上记住非评分计算是首先执行的,这将有助于写出高效又快速的搜索请求。

一个 bool 过滤器由三部分组成:

1
2
3
4
5
6
7
{
"bool" : {
"must" : [],
"should" : [],
"must_not" : [],
}
}

must
所有的语句都 必须(must) 匹配,与 AND 等价。
must_not
所有的语句都 不能(must not) 匹配,与 NOT 等价。
should
至少有一个语句要匹配,与 OR 等价。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET /my_store/products/_search
{
"query" : {
"filtered" : {
"filter" : {
"bool" : {
"should" : [
{ "term" : {"productID" : "KDKE-B-9947-#kL5"}},
{ "bool" : {
"must" : [
{ "term" : {"productID" : "JODL-X-1937-#pV7"}},
{ "term" : {"price" : 30}}
]
}}
]
}
}
}
}
}

term 和 terms 是 包含(contains) 操作,而非 等值(equals) (判断)。 如何理解这句话呢?

如果我们有一个 term(词项)过滤器 { “term” : { “tags” : “search” } } ,它会与以下两个文档 同时 匹配:

{ “tags” : [“search”] }
{ “tags” : [“search”, “open_source”] }

尽管第二个文档包含除 search 以外的其他词,它还是被匹配并作为结果返回。

如果一定期望得到我们前面说的那种行为(即整个字段完全相等),最好的方式是增加并索引另一个字段, 这个字段用以存储该字段包含词项的数量,同样以上面提到的两个文档为例,现在我们包括了一个维护标签数的新字段:

{ “tags” : [“search”], “tag_count” : 1 }
{ “tags” : [“search”, “open_source”], “tag_count” : 2 }

范围查询

range 支持数值、日期、字符串

“range” : {
“timestamp” : {
“gt” : “2014-01-01 00:00:00”,
“lt” : “2014-01-07 00:00:00”
}
}

日期计算
“range” : {
“timestamp” : {
“gt” : “now-1h” # 过去一个小时
}
}
“range” : {
“timestamp” : {
“gt” : “2014-01-01 00:00:00”,
“lt” : “2014-01-01 00:00:00||+1M” #加一个月
}
}
时间格式参考文档

字符串范围可采用 字典顺序(lexicographically) 或字母顺序(alphabetically)。例如,下面这些字符串是采用字典序(lexicographically)排序的:

5, 50, 6, B, C, a, ab, abb, abc, b

“range” : {
“title” : {
“gte” : “a”,
“lt” : “b”
}
}
字符串范围在过滤 低基数(low cardinality) 字段(即只有少量唯一词项)时可以正常工作,但是唯一词项越多,字符串范围的计算会越慢。

那么我们如何用 exists 或 missing 查询 name 字段呢? name 字段并不真实存在于倒排索引中。

原因是当我们执行下面这个过滤的时候:

{
“exists” : { “field” : “name” }
}
实际执行的是:

{
“bool”: {
“should”: [
{ “exists”: { “field”: “name.first” }},
{ “exists”: { “field”: “name.last” }}
]
}
}
这也就意味着,如果 first 和 last 都是空,那么 name 这个命名空间才会被认为不存在。

Elasticsearch 会基于使用频次自动缓存查询。如果一个非评分查询在最近的 256 词查询中被使用过(次数取决于查询类型),那么这个查询就会作为缓存的候选。但是,并不是所有的片段都能保证缓存 bitset 。只有那些文档数量超过 10,000 (或超过总文档数量的 3% )才会缓存 bitset 。因为小的片段可以很快的进行搜索和合并,这里缓存的意义不大。

全文搜索

基于词项与基于全文

文本查询可以划分成两大家族:

  • 基于词项的查询
    如 term 或 fuzzy 这样的底层查询不需要分析阶段,它们对单个词项进行操作。用 term 查询词项 Foo 只要在倒排索引中查找 准确词项 ,并且用 TF/IDF 算法为每个包含该词项的文档计算相关度评分 _score 。
    记住 term 查询只对倒排索引的词项精确匹配,这点很重要,它不会对词的多样性进行处理(如, foo 或 FOO )。这里,无须考虑词项是如何存入索引的。如果是将 [“Foo”,”Bar”] 索引存入一个不分析的( not_analyzed )包含精确值的字段,或者将 Foo Bar 索引到一个带有 whitespace 空格分析器的字段,两者的结果都会是在倒排索引中有 Foo 和 Bar 这两个词。

  • 基于全文的查询
    像 match 或 query_string 这样的查询是高层查询,它们了解字段映射的信息:
    如果查询 日期(date) 或 整数(integer)字段,它们会将查询字符串分别作为日期或整数对待。
    如果查询一个( not_analyzed )未分析的精确值字符串字段, 它们会将整个查询字符串作为单个词项对待。
    但如果要查询一个( analyzed )已分析的全文字段, 它们会先将查询字符串传递到一个合适的分析器,然后生成一个供查询的词项列表。
    一旦组成了词项列表,这个查询会对每个词项逐一执行底层的查询,再将结果合并,然后为每个文档生成一个最终的相关度评分。

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

1.检查字段类型 。

2.分析查询字符串 。

3.查找匹配文档 。

4.为每个文档评分 。

多词查询

因为 match 查询必须查找两个词( [“brown”,”dog”] ),它在内部实际上先执行两次 term 查询,然后将两次查询的结果合并作为最终结果输出。

match 查询还可以接受 operator 操作符作为输入参数,默认情况下该操作符是 or 。我们可以将它修改成 and 让所有指定词项都必须匹配:

1
2
3
4
5
6
7
8
9
10
11
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "BROWN DOG!",
"operator": "and"
}
}
}
}

match 查询支持 minimum_should_match 最小匹配参数, 这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量:

1
2
3
4
5
6
7
8
9
10
11
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "quick brown dog",
"minimum_should_match": "75%"
}
}
}
}

组合查询

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": { "match": { "title": "quick" }},
"must_not": { "match": { "title": "lazy" }},
"should": [
{ "match": { "title": "brown" }},
{ "match": { "title": "dog" }}
]
}
}
}

拷贝为 CURL在 SENSE 中查看
以上的查询结果返回 title 字段包含词项 quick 但不包含 lazy 的任意文档。目前为止,这与 bool 过滤器的工作方式非常相似。

==区别就在于两个 should 语句,也就是说:一个文档不必包含 brown 或 dog 这两个词项,但如果一旦包含,我们就认为它们 更相关==

bool 查询会为每个文档计算相关度评分 _score , 再将所有匹配的 must 和 should 语句的分数 _score 求和,最后除以 must 和 should 语句的总数。

must_not 语句不会影响评分; 它的作用只是将不相关的文档排除。
默认情况下,没有 should 语句是必须匹配的,只有一个例外:那就是当没有 must 语句的时候,至少有一个 should 语句必须匹配。

如何使用布尔匹配

以下两个查询是等价的:

1
2
3
4
5
6
7
8
9
10
11
12
{
"match": { "title": "brown fox"}
}

{
"bool": {
"should": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }}
]
}
}

查询语句提升权重

我们可以通过指定 boost 来控制任何查询语句的相对的权重, boost 的默认值为 1 ,大于 1 会提升一个语句的相对权重。所以下面重写之前的查询:

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
GET /_search
{
"query": {
"bool": {
"must": {
"match": {
"content": {
"query": "full text search",
"operator": "and"
}
}
},
"should": [
{ "match": {
"content": {
"query": "Elasticsearch",
"boost": 3
}
}},
{ "match": {
"content": {
"query": "Lucene",
"boost": 2
}
}}
]
}
}
}

控制分析

分析器可以从三个层面进行定义:按字段(per-field)、按索引(per-index)或全局缺省(global default)。Elasticsearch 会按照以下顺序依次处理,直到它找到能够使用的分析器。

  • 索引时的顺序如下:
    字段映射里定义的 analyzer ,否则
    索引设置中名为 default 的分析器,默认为
    standard 标准分析器

  • 在搜索时,顺序有些许不同:
    查询自己定义的 analyzer ,否则
    字段映射里定义的 analyzer ,否则
    索引设置中名为 default 的分析器,默认为
    standard 标准分析器

持一个可选的 search_analyzer 映射,它仅会应用于搜索时( analyzer 还用于索引时)。还有一个等价的 default_search 映射,用以指定索引层的默认配置。

如果考虑到这些额外参数,一个搜索时的 完整 顺序会是下面这样:

查询自己定义的 analyzer ,否则
字段映射里定义的 search_analyzer ,否则
字段映射里定义的 analyzer ,否则
索引设置中名为 default_search 的分析器,默认为
索引设置中名为 default 的分析器,默认为
standard 标准分析器

==最简单的途径就是在创建索引或者增加类型映射时,为每个全文字段设置分析器。这种方式尽管有点麻烦,但是它让我们可以清楚的看到每个字段每个分析器是如何设置的。可以在索引级别设置中,为绝大部分的字段设置你想指定的 default 默认分析器。然后在字段级别设置中,对某一两个字段配置需要指定的分析器。==

被破坏的相关度!

由于性能原因, Elasticsearch 不会计算索引内所有文档的 IDF 。 相反,每个分片会根据 该分片 内的所有文档计算一个本地 IDF 。

在实际应用中,这并不是一个问题,本地和全局的 IDF 的差异会随着索引里文档数的增多渐渐消失,在真实世界的数据量下,局部的 IDF 会被迅速均化,所以上述问题并不是相关度被破坏所导致的,而是由于数据太少。

在搜索请求后添加 ?search_type=dfs_query_then_fetch , dfs 是指 分布式频率搜索(Distributed Frequency Search) , 它告诉 Elasticsearch ,先分别获得每个分片本地的 IDF ,然后根据结果再计算整个索引的全局 IDF 。
不要在生产环境上使用 dfs_query_then_fetch 。完全没有必要。只要有足够的数据就能保证词频是均匀分布的。没有理由给每个查询额外加上 DFS 这步。

多字段搜索

多字符串查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "War and Peace" }},
{ "match": { "author": "Leo Tolstoy" }},
{ "bool": {
"should": [
{ "match": { "translator": "Constance Garnett" }},
{ "match": { "translator": "Louise Maude" }}
]
}}
]
}
}
}

答案在于评分的计算方式。 bool 查询运行每个 match 查询,再把评分加在一起,然后将结果与所有匹配的语句数量相乘,最后除以所有的语句数量。处于同一层的每条语句具有相同的权重。在前面这个例子中,包含 translator 语句的 bool 查询,只占总评分的三分之一。如果将 translator 语句与 title 和 author 两条语句放入同一层,那么 title 和 author 语句只贡献四分之一评分。

单字符串查询

最佳字段

dis_max 查询
不使用 bool 查询,可以使用 dis_max 即分离 最大化查询(Disjunction Max Query) 。分离(Disjunction)的意思是 或(or) ,这与可以把结合(conjunction)理解成 与(and) 相对应。分离最大化查询(Disjunction Max Query)指的是: 将任何与任一查询匹配的文档作为结果返回,但==只将最佳匹配的评分作为查询的评分结果返回== :

1
2
3
4
5
6
7
8
9
10
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
]
}
}
}

最佳字段查询调优

tie_breaker 参数
可以通过指定 tie_breaker 这个参数将其他匹配语句的评分也考虑其中:

1
2
3
4
5
6
7
8
9
10
11
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Quick pets" }},
{ "match": { "body": "Quick pets" }}
],
"tie_breaker": 0.3
}
}
}

tie_breaker 参数提供了一种 dis_max 和 bool 之间的折中选择,它的评分方式如下:

  • 1.获得最佳匹配语句的评分 _score 。
  • 2.将其他匹配语句的评分结果与 tie_breaker 相乘。
  • 3.对以上评分求和并规范化。

有了 tie_breaker ,会考虑所有匹配语句,但最佳匹配语句依然占最终结果里的很大一部分。

注意

tie_breaker 可以是 0 到 1 之间的浮点数,其中 0 代表使用 dis_max 最佳匹配语句的普通逻辑, 1 表示所有匹配语句同等重要。最佳的精确值需要根据数据与查询调试得出,但是合理值应该与零接近(处于 0.1 - 0.4 之间),这样就不会颠覆 dis_max 最佳匹配性质的根本。

multi_match 查询

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

注意
multi_match 多匹配查询的类型有多种,其中的三种恰巧与 了解我们的数据 中介绍的三个场景对应,即: best_fields 、 most_fields 和 cross_fields (最佳字段、多数字段、跨字段)。

1
2
3
4
5
6
7
8
9
{
"multi_match": {
"query": "Quick brown fox",
"type": "best_fields",
"fields": [ "title", "body" ],
"tie_breaker": 0.3,
"minimum_should_match": "30%"
}
}

查询字段名称的模糊匹配

字段名称可以用模糊匹配的方式给出:任何与模糊模式正则匹配的字段都会被包括在搜索条件中, 例如可以使用以下方式同时匹配 book_title 、 chapter_title 和 section_title (书名、章名、节名)这三个字段:

1
2
3
4
5
6
{
"multi_match": {
"query": "Quick brown fox",
"fields": "*_title"
}
}

提升单个字段的权重编辑

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

1
2
3
4
5
6
{
"multi_match": {
"query": "Quick brown fox",
"fields": [ "*_title", "chapter_title^2" ]
}
}

chapter_title 这个字段的 boost 值为 2 ,而其他两个字段 book_title 和 section_title 字段的默认 boost 值为 1 。

多数字段

多字段映射编辑
首先要做的事情就是对我们的字段索引两次: 一次使用词干模式以及一次非词干模式。为了做到这点,采用 multifields 来实现,已经在 multifields 有所介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
DELETE /my_index

PUT /my_index
{
"settings": { "number_of_shards": 1 },
"mappings": {
"my_type": {
"properties": {
"title": {
"type": "string",
"analyzer": "english",
"fields": {
"std": {
"type": "string",
"analyzer": "standard"
}
}
}
}
}
}
}

跨字段实体搜索

1
2
3
4
5
6
7
8
9
{
"query": {
"multi_match": {
"query": "Poland Street W1V",
"type": "most_fields",
"fields": [ "street", "city", "country", "postcode" ]
}
}
}

most_fields 方式的问题
用 most_fields 这种方式搜索也存在某些问题,这些问题并不会马上显现:

  • 它是为多数字段匹配 任意 词设计的,而不是在 所有字段 中找到最匹配的。
  • 它不能使用 operator 或 minimum_should_match 参数来降低次相关结果造成的长尾效应。
  • 词频对于每个字段是不一样的,而且它们之间的相互影响会导致不好的排序结果。

字段中心式查询

解决方案
存在这些问题仅仅是因为我们在处理着多个字段,如果将所有这些字段组合成单个字段,问题就会消失。可以为 person 文档添加 full_name 字段来解决这个问题:

1
2
3
4
5
{
"first_name": "Peter",
"last_name": "Smith",
"full_name": "Peter Smith"
}

当查询 full_name 字段时:

具有更多匹配词的文档会比只有一个重复匹配词的文档更重要。
minimum_should_match 和 operator 参数会像期望那样工作。
姓和名的逆向文档频率被合并,所以 Smith 到底是作为姓还是作为名出现,都会变得无关紧要。
这么做当然是可行的,但我们并不太喜欢存储冗余数据。取而代之的是 Elasticsearch 可以提供两个解决方案——一个在索引时,而另一个是在搜索时——随后会讨论它们。

自定义 _all 字段

Elasticsearch 在字段映射中为我们提供 copy_to 参数来实现这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PUT /my_index
{
"mappings": {
"person": {
"properties": {
"first_name": {
"type": "string",
"copy_to": "full_name"
},
"last_name": {
"type": "string",
"copy_to": "full_name"
},
"full_name": {
"type": "string"
}
}
}
}
}

first_name 和 last_name 字段中的值会被复制到 full_name 字段。

cross-fields 跨字段查询

cross_fields 使用词中心式(term-centric)的查询方式,这与 best_fields 和 most_fields 使用字段中心式(field-centric)的查询方式非常不同,它将所有字段当成一个大字段,并在 每个字段 中查找 每个词 。

1
2
3
4
5
6
7
8
9
10
11
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "cross_fields",
"operator": "and",
"fields": [ "first_name", "last_name" ]
}
}
}

用 cross_fields 词中心式匹配。

它通过 混合 不同字段逆向索引文档频率的方式解决了词频的问题:

1
2
+blended("peter", fields: [first_name, last_name])
+blended("smith", fields: [first_name, last_name])

==自定义单字段查询是否能够优于多字段查询,取决于在多字段查询与单字段自定义 _all 之间代价的权衡,即哪种解决方案会带来更大的性能优化就选择哪一种。==

Exact-Value 精确值字段

在 multi_match 查询中避免使用 not_analyzed 字段。

近似匹配

短语匹配

match_phrase 查询首先将查询字符串解析成一个词项列表,然后对这些词项进行搜索,但只保留那些包含 全部 搜索词项,且 位置 与搜索词项相同的文档。

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

混合起来

slop 参数告诉 match_phrase 查询词条相隔多远时仍然能将文档视为匹配 。 相隔多远的意思是为了让查询和文档匹配你需要移动词条多少次?

1
2
3
4
5
6
7
8
9
10
11
GET /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": {
"query": "quick fox",
"slop": 1
}
}
}
}

为了使查询 fox quick 匹配我们的文档, 我们需要 slop 的值为 3:

            Pos 1         Pos 2         Pos 3
-----------------------------------------------
Doc:        quick         brown         fox
-----------------------------------------------
Query:      fox           quick
Slop 1:     fox|quick  ↵  <1>
Slop 2:     quick      ↳  fox
Slop 3:     quick                 ↳     fox

<1> 注意 foxquick 在这步中占据同样的位置。 因此将 fox quick 转换顺序成 quick fox 需要两步, 或者值为 2slop

PS:是将查询词进行移动,不是移动文档中的词。

多值字段

数组相关,略。

越近越好

通过设置一个像 50 或者 100 这样的高 slop 值, 你能够排除单词距离太远的文档, 但是也给予了那些单词临近的的文档更高的分数。

使用邻近度提高相关度

性能优化

短语查询和邻近查询都比简单的 query 查询代价更高 。 一个 match 查询仅仅是看词条是否存在于倒排索引中,而一个 match_phrase 查询是必须计算并比较多个可能重复词项的位置。

Lucene nightly benchmarks 表明一个简单的 term 查询比一个短语查询大约快 10 倍,比邻近查询(有 slop 的短语 查询)大约快 20 倍。当然,这个代价指的是在搜索时而不是索引时。

重新评分阶段支持一个代价更高的评分算法–比如 phrase 查询–只是为了从每个分片中获得前 K 个结果。 然后会根据它们的最新评分 重新排序。

该请求如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "quick brown fox",
"minimum_should_match": "30%"
}
}
},
"rescore": {
"window_size": 50,
"query": {
"rescore_query": {
"match_phrase": {
"title": {
"query": "quick brown fox",
"slop": 50
}
}
}
}
}
}

match 查询决定哪些文档将包含在最终结果集中,并通过 TF/IDF 排序。
window_size 是每一分片进行重新评分的顶部文档数量。
目前唯一支持的重新打分算法就是另一个查询,但是以后会有计划增加更多的算法。

寻找相关词

要将每个单词 以及它的邻近词 作为单个词项索引:

[“sue ate”, “ate the”, “the alligator”]
这些单词对(或者 bigrams )被称为 shingles 。
ps:索引三个单词( trigrams )

Trigrams 提供了更高的精度,但是也大大增加了索引中唯一词项的数量。在大多数情况下,Bigrams 就够了。

生成 Shingles

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
PUT /my_index
{
"settings": {
"number_of_shards": 1,
"analysis": {
"filter": {
"my_shingle_filter": {
"type": "shingle",
"min_shingle_size": 2,
"max_shingle_size": 2,
"output_unigrams": false
}
},
"analyzer": {
"my_shingle_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"my_shingle_filter"
]
}
}
}
}
}
PUT /my_index/_mapping/my_type
{
"my_type": {
"properties": {
"title": {
"type": "string",
"fields": {
"shingles": {
"type": "string",
"analyzer": "my_shingle_analyzer"
}
}
}
}
}
}

现在在查询里添加 shingles 字段。不要忘了在 shingles 字段上的匹配是充当一 种信号–为了提高相关度评分–所以我们仍然需要将基本 title 字段包含到查询中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": {
"match": {
"title": "the hungry alligator ate sue"
}
},
"should": {
"match": {
"title.shingles": "the hungry alligator ate sue"
}
}
}
}
}

shingles 不仅比短语查询更灵活, 而且性能也更好。 shingles 查询跟一个简单的 match 查询一样高效,而不用每次搜索花费短语查询的代价。只是在索引期间因为更多词项需要被索引会付出一些小的代价, 这也意味着有 shingles 的字段会占用更多的磁盘空间。 然而,大多数应用写入一次而读取多次,所以在索引期间优化我们的查询速度是有意义的。

部分匹配

prefix 前缀查询

1
2
3
4
5
6
7
8
GET /my_index/address/_search
{
"query": {
"prefix": {
"postcode": "W1"
}
}
}

prefix 查询或过滤对于一些特定的匹配是有效的,但使用方式还是应当注意。 当字段中词的集合很小时,可以放心使用,但是它的伸缩性并不好,会对我们的集群带来很多压力。可以使用较长的前缀来限制这种影响,减少需要访问的量。

通配符与正则表达式查询

wildcard 通配符查询: ? 匹配任意字符, * 匹配 0 或多个字符.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /my_index/address/_search
{
"query": {
"wildcard": {
"postcode": "W?F*HW"
}
}
}
GET /my_index/address/_search
{
"query": {
"regexp": {
"postcode": "W[0-9].+"
}
}
}

wildcard 和 regexp 查询的工作方式与 prefix 查询完全一样,它们也需要扫描倒排索引中的词列表才能找到所有匹配的词,然后依次获取每个词相关的文档 ID ,与 prefix 查询的唯一不同是:它们能支持更为复杂的匹配模式。

prefix 、 wildcard 和 regexp 查询是基于词操作的,如果用它们来查询 analyzed 字段,它们会检查字段里面的每个词,而不是将字段作为整体来处理。

查询时输入即搜索

1
2
3
4
5
6
7
8
9
{
"match_phrase_prefix" : {
"brand" : {
"query": "johnnie walker bl",
"max_expansions": 50,
"slop": 10
}
}
}

与 match_phrase 一样,它也可以接受 slop 参数(参照 slop )让相对词序位置不那么严格:
参数 max_expansions 控制着可以与前缀匹配的词的数量,它会先查找第一个与前缀 bl 匹配的词,然后依次查找搜集与之匹配的词(按字母顺序),直到没有更多可匹配的词或当数量超过 max_expansions 时结束。

索引时输入即搜索

自定义分析器 autocomplete

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
PUT /my_index
{
"settings": {
"number_of_shards": 1,
"analysis": {
"filter": {
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 20
}
},
"analyzer": {
"autocomplete": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"autocomplete_filter"
]
}
}
}
}
}

应用在字段

1
2
3
4
5
6
7
8
9
10
11
12
PUT /my_index/my_type/_mapping
{
"my_type": {
"properties": {
"name": {
"type": "string",
"index_analyzer": "autocomplete",
"search_analyzer": "standard"
}
}
}
}

因为大多数工作是在索引时完成的,所有的查询只要查找 brown 和 fo 这两个词,这比使用 match_phrase_prefix 查找所有以 fo 开始的词的方式要高效许多。

控制相关度

相关度评分背后的理论

以下三个因素——词频(term frequency)、逆向文档频率(inverse document frequency)和字段长度归一值(field-length norm)——是在索引时计算并存储的。 最后将它们结合在一起计算单个词在特定文档中的 权重 。

Lucene 的实用评分函数

实用评分函数(practical scoring function)
…………………………..
score(q,d) = <1>
queryNorm(q) <2>
· coord(q,d) <3>
· ∑ ( <4>
tf(t in d) <5>
· idf(t)² <6>
· t.getBoost() <7>
· norm(t,d) <8>
) (t in q) <4>
…………………………..

<1> score(q,d) 是文档 d 与查询 q 的相关度评分。
<2> queryNorm(q) 是 <<query-norm,_查询归一化_ 因子>> (新)。
<3> coord(q,d) 是 <<coord,_协调_ 因子>> (新)。
<4> 查询 q 中每个词 t 对于文档 d 的权重和。
<5> tf(t in d) 是词 t 在文档 d 中的 <<tf,词频>> 。
<6> idf(t) 是词 t 的 <<idf,逆向文档频率>> 。
<7> t.getBoost() 是查询中使用的 <<query-time-boosting,_boost_>>(新)。
<8> norm(t,d) 是 <<field-norm,字段长度归一值>> ,与 <<index-boost,索引时字段层 boost>> (如果存在)的和(新)。

我们不建议在建立索引时对字段提升权重,有以下原因:
将提升值与字段长度归一值合在单个字节中存储会丢失字段长度归一值的精度,这样会导致 Elasticsearch 不知如何区分包含三个词的字段和包含五个词的字段。
要想改变索引时的提升值,就必须重新为所有文档建立索引,与此不同的是,查询时的提升值可以随着每次查询的不同而更改。
如果一个索引时权重提升的字段有多个值,提升值会按照每个值来自乘,这会导致该字段的权重急剧上升。
查询时赋予权重 是更为简单、清楚、灵活的选择。

查询时权重提升

在实际应用中,无法通过简单的公式得出某个特定查询语句的 “正确” 权重提升值,只能通过不断尝试获得。需要记住的是 boost 只是影响相关度评分的其中一个因子;它还需要与其他因子相互竞争。在前例中, title 字段相对 content 字段可能已经有一个 “缺省的” 权重提升值,这因为在 字段长度归一值 中,标题往往比相关内容要短,所以不要想当然的去盲目提升一些字段的权重。选择权重,检查结果,如此反复。

当在多个索引中搜索时, 可以使用参数 indices_boost 来提升整个索引的权重

1
2
3
4
5
6
7
8
9
10
11
12
GET /docs_2014_*/_search
{
"indices_boost": {
"docs_2014_10": 3,
"docs_2014_09": 2
},
"query": {
"match": {
"text": "quick brown fox"
}
}
}

使用查询结构修改相关度

function_score 查询

function_score 查询 是用来控制评分过程的终极武器,它允许为每个与主查询匹配的文档应用一个函数, 以达到改变甚至完全替换原始查询评分 _score 的目的。
实际上,也能用过滤器对结果的 子集 应用不同的函数,这样一箭双雕:既能高效评分,又能利用过滤器缓存。

Elasticsearch 预定义了一些函数:

  • weight
    为每个文档应用一个简单而不被规范化的权重提升值:当 weight 为 2 时,最终结果为 2 * _score 。
  • field_value_factor
    使用这个值来修改 _score ,如将 popularity 或 votes (受欢迎或赞)作为考虑因素。
  • random_score
    为每个用户都使用一个不同的随机评分对结果排序,但对某一具体用户来说,看到的顺序始终是一致的。
  • 衰减函数 —— linear 、 exp 、 gauss
    将浮动值结合到评分 _score 中,例如结合 publish_date 获得最近发布的文档,结合 geo_location 获得更接近某个具体经纬度(lat/lon)地点的文档,结合 price 获得更接近某个特定价格的文档。
  • script_score
    如果需求超出以上范围时,用自定义脚本可以完全控制评分计算,实现所需逻辑。
    如果没有 function_score 查询,就不能将全文查询与最新发生这种因子结合在一起评分,而不得不根据评分 _score 或时间 date 进行排序;这会相互影响抵消两种排序各自的效果。这个查询可以使两个效果融合:可以仍然根据全文相关度进行排序,但也会同时考虑最新发布文档、流行文档、或接近用户希望价格的产品。

按受欢迎度提升权重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /blogposts/post/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p"
}
}
}
}

修饰语 modifier 的值可以为: none (默认状态)、 log 、 log1p 、 log2p 、 ln 、 ln1p 、 ln2p 、 square 、 sqrt 以及 reciprocal 。想要了解更多信息请参照: field_value_factor 文档.

boost_mode
或许将全文评分与 field_value_factor 函数值乘积的效果仍然可能太大, 我们可以通过参数 boost_mode 来控制函数与查询评分 _score 合并后的结果,参数接受的值为:

multiply
评分 _score 与函数值的积(默认)
sum
评分 _score 与函数值的和
min
评分 _score 与函数值间的较小值
max
评分 _score 与函数值间的较大值
replace
函数值替代评分 _score

可以使用 max_boost 参数限制一个函数的最大效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET /blogposts/post/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p",
"factor": 0.1
},
"boost_mode": "sum",
"max_boost": 1.5
}
}
}

过滤集提升权重

用过滤器将结果划分为多个子集(每个特性一个过滤器),并为每个子集使用不同的函数。

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
GET /_search
{
"query": {
"function_score": {
"filter": {
"term": { "city": "Barcelona" }
},
"functions": [
{
"filter": { "term": { "features": "wifi" }},
"weight": 1
},
{
"filter": { "term": { "features": "garden" }},
"weight": 1
},
{
"filter": { "term": { "features": "pool" }},
"weight": 2
}
],
"score_mode": "sum",
}
}
}

随机评分

越近越好(衰减函数)

function_score 查询会提供一组 衰减函数(decay functions) , 让我们有能力在两个滑动标准,如地点和价格,之间权衡。

有三种衰减函数—— linear 、 exp 和 gauss (线性、指数和高斯函数),它们可以操作数值、时间以及经纬度地理坐标点这样的字段。所有三个函数都能接受以下参数:

origin
中心点 或字段可能的最佳值,落在原点 origin 上的文档评分 _score 为满分 1.0 。
scale
衰减率,即一个文档从原点 origin 下落时,评分 _score 改变的速度。(例如,每 £10 欧元或每 100 米)。
decay
从原点 origin 衰减到 scale 所得的评分 _score ,默认值为 0.5 。
offset
以原点 origin 为中心点,为其设置一个非零的偏移量 offset 覆盖一个范围,而不只是单个原点。在范围 -offset <= origin <= +offset 内的所有评分 _score 都是 1.0 。

理解 price 价格语句

脚本评分

https://www.elastic.co/guide/cn/elasticsearch/guide/current/script-score.html

可插拔的相似度算法

https://www.elastic.co/guide/cn/elasticsearch/guide/current/pluggable-similarites.html

更改相似度

https://www.elastic.co/guide/cn/elasticsearch/guide/current/changing-similarities.html

调试相关度是最后 10% 要做的事情

相关度的调试就有如兔子洞,一旦跳进去就很难再出来。 最相关 这个概念是一个难以触及的模糊目标,通常不同人对文档排序又有着不同的想法,这很容易使人陷入持续反复调整而没有明显进展的怪圈。

我们强烈建议不要陷入这种怪圈,而要监控测量搜索结果。监控用户点击最顶端结果的频次,这可以是前 10 个文档,也可以是第一页的;用户不查看首次搜索的结果而直接执行第二次查询的频次;用户来回点击并查看搜索结果的频次,等等诸如此类的信息。

处理人类语言

开始处理各种语言

略,混合不同语言的处理。

词汇识别

官方分词插件

归一化词元

将单词还原为词根

停用词: 性能与精度

停用词的优缺点

在此基础上,从索引里将这些词移除会使我们降低某种类型的搜索能力。将前面这些所列单词移除会让我们难以完成以下事情:

区分 happy 和 _not happy_。
搜索乐队名称 The The。
查找莎士比亚的名句 “To be, or not to be” (生存还是毁灭)。
使用挪威的国家代码: no
移除停用词的最主要好处是性能,假设我们在个具有上百万文档的索引中搜索单词 fox。或许 fox 只在其中 20 个文档中出现,也就是说 Elasticsearch 需要计算 20 个文档的相关度评分 _score 从而排出前十。现在我们把搜索条件改为 the OR fox,几乎所有的文件都包含 the 这个词,也就是说 Elasticsearch 需要为所有一百万文档计算评分 _score`。 由此可见第二个查询肯定没有第一个的结果好。

幸运的是,我们可以用来保持常用词搜索,同时还可以保持良好的性能。

停用词与性能

保留停用词最大的缺点就影响搜索性能。
我们想要减少待评分文档的数量,最简单的方式就是在and 操作符 match 查询时使用 and 操作符, 这样可以让所有词都是必须的。

以下是 match 查询:

1
2
3
4
5
6
7
8
{
"match": {
"text": {
"query": "the quick brown fox",
"operator": "and"
}
}
}

最少匹配数(minimum_should_match)
在精度匹配控制精度的章节里面,我们讨论过使用 minimum_should_match 配置去掉结果中次相关的长尾。 虽然它只对这个目的奏效,但是也为我们从侧面带来一个好处,它提供 and 操作符相似的性能。

1
2
3
4
5
6
7
8
{
"match": {
"text": {
"query": "the quick brown fox",
"minimum_should_match": "75%"
}
}
}

在上面这个示例中,四分之三的词都必须匹配,这意味着我们只需考虑那些包含最低频或次低频词的文档。 相比默认使用 or 操作符的简单查询,这为我们带来了巨大的性能提升。不过我们有办法可以做得更好……

词项的分别管理

match 查询接受一个参数 cutoff_frequency ,从而可以让它将查询字符串里的词项分为低频和高频两组。 低频组(更重要的词项)组成 bulk 大量查询条件,而高频组(次重要的词项)只会用来评分,而不参与匹配过程。通过对这两组词的区分处理,我们可以在之前慢查询的基础上获得巨大的速度提升。
cutoff_frequency 会查看索引里词项的具体频率,这些词会被自动归类为 高频词汇 。

1
2
3
4
5
6
7
{
"match": {
"text": {
"query": "Quick and the dead",
"cutoff_frequency": 0.01
}
}

任何词项出现在文档中超过1%,被认为是高频词。cutoff_frequency 配置可以指定为一个分数( 0.01 )或者一个正整数( 5 )。

此查询通过 cutoff_frequency 配置,将查询条件划分为低频组( quick , dead )和高频组( and , the )。然后,此查询会被重写为以下的 bool 查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"bool": {
"must": {
"bool": {
"should": [
{ "term": { "text": "quick" }},
{ "term": { "text": "dead" }}
]
}
},
"should": {
"bool": {
"should": [
{ "term": { "text": "and" }},
{ "term": { "text": "the" }}
]
}
}
}
}

停用词与短语查询

一个典型的索引会可能包含部分或所有以下数据:

词项字典(Terms dictionary)
索引中所有文档内所有词项的有序列表,以及包含该词的文档数量。
倒排表(Postings list)
包含每个词项的文档(ID)列表。
词频(Term frequency)
每个词项在每个文档里出现的频率。
位置(Positions)
每个词项在每个文档里出现的位置,供短语查询或近似查询使用。
偏移(Offsets)
每个词项在每个文档里开始与结束字符的偏移,供词语高亮使用,默认是禁用的。
规范因子(Norms)
用来对字段长度进行规范化处理的因子,给较短字段予以更多权重。

我们首先应该问自己:是否真的需要使用短语查询 或 近似查询 ?

答案通常是:不需要。在很多应用场景下,比如说日志,我们需要知道一个词 是否 在文档中(这个信息由倒排表提供)而不是关心词的位置在哪里。或许我们要对一两个字段使用短语查询,但是我们完全可以在其他 analyzed 字符串字段上禁用位置信息。

index_options 参数 允许我们控制索引里为每个字段存储的信息。 可选值如下:

docs
只存储文档及其包含词项的信息。这对 not_analyzed 字符串字段是默认的。
freqs
存储 docs 信息,以及每个词在每个文档里出现的频次。词频是完成TF/IDF 相关度计算的必要条件,但如果只想知道一个文档是否包含某个特定词项,则无需使用它。
positions
存储 docs 、 freqs 、 analyzed ,以及每个词项在每个文档里出现的位置。 这对 analyzed 字符串字段是默认的,但当不需使用短语或近似匹配时,可以将其禁用。
offsets
存储 docs,freqs,positions, 以及每个词在原始字符串中开始与结束字符的偏移信息( postings highlighter )。这个信息被用以高亮搜索结果,但它默认是禁用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUT /my_index
{
"mappings": {
"my_type": {
"properties": {
"title": {
"type": "string"
},
"content": {
"type": "string",
"index_options": "freqs"
}
}
}
}

common_grams 过滤器

common_grams 过滤器是针对短语查询能更高效的使用停用词而设计的。

停用词与相关性

在索引中保留停用词会降低相关度计算的准确性,特别是当我们的文档非常长时。
基于逆文档频率的影响,非常常用的词可能只有很低的权重,但是在长文档中,单个文档出现的绝对数量很大的停用词会导致这些词被不自然的加权。

同义词

//TODO

拼写错误(模糊查询)

//TODO

聚合

高阶概念

要掌握聚合,你只需要明白两个主要的概念:

桶(Buckets)
满足特定条件的文档的集合
指标(Metrics)
对桶内的文档进行统计计算

尝试聚合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"colors": {
"terms": {
"field": "color"
},
"aggs": {
"avg_price": {
"avg": {
"field": "price"
}
}
}
}
}
}

正如所见,我们用前面的例子加入了新的 aggs 层。这个新的聚合层让我们可以将 avg 度量嵌套置于 terms 桶内。实际上,这就为每个颜色生成了平均价格。

正如 颜色 的例子,我们需要给度量起一个名字( avg_price )这样可以稍后根据名字获取它的值。最后,我们指定度量本身( avg )以及我们想要计算平均值的字段( price )

嵌套桶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
GET /cars/transactions/_search
{
"size" : 0,
"aggs": {
"colors": {
"terms": {
"field": "color"
},
"aggs": {
"avg_price": { "avg": { "field": "price" }
},
"make" : {
"terms" : {
"field" : "make"
},
"aggs" : {
"min_price" : { "min": { "field": "price"} },
"max_price" : { "max": { "field": "price"} }
}
}
}
}
}
}

新增的这个 make 聚合,它是一个 terms 桶(嵌套在 colors 、 terms 桶内)。这意味着它 会为数据集中的每个唯一组合生成( color 、 make )元组。

条形图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET /cars/transactions/_search
{
"size" : 0,
"aggs":{
"price":{
"histogram":{
"field": "price",
"interval": 20000
},
"aggs":{
"revenue": {
"sum": {
"field" : "price"
}
}
}
}
}
}

如我们所见,查询是围绕 price 聚合构建的,它包含一个 histogram 桶。它要求字段的类型必须是数值型的同时需要设定分组的间隔范围。 间隔设置为 20,000 意味着我们将会得到如 [0-19999, 20000-39999, …] 这样的区间。

Doc Values and Fielddata

Doc values 可以使聚合更快、更高效并且内存友好。
文档值是在索引时与倒排索引同时产生的。也就是说文档值是按段来产生的并且是不可变的,正如用于搜索的倒排索引一样。 同样,和倒排索引一样,文档值也序列化到磁盘。这些对于性能和伸缩性很重要。

==文档值默认对所有字段启用,除了分析字符类型字段。也就是说所有的数字、地理坐标、日期、IP 和不分析( not_analyzed )字符类型。==

字段 “session_id” 禁用了文档值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"session_id": {
"type": "string",
"index": "not_analyzed",
"doc_values": false
}
}
}
}
}

地理位置

//TODO

Blockquote

数据建模

关联关系处理

以下四种常用的方法,用来在 Elasticsearch 中进行关系型数据的管理:

Application-side joins
Data denormalization
Nested objects
Parent/child relationships

当非规范化成为很多项目的一个很好的选择,采用锁方案的需求会带来复杂的实现逻辑。 作为替代方案,Elasticsearch 提供两个模型帮助我们处理相关联的实体: 嵌套的对象 和 父子关系 。

嵌套对象

嵌套对象 就是来解决这个问题的。将 comments 字段类型设置为 nested 而不是 object 后,每一个嵌套对象都会被索引为一个 隐藏的独立文档 ,举例如下:

{
“comments.name”: [ john, smith ],
“comments.comment”: [ article, great ],
“comments.age”: [ 28 ],
“comments.stars”: [ 4 ],
“comments.date”: [ 2014-09-01 ]
}
{
“comments.name”: [ alice, white ],
“comments.comment”: [ like, more, please, this ],
“comments.age”: [ 31 ],
“comments.stars”: [ 5 ],
“comments.date”: [ 2014-10-22 ]
}
{
“title”: [ eggs, nest ],
“body”: [ making, money, work, your ],
“tags”: [ cash, shares ]
}

设置一个字段为 nested 很简单 —  你只需要将字段类型 object 替换为 nested 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PUT /my_index
{
"mappings": {
"blogpost": {
"properties": {
"comments": {
"type": "nested",
"properties": {
"name": { "type": "string" },
"comment": { "type": "string" },
"age": { "type": "short" },
"stars": { "type": "short" },
"date": { "type": "date" }
}
}
}
}
}
}

由于嵌套对象 被索引在独立隐藏的文档中,我们无法直接查询它们。 相应地,我们必须使用 nested 查询 去获取它们:
默认情况下,根文档的分数是这些嵌套文档分数的平均值。可以通过设置 score_mode 参数来控制这个得分策略,相关策略有 avg (平均值), max (最大值), sum (加和) 和 none (直接返回 1.0 常数值分数)。

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
GET /my_index/blogpost/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "eggs"
}
},
{
"nested": {
"path": "comments",
"score_mode": "max",
"query": {
"bool": {
"must": [
{
"match": {
"comments.name": "john"
}
},
{
"match": {
"comments.age": 28
}
}
]
}
}
}
}
]
}
}
}

使用嵌套字段排序

假如我们想要查询在10月份收到评论的博客文章,并且按照 stars 数的最小值来由小到大排序,那么查询语句如下:

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
GET /_search
{
"query": {
"nested": {
"path": "comments",
"filter": {
"range": {
"comments.date": {
"gte": "2014-10-01",
"lt": "2014-11-01"
}
}
}
}
},
"sort": {
"comments.stars": {
"order": "asc",
"mode": "min",
"nested_path": "comments",
"nested_filter": {
"range": {
"comments.date": {
"gte": "2014-10-01",
"lt": "2014-11-01"
}
}
}
}
}
}

我们为什么要用 nested_path 和 nested_filter 重复查询条件呢?原因在于,排序发生在查询执行之后。 查询条件限定了只在10月份收到评论的博客文档,但返回整个博客文档。如果我们不在排序子句中加入 nested_filter , 那么我们对博客文档的排序将基于博客文档的所有评论,而不是仅仅在10月份接收到的评论。

父子关系文档

与 nested objects 相比,父-子关系的主要优势有:

更新父文档时,不会重新索引子文档。
创建,修改或删除子文档时,不会影响父文档或其他子文档。这一点在这种场景下尤其有用:子文档数量较多,并且子文档创建和修改的频率高时。
子文档可以作为搜索结果独立返回。
Elasticsearch 维护了一个父文档和子文档的映射关系,得益于这个映射,父-子文档关联查询操作非常快。但是这个映射也对父-子文档关系有个限制条件:父文档和其所有子文档,都必须要存储在同一个分片中。

==当文档索引性能远比查询性能重要 的时候,父子关系是非常有用的,但是它也是有巨大代价的。其查询速度会比同等的嵌套查询慢5到10倍!==

当你考虑父子关系是否适合你现有关系模型时,请考虑下面这些建议 :

尽量少地使用父子关系,仅在子文档远多于父文档时使用。
避免在一个查询中使用多个父子联合语句。
在 has_child 查询中使用 filter 上下文,或者设置 score_mode 为 none 来避免计算文档得分。
保证父 IDs 尽量短,以便在 doc values 中更好地压缩,被临时载入时占用更少的内存。
最重要的是: 先考虑下我们之前讨论过的其他方式来达到父子关系的效果。

扩容设计

副本分片与主分片做着相同的工作;它们只是扮演着略微不同的角色。没有必要确保主分片均匀地分布在所有节点中。
PS:主分片数>=节点数,大于是预留给节点数的扩充。

使用索引别名来指向当前版本的索引。 举例来说,给你的索引命名为 tweets_v1 而不是 tweets 。你的应用程序会与 tweets 进行交互,但事实上它是一个指向 tweets_v1 的别名。 这允许你将别名切换至一个更新版本的索引而保持服务运转。

一个搜索请求可以以多个索引为目标,所以将搜索别名指向 tweets_1 以及 tweets_2 是完全有效的。 然而,索引写入请求只能以单个索引为目标。因此,我们必须将索引写入的别名只指向新的索引。
PUT /tweets_1/_alias/tweets_search
PUT /tweets_1/_alias/tweets_index
POST /_aliases
{
“actions”: [
{ “add”: { “index”: “tweets_2”, “alias”: “tweets_search” }},
{ “remove”: { “index”: “tweets_1”, “alias”: “tweets_index” }},
{ “add”: { “index”: “tweets_2”, “alias”: “tweets_index” }}
]
}
一个文档 GET 请求,像一个索引写入请求那样,只能以单个索引为目标。 这导致在通过ID获取文档这样的场景下有一点复杂。作为代替,你可以对 tweets_1 以及 tweets_2 运行一个 ids 查询 搜索请求, 或者 multi-get 请求。

按时间范围索引

POST /_aliases
{
“actions”: [
{ “add”: { “alias”: “logs_current”, “index”: “logs_2014-10” }},
{ “remove”: { “alias”: “logs_current”, “index”: “logs_2014-09” }},
{ “add”: { “alias”: “last_3_months”, “index”: “logs_2014-10” }},
{ “remove”: { “alias”: “last_3_months”, “index”: “logs_2014-07” }}
]
}

索引模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PUT /_template/my_logs
{
"template": "logstash-*",
"order": 1,
"settings": {
"number_of_shards": 1
},
"mappings": {
"_default_": {
"_all": {
"enabled": false
}
}
},
"aliases": {
"last_3_months": {}
}
}

这个模板指定了所有名字以 logstash- 为起始的索引的默认设置,不论它是手动还是自动创建的。 如果我们认为明天的索引需要比今天更大的容量,我们可以更新这个索引以使用更多的分片。

这个模板还将新建索引添加至了 last_3_months 别名中,然而从那个别名中删除旧的索引则需要手动执行。

数据过期

删除整个索引比删除单个文档要更加高效:Elasticsearch 只需要删除整个文件夹。

这些索引可以被关闭。它们还会存在于集群中,但它们不会消耗磁盘空间以外的资源。重新打开一个索引要比从备份中恢复快得多。
在关闭之前,值得我们去刷写索引来确保没有事务残留在事务日志中。一个空白的事务日志会使得索引在重新打开时恢复得更快:
POST /logs_2014-01-/_flush
POST /logs_2014-01-
/_close
POST /logs_2014-01-*/_open

扩容并不是无限的

需要记住的是相同的数据结构需要在每个节点的内存中保存,并且当它发生更改时必须发布到每一个节点。 集群状态的数据量越大,这个操作就会越久。

管理、监控和部署

监控

X-Pack

部署

Transport Client 与 Node Client

如果你使用的是 Java,你可能想知道何时使用传输客户端(注:Transport Client,下同)与节点客户端(注:Node Client,下同)。 在书的开头所述, 传输客户端作为一个集群和应用程序之间的通信层。它知道 API 并能自动帮你在节点之间轮询,帮你嗅探集群等等。但它是集群 外部的 ,和 REST 客户端类似。

另一方面,节点客户端,实际上是一个集群中的节点(但不保存数据,不能成为主节点)。因为它是一个节点,它知道整个集群状态(所有节点驻留,分片分布在哪些节点,等等)。 这意味着它可以执行 APIs 但少了一个网络跃点。

这里有两个客户端案例的使用情况:

如果要将应用程序和 Elasticsearch 集群进行解耦,传输客户端是一个理想的选择。例如,如果您的应用程序需要快速的创建和销毁到集群的连接,传输客户端比节点客户端”轻”,因为它不是一个集群的一部分。

类似地,如果您需要创建成千上万的连接,你不想有成千上万节点加入集群。传输客户端( TC )将是一个更好的选择。

另一方面,如果你只需要有少数的、长期持久的对象连接到集群,客户端节点可以更高效,因为它知道集群的布局。但是它会使你的应用程序和集群耦合在一起,所以从防火墙的角度,它可能会构成问题。

参考文献

Elasticsearch: 权威指南