一、引言
Elastic官方对ElasticSearch的定义如下:ElasticSearch is a highly scalable open-source fullt-text search and analytics engine。即:在官方定义中ElasticSearch被视为一种高度可伸缩的全文检索和分析引擎,这体现了ElasticSearch具有强大的文档检索和分析能力。
事实上,ElasticSearch也包含了强大的数据存储能力,它所检索的数据不依赖于外部数据源,而由ElasticSearch统一管理。不仅如此,ElasticSearch还具备创建数据分片(Shard)和数据副本(Replica)的能力,可以满足大数据量下的高可用和高性能要求。
那么ElasticSearch的功能总结起来就是:
- 强大的
数据存储
能力 - 分布式的
全文检索
引擎 - 大数据量下近实时的
分析
引擎
上面简单快速的对ES的功能进行了分门别类。而本系列主要来谈其中的一类,即基于text数据类型的Full-text即全文检索查询,主要围绕如下3个主题来讲解:
-
全文检索
相关概念
介绍,包括倒排索引、分析、相关度、BM25算法等 -
如何选择
合适
的分词器 -
如何使用
正确的查询语句
来进行全文检索
Tips:
1)本文中涉及的所有代码示例及结论,都已经在ElasticSearch 7.1.1版本中测试并验证通过
2)本文中所提到的术语doc和文档,都是指索引中的一条数据。这是ElasticSearch中对于一条数据的专业叫法,正如在RMDB中,我们用一条记录或者record来表示一条数据
3)本文涉及的所有命令,都可在kibana的Dev Tools中直接运行
4)为了更好的理解本系列所讲解的知识,需要你有一定的ElasticSearch基础,比如ES基本的数据模型、settings、mappings的概念,基本的DSL查询等,bool复核查询最好也要了解点
二、相关概念
2.1 全文检索
先来解释一下什么叫全文检索
。
先说数据检索,数据检索的目的是从一系列的数据中,根据某一或某些数据特征将特定的数据查找出来。例如下面常见的sql:
-- sql1,查询主键id=1的 1 条数据
select * from tb_article where id = 1;
-- sql2,查询type=1或status=2的数据,有可能有多条
select * from tb_article where type = 1 or status = 2;
-- sql3,查询content字段中,包含了 elasticsearch server 的数据
select * from tb_article where content like '%elasticsearch server%'
从数据检索的角度来看,数据大体上可以分为两种类型:
- 结构化数据
int、float、boolean、string
特点:数据具有固定的格式和有限的长度
典型的结构化数据就是传统关系型数据库RMDB的表结构,数据特征直接体现在表结构的字段上,所以根据某一特征做数据检索很直接,速度也比较快。比如,根据文档的名称将文档数据全部查询出来,那通过一条sql就能实现。如果想要提高查询速度,只要在文档名称上创建索引就可以了。
- 非结构化数据
文章、网页、邮件、图片、视频
特点:没有预先定义好的结构化特征,也没有固定的格式和固定的长度
对于非结构数据的检索中,图片、视频等非文本数据
的检索暂且不论,而对于像文章、网页、邮件这种全文本(Full-text)数据的检索需求占了大多数。对于这种在全文数据中检索单个文档或文档集合的搜索技术,就被称为全文检索。
如下所示就是全文检索的典型示例,注意跟关系型数据库的like查询做区别
那么在ElasticSearch中,它的全文检索的实现步骤是怎样的呢?请看下图(下图来自网络)。
我们从存和取两个方面来进行说明
2.2 倒排索引
与结构化查询相比,全文检索面临的最大问题就是性能问题
。全文检索最一般的应用场景是根据一些关键字查询查找包含这些关键字的文档,比如互联网搜索引擎要实现的功能就是根据一些关键字查找网页(如上图)。显然,如果没有对文档特别处理,查找的方法似乎只能是逐条比对。具体来说就是先将文档都读取出来,再对文档内容做逐行扫描看是否包含这些关键字。例如,Linux中的grep
命令就是通过这种算法实现的。但这种方法在数据量非常大的情况下就像海底捞针一样,速度一定会非常慢。而类似互联网搜索引擎这样的应用面对的文档数量往往都是天文数字,对检索速度要求非常严苛,所以需要有一种更好的办法实现全文检索
。
关系型数据库提升数据查询速度的常用方法是给字段添加索引,有了索引的字段会根据字段值排序并创建类似排序二叉树的数据结构(如B树),这样就可以利用二分查找等算法提升查询速度。所以在字段添加索引后,通过这些字段做查询时速度能够得到非常明显的提升。但由于添加索引后需要对字段排序,所以添加和删除数据时速度会变慢,并且还需要额外的空间存储索引。这是典型的利用空间换取时间
的策略。普通的索引对全文检索并不适用,因为这种索引使用字段【整体值】参与排序,所以在检索时也要通过字段的整体值做查询条件
。而全文检索一般是查询包含某一或某些关键字的文档,所以通过文档整体值建立的索引对查询速度是没有任何帮助的。为了解决这个问题,人们创建了一种新索引方法,这种索引方法就是倒排索引
(Inverted Index)。
like模糊搜索有什么缺陷?缺陷在于,有时候它就不走索引了,那就是全表扫描了。最左匹配原则。
而ES解决的问题,就是大数据量下的搜索,几百万几千万,甚至上亿条数据的情况下,进行全文检索,如果做全表扫描,速度肯定是非常慢的。
倒排索引先将文档中包含的关键字全部提取出来,然后再将关键字与文档的对应关系保存起来,最后再对关键字本身做索引排序。用户在检索某一关键字时,可以先对关键字的索引进行查找,再通过关键字与文档的对应关系找到所在文档。这类似于查字典一样,字典的拼音表和部首表就是关键字索引,而拼音表和部首表中的内容就是关键字与文档的对应关系。为了说明倒排索引的基本思想,以下面两条文档为例:
文档一:I love elasticsearch.
文档二:I love logstash.
第一步:针对这两份文档创建倒排索引的第一步,是先对文档提取关键字。对于英文来说比较简单按空格分隔即可,两份文档共提取到4个关键字:I、love、elasticsearch和logstash。
第二部:接下来就是建立关键字与文档之间的对应关系,即标识关键字都被哪些文档包含。这里使用如下表所示的形式来表示这种关系.
term dictionary | Posting List TF |
---|---|
I | 1,2 |
love | 1,2 |
elasticsearch | 1 |
logstash | 2 |
注:上面的倒排表,我们是以二维表的形式来展示出来了,但是实际上,它不是这个样子的,这么说是为了方便我们去理解的。
如上所示,一个倒排索引是由**单词词典(Term Dictionary)和倒排列表(Posting List)**组成的,
-
term:词条是索引里面最小的存储和查询单元。一般来说,在英文语境中词条是一个单词,在中文语境中词条指的是分词后的一个词组
-
Term Dictionary:词典,又称字典,是词条的集合。单词词典一般是由网页或文章集合中出现过的所有词构成的字符串集合。
- term dictionary是按照
字典序
来排列的 问:要怎样通过我们给定的关键词快速找到这个Term呢?—— 答案是建索引,为Terms建立索引,最好的就是B-Tree索引
- term dictionary是按照
-
Posting List:倒排列表记录了出现过某个单词的所有文档的文档列表及单词在该文档中出现的位置信息,每条记录称为一个
倒排项
(Posting)。注:实际的倒排列表中并不只是存了文档ID这么简单,还有一些其它的信息如词频、位置、偏移量等,你可以把一个倒排项想象成是Python中的元组,或者Java中的对象。
- 文档ID
- 词频TF - 该词项在文档中出现的次数,用于相关性评分
- 位置(Position)- 词项在文档中分词的位置,用于语句搜索(phrase query)
- 偏移(Offset)- 记录单词的开始和结束位置,实现高亮显示
倒排项中的信息都有哪些,跟索引的mappings设置中的index_options
选项配置是有关的!
在倒排索引中,通过Term index可以找到Term在Term Dictionary中的位置,进而找到Posting List,有了倒排列表就可以根据ID找到文档了
正排索引通过id找数据,倒排索引是通过数据找id,然后再由id找到数据,多了这么一个步骤。
(下图来源于网络)
另外,词典和倒排表是分两部分存储的,词典存储在内存中,倒排表存储在磁盘上
。
(下图来源于网络)
有了倒排索引,用户检索就可以在倒排索引中快速定位到包含关键字的文档。倒排索引与关系型数据库索引类似,会根据关键字做排序。但关系型数据库索引一般都是对主键创建,然后索引指向数据内容;而倒排索引则正好相反,它是针对文档内容创建索引,然后索引指向主键(文档一、文档二),这就是这种索引被称为倒排索引的原因
。
从以上分析可以看出,倒排索引实际上是对全文数据结构化的过程
。对于存储在关系型数据库中的数据来说,它们依赖于人的预先分析将数据拆解为不同的字段,所以在数据插入时就已经是结构化的;而在全文数据库中,文档在插入时还不是结构化的,需要应用程序根据规则自动提取关键字,并形成关键字与文档之间的结构化对应关系。由于文档在创建时需要提取关键字并创建索引,所以向全文检索添加文档比关系型数据库要慢一些。
不难看出,全文检索中提取关键字是非常重要的一步。这些预先提取出来的关键字,在ElasticSearch及全文检索的相关文献中一般称为词项
(Term
),本文后续章节将不再使用关键字而改用词性这个专业术语。文档的词项提取在ElasticSearch中成为文档分析(Analysis),是整个全文检索中较为核心的过程。这个过程必须要区分哪些是词项,哪些不是。
- 对于英文来说,它必须要知道apple和apples指的是同一个东西,而run和running指的是同一个动作。
- 对于中文来说就更麻烦了,因为中文语不是以空格分隔,所以面临的第一难题是如何将词语分辨出来。
在ElasticSearch中,添加或者更新文档时最重要的动作是将它们编入倒排索引,未被编入倒排索引的文档将不能被检索。也就是说,ElasticSearch中所有数据的检索都必须要通过倒排索引来检索,离开了倒排索引文档就相当于不存在。所以从检索的角度来看,文档以倒排索引的形式表现其存在性。正是基于这个原因,ElasticSearch没有引入库的概念,而是将文档的容器直接称为索引(Index)。而这里的索引就是倒排索引,或者更准确的说是一组倒排索引。
在概念上可以将索引理解为文档在物理上的区分
,同一索引中的文档具有相同的索引策略,或者说它们被编入到同一组索引中。从检索的角度来说,用户在检索文档时也要指定从哪一个索引中检索文档。所以从存储和索引两个角度来看,以索引区分文档实在是最好不过了。
另外,因为文档存储前的分析和索引过程比较耗资源,所以为了提升性能,文档在添加到ElasticSearch中时并不会立即被编入索引。在默认情况下,ElasticSearch会
每隔1s
统一处理一次新加入的文档,可以通过index.refresh_interval参数修改。为了提升性能,在ElasticSearch7中还添加了index.search.idle.after参数,它的默认值为30s。其大体含义是,如果索引在一段时间内没有收到检索数据的请求,那么它至少要等到30s后才会刷新索引数据。所以,从这两个参数的作用来看,ElasticSearch实际上是准实时的(Near Realtime,NRT)
。也就是说,新添加到索引中的文档,有可能再一段时间内不能被检索到。如果的确需要立即检索到文档,ElasticSearch也提供了强制刷新到索引的方式,包括使用_refresh接口和在操作文档时使用refresh参数,但这会对性能有一定的影响,若在使用场景中对于实时性
的要求没有那么严格,则可以不手动刷新。
需要注意,正向索引和反向索引都有自己的用户之地,不能在学完反向索引之后,就觉得既然反向索引这么快,那么为什么不全都用反向索引呢?我们生活中使用到的绝大多数,依然是正向索引。
2.3 分析(Analysis)
文本分析Analysis是把全文转换成一系列有区别的、规范化的单词(term/token)的过程,其实更通俗易懂的叫法应该是:分词。文本分析通过Analyzer
即分词器来实现的。
除了在数据
写入时
转换词条,匹配Query语句时
也需要用相同的分词器对查询语句进行分析
话不多说,先直接来看一下分词器的分词效果是什么样的。
下图示例中,ElasticSearch Server这一段全文本,被ElasticSearch的默认分词器Standard分词成了elasticsearch和server共两个词项。
下面中文分词器的示例,用IK分词器对他说的确实在理进行分词
2.4 相关性(Relevance)
2.4.1 相关性的概念
Elasticsearch中,相关性
概念非常重要,也是完全区别于传统关系型数据库的一个概念。
通俗的说,相关性评分(relevance score),是用于衡量每个文档与输入查询匹配的程度
的。
默认情况下,Elasticsearch根据相关性评分对匹配的搜索结果进行排序。
相关性评分是一个正浮点数,在Search API的score元数据字段中返回。score越高,说明文档越相关。
下面直接举一个栗子来说明相关性
# 1) 为防止用于测试索引以存在,在下一步的创建之间,可以先进行删除操作
DELETE idx-susu-test-relevance
# 2)设置测试索引的settings和mapppings
PUT idx-susu-test-relevance
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
},
"mappings": {
"properties": {
"id": {
"type": "long"
},
"name": {
"type": "keyword"
},
"about": {
"type": "text",
"analyzer": "standard"
}
}
}
}
# 3)往测试索引中插入两条测试数据
POST idx-susu-test-relevance/_bulk?refresh=true
{ "index": { "_id": "1" }}
{ "id": 1, "name": "zhangsan", "about": "I like to collect rock albums"}
{ "index": { "_id": "2" }}
{ "id": 2, "name": "lisi", "about": "I love to go rock climbing"}
# 4)查询全部数据,看是否插入成功
GET idx-susu-test-relevance/_search
# 5)在about字段中做全文检索,查询"rock climbing"
GET idx-susu-test-relevance/_search
{
"query": {
"match": {
"about": "rock climbing"
}
}
}
查询结果如下:
{
……(省略掉无关部分)
"hits" : [
{
"_index" : "idx-susu-test-relevance",
"_type" : "_doc",
"_id" : "2",
"_score" : 0.87546873, <------ 【注1】
"_source" : {
"id" : 2,
"name" : "lisi",
"about" : "I love to go rock climbing"
}
},
{
"_index" : "idx-susu-test-relevance",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.18232156, <------ 【注2】
"_source" : {
"id" : 1,
"name" : "zhangsan",
"about" : "I like to collect rock albums"
}
}
]
}
}
从上面的查询结果中可以看到,id=2的文档,排在了最前面,因为它的相关性算分_score=0.87546873,要高于id=1文档的算分。
那么为什么在搜索rock climbing时,id=2文档的算分最高呢?—— 那在这个例子中原因是显而易见的,因为id=2的文档中,about字段中同时包含了rock和climbing共2个term;而id=1的文档中因为只包含了climbing共1个term。两相比较下,当然是id=2的文档的匹配度更高,因而算分也就更高了。
上述案例就阐明了Elasticsearch中何为相关性。
2.4.2 相似度算法-BM25算法
从上面知道,相关性(Relevance Score)是评价查询条件与查询结果之间的匹配程度
,并根据这种匹配程度对结果进行排序
的一种能力。
而衡量匹配程度的标志,就是相似度算分了。
那这个相似度算分,又是如何计算出来的呢? – 答案就是 【相似度算法
】!
根据ElasticSearch7.1版本官网中关于相似度算法的介绍,Elasticsearch 的相似度算法(评分/排名模型)定义了匹配文档的评分方式。而且由于每个字段都具有相似度,这意味着可以通过mappings为每个字段
定义不同的相似度算法。
ES提供了许多相似度算法供用户使用,如下:
BM25、DFR、DFI、IB、LMDirichlet、LMJelinekMercer等
关于这些相似度算法的描述,可以自己去官网查看,也可以看这篇博客(感觉这篇博客就是对官网的翻译,我这里就不重复造车了)
当然用户也可以自定义相似度算法。不过配置自定义相似度被认为是专家功能(Configuring a custom similarity is considered an expert feature
),并且ES内置的相似度算法一般来说已经足以满足绝大部分的需求了,所以理论上来说不大推荐自定义相似度算法。
从ES5.0(注意是5.0,官网截图如下)版本之后,ES中默认使用的是BM25算法。
接下来着重讲解BM25算法,内容全部参考自官方博客:
1.practical-bm25-part-2-the-bm25-algorithm-and-its-variables
2.practical-bm25-part-3-considerations-for-picking-b-and-k1-in-elasticsearch
此外,这里讲的算是BM25算法的基本知识,如果想更深入了解,可以去官网中查看更多关于BM25算法的描述。
BM25全称Okapi BM25。Okapi 是使用它的第一个系统的名称,即Okapi信息检索系统,BM则是best matching的缩写。
BM25是基于TF-IDF算法并做了改进,基于概率模型的文档检索算法,目前BM25及其较新的变体(例如BM25F)代表了文档检索中使用的最先进
的TF/IDF类检索功能。
现在,先不考虑中文分词器、同义词、停用词等一切可能的干扰项,直接使用ES默认的Standard分词器,准备一点英文文档数据:
# 1) 为防止索引已存在,可提前删除
DELETE idx-susu-test-bm25
# 2)设置索引的settings和mappings
PUT idx-susu-test-bm25
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval" : "1s"
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "standard"
}
}
}
}
# 3)往索引中插入数据
PUT idx-susu-test-bm25/_bulk?refresh=true
{ "index" : {"_id" : "1" } }
{ "title": "Shane" }
{ "index" : {"_id" : "2" } }
{ "title": "Shane C" }
{ "index" : {"_id" : "3" } }
{ "title": "Shane Connelly" }
{ "index" : {"_id" : "4" } }
{ "title": "Shane P Connelly" }
#4)查询全部数据
GET idx-susu-test-bm25/_search
下面是官方博客中BM25算法的标准公式,表示给定一个查询Q,包含关键字 q{1},…,q{n},文档D的BM25分数计算公式为
s
c
o
r
e
(
D
,
Q
)
=
∑
i
=
1
n
I
D
F
(
q
i
)
f
(
q
i
,
D
)
∗
(
k
1
+
1
)
f
(
q
i
,
D
)
+
k
1
∗
(
1
−
b
+
b
∗
f
i
e
l
d
L
e
n
a
v
g
F
i
e
l
d
L
e
n
)
\huge score(D,Q) = \displaystyle \sum^{n}_{i=1}IDF(q_i){\frac{f(q_i, D)\ast(k_1+1)}{f(q_i, D)+k_1\ast(1-b+b\ast \frac{fieldLen}{avgFieldLen})}}
score(D,Q)=i=1∑nIDF(qi)f(qi,D)+k1∗(1−b+b∗avgFieldLenfieldLen)f(qi,D)∗(k1+1)
从公式不难看出,一个查询Q的算分结果,等于q{1},…,q{n}的相关度算分的总和∑
。
上面的公式是从官方博客中直接摘抄下来的,跟我们的ES7.1.1版本中explain结果中的参数名称有点出入,为了在我们查看explain结果的时候方便理解,下面修改了一下公式中的参数名称,得到新的公式如下:
s
c
o
r
e
(
D
,
Q
)
=
∑
i
=
1
n
I
D
F
(
q
i
)
f
(
q
i
,
D
)
∗
(
k
1
+
1
)
f
(
q
i
,
D
)
+
k
1
∗
(
1
−
b
+
b
∗
d
l
a
v
g
d
l
)
\huge score(D,Q) = \displaystyle \sum^{n}_{i=1}IDF(q_i){\frac{f(q_i, D)\ast(k_1+1)}{f(q_i, D)+k_1\ast(1-b+b\ast \frac{dl}{avgdl})}}
score(D,Q)=i=1∑nIDF(qi)f(qi,D)+k1∗(1−b+b∗avgdldl)f(qi,D)∗(k1+1)
接下来对公式中的参数进行逐项讲解