一、引言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)**组成的,
term:词条是索引里面最小的存储和查询单元。一般来说,在英文语境中词条是一个单词,在中文语境中词条指的是分词后的一个词组
Term Dictionary:词典,又称字典,是词条的集合。单词词典一般是由网页或文章集合中出现过的所有词构成的字符串集合。
term dictionary是按照字典序来排列的问:要怎样通过我们给定的关键词快速找到这个Term呢?—— 答案是建索引,为Terms建立索引,最好的就是B-Tree索引
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分词器对他说的确实在理进行分词
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_1ast(1-b+bast 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_1ast(1-b+bast frac{dl}{avgdl})}}
score(D,Q)=i=1∑nIDF(qi)f(qi,D)+k1∗(1−b+b∗avgdldl)f(qi,D)∗(k1+1)
接下来对公式中的参数进行逐项讲解
1) 搜索项 qi푞푖 : 查询项,例如我搜索“Shane”,因为经过Standard Analyzer分词后只有一个查询项,所以 푞0是shane。
假设用英语搜索Shane Connelly,使用ES的默认分词器Standard Analyzer分词后将得到如下2个Term:[“shane”, “connelly”],因此q0和q1的内容如下
푞0 :shane푞1: connelly
结合求和公式∑,那么搜索Shane Connelly时的相关度算分,就得等于shane的相关度算分 + connelly的相关度算分!
2) 逆文档频率 IDFIDF(qi): 查询项的逆文档频率,用于衡量qi这个词提供了多少信息,也就是说它在所有文档中是常见的还是罕见的。
如果一个搜索词是非常罕见的词语(例如专业术语)且在某个doc中匹配了,则该doc的算分会提高;反之对于非常常见的匹配词降低分数。
IDF公式如下:
I
D
F
(
q
i
)
=
ln
(
1
+
d
o
c
C
o
u
n
t
−
f
(
q
i
)
+
0.5
f
(
q
i
)
+
0.5
)
huge IDF(q_i) = ln(1 + frac{docCount - f(q_i) + 0.5}{f(q_i)+0.5})
IDF(qi)=ln(1+f(qi)+0.5docCount−f(qi)+0.5)
同样的,官方的这个公式跟ES7.1.1版本中explain结果中的参数名称也有出入,下面对公式中的变量名称做一些微调:
I
D
F
(
q
i
)
=
ln
(
1
+
N
−
n
+
0.5
n
+
0.5
)
huge IDF(q_i) = ln(1 + frac{N - n + 0.5}{n+0.5})
IDF(qi)=ln(1+n+0.5N−n+0.5)
其中:
푛 :索引中包含查询项푞푖的文档数量푁:是索引中的文档总数
以这一小节开头写入的索引数据为例,假如我们在当前索引idx-susu-test-bm25 中搜索文本“Shane”,“Shane”这个词语出现在4个文档中,将这个数值代入到上面的参数:
푛(qi): 索引文章中包含查询项Shane 的文档数量 = 4N:索引中的文档总数 = 4
将参数代入公式进行计算,结果如下:
I
D
F
(
q
i
)
=
ln
(
1
+
N
−
n
+
0.5
n
+
0.5
)
=
ln
(
1
+
4
−
4
+
0.5
4
+
0.5
)
=
ln
(
1
+
0.5
4.5
)
=
0.105360515
Large IDF(q_i) = ln(1 + frac{N - n + 0.5}{n+0.5}) = ln(1 + frac{4 - 4 + 0.5}{4+0.5}) = ln(1 + frac{0.5}{4.5}) = 0.105360515
IDF(qi)=ln(1+n+0.5N−n+0.5)=ln(1+4+0.54−4+0.5)=ln(1+4.50.5)=0.105360515
我们在Kibana中请求Elasticsearch的 explain API来验证一下上面的公式计算结果,可以看到,结果是一样的。
关于IDF公式在全文检索中所起到的指导意义,可以参考《2.4.6小节的结论2》
3)词频 푓(푞푖,퐷)注:需要注意,IDF中的小n和大N参数,都是基于分片计算的!若不理解这句话是什么意思,则可以去参考《ElasticSearch中Shard对于评分的影响》小节。
푓(푞푖,퐷) : D是文档, 푓(푞푖,퐷)是指Term在文档中出现的频率,即词频。Term在文档中出现的权重与频率成正比。用最通俗易懂的话来说就是查询的词语在doc中出现了多少次。
这很有直觉意义,例如我正在搜索Elasticsearch,一篇文章内提到了一次可能只是简单的引用,如果文章中出现了很多次Elasticsearch那就更有可能与我们的搜索内容相关。
4) 字段长度dl与平均长度部分avgdl
dl: fieldLen,文档中当前字段的长度。其实准确的说,是当前字段的值可以被分词为多少个term。
如下图,还是以上面的查询为例。对于id=3的doc来说,在title字段上执行全文检索时,相关度算分中的n参数=2。结合id=3的doc中title字段的值(Shane Connelly),以为它可以被Standard Analyzer分词为2个Term:[“shane”, “connelly”],因此参数n=2,没毛病
avgdl:平均字段长度(avgFieldLen)
如下图,由于索引总共有4个文档,这4个文档的title字段的值,被Standard Analyzer分词后总共可以得到8个Term,因此avgdl = 8 ➗ 4 = 2
注:avgdl也是基于分片计算的。
这里说的“长度”,指的都是**term的个数,而不是**字符串长度如"Shane Connelly".length = 14
我们把算分公式拿过来,然后对比着看一下这两个参数对于全文检索有什么算分上的指导意义
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
)
Large 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_1ast(1-b+bast frac{dl}{avgdl})}}
score(D,Q)=i=1∑nIDF(qi)f(qi,D)+k1∗(1−b+b∗avgdldl)f(qi,D)∗(k1+1)
1)如果当前字段长度dl比平均值字段长度avgdl长,那么分母就会变大,从而导致最终算分将低
2)如果当前字段长度dl比平均值字段长度avgdl短,则分母就会变小,从而使得最终得分变高
注意,Elasticsearch中字段长度的实现是基于Term数量的(而不是字符长度之类的)。考虑这个问题的方法是,文档中的词语越多(至少是与查询不匹配的术语),文档的得分就越低。
可查看下面《结论三》查看这两个参数对算分的影响。
5)可调节变量 b 和 k1b和k1是BM25算法中可调节的两个参数,在使用Elasticsearch的过程中也可以作为一些特殊搜索场景的调优点。
为了方便讲解,再次把算分公式拿过来
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
)
Large 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_1ast(1-b+bast frac{dl}{avgdl})}}
score(D,Q)=i=1∑nIDF(qi)f(qi,D)+k1∗(1−b+b∗avgdldl)f(qi,D)∗(k1+1)
k1 : 词的饱和度值参数,Elasticsearch中默认值为1.2。根据官网,k1 is a variable which helps determine term frequency saturation characteristics. ,k1是用于控制词频饱和度的一个参数,用通俗的话说就是控制Term在文档中出现的次数对于得分的重要性。
例如说我觉得在某些场景,一个搜索词在文档中出现越多则越接近我希望搜索的内容,就可以将这个参数调大一点。
假设k1=0,则此时TF的计算公式如下:
T
F
=
f
(
q
i
,
D
)
∗
(
k
1
+
1
)
f
(
q
i
,
D
)
+
k
1
∗
(
1
−
b
+
b
∗
d
l
a
v
g
d
l
)
=
f
(
q
i
,
D
)
∗
(
0
+
1
)
f
(
q
i
,
D
)
+
0
∗
(
1
−
b
+
b
∗
d
l
a
v
g
d
l
)
=
f
(
q
i
,
D
)
f
(
q
i
,
D
)
=
1
Large TF = {frac{f(q_i, D)ast(k_1+1)}{f(q_i, D)+k_1ast(1-b+bast frac{dl}{avgdl})}} = {frac{f(q_i, D)ast(0+1)}{f(q_i, D)+0ast(1-b+bast frac{dl}{avgdl})}} = {frac{f(q_i, D)}{f(q_i, D)}} = 1
TF=f(qi,D)+k1∗(1−b+b∗avgdldl)f(qi,D)∗(k1+1)=f(qi,D)+0∗(1−b+b∗avgdldl)f(qi,D)∗(0+1)=f(qi,D)f(qi,D)=1
可以看到,当k1参数=0时,将只有IDF逆文档频率对最终算分有作用,TF将不影响算分了。
k1 is a variable which helps determine term frequency saturation characteristics(词频饱和度). That is, it limits how much a single query term can affect the score of a given document. It does this through approaching an asymptote(渐近线). You can see the comparison of BM25 against TF/IDF in this:
A higher/lower k1 value means that the slope of “tf() of BM25” curve changes. This has the effect of changing how “terms occurring extra times add extra score.” An interpretation of k1 is that for documents of the average length, it is the value of the term frequency that gives a score of half the maximum score for the considered term. The curve of the impact of tf on the score grows quickly when tf() ≤ k1 and slower and slower when tf() > k1. Continuing with our example, with k1 we’re controlling the answer to the question “how much more should adding a second ‘shane’ to the document contribute to the score than the first or the third compared to the second?” A higher k1 means that the score for each term can continue to go up by relatively more for more instances of that term. A value of 0 for k1 would mean that everything except IDF(qi) would cancel out. By default, k1 has a value of 1.2 in Elasticsearch.
b :长度归一化参数,默认值为0.75。控制文档长度对于分数的惩罚力度。应该注意到,变量b处于分母上,它乘以刚刚讨论过的字段长度的比值。 这意味着:
1)如果b越大,则文档长度相对于平均长度的影响越大。
2)如果将b设置为0,那么长度比的效果将完全为零。b=0时TF的计算公式如下:
T F = f ( q i , D ) ∗ ( k 1 + 1 ) f ( q i , D ) + k 1 ∗ ( 1 − b + b ∗ d l a v g d l ) = f ( q i , D ) ∗ ( k 1 + 1 ) f ( q i , D ) + k 1 ∗ ( 1 − 0 + 0 ∗ d l a v g d l ) = f ( q i , D ) ∗ ( k 1 + 1 ) f ( q i , D ) + k 1 Large TF = {frac{f(q_i, D)ast(k_1+1)}{f(q_i, D)+k_1ast(1-b+bast frac{dl}{avgdl})}} = {frac{f(q_i, D)ast(k_1+1)}{f(q_i, D)+k_1ast(1-0+0ast frac{dl}{avgdl})}} = {frac{f(q_i, D)ast(k_1+1)}{f(q_i, D)+k_1}} TF=f(qi,D)+k1∗(1−b+b∗avgdldl)f(qi,D)∗(k1+1)=f(qi,D)+k1∗(1−0+0∗avgdldl)f(qi,D)∗(k1+1)=f(qi,D)+k1f(qi,D)∗(k1+1)
可以看到,此时长度比率的影响将完全无效,文档的长度将与分数无关。
接下里举一个栗子,来帮助大家更好的理解k1和b参数之间的相互作用。
上面的代码块中是词频部分的计算结果,此时是默认的索引设置,k1 = 1.2, b=0.75 。在这种默认设置下文档的长度惩罚、词频相关较为均衡。例如现在加入一条混淆数据:
# 6) 往索引中插入一条混淆数据 PUT idx-susu-test-bm25/_doc/5 { "title": "Shane Shane P" }
接下来在title字段上搜索关键字“Shane”。
GET idx-susu-test-bm25/_search { "query":{ "match":{ "title":"Shane" } } } # 返回结果 { "hits" : { "total" : { "value" : 5, "relation" : "eq" }, "max_score" : 0.112004004, "hits" : [ { "_index" : "idx-susu-test-bm25", "_type" : "_doc", "_id" : "1", "_score" : 0.112004004, < ------ 【注1】 "_source" : { "title" : "Shane" } }, { "_index" : "idx-susu-test-bm25", "_type" : "_doc", "_id" : "5", "_score" : 0.10853996, < ------ 【注2】 "_source" : { "title" : "Shane Shane P" } } ……(省略掉其他doc的返回) ] } }
此时发现文档5这条数据虽然因为有两个单词匹配,但是因为它的文档长度的原因,最终分数略低于完全匹配的1号文档,如下图是通过explain api对比了两条doc的算分详情的结果
假如我们希望能在文档中尽量匹配更多的搜索词,即使文档长度稍微长一点也没关系,则可以尝试着将 k1 增大, 同时降低 变量 b :
# 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", "index": { "similarity": { "my_bm25": { "type": "BM25", "b": 0.5, "k1": 1.5 } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard", "similarity": "my_bm25" } } } } # 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" } { "index" : {"_id" : "5" } } { "title": "Shane Shane P" } #4)查询全部数据 GET idx-susu-test-bm25/_search # 5) 全文检索 GET idx-susu-test-bm25/_search { "query": { "match": { "title": "Shane" } } }
现在,搜索的最佳结果就是我们期望的5号文档了!
{ "hits" : { "total" : { "value" : 5, "relation" : "eq" }, "max_score" : 0.11531628, "hits" : [ { "_index" : "idx-susu-test-bm25", "_type" : "_doc", "_id" : "5", "_score" : 0.11531628, "_source" : { "title" : "Shane Shane P" } }, { "_index" : "idx-susu-test-bm25", "_type" : "_doc", "_id" : "1", "_score" : 0.10403534, "_source" : { "title" : "Shane" } }, …… ] } }
注:上面这是对于修改k1和b参数的一个示例。
默认的索引设置中,k1=1.2, b=0.75 。在这种默认设置下文档的长度惩罚、词频相关已经是较为均衡的了,若无特殊要求,没有必要修改这两个参数。
其实关于b和k1参数的意义,或者说这两个参数在算分中的更深入的作用,我这里没有再往下详细列举了。有需要可以去官网博客中查看。
2.4.3 explian结果详解
ES中提供一个explain方法,能够解释doc的Score是怎么得来的,具体每一部分的得分都可以详细地打印出来
下面举个栗子,来对explian的结果进行详细分析
# 1)步骤1先删除 DELETE idx-susu-test-explain # 2)设置索引的settings和mappings # 注意这里我们将number_of_shards设置为1,这一点很重要,因为在ES中 多shard 对于算分是有影响的,将number_of_shard设置为1有利于我们更好的观察和对比算分规则。 # 若不知道为什么 多shard 对于算分有影响,以及会产生什么样的影响,可以去看《Elasticsearch中Shard对于评分的影响》章节 PUT idx-susu-test-explain { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard" } } } } # 3)批量插入数据 PUT idx-susu-test-explain/_bulk?refresh=true { "index" : { "_id" : "1" } } { "title": "java scala" } { "index" : { "_id" : "2" } } { "title": "python scala" } { "index" : { "_id" : "3" } } { "title": "java java php" } # 4) 查询全部数据 GET idx-susu-test-explain/_search
接下来,我们直接在索引的title字段上,查询关键字java。从查询结果很容易看到,最终检索到了**_id=1和_id=3的doc。并且由于_id=3的doc的相关性算分_score=0.59818643,要高于_id=1的doc的0.4991763,因此_id=3的doc在返回结果中排在了前面**!
# 5)指定在title字段上执行查询关键字java GET idx-susu-test-explain/_search { "query": { "match": { "title": "java" } } } # 查询结果如下: { …… "hits" : [ { "_index" : "idx-susu-test-explain", "_type" : "_doc", "_id" : "3", "_score" : 0.59818643, "_source" : { "title" : "java java php" } }, { "_index" : "idx-susu-test-explain", "_type" : "_doc", "_id" : "1", "_score" : 0.4991763, "_source" : { "title" : "java scala" } } ] }
接下来我们就利用explainapi,来查看搜索java时,在id=1的doc中的算分详情
# 6)通过explain,查看算分详情 GET idx-susu-test-explain/_explain/1 { "query":{ "match":{ "title":"java" } } }
命令执行结果如下:
{ "_index" : "idx-susu-test-explain", "_type" : "_doc", "_id" : "1", "matched" : true, "explanation" : { "value" : 0.4991763, <------ 注1 "description" : "weight(title:java in 0) [PerFieldSimilarity], result of:", <------ 注2 "details" : [ <------ 注3 { "value" : 0.4991763, "description" : "score(freq=1.0), product of:", <------ 注4 "details" : [ { "value" : 2.2, "description" : "boost", <------ 注5 "details" : [ ] }, { "value" : 0.47000363, <------ 注6 "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details" : [ { "value" : 2, "description" : "n, number of documents containing term", < ------ 注6.1 "details" : [ ] }, { "value" : 3, "description" : "N, total number of documents with field", < ------ 注6.2 "details" : [ ] } ] }, { "value" : 0.4827586, < ------ 注7 "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details" : [ { "value" : 1.0, < ------ 注7.1 "description" : "freq, occurrences of term within document", "details" : [ ] }, { "value" : 1.2, < ------ 注7.2 "description" : "k1, term saturation parameter", "details" : [ ] }, { "value" : 0.75, < ------ 注7.3 "description" : "b, length normalization parameter", "details" : [ ] }, { "value" : 2.0, < ------ 注7.4 "description" : "dl, length of field", "details" : [ ] }, { "value" : 2.3333333, < ------ 注7.5 "description" : "avgdl, average length of field", "details" : [ ] } ] } ] } ] } }
结合上面的结果,逐点分析:
注1:最终的相关性得分为 0.4991763
注2:从description可以看出,这个0.4991763的得分,是通过计算java这个关键字在title字段中的权重得到的;
注3:这个details数组中,就展示了0.4991763得分的计算细节
注4:从这个description可以看出,score=0.4991763是通过下面几项相乘得到的(product of)。即:score = 0.4991763 = boost * idf * tf = 2.2 * 0.47000363 * 0.4827586
注5:boost的值为2.2。因为我们在查询时、以及在设置所以会的title字段时都没有手动指定boost权重,因此这里展示的就是boost的原本的默认值2.2。
假设我们在设置索引的mappings时的配置如下:
PUT idx-susu-test-explain { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard", "boost": 2 } } } }
那么此时explain的结果中,boost的值 = 2.2的默认值 * 2 = 4.4,如下图:
再此基础上,假如在查询时,又指定了boost权重,示例如下:
如上,在查询时指定了boost=4,因此最终的boost值=默认值2.2✖️index-time-boost✖️query-time-boost = 2.2✖️2✖️4 = 17.6
注6:idf-逆文档频率 的值,从下面的description可以看出,它的计算公式是log(1 + (N - n + 0.5) / (n + 0.5))
注6.1:小n表示:_id=1的doc所在的分片上,该索引的title字段中包含了java关键字的doc有多少条
注6.2:大N表示:_id=1的doc所在的分片上,该索引总共有多少条数据
注7:tf,即Term Frequency,是指Term在文档中出现的频率的值,从下面的description可以看出它的计算公式为freq / (freq + k1 * (1 - b + b * dl / avgdl))
注7.1:freq,词频。在本例中,它表示的是在_id=1的doc中,java关键词在title字段中出现的次数。
注7.2:k1,词的饱和度值,默认值为1.2
注7.3:b,长度归一化参数,默认值为0.75
注7.4:dl,被检索字段分词后得到的term的个数。比如在_id=1的这条doc中,title字段的内容是"java scala",分词后得到[“java”, “scala”],共2个term,因此它的dl=2.0
注7.5:avgdl,分片中当前被检索字段的平均词组数值。比如在本示例中,索引中共3条数据,而这3条数据的title字段的值,经过standard分词器分词后,得到的结果为:[“java”, “scala”, “python”, “scala”, “java”, “java”, “php”],共7个term,因此这里的avgdl=7/3=2.3333333
Tip:
输出 explain 结果代价是十分昂贵的,它只能用作调试工具 。千万不要用于生产环境。
JSON 形式的 explain 描述是难以阅读的, 但是转成 YAML 会好很多,只需要在参数中加上 format=yaml 。
最后,我们再看一种情况:
接下来我们就利用explainapi,来查看搜索java php时,在id=1的doc中的算分详情
# 7)通过explain,查看算分详情,注意这的输入变成了java php!! GET idx-susu-test-explain/_explain/1 { "query":{ "match":{ "title":"java php" } } }
此时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_1ast(1-b+bast frac{dl}{avgdl})}} score(D,Q)=i=1∑nIDF(qi)f(qi,D)+k1∗(1−b+b∗avgdldl)f(qi,D)∗(k1+1)
2.4.4 Elasticsearch中Shard对于评分的影响
本小节内容全部参考自官方博客:practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch
这是非常重要单又往往容易被忽略的一个知识点。为了说明它的重要性,下面先讲一个实际业务中真实存在的问题
问题很简单,如上图,有用户在群里反馈:系统在用表名去做全文检索的时候,搜索了vehicle_portrait,直觉上看起来明明是vehicle_portrait这张表是最匹配的,为什么它却排在了第3位?更过分的是为什么company_portrait排在了它前面?
由于线上已经把该问题修复了,无法重现,因此下面在测试环境中自己mock数据来重现这一场景。
测试数据准备:
# 1)为防止索引已存在,可先删除 DELETe idx-susu-test-shard # 2)设置测试索引的settings和mapppings # 注意为了保持跟线上一致,这里把number_of_shards设置为了3。而也正是因为这个设置导致了上述问题 PUT idx-susu-test-shard { "settings": { "number_of_shards": 3, "number_of_replicas": 0 }, "mappings": { "properties": { "id": { "type": "long" }, "name": { "type": "keyword" }, "table": { "type": "text", "analyzer": "simple" } } } } # 3)往测试索引中插入两条测试数据 POST idx-susu-test-shard/_bulk?refresh=true { "index": { "_id": "1" }} { "id": 1, "name": "企业画像", "table": "company_portrait"} { "index": { "_id": "2" }} { "id": 2, "name": "车辆-画像", "table": "vehicle_portrait"} { "index": { "_id": "3" }} { "id": 3, "name": "用户画像", "table": "user_portrait"} { "index": { "_id": "4" }} { "id": 4, "name": "明细-用户车辆关系", "table": "dwd_user_vehicle_relation"} { "index": { "_id": "5" }} { "id": 5, "name": "车辆基本信息变更-明细", "table": "dwd_vehicle_field_change"} { "index": { "_id": "6" }} { "id": 6, "name": "疑似二手车", "table": "dwd_vehicle_second_hand_possible"} { "index": { "_id": "7" }} { "id": 7, "name": "车辆基本信息-历史", "table": "vehicle_static_v3"} { "index": { "_id": "8" }} { "id": 8, "name": "汇总-车辆道路超速-月", "table": "dws_vehicle_roadoverspd_m"} # 4)查询全部数据 GET idx-susu-test-shard/_search
接着,在上面测试数据的基础上,进行全文检索,同样的,我们也搜索vehicle_portrait,效果如下所示
如上图,我们依然在table字段上进行全文检索,搜索词输入为vehicle_portrait。
可以看到,id=1这行数据的相关度评分最高,排在了第一!这是不合理的,因为显而易见的,id=2这一行数据才是最匹配的,它的算分才应该最高!
至此,我们已经成功的把问题复现出来了。
接下来,顺着排查思路往下看。
既然上图看到了,id=1这一行数据的算分最高,我们对它的分数是有异议的,那么肯定是想知道,它的算分是怎么计算出来的!
下面通过explain命令,来查看算分的详细情况!
# 6)在table字段上做全文检索 (将explain设置为true!) GET idx-susu-test-shard/_search { "explain": true, "query": { "match": { "table": "vehicle_portrait" } } }
查询结果如下:
{ ……(只展示_id=1这个doc的算分详情) "hits" : [ { "_shard" : "[idx-susu-test-shard][2]", < ------ 【注1】从这里能看得出,id=1的doc文档,是分布在2号shard的!!! "_node" : "LAonGnLIQsGo4yg152mZpw", "_index" : "idx-susu-test-shard", "_type" : "_doc", "_id" : "1", "_score" : 1.2048765, "_source" : { "id" : 1, "name" : "企业画像", "table" : "company_portrait" }, "_explanation" : { "value" : 1.2048765, "description" : "sum of:", "details" : [ { "value" : 1.2048765, "description" : "weight(table:portrait in 0) [PerFieldSimilarity], result of:", "details" : [ { "value" : 1.2048765, "description" : "score(freq=1.0), product of:", "details" : [ { "value" : 2.2, < ------ 【注2】 "description" : "boost", "details" : [ ] }, { "value" : 0.98082924, < ------ 【注3】 "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details" : [ { "value" : 1, "description" : "n, number of documents containing term", "details" : [ ] }, { "value" : 3, "description" : "N, total number of documents with field", "details" : [ ] } ] }, { "value" : 0.55837566, < ------ 【注4】 "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details" : [ { "value" : 1.0, "description" : "freq, occurrences of term within document", "details" : [ ] }, { "value" : 1.2, "description" : "k1, term saturation parameter", "details" : [ ] }, { "value" : 0.75, "description" : "b, length normalization parameter", "details" : [ ] }, { "value" : 2.0, "description" : "dl, length of field", "details" : [ ] }, { "value" : 3.6666667, < ------ 【注5】 "description" : "avgdl, average length of field", "details" : [ ] } ] } ] } ] } ] } }, ……(省略掉其他doc的算分详情) ] } }
explain的结果显示,id=1的doc,根据算分公式,相关度得分_score = boost * idf * tf = 2.2 * 0.98082924 * 0.55837566 = 1.2048765
问题其实就出现在IDF的值(0.98082924)上,看IDF计算公式中的小n和大N参数:n=1, N=3。
n:table字段中包含了portrait关键字的doc有多少条。结合我们插入的测试数据,显然小n应该等于3,而不是1!
N:索引总共有多少条数据。我们前面总共就插入了8条数据,所以N应该等于8,而不应该等于3!
疑问:为什么小n和大N参数的值,跟我们理解的不一样呢?
其实在《practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch》这篇官方博客中,已经给出了解释:**默认情况下,是按照每个分片来计算分数的!**如下图:
因为在Elasticsearch中,是按每个分片计算分数,而不是按整个索引计算分数。这也就意味着,BM25算法中的词频和逆文档频率部分的计算过程中,我们需要用到 索引文章中包含查询项的文档数量(即小n) 和 索引中的文档总数(即大N)和avgdl参数,这些都是在分片内计算的。
也就是说,explian结果中的n=1, N=3,是在分片内计算出来的;而我们自己推导出来的n=3, N=8,是因为我们的视角是基于整个索引的!
为了证明默认情况下,ES是按照每个分片来计算分数的!这个结论,我们继续往下排查
首先查看这个索引的数据总共分布在了几个shard中:
# 7)查看shard GET _cat/shards/idx-susu-test-shard?v # 查询结果: index shard prirep state docs store ip node idx-susu-test-shard 2 p STARTED 3 4.2kb 192.168.104.137 data-02 idx-susu-test-shard 1 p STARTED 3 4kb 192.168.104.134 data-01 idx-susu-test-shard 0 p STARTED 2 4kb 192.168.104.139 data-03
可以看到,该索引的数据确实分布在了0、1、2共3个shard上。
通过上面explain结果中的【注1】,可以知道,目前id=1的doc,是分布在2号shard上的。
通过如下命令,查看2号shard上都有哪些数据(从下面结果看出,2号shard上总共有3条文档)
# 8)通过preference参数,查询指定的2号shard中的数据 GET idx-susu-test-shard/_search?preference=_shards:2 { "query": { "match_all": {} } } # 查询结果 { …… "hits" : [ { "_index" : "idx-susu-test-shard", "_type" : "_doc", "_id" : "1", "_score" : 1.0, "_source" : { "id" : 1, "name" : "企业画像", "table" : "company_portrait" } }, { "_index" : "idx-susu-test-shard", "_type" : "_doc", "_id" : "6", "_score" : 1.0, "_source" : { "id" : 6, "name" : "疑似二手车", "table" : "dwd_vehicle_second_hand_possible" } }, { "_index" : "idx-susu-test-shard", "_type" : "_doc", "_id" : "8", "_score" : 1.0, "_source" : { "id" : 8, "name" : "汇总-车辆道路超速-月", "table" : "dws_vehicle_roadoverspd_m" } } ] } }
在这个shard的数据中,再去看IDF计算公式中的小n和大N参数,就会发现,小n确实等于1, 大N确实等于3!
并且该shard上索引中数据的table字段,经过分次之后,能得到11个term,所以avgdl = 11 / 3条数据 = 3.6666667,这个值跟上面explain中给出的avgdl的值(【注5】)是一样的!
如果刚开始在索引中加载几个文档,此时就问“为什么文档A的分数比文档B高/低”,有时答案是碎片与文档的比率相对较高,从而使分数在不同的碎片之间倾斜。有几种方法可以在各个碎片之间获得更一致的分数:
加载到索引中的文档越多,分片的统计数据就会变得越规范化。有了足够多的文档,词频统计数据的细微差异不足以影响到每个分片中的评分细节。
可以使用更低的碎片计数来减少术语频率的统计偏差。
尝试在请求中添加search_type=dfs_query_then_fetch参数,该请求首先收集分布式词频(DFS =分布式词频搜索),然后使用它们计算分数。这种情况下返回的分数就像是索引只有一个shard一样。这个选项使用从运行搜索的所有分片收集的信息,全局计算分布式项频率。虽然提高了评分的准确性,但它增加了对每个分片的往返搜索时间,可能导致更慢的搜索请求。
# 8)在table字段上做全文检索 (添加search_type=dfs_query_then_fetch!) GET idx-susu-test-shard/_search?search_type=dfs_query_then_fetch { "explain": true, "query": { "match": { "table": "vehicle_portrait" } } }
2.4.5 相关性的一些结论 结论一:词频越高,分数越高
词频越高,分数越高。
关键词在网页中的词频越高,意味着出现的次数越多,说明页面与搜索词的关系越密切。
下面我们以一个栗子来说明该结论。
首先创建一个测试用的索引,并往里面批量插入数据,命令如下。
Tip:
由相关性算分的公式可以知道,影响算分的因素有很多,如boost、idf(n和N参数)、tf(freq、k1、b、dl、avgdl参数)。
而"词频越高,分数越高"这个结论,就是freq参数在算分公式中所起到的作用的最直观体现。
因此,为了更好的体现出freq参数等于不同的值的时候对算分的不同影响,下面例子中示例数据是经过特殊设计的,使得在最终的算分中,boost、idf、dl、avgdl等参数的值都一样,唯独freq不一样,从而更直观的看出“freq参数等于不同的值的时候,对算分的不同影响”的规律性。
# 1)步骤1先删除(为了防止用于测试的索引已经存在,因此这里第一步先删除) DELETe idx-susu-test-freq # 2)设置索引的settings和mappings # 注意这里我们将number_of_shards设置为1,这一点很重要,因为在ES中 多shard 对于算分是有影响的,而将number_of_shard设置为1有利于我们更好的观察和对比算分规则。 # 若不知道为什么 多shard 对于算分有影响,以及会产生什么样的影响,可以去看《Elasticsearch中Shard对于评分的影响》章节 PUT idx-susu-test-freq { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard" } } } } # 3)批量插入数据 PUT idx-susu-test-freq/_bulk?refresh=true { "index" : { "_id" : "1" } } { "title": "java scala python" } { "index" : { "_id" : "2" } } { "title": "python scala" } { "index" : { "_id" : "3" } } { "title": "java java php" } # 4) 查询全部数据 GET idx-susu-test-freq/_search
接下来,我们直接在索引的title字段上,查询关键字java。从查询结果很容易看到,_id=3的doc的得分要高于**_id=1的doc,它在返回结果中排在了前面**!
# 5)指定在title字段上执行查询关键字java GET idx-susu-test-freq/_search { "query": { "match": { "title": "java" } } } # 查询结果如下: { ……(省略掉无关部分) "hits" : [ { "_index" : "idx-susu-test-freq", "_type" : "_doc", "_id" : "3", "_score" : 0.62430674, "_source" : { "title" : "java java php" } }, { "_index" : "idx-susu-test-freq", "_type" : "_doc", "_id" : "1", "_score" : 0.4471386, "_source" : { "title" : "java scala python" } } ] }
接下来,我们就是用explain api,来具体分析一下,为什么**_id=3的doc的相关性算分为0.62430674,而_id=1**的doc相关性算分为0.4471386。
调用explain api,查看java关键词在**_id=1和_id=3**的文档中的算分详情,命令分别如下
# 6)调用explain api,查看java关键词在_id=1的doc中的算分详情 GET idx-susu-test-freq/_explain/1 { "query":{ "match":{ "title":"java" } } }
# 7)调用explain api,查看java关键词在_id=3的doc中的算分详情 GET idx-susu-test-freq/_explain/3 { "query":{ "match":{ "title":"java" } } }
为了本文的篇幅不至于太过冗余,这里就不分别贴出explain的结果了,直接贴出它们的对比图:
大家可以结合《expalin结果详解》章节去进行自行分析
为了方便说明,下面将上图中的关键信息提取出来,如下所示:
从上面表格可以看到,_id=1和**_id=3**的doc中的算分之所以不一样,是因为他们的tf值也就是词频计算分数不一样!
而tf值的计算公式是freq / (freq + k1 * (1 - b + b * dl / avgdl))。
那么接下里我们再从explain的结果中,将tf计算公式中的各个参数的值提取出来,如下所示:
从上面表格可以看出,正是由于**_id=3的doc中freq参数值比_id=1大,从而导致了计算得到的tf值的不同,最终使得_id=3**的doc的相关性评分_score比 _id=1 的高!
而为什么**_id=3的doc中,freq=2;而_id=1的doc中,freq=1呢?那是因为_id=3 的doc中,title字段中有2个java,而_id=1 **的doc中,title字段中只有1个java。
结论二:物以稀为贵
关键词的使用频率指的是日常生活用词的频率,如“有的”“有点”“可能”“非常”这些词经常出现在日常交流过程中,但在搜索引擎看来,这些词汇的意义并不大。
对比到相关性算法中,如果一个搜索词是非常罕见的词语(例如专业术语)且在某个文档中匹配了,则分数会提高;反之对于非常常见的匹配词降低分数。
这条结论也是 算分公式中**IDF(逆文档频率)**的最佳体现
下面我们以一个例子来说明该结论。
Tip:
同结论一中的例子一样,本例中的测试数据,也是经过特殊设计的,使得在最终的算分中,boost、tf(freq、k1、b、dl、avgdl)等参数的值都一样,唯独IDF值不一样(准确说是IDF中的n参数不一样),从而更直观的看出“IDF等于不同的值的时候,对算分的不同影响”的规律性。
# 1)步骤1先删除(为了防止用于测试的索引已经存在,因此这里第一步先删除) DELETE idx-susu-test-idf # 2)设置索引的settings和mappings # 注意这里我们将number_of_shards设置为1,这一点很重要,因为在ES中 多shard 对于算分是有影响的,而将number_of_shard设置为1有利于我们更好的观察和对比算分规则。 # 若不知道为什么 多shard 对于算分有影响,以及会产生什么样的影响,可以去看《Elasticsearch中Shard对于评分的影响》章节 PUT idx-susu-test-idf { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard" } } } } # 3)批量插入数据 PUT idx-susu-test-idf/_bulk?refresh=true { "index" : { "_id" : "1" } } { "title": "java scala python" } { "index" : { "_id" : "2" } } { "title": "python scala" } { "index" : { "_id" : "3" } } { "title": "java java php" } # 4) 查询全部数据 GET idx-susu-test-idf/_search
直接上查询
# 5) 在idx-susu-test-idf索引上查询 scala php ,可以看到,id=3的doc,排在了id=1的doc前面 GET idx-susu-test-idf/_search { "query": { "match": { "title": "scala php" } } } # 查询结果如下 { ……(省略掉无关部分)) "hits" : [ { "_index" : "idx-susu-test-idf", "_type" : "_doc", "_id" : "3", "_score" : 0.9331132, "_source" : { "title" : "java java php" } }, { "_index" : "idx-susu-test-idf", "_type" : "_doc", "_id" : "2", "_score" : 0.52354836, "_source" : { "title" : "python scala" } }, { "_index" : "idx-susu-test-idf", "_type" : "_doc", "_id" : "1", "_score" : 0.4471386, "_source" : { "title" : "java scala python" } } ] }
接下来,我们就使用explain api,来具体分析一下,为什么**_id=3的doc的相关性算分要高于_id=1**的doc相关性算分。
调用explain api,查看java关键词在**_id=1和_id=3**的文档中的算分详情,命令分别如下
# 6)调用explain id=1的doc GET idx-susu-test-idf/_explain/1 { "query":{ "match":{ "title":"scala php" } } }
# 7)调用explain id=3的doc GET idx-susu-test-idf/_explain/3 { "query":{ "match":{ "title":"scala php" } } }
为了本文的篇幅不至于太过冗余,这里就不分别贴出explain的结果了,直接贴出它们的对比图:
这需要说明的是:
1)上图中:左图其实算的是scala关键词在_id=1的doc的title字段中的算分详情;右图算的是php关键词在_id=3的doc的title字段中的算分详情。
2)以左图为例,如果有如下疑问为什么我明明explain的是scala php这两个关键词在title字段中的算分详情,结果却只显示scala的算分详情?, 那么请去看《explain结果详解》章节
也可以结合《expalin结果详解》章节去进行自行分析
为了方便说明,下面将上图中的关键信息提取出来,如下所示:
从上面表格可以看到,_id=1和**_id=3**的doc中的算分之所以不一样,是因为他们的idf值也就是逆文本频率计算分数不一样!
而idf值的计算公式是log(1 + (N - n + 0.5) / (n + 0.5))。
那么接下里我们再从explain的结果中,将tf计算公式中的各个参数的值提取出来,如下所示:
从上面表格可以看出,正是由于**_id=3的doc中小n参数值比_id=1小,从而导致了计算得到的tf值的不同,最终使得_id=3**的doc的相关性评分_score比 _id=1 的高!
这就是因为,对于左图的_id=1的doc来说,它算的是scala的相关度算分,而因为scala在_id=1和_id=2共2个文档中都出现了,因此n=2;而对于右图中的_id=3的doc来说,它算的是php的相关度算分,而因为php只在__id=3共1个文档中出现了,因此n=1,但是也是正因为如此,它在索引中总共出现的次数少,因此显得更金贵!导致算得的idf数值更大,最终使得_id=3的doc的整体相关度评分更高了。
结论三:占比越大,得分越高
一个关键词如果在它所搜索的文档字段中占比越大,则最终得分越高!
该结论跟tf计算公式中的dl参数有关
# 1)步骤1先删除 DELETE idx-susu-test-tf # 2)设置索引的settings和mappings PUT idx-susu-test-tf { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard" } } } } # 3)批量插入数据 PUT idx-susu-test-tf/_bulk?refresh=true { "index" : {"_id" : "1" } } { "title": "flink basic java java" } { "index" : {"_id" : "2" } } { "title": "scala scala" } { "index" : {"_id" : "3" } } { "title": "basic java" } # 4) 查询全部数据 GET idx-susu-test-tf/_search # 5)然后调用explain api,查看basic关键词在id=1的doc中的算分明细 # n=2, N=3 --> idf=0.47000363 # freq=1.0, k1=1.2, b=0.75, dl=4.0, avgdl=2.6666667 --> tf=0.37735847 # 最终得分_score = boost*idf*df = 2.2*0.47000363*0.37735847 = 0.39019167 GET idx-susu-test-tf/_explain/1 { "query":{ "match":{ "title":"basic" } } } # 6)然后调用explain api,查看basic关键其次在id=3的doc中的算分明细 # n=2, N=3 --> idf=0.47000363 # freq=1.0, k1=1.2, b=0.75, dl=2.0, avgdl=2.6666667 --> tf=0.5063291 # 最终得分_score = boost*idf*df = 2.2*0.47000363*0.5063291 = 0.52354836 GET idx-susu-test-tf/_explain/3 { "query":{ "match":{ "title":"basic" } } } # 7)结论: 文档id boost idf tf 最终得分 1 2.2 0.47000363 0.37735847 0.39019167 3 2.2 0.47000363 0.5063291 0.52354836 可以看到,导致最终得分不一样的原因,是因为两个doc中,计算得到的tf是不一样的! 接下来我们把tf计算过程所需的参数都列出来,如下: 1)在_id=1的doc中:freq=1.0, k1=1.2, b=0.75, dl=4.0, avgdl=2.6666667, 最后所有的入参代入tf的计算公式freq / (freq + k1 * (1 - b + b * dl / avgdl)),计算得分=0.37735847 2)在_id=3的doc中:freq=1.0, k1=1.2, b=0.75, dl=2.0, avgdl=2.6666667, 最后所有的入参代入tf的计算公式freq / (freq + k1 * (1 - b + b * dl / avgdl)),计算得分=0.5063291 可以看到,_id=1和_id=3的doc,他们的tf算分之所以不一样,是因为dl参数的值不一样所致的! 而我们知道,dl参数,代表的是文档的长度,而avgdl 代表平均字段长度(avgFieldLen) 在_id=1的doc中,title字段的值为"flink basic java java",根据standard分词器,可以分词为["flink","basic", "java", "java"],即分词之后得到4个term,所以它的dl=4.0 同理,在_id=3的doc中,title字段的值为"basic java",根据standard分词器,可以分词为["basic", "java"],即分词之后得到2个term,所以它的dl=2.0 注:上面这段话,结合案例,讲解了dl是什么意思,以及在具体情形中是如何计算出来的. 接下来我们再看avgdl是如何计算出来的 从上面我们也可以看出,_id=1和_id=3的doc,他们的avgdl的值是一样的,都是2.6666667。而avgdl代表平均字段长度。那么2.6666667这个值它是如何计算出来的呢? 在我们的示例索引idx-susu-test-idf中 有如下的3条数据: { "title": "flink basic java java" } ——> ["flink","basic", "java", "java"] ——> 共4个term { "title": "scala scala" } ——> ["scala","scala"] ——> 共2个term { "title": "basic java" } ——> ["basic", "java"] ——> 共2个term 可以看到,在整个索引的title字典中,总共有4+2+2=8个term,而当前索引中总共有3条doc,索引avgdl=8/3=2.6666667 上面就是结合案例,解释了avgdl是如何计算出来的的。 综上,对于在索引的某个字段中搜索某个关键词的场景中(比如这里:在索引的title字段上,搜索basic),对于不同的doc: 1)假如这些doc都分布在同一个shard中,那么他们在计算相关性得分时计算得到的avgdl肯定是一样的! 那么这时候影响最终的tf算分的关键因素之一,就是dl了,也就是文档的长度。 注:这里之所以说是dl是影响的因素之一,是因为由tf的计算公式freq / (freq + k1 * (1 - b + b * dl / avgdl))也可以知道,影响tf最终算分结果的因素是有很多的,因为我们主要是要讲解dl的作用及意义,因此在这里特意设计的案例中,也是特使使得freq、k1、b等参数都一样,使得我们更方便明显的来观察不同的dl对算分的影响 那么由此我们得出如下一个比较通俗理解的结论:一个关键词如果在它所搜索的文档字段中占比越大,则最终得分越高! 就如在我们这个例子中(在title字段上搜索basic),因为在_id=1的doc中,title字段的值经过分词后共得到4个term,即["flink","basic", "java", "java"],而我们要搜索的basic占比是1/4=25% 而在_id=3的doc中,title字段的值经过分伺候共2个term,即["basic", "java"],而我们要搜索的basic占比是1/2=50% 那么显然,在_id=3的doc中,basic这个词的重要性是比较大的! 2)而如果这些doc分布在不同的shard中,那么那么由于在不同的shard中所包含的索引数不一定一样,因此最后计算的到的avgdl就不一定相同。当然了,这个也是ES中数据多shard的弊端之一!
结论四:位置越重要,得分越高
**关键词出现在在所在网页的位置**是指关键词是否出现在了比较重要的位置,如标题。关键词出现在所在网页的位置越重要,说明页面与关键词越相关。一般而言,倒排索引库在建立时,关键词出现在所在网页的位置就会被记录在其中的。
当然了,这句话并不是规律,而是一种指导意义,或者说控制相关度的一种指导思路。
查询时可以通过手动指定boost权重,来使得出现在重要字段的查询得分较高。如下,在table和describe两个字段中查询,但是在表名table字段上的查询权重boost设置为5,要高于备注describe字段上的权重
GET sjzt-dmp-da-table/_search { "query": { "bool": { "should": [ { "match": { "table": { "query": "vehicle", "boost": 5 < ------ 【注1】 } } }, { "match": { "describe": { "query": "vehicle", "boost": 2 < ------ 【注2】 } } } ] } } }
结论五:网页链接和重要性
网页链接及重要性指的是页面有越多以搜索词为关键词的导入链接,说明页面的相关性越强;链接分析还包括了链接源页面本身的主题、目标文字周围的文字等。
如下测试,我们在计算文档的相关性的同时,还考虑到了文档浏览数对算分的影响。
# 1) 先删除 DELETE idx-susu-test-func # 2) 设置setttings和mappings PUT idx-susu-test-func { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard" }, "view": { "type": "long" } } } } # 3)批量插入数据 PUT idx-susu-test-func/_bulk?refresh=true { "index" : { "_id" : "1" } } { "title": "java tomcat", "view": 2} { "index" : { "_id" : "2" } } { "title": "java nginx", "view": 100} # 4)在title字段上搜java,此时返回结果中的两条文档的算分都一样 GET idx-susu-test-func/_search { "query": { "match": { "title": "java" } } } # 5)利用function_score,使得new_score = old_score + log(1 + 0.1 * view) GET idx-susu-test-func/_search { "query": { "function_score": { "query": { "match": { "title": "java" } }, "functions": [ { "field_value_factor": { "field": "view", "modifier": "log1p", "factor": 0.1 } } ], "boost_mode": "sum" } } }
当然,在目前的搜索引擎中,还都不约而同的引入了 用户行为分析、数据挖掘 等技术,来提升搜索结果的质量。
三、分析和分词器
官网链接:
- https://www.elastic.co/guide/cn/elasticsearch/guide/current/analysis-intro.htmlhttps://www.elastic.co/guide/en/elasticsearch/reference/current/analysis.html
由前文可知,在实现全文检索的过程中,一般都需要 提取非结构化数据中的有效信息,重新组织数据的承载结构形式, 而搜索数据时,需要基于新结构化的数据展开,从而达到提高搜索速度的目的。显而易见,全文检索是一种空间换时间的做法——前期进行数据索引的创建,需要花费一定的时间和空间,但能显著提高搜索的效率。
在ElasticSearch中,”提取非结构化数据中的有效信息“的方式,就是利用分词器来完成的。利用分词器将输入切分为一个个词项的过程,就叫做**分析**。
分词器一般在如下两个场合中起作用:
在indexing的时候,也即在建立索引的时候在qury的时候,也即在搜索时,分析需要搜索的词语
3.1 分词器Analyzer 3.1.1 Analyzer的组成
分词器是专门处理分词的组件,它由3部分组成:
Character Filters
针对原始文本处理,例如去除html等
Tokenizer
按照规则切分为单词
Token Filter
将切分出来的单词进行加工,小写,删除stopwords,增加同义词等。
总体说来一个analyzer可以分为如下的几个部分:
0个或1个以上的character filter有且仅有1个tokenizer0个或1个以上的token filter
它们就像是连接在一起的管道,共同完成对全文数据的词项提取工作。
3.1.2 使用 _analyzer API 1)直接指定analyzer进行测试
POST _analyze { "analyzer": "ik_max_word", "text": ["他说的确实在理"] }
2)指定索引的某个字段中定义的分词器进行测试
POST sjzt-dmp-da-table/_analyze { "field": "comment", "text": ["他说的确实在理"] }
注:前提是,字段上必须指定了分词器
POST _analyze { "tokenizer": "standard", "filter": ["lowercase"], "text": ["Mastering Elasticsearch"] }
3.1.3 内置分词器
ES中的内置分词器有如下8种
1)Standard Analyzer默认分词器,按词切分,小写处理
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-standard-analyzer.html
Standard Analyzer是es默认的分词器, 它会对输入的文本,按词的方式进行切分,切分完之后,会进行一个转小写的处理。默认情况下它的stop word是关闭的。
下面我们通过实际运行查看Standard Analyzer的分词效果
输入如下:
# 使用Standard Analyzer,对一样输入进行分词,并查看效果 GET _analyze { "analyzer": "standard", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
执行效果如下
[ the, 2, quick, brown, foxes, jumped, over, the, lazy, dog's, bone ]
可以看到standard分词器默认是按照空格对输入进行了分词。
由Quick经过分词变成了quick,可见standard分词器是会把输入变成小写的;
比如in、the等停用词也没有真正的被去掉!
停用词是指在文本中出现的频率非常高,但是对文本所携带的信息基本不产生影响的词。
文本经过分词之后,停用词通常被过滤掉,不会被进行索引。
排除停用词可以加快建立索引的速度,减小索引库文件的大小。
一个 standard analyzer 可以做如下的属性设置:
由上面我们也看到了,在ES默认的Standard Analyzer中,stop word即停用词的token filter是关闭的。那如果我们在使用Standard Analyzer的时候,就想把这stop word功能启用起来,那该如何呢?
这个时候,就需要基于Standard Analyzer,新建(自定义)一个分词器出来。如下所示:
# 在索引idx-susu-test-standard中,基于standard分词器,自定义一个名为su_standard的分词器(添加了stop word的token filter) PUT /idx-susu-test-standard { "settings": { "analysis": { "analyzer": { "su_standard": { "tokenizer": "standard", "filter": [ "lowercase", "stop" ] } } } } } #使用索引idx-susu-test-standard中自定义的分词器su_standard,对输入进行分词 POST idx-susu-test-standard/_analyze { "analyzer": "su_standard", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
此时分词结果如下:
[ 2, quick, brown, foxes, jumped, over, the, lazy, dog's, bone ]
可以看到,相比于默认的standard分词器,我们基于standard进行自定义的分词器,多了过滤停用词的功能(停用词the已被过滤掉)!
2)Simple Analyzer按照非字母切分(符号被过滤),小写处理
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-simple-analyzer.html
Simple Analyzer它只包括了一个叫做Lower Case的Tokenizer,它会按照非字母的方式对输入进行切分,非字母的都会被去除,最后对切分好的term做转小写的处理。
下面我们通过实际运行查看Simple Analyzer的分词效果
输入如下:
# 使用Simple Analyzer,对一样输入进行分词,并查看效果 GET _analyze { "analyzer": "simple", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
执行效果如下
- 输入中的2已经被去掉了brown-foxes也被拆分了(可见所有非字母的都会被去除了)进行了小写的转换。
[ the, quick, brown, foxes, jumped, over, the, lazy, dog, s, bone ]3) Whitespace Analyzer
按照空格切分,不转小写
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-whitespace-analyzer.html
Whitespace Analyzer分词器非常简单,它按照空格对输入进行切分。
下面我们通过实际运行查看Whitespace Analyzer的分词效果
输入如下:
# 使用Whitespace Analyzer,对一样输入进行分词,并查看效果 GET _analyze { "analyzer": "whitespace", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
执行效果如下
[ The, 2, QUICK, Brown-Foxes, jumped, over, the, lazy, dog's, bone. ]
可以看到,该分词器:
- 只是简单的按照空格对输入进行切分保留了非字母保留了停用词并且Brown-Foxes也没拆分QUICK也保留了原样,没有进行小写转换
小写处理,停用词过滤(the,a,is)
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-stop-analyzer.html
Stop Analyzer除了有Lower Case的Tokenizer,还多了stopword的token filter,这个token filter的作用是会把the、a、is等修饰性的词语去除。
下面我们通过实际运行查看Stop Analyzer的分词效果
输入如下:
#使用stop analyzer,对输入进行分词,并查看效果 GET _analyze { "analyzer": "stop", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone."" }
执行效果如下
[ quick, brown, foxes, jumped, over, lazy, dog, s, bone ]
可以看到,2已经不在了,并且in、the等停用词都被过滤掉了
※ 定制化stop analyzer一个 stop analyzer 可以做如下的属性设置:
#在索引idx-susu-test-stop中,基于stop analyzer定制化一个名为su_stop_analyzer的分词器 PUT idx-susu-test-stop { "settings": { "analysis": { "analyzer": { "su_stop_analyzer": { "type": "stop", "stopwords": ["the", "over"] } } } } } #使用自定义的分词器,对输入进行分词 POST idx-susu-test-stop/_analyze { "analyzer": "su_stop_analyzer", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
分词效果如下
[ quick, brown, foxes, jumped, lazy, dog, s, bone ]
因为在上面我们将over这个单词也设置为了停用词,因此最后的输出结果中,over已经被过滤掉了
5) Keyword Analyzer不分词,直接将输入当做输出
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-keyword-analyzer.html
下面我们通过实际运行查看Keyword Analyzer的分词效果
输入如下:
#使用Keyword analyzer,对输入进行分词,并查看效果 GET _analyze { "analyzer": "keyword", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
执行效果如下
{ "tokens" : [ { "token" : "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone.", "start_offset" : 0, "end_offset" : 56, "type" : "word", "position" : 0 } ] }6)Pattern Analyzer
通过正则表达式将文本分成"terms"。默认按W(非字符分隔)分隔
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-pattern-analyzer.html
注:停用词的token filter默认是关闭的
下面我们通过实际运行查看Pattern Analyzer的分词效果
输入如下:
#使用Pattern analyzer,对输入进行分词,并查看效果 GET _analyze { "analyzer": "pattern", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
效果展示如下
[ the, 2, quick, brown, foxes, jumped, over, the, lazy, dog, s, bone ]
- 由于Pattern Analyzer默认是按照W+的正则,会按非字符的符号对输入进行分词,因此brown-foxes也会被分词为brown和foxes且因为该分词器有Lower Case的tokenizer,因此分词后的token也会转换成小写但是由于停用词的token filter默认是关闭的,因此类似the这样的停用词并没有被过滤掉
一个 pattern analyzer 可以做如下的属性设置:
由上面的示例可知,默认情况下,pattern analyzer的停用词token filter是关闭的,并且,默认是按照W+的正则进行token分隔的。那如果我们想自定义正则表达式,又或者想将停用词的token filter启用起来,该如何做呢?关于这点,官网描述如下:
由官网可知,此时我们只需要基于pattern analyzer,再自定义一个分词器即可。
下面根据真实情况举一个栗子。
我们中台有一个名为车辆详细信息查询(手机号)的接口,它的url为/bigdata-sjzt/vehicle/queryVehicleDetailsByPhone,此时不管是用ES自带的standard analyzer还是pattern analyzer去做分词,分词效果都不理想
# 车辆详细信息查询(手机号) POST /_analyze { "analyzer": "pattern", "text": ["/bigdata-sjzt/vehicle/queryVehicleDetailsByPhone"] }
分词结果如下:
[bigdata, sjzt, vehicle, queryvehicledetailsbyphone]
可以看到,默认分词器对于url中的queryVehicleDetailsByPhone的分词结果,并不尽如人意。
此时我们就可以基于pattern analyzer,自定义一个分词器,来处理这种驼峰式命名的情形。
如下所示:
# 在索引idx-susu-test-pattern上,定义一个名为su_pattern_camel的分词器,用来处理“驼峰式命名”的单词 PUT idx-susu-test-pattern { "settings": { "analysis": { "analyzer": { "su_pattern_camel": { "type": "pattern", "pattern": "([^\p{L}\d]+)|(?<=\D)(?=\d)|(?<=\d)(?=\D)|(?<=[\p{L}&&[^\p{Lu}]])(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}[\p{L}&&[^\p{Lu}]])" } } } } } # 使用上面自定义的su_pattern_camel分词器,来对输入进行分词 GET idx-susu-test-pattern/_analyze { "analyzer": "su_pattern_camel", "text": ["/bigdata-sjzt/vehicle/queryVehicleDetailsByPhone"] }
此时分词结果如下:
[bigdata, sjzt, vehicle, query, vehicle, details, by, phone]7) English Analyzer
es其实还会针对不同国家语言的输入,提供针对不同语言的Analyzer!
下面是english analyzer的示例
#使用english analyzer,对输入进行分词,并查看效果 GET _analyze { "analyzer": "english", "text": "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone." }
效果如下
[ 2, quick, brown, fox, jump, over, lazi, dog, bone ]
可以看到输入中的Brown-Foxes变成了brown和fox,并且是将foxes由复数变成了单数fox。同时因为它也有stop的过滤器,因此经过分词之后,停用词the已被过滤掉了。
官网链接:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html#english-analyzer
返回单一结果,结果自动排序,去除重复词。规范化删掉扩展符。
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-fingerprint-analyzer.html
fingerprint tokenizer 由以下构成:
Tokenizer
Standard Tokenizer
Token Filters(依次如下)
Lower Case Token FilterASCII Folding Token FilterStop Token Filter (默认禁用)Fingerprint Token Filter
效果示例
# 使用fingerprint对输入进行分词 POST _analyze { "analyzer": "fingerprint", "text": "Yes yes, Gödel said this sentence is consistent and." }
分词效果:
{ "tokens" : [ { "token" : "and consistent godel is said sentence this yes", "start_offset" : 0, "end_offset" : 52, "type" : "fingerprint", "position" : 0 } ] }
可以看到
1)输出结果中只有一个token
2)对单词进行了排序 a->z
3)实现了去重:两个yes变成了一个
4)规范化删掉扩展符:Gödel变成了godel
9)内置分词器总结
Whitespace会遇到空格就拆分,而Standard则是提取出单词,例如:对于Brown-Foxes,Whitespace切分之后还是这样,而Standard切分后则是brown和foxes。
Simple遇到非字母就切分,而Standard未必,例如:对于dog's,Simple会切分成dog和s,而Standard切分后则是dog's。
总之,Whitespace遇到空格就切分,Simple遇到非字母就切分,Standard切分单词(可以是所有格形式)。
3.1.4 自定义分词器
当ElasticSearch内置的分词器无法满足你的业务场景时,这时就可以按需定制自己的分词器了。
由上面的2.2.1小节可知,一个analyzer可以分为如下的几个部分:
0个或1个以上的character filter有且仅有1个tokenizer0个或1个以上的token filter
那么显然,所谓的自定义analyzer,其实就是通过自行组合上述组件来实现的。
1)Character Filter在Tokenizer之前对文本进行处理,例如增加、删除或替换字符,比如一个Character Filter可以将诸如(٠١٢٣٤٥٦٧٨٩)的阿拉伯文字,转换成(0123456789)的拉丁文.
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-charfilters.html
一个analyzer中可以配置0到多个Character Filter
Character Filter会影响Tokenizer的position和offset信息
一些自带的Character Filter:
HTML strip:去除html标签的,比如把加粗标签从内容中去除掉。使用效果看下面的示例1Mapping:字符串替换Pattern replace:正则匹配替换 2)Tokenizer
将原始的文本按照一定的规则,切分为词(term或token)
ElasticSearch中内置的tokenizer:
whitespace / tandard / uax_url_mail / pattern / keyword / path hierarchy
可以用Java开发插件,实现自己的Tokenizer
3)Token Filter将Tokenizer输出的单词(term),进行增加、修改或删除
ElasticSearch中自带的Token Filter:
Lowercase / top / synonym(添加近义词)
4) es自带analyzer效果示例 示例1:Character Filter之HTML strip这里我们选用的tokenizer是keyword,那么显然它是不会对输入进行任何的分词处理的!
POST _analyze { "tokenizer": "keyword", "char_filter": ["html_strip"], "text": "hello world" }
分析效果如下,可以看到,因为我们使用的是html_strip这个character filter,因此经过分析之后,输入中的html标签已经被剥离掉了。
{ "tokens" : [ { "token" : "hello world", "start_offset" : 3, "end_offset" : 18, "type" : "word", "position" : 0 } ] }
当我们处理一些网络爬虫抓取的数据的时候,就可以使用该character filter对一些不必要的html标签元素进行过滤剥离。又或者当某个字段存储的是富文本内容时,也同样可以对内容中的html标签进行剔除。
示例2:Character Filter之Mapping有些时候,我们可能需要对输入中的一些元素进行一些替换,比如在下面的示例中,利用mapping的character filter,将网络上的一些表情符号,来替换成相应的文字
#使用character filter,替换表情符号 POST _analyze { "tokenizer": "standard", "char_filter": [ { "type": "mapping", "mappings": [ ":) => happy", ":( => sad" ] } ], "text": ["i am felling :)", "feeling :( today"] }
分词效果如下:
i, am, felling, happy, feeling, sad, today
可以看到,输入的网络表情符号:)和:(都已经分别被替换成了happy和sad了
示例3:Character Filter之Pattern replace#基于正则的character filter POST _analyze { "tokenizer": "standard", "char_filter": [ { "type": "pattern_replace", "pattern": "http://(.*)", "replacement": "" } ], "text": "http://www.elastic.co" }
输出效果:
www.elastic.co
可以看到,输入中的http://前缀已经被处理掉了
示例4:tokenizer示例前面基本都是character filter的示例,下面我们来看一下tokenizer的示例。
tokenizer其实可以按照文件的路径,对输入进行拆分
#tokenizer示例1(按照文件路径进行拆分) POST _analyze { "tokenizer": "path_hierarchy", "text": "/users/susu/study/elastic" }
输出效果:
/users, /users/susu, /users/susu/study, /users/susu/study/elastic示例5:Token Filters之stop
#token filter示例 POST _analyze { "tokenizer": "whitespace", "filter": ["stop"], "text": "The girls in China are playing this game" }
分词效果:
The, girls, China, playing, game
根据上面的设置,es会对输入按照空格进行切分,然后因为设置的filter是stop,因此切分之后会对一些停用词进行过滤,然后就得到了如上的效果,可以看到are、in等停用词都被过滤掉了。
但是The没有被过滤掉,这是因为我们是按照whitespace去进行切词的,切完之后,它并没有进行小写的转换,而对大写的单词,是不会被当做stop word进行过滤的。
示例6:Token Filters之lowercase还是上面的示例,如果我们一定要过滤掉The,那么我们可以做如下的分词设置
#token filter示例(lowercase filter) POST _analyze { "tokenizer": "whitespace", "filter": ["lowercase", "stop"], "text": "The girls in China are playing this game" }
分词效果:
girls, china, playing, game
从分词效果中,可以看到,The已经被过滤掉了,这是因为我们在示例5第一个token filter的基础上加了个lowercase,使得es在切分之后,先做了一个转小写的处理,然后再利用stop去做停用词的过滤。
3.2 中文分词器
在分词方面,中英文分词有天然的差异性。相较而言,英文分词会简单一些,因为英文有天然的空格作为单词的分隔符。中文不仅没有天然的空格来分隔汉字,而且汉字的词组大部分由两个及以上的汉字组成,汉语语句也习惯连续性书写,因IC增加了中文分词的难度。
而且,一句中文,在不同的上下文中,也有不同的理解,比如这个苹果不大好吃,就可以分为如下两种
这个苹果,不大好吃
这个苹果,不大,好吃
此外,同英文分词一样,中文分词中,也有”停用词“的概念(stop word),用于表示无内容指示意义的词。
我们先看标准分词器standard analyzer的分词效果,可以看到,标准分词器是简单的将中文输入按照一个一个字的进行了拆分。
#使用 analyzer,对输入进行分词,并查看效果 POST _analyze { "analyzer": "standard", "text": "他说的确实在理" }
分析效果:
[他, 说, 的, 确, 实, 在, 理]
1.IK
1)ik_max_word免费开源的java分词器,目前比较流行的中文分词器之一,简单、稳定,想要特别好的效果,需要自行维护词库,支持自定义词典。支持热更新分词词典。
https://github.com/medcl/elasticsearch-analysis-ik/tree/v7.1.1
做最细粒度的拆分,穷举所有可能词,导致搜索一些不相关的也会被搜到
使用ik-analyzer进行分词:
#使用 analyzer,对输入进行分词,并查看效果 POST _analyze { "analyzer": "ik_max_word", "text": "他说的确实在理" }
分词效果:
他, 说, 的确, 确实在, 确实, 实在, 在理
2)ik_smart
会做最粗粒度的拆分
使用ik-analyzer进行分词:
#使用 analyzer,对输入进行分词,并查看效果 POST _analyze { "analyzer": "ik_smart", "text": "他说的确实在理" }
分词效果:
他, 说, 的确, 实, 在理3)使用ik分词器需要注意的事项
# 分词结果为:["分析", "和", "分析器", "分析", "器"] GET sjzt-dmp-da-table/_analyze { "field": "comment", "text": ["分析和分析器"] } # 此时,如果我们根据"析"去做检索,则查询不到任何数据 #下面是示例 #1)往sjzt-dmp-da-table的index中插入数据 POST sjzt-dmp-da-table/_doc/2333 { "id":2333, "table": "d_test", "comment": "分析和分析器" } #2)根据id,能查询到方才插入的数据 GET sjzt-dmp-da-table/_search { "query": { "term": { "id": { "value": 2333 } } } } #3)但是根据"析"去comment字段中做检索,是查询不到数据的 GET sjzt-dmp-da-table/_search { "query": { "match": { "comment": "析" } } } #4)根据"分析"去comment字段中做检索,此时可以查询到数据 GET sjzt-dmp-da-table/_search { "query": { "match": { "comment": "分析" } } }
2.pinyin
Elastic的Medcl提供了一种搜索Pinyin搜索的方法。拼音搜索在很多的应用场景中都有被用到。比如在百度搜索中,我们使用拼音就可以出现汉字
https://github.com/medcl/elasticsearch-analysis-pinyin
3.结巴分词
开源的python分词器,github有对应的java版本,有自行识别新词的功能,支持自定义词典。
4.Ansj中文分词
基于n-Gram+CRF+HMM的中文分词的java实现,免费开源,支持应用自然语言处理。
5.hanlp
免费开源,国人自然处理语言牛人无私奉献的
6.THULAC
THU Lexical Analyzer for Chinese 由清华大学自然语言处理与社会人文计算实验室研制推出的一套中文词法分析工具包,具有中文分词和词性标注功能。
四、深入检索
理解每个查询如何贡献相关度评分_score有助于调试我们的查询:确保我们认为的最佳匹配文档出现在结果首页,以及削减结果中几乎不相关的长尾(long tail)。
下面介绍几种方法,来手动控制全文检索的精准度,或者说,来手动控制搜索结果的相关性评分!
如何在查询中更精细的控制相关度评分(官网):https://www.elastic.co/guide/en/elasticsearch/guide/current/controlling-relevance.html
4.1 “operator”: “and”
搜索结果精准控制的方法一:灵活使用operator关键字,如果你是希望**所有的搜索关键字全都要匹配的**,那么就用and,可以实现默认的match query无法实现的效果
插入如下的测试数据
# 1.为了防止index已存在,先删除一下 DELETE test_article_01 # 2.手动设置index的mapping PUT test_article_01/ { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "fields": { "keyword": { "ignore_above": 256, "type": "keyword" } } } } } } # 3. 往index中插入数据 POST /test_article_01/_bulk?refresh=true { "index": { "_id": "1"} } {"id": 1, "title" : "this is java and elasticsearch blog"} { "index": { "_id": "2"} } {"id": 2, "title" : "this is java blog"} { "index": { "_id": "3"} } {"id": 3, "title" : "this is elasticsearch blog"} { "index": { "_id": "4"} } {"id": 4, "title" : "this is java, elasticsearch, hadoop blog"} { "index": { "_id": "5"} } {"id": 5, "title" : "this is spark blog"}
在测试数据的基础上执行如下查询(多值搜索):
# 4. 搜索包含java或elasticsearch的doc GET test_article_01/_search { "query": { "match": { "title": { "query": "java elasticsearch" } } } }
则此时的查询结果如下所示:
{ …… "hits" : [ { "_score" : 0.8416344, "_source" : { "id" : 4, "title" : "this is java, elasticsearch, hadoop blog" } }, { "_score" : 0.5753642, "_source" : { "id" : 1, "title" : "this is java and elasticsearch blog" } }, { "_score" : 0.4991763, "_source" : { "id" : 2, "title" : "this is java blog" } }, { "_score" : 0.4991763, "_source" : { "id" : 3, "title" : "this is elasticsearch blog" } } ] } }
可以看到,id=2和3的doc也被同时查询出来了,当然了,这是正确的。
这是因为:
我们这里做的是match查询,此时查询条件java elasticsearch会先被分词,得到2个term:[“java”, “elasticsearch”],然后再拿着分词结果去一一做检索。
对于诸如上面的match query进行多值搜索的时候,因为 match 查询必须查找两个词(java和 elasticsearch),它在内部实际上先执行两次 term 查询,然后将两次查询的结果合并作为最终结果输出。为了做到这点,它将两个 term 查询包入一个 bool 查询中,es会在底层自动将这个match query转换为bool的语法!bool should,指定多个搜索词,同时使用term query:
GET test_article_01/_search { "query": { "bool": { "should": [ { "term": { "title": "java" }}, { "term": { "title": "elasticsearch" }} ] } } }
因此,此时id=3的doc虽然只匹配了 elasticsearch,但它也会被检索出来,它是符合上面的查询语义的,只不过它的_score评分相对较低而已!
**而如果我们希望,被检索出来的结果,只有id为1和4的doc,即只有同时包含了java和 elasticsearch的文档才会被检索出来,那么就需要使用到这一小节要讲解的知识点"operator": "and"了。**如下,查询DSL变成:
GET test_article_01/_search { "query": { "match": { "title": { "query": "java elasticsearch", "operator": "and" } } } }
可以看到,在查询条件下多了个"operator": "and"的关键字!
查询结果:
{ …… "hits" : [ { "_score" : 0.8416344, "_source" : { "id" : 4, "title" : "this is java, elasticsearch, hadoop blog" } }, { "_score" : 0.5753642, "_source" : { "id" : 1, "title" : "this is java and elasticsearch blog" } } ] } }
而此时的添加了"operator": "and"之后的查询,ES会自动将其转换为如下DSL:
GET test_article_01/_search { "query": { "bool": { "must": [ { "term": { "title": "java" }}, { "term": { "title": "elasticsearch" }} ] } } }
注:默认情况下,match查询的参数operator=false,我们需要根据自己的查询需求,来决定是否显式的将其设置为true
4.2 minimum_should_match
minimum_should_match参数是用来去长尾(long tail)的
什么叫长尾呢?比如你搜索java elsticsearch server,这个搜索将会被分为3个词项:[“java”, “elsticsearch”, “server”],但是在搜索结果中,很多搜索结果是只匹配1个关键词的,其实跟你想要的结果相差甚远,这些结果就是长尾。
通过minimum_should_match参数,指定一些关键字中必须至少匹配其中的多少个关键字,才能作为结果返回。
准备测试数据:
# 1.为了防止index已存在,先删除一下 DELETE idx-susu-test-minimum # 2.手动设置index的settings和mappings PUT idx-susu-test-minimum/ { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "standard" } } } } # 3. 往index中插入数据 POST idx-susu-test-minimum/_bulk?refresh=true { "index": { "_id": "1"} } {"id": 1, "title" : "server of java and elasticsearch"} { "index": { "_id": "2"} } {"id": 2, "title" : "java server operation tips"} { "index": { "_id": "3"} } {"id": 3, "title" : "this is elasticsearch blog"} { "index": { "_id": "4"} } {"id": 4, "title" : "focus on the server side"} # 4. 查看全部数据 GET idx-susu-test-minimum/_search
在测试数据上执行如下查询
# 5. 全文检索 GET idx-susu-test-minimum/_search { "query": { "match": { "title": { "query": "java elasticsearch server" } } } }
此时的查询相当于”查询title字段中包含java或elasticsearch或server的doc文档“,查询结果:
{ …… "hits" : [ { "_index" : "idx-susu-test-minimum", "_type" : "_doc", "_id" : "1", "_score" : 1.667188, "_source" : { "id" : 1, "title" : "server of java and elasticsearch" } }, { "_index" : "idx-susu-test-minimum", "_type" : "_doc", "_id" : "2", "_score" : 1.0998137, "_source" : { "id" : 2, "title" : "java server operation tips" } }, { "_index" : "idx-susu-test-minimum", "_type" : "_doc", "_id" : "3", "_score" : 0.7261542, "_source" : { "id" : 3, "title" : "this is elasticsearch blog" } }, { "_index" : "idx-susu-test-minimum", "_type" : "_doc", "_id" : "4", "_score" : 0.34116736, "_source" : { "id" : 4, "title" : "focus on the server side" } } ] } }
可以看到,像id=3、4这些相关度很低的doc也被作为结果返回了,这些相关度很低的doc,就可以认为是本次查询的long tail即长尾。
此时我们就可以通过指定minimum_should_match关键字,来规定doc中必须至少匹配其中的多少个关键字,才能作为结果返回。
如下查询,就表示doc中必须同时包含java elasticsearch serverkbd>中的2个,才能被作为结果返回!
# 6. 全文检索 GET idx-susu-test-minimum/_search { "query": { "match": { "title": { "query": "java elasticsearch server", "minimum_should_match": 2 } } } }
此时查询结果如下:
match 查询支持 minimum_should_match 最小匹配参数,这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量,示例如下:
# 7. 全文检索 GET idx-susu-test-minimum/_search { "query": { "match": { "title": { "query": "java elasticsearch server", "minimum_should_match": "67%" } } } }
4.3 query-time boost
在match查询中,可以使用boost参数来使得一个查询项比其它的更重要.
测试数据
# 1.为了防止index已存在,先删除一下 DELETE idx-susu-test-boost # 2.手动设置index的mapping PUT idx-susu-test-boost { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "standard" } } } } # 3. 往index中插入数据 POST idx-susu-test-boost/_bulk?refresh=true { "index": { "_id": "1"} } {"id": 1, "title" : "this is java blog"} { "index": { "_id": "2"} } {"id": 2, "title" : "this is java and hadoop blog"} { "index": { "_id": "3"} } {"id": 3, "title" : "this is java and elasticsearch blog"} { "index": { "_id": "4"} } {"id": 4, "title" : "this is java blog"} { "index": { "_id": "5"} } {"id": 5, "title" : "this is nginx blog"} # 4. 查询全部数据 GET idx-susu-test-boost/_search
需求: 1)搜索标题中包含java的文档,同时呢,如果标题中包含hadoop或elasticsearch就优先搜索出来。2)此外,如果一个帖子包含java hadoop,一个帖子包含java elasticsearch,包含elasticsearch的帖子要比hadoop优先搜索出来
知识点:通过boost参数来调整一个查询的权重。当匹配这个搜索条件和匹配另一个搜索条件的document,计算relevance score时,匹配权重更大的搜索条件的document,relevance score会更高,当然也就会优先被返回回来。
我们把上面的需求拆分成两个,先看需求: 1)搜索标题中包含java的文档,同时呢,如果标题中包含hadoop或elasticsearch就优先搜索出来
# 5 全文检索 GET idx-susu-test-boost/_search { "query": { "bool": { "must": [ { "match": { "title": { "query": "java" } } } ], "should": [ { "match": { "title": { "query": "hadoop" } } }, { "match": { "title": { "query": "elasticsearch" } } } ] } } }
should是可以影响相关度分数的,must查询是确保说,谁必须有这个关键字,同时会根据这个must的条件去计算出document对这个搜索条件的relevance score,在满足must的基础之上,should中的条件,不匹配也可以,但是如果匹配的更多,那么document的relevance score就会更高
如此,就满足了用于的上述的第一个需求。查询结果如下:
{ …… "hits" : [ { "_score" : 1.0601485, "_source" : { "id" : 2, "title" : "this is java and hadoop blog" } }, { "_score" : 1.0601485, "_source" : { "id" : 3, "title" : "this is java and elasticsearch blog" } }, { "_score" : 0.2876821, "_source" : { "id" : 1, "title" : "this is java blog" } }, { "_score" : 0.14874382, "_source" : { "id" : 4, "title" : "this is java blog" } } ] } }
但是上面的查询,并不满足第二个需求:如果一个帖子包含java hadoop,一个帖子包含java elasticsearch,包含elasticsearch的帖子要比hadoop优先搜索出来
这就要求了:匹配了elasticsearch的相关性评分要比匹配了hadoop的评分要高!
这时就可以通过在查询中手动指定boost来手动控制查询的相关度了
搜索条件的权重,boost,可以将某个搜索条件的权重加大,此时当匹配这个搜索条件和匹配另一个搜索条件的document计算relevance score时,匹配权重更大的搜索条件的document,relevance score会更高,当然也就会优先被返回回来,默认情况下,搜索条件的权重(即query-time boost)都是一样的,都是1
那么结合boost,要完成上面的需求,DSL应该这么写:
# 6 全文检索(手动指定query-time boost) GET idx-susu-test-boost/_search { "query": { "bool": { "must": [ { "match": { "title": { "query": "java" } } } ], "should": [ { "match": { "title": { "query": "hadoop" } } }, { "match": { "title": { "query": "elasticsearch", "boost": 4 } } } ] } } }
注:最后一个match中,我们将boost设置为了4
此时的查询结果如下,可以看到,id=3即包含了java和elasticsearch的doc已经排在最前面了
{ …… "hits" : [ { "_score" : 3.8594882, "_source" : { "id" : 3, "title" : "this is java and elasticsearch blog" } }, { "_score" : 1.0601485, "_source" : { "id" : 2, "title" : "this is java and hadoop blog" } }, { "_score" : 0.2876821, "_source" : { "id" : 1, "title" : "this is java blog" } }, { "_score" : 0.14874382, "_source" : { "id" : 4, "title" : "this is java blog" } } ] } }
4.4 negative boost
搜索包含java,不包含spark的doc,但是这样子很死板
搜索包含java,尽量不包含spark的doc,如果包含了spark,不会说排除掉这个doc,而是说将这个doc的分数降低
包含了negative term的doc,分数乘以negative boost,分数降低
GET /forum/article/_search { "query": { "bool": { "must": [ { "match": { "content": "java" } } ], "must_not": [ { "match": { "content": "spark" } } ] } } } GET /forum/article/_search { "query": { "boosting": { "positive": { "match": { "content": "java" } }, "negative": { "match": { "content": "spark" } }, "negative_boost": 0.2 } } }
negative的doc,会乘以negative_boost,降低分数
4.5 多字段搜索 dis_max 4.5.1 best fields
关于该搜索方式,可以直接去看官网,官网说的已经非常好了!
首先说一下什么叫做【多字段】搜索
下面这个就是multi-field搜索,多字段搜索
# 1.为了防止index已存在,先删除一下 DELETE idx-susu-test-dismax # 2.手动设置index的mapping PUT idx-susu-test-dismax { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "standard" }, "body": { "type": "text", "analyzer": "standard" } } } } # 3. 往index中插入数据 POST idx-susu-test-dismax/_bulk?refresh=true { "index": { "_id": "1"} } {"id": 1, "title" : "Quick brown rabbits", "body": "Brown rabbits are commonly seen."} { "index": { "_id": "2"} } {"id": 2, "title" : "Keeping pets healthy", "body": "My quick brown fox eats rabbits on a regular basis."} # 4. 查询全部数据 GET idx-susu-test-dismax/_search
现在,用户输入词组 “Brown fox” 然后点击搜索按钮。事先,我们并不知道用户的搜索项是会在 title 还是在 body 字段中被找到,因此,我们会执行以下 bool 查询来完成该需求:
# 5. 查询title或body中包含“Brown fox”的文档 GET idx-susu-test-dismax/_search { "query": { "bool": { "should": [ { "match": { "title": "Brown fox" } }, { "match": { "body": "Brown fox" } } ] } } }
在看查询结果之前,根据我们用肉眼判断,文档 2 的匹配度更高,因为它同时包括要查找的两个词。所以在我们的预想中,文档2应该是排在前面的。
然而事实却并非如此,如下,我们发现查询的结果是文档 1 的评分更高:
{ …… "hits" : [ { "_score" : 0.90425634, "_source" : { "id" : 1, "title" : "Quick brown rabbits", "body" : "Brown rabbits are commonly seen." } }, { "_score" : 0.77041256, "_source" : { "id" : 2, "title" : "Keeping pets healthy", "body" : "My quick brown fox eats rabbits on a regular basis." } } ] } }
为了理解导致这样的原因,我们可以在上面查询的基础上,添加explain="true"参数,来查看这两个文档的算分详情:
1)文档 1 的两个字段都包含 brown 这个词,所以两个 match 语句都能成功匹配并且有一个评分。
2)文档 2 的 body 字段同时包含 brown 和 fox 这两个词,但 title 字段没有包含任何词。
文档1: title字段中brown的算分 + body字段中brown的算分 0.90425634 = 0.6931472 + 0.21110919 文档2 title字段中brown的算分 + body字段中brown和fox的算分 0.77041256 = 0 + 0.77041256
这样, body 查询结果中的高分,加上 title 查询中的 0 分,但是最终算分的结果,依然还是比文档 1 有更低的整体评分。
上面的算分结果当然没有错,它是严格按照我们书写的查询语义来算分的。但是也正如前面说的,从直觉上来说,这个结果排序并不符合我们的预期 —— 就是说,在某种业务场景下,有可能我们想要使得我们搜索到的结果,应该是某一个field中匹配到了尽可能多的关键词,被排在前面;而不是尽可能多的field匹配到了少数的关键词,排在了前面
这个时候,就引出了这一小节我们要讲解的知识点了 —— dis_max查询(中的best_fields最佳匹配字段策略)。
dis_max ,也叫分离最大化查询(Disjunction Max Query) 。分离(Disjunction)的意思是 或(or) ,这与可以把结合(conjunction)理解成 与(and) 相对应。分离最大化查询(Disjunction Max Query)指的是: 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回。
那么通过dis_max,就不是简单将每个字段的评分结果加在一起,而是将最佳匹配字段的评分作为查询的整体评分。
如下:
# 6. 使用dis_max查询 GET idx-susu-test-dismax/_search { "explain": true, "query": { "dis_max": { "queries": [ { "match": { "title": "Brown fox" }}, { "match": { "body": "Brown fox" }} ] } } } # 查询结果 { …… "hits" : [ { "_score" : 0.77041256, "_source" : { "id" : 2, "title" : "Keeping pets healthy", "body" : "My quick brown fox eats rabbits on a regular basis." } }, { "_score" : 0.6931472, "_source" : { "id" : 1, "title" : "Quick brown rabbits", "body" : "Brown rabbits are commonly seen." } } ] } }
如上, 同时包含brown和fox的单个字段比反复出现相同词语的多个不同字段有更高的相关度。
4.5.2 基于tie_breaker参数优化dis_max搜索效果
先基于测试数据看一个栗子
# 1.为了防止index已存在,先删除一下 DELETE idx-susu-test-tiebreaker # 2.手动设置index的mapping PUT idx-susu-test-tiebreaker { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "standard" }, "body": { "type": "text", "analyzer": "standard" } } } } # 3. 往index中插入数据 POST idx-susu-test-tiebreaker/_bulk?refresh=true { "index": { "_id": "1"} } {"id": 1, "title" : "Quick brown rabbits", "body": "Brown rabbits are commonly seen."} { "index": { "_id": "2"} } {"id": 2, "title" : "Keeping pets healthy", "body": "My quick brown fox eats rabbits on a regular basis."} { "index": { "_id": "3"} } {"id": 3, "title" : "Keeping brown pets healthy", "body": "My quick brown fox eats rabbits on a regular basis."} # 4. 查询全部数据 GET idx-susu-test-tiebreaker/_search
执行dis_max查询
# 5. 使用dis_max查询 GET idx-susu-test-tiebreaker/_search { "query": { "dis_max": { "queries": [ { "match": { "title": "Brown fox" } }, { "match": { "body": "Brown fox" } } ] } } } # 查询结果 { …… "hits" : [ { "_score" : 0.55788946, "_source" : { "id" : 2, "title" : "Keeping pets healthy", "body" : "My quick brown fox eats rabbits on a regular basis." } }, { "_score" : 0.55788946, "_source" : { "id" : 3, "title" : "Keeping brown pets healthy", "body" : "My quick brown fox eats rabbits on a regular basis." } }, { "_score" : 0.49005118, "_source" : { "id" : 1, "title" : "Quick brown rabbits", "body" : "Brown rabbits are commonly seen." } } ] }
可以看到,文档2和文档3的得分比文档1的高,排在了前面 —— 这个没问题,符合dis_max默认的查询语义。
但是我们看文档2和文档3,发现它们有相同的评分。这个时候,从我们直觉上来说,其实我们会觉得文档3应该有更高的相关度评分,因为它不仅跟文档一样在body字段中同时包含了brown和fox这两个词项,文档3的title字段还多包含了一个brown词项。
其实就是说,有时候我们不希望采取上面的best fields这种一刀切的方式来处理检索结果。这时候就引入了一个新的参数:tie_breaker
tie_breaker参数的意义在于说,将其他query的分数,乘以tie_breaker,然后综合与最高分数的那个query的分数,综合在一起进行计算
通过tie_breaker参数,除了取最高分以外,还会考虑其他的query的分数tie_breaker的值,在0~1之间,是个小数
# 6. 使用dis_max查询,并使用tie_breaker参数 GET idx-susu-test-tiebreaker/_search { "query": { "dis_max": { "queries": [ { "match": { "title": "Brown fox" } }, { "match": { "body": "Brown fox" } } ], "tie_breaker": 0.3 } } } # 查询结果 { …… "hits" : [ { "_score" : 0.6882266, "_source" : { "id" : 3, "title" : "Keeping brown pets healthy", "body" : "My quick brown fox eats rabbits on a regular basis." } }, { "_score" : 0.55788946, "_source" : { "id" : 2, "title" : "Keeping pets healthy", "body" : "My quick brown fox eats rabbits on a regular basis." } }, { "_score" : 0.5379483, "_source" : { "id" : 1, "title" : "Quick brown rabbits", "body" : "Brown rabbits are commonly seen." } } ] }
从结果中可以看到,文档3的算分就已经比文档2的算分要高了。
4.6 基于multi_match语法实现dis_max+tie_breaker
下面是dis_max+tie_breaker,还有minimum_should_match和boost参数相结合,写法如下:
GET idx-susus-test-multimatch/_search { "query": { "dis_max": { "queries": [ { "match": { "title": { "query": "java beginner", "minimum_should_match": "50%", "boost": 2 } } }, { "match": { "body": { "query": "java beginner", "minimum_should_match": "30%" } } } ], "tie_breaker": 0.3 } } }
但是其实还有更简便的写法!如下,即:使用multi_match语法来实现相同的查询语义
GET idx-susus-test-multimatch/_search { "query": { "multi_match": { "query": "java solution", "type": "best_fields", "fields": [ "title^2", "content" ], "tie_breaker": 0.3, "minimum_should_match": "50%" } } }
4.7 function_score
假如你有这样的需求:
- 若搜索的关键词在两个文档中都有出现,那么浏览数越大的文档,要优先返回查询文档时,要考虑到文档的发布时间create_time,在评分相同的情况下,最新发布的文章应该优先返回在地图中搜索地点,距离我当前定位越近的地点,应该优先返回
针对这样的需求,就需要使用到function_scre了。
function_score查询是用来控制评分过程的终极武器,它允许为每个与主查询匹配的文档应用一个函数,以达到改变甚至完全替换原始查询评分 _score 的目的。
官网地址:https://www.elastic.co/guide/en/elasticsearch/reference/7.1/query-dsl-function-score-query.html
https://www.elastic.co/guide/cn/elasticsearch/guide/current/function-score-query.html
The function_score query provides several types of score functions.
weight
为每个文档应用一个简单而不被规范化的权重提升值:当 weight 为 2 时,最终结果为 2 * _score 。
random_score
为每个用户都使用一个不同的随机评分对结果排序,但对某一具体用户来说,看到的顺序始终是一致的。
field_value_factor
见名知意,field_value_factor就是利用文档中的某个field的value值,来作为影响算分的因素(factor)。即:使用这个field的值来修改主查询的_score算分 ,如将 浏览数views 或 收藏数fav 作为考虑因素。
decay functions(衰减函数): gauss, linear, exp
将浮动值结合到评分 _score 中,例如结合 publish_date 获得最近发布的文档,结合 geo_location 获得更接近某个具体经纬度(lat/lon)地点的文档,结合 price 获得更接近某个特定价格的文档。
script_score
如果需求超出以上范围时,用自定义脚本可以完全控制评分计算,实现所需逻辑。
如果没有 function_score 查询,就不能将全文查询与例如 文档浏览数 或者 文档发布日期 这种因子结合在一起来综合评分,而不得不根据评分 _score 或时间 published_date 进行排序,但是这样一来,这会相互影响抵消两种排序各自的效果了。
那么通过function_score查询,可以使两个效果融合:可以仍然根据主查询的全文相关度进行排序,但也会同时考虑最新发布文档、流行文档、或接近用户希望价格的产品。
调试相关度是最后10%需要做的事情
4.7.1 field_value_factor
直接看官网,官网已经讲解的很好了
按受欢迎度提升权重:https://www.elastic.co/guide/cn/elasticsearch/guide/current/boosting-by-popularity.html
4.7.2 weight
# 1.为了防止index已存在,先删除一下 DELETE idx-susu-test-weight # 2.手动设置index的mapping PUT idx-susu-test-weight { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "standard" }, "comment": { "type": "keyword" } } } } # 3. 往index中插入数据 POST idx-susu-test-weight/_bulk?refresh=true { "index": { "_id": "1"} } {"id": 1, "title" : "Head for java", "comment": "B站", "ad_status": 0} { "index": { "_id": "2"} } {"id": 2, "title" : "Introduction to Java", "comment": "黑马培训", "ad_status": 1} { "index": { "_id": "3"} } {"id": 3, "title" : "java thread", "body": "开课吧", "ad_status": 0} # 4. 查询全部数据 GET idx-susu-test-weight/_search # 5. 全文检索 GET idx-susu-test-weight/_search { "query": { "match": { "title": "java" } } } # 6. 全文检索,利用weight参数调整算分 GET idx-susu-test-weight/_search { "query": { "function_score": { "query": { "match": { "title": "java" } }, "functions": [ { "filter": { "term": { "ad_status": 1 } }, "weight": 3 } ] } } }
4.7.3 decay 衰减函数
- 参考的博客连接:https://blog.csdn.net/weixin_40341116/article/details/81003513官网(中文):https://www.elastic.co/guide/cn/elasticsearch/guide/current/decay-functions.html官网(英文最新版): https://www.elastic.co/guide/en/elasticsearch/reference/7.1/query-dsl-function-score-query.html#function-decay
# 1)为了防止用于测试的索引已经存在,因此这里第一步先删除 DELETE idx-susu-test-decay # 2)设置索引的settings和mappings PUT idx-susu-test-decay { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "standard" }, "createTime": { "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_second", "type": "date" } } } } # 3)批量插入数据 PUT idx-susu-test-decay/_bulk?refresh=true { "index" : { "_id" : "1" } } { "title": "java scala python", "createTime": "2021-12-10 01:01:01"} { "index" : { "_id" : "2" } } { "title": "java scala python", "createTime": "2021-12-20 01:01:01" } { "index" : { "_id" : "3" } } { "title": "java scala python", "createTime": "2022-01-02 01:01:01" } { "index" : { "_id" : "4" } } { "title": "python scala", "createTime": "2021-11-02 01:01:01" } { "index" : { "_id" : "5" } } { "title": "java java php", "createTime": "2021-10-02 01:01:01" } # 4) 查询全部数据 GET idx-susu-test-decay/_search # 5) function score查询 decay衰减函数 GET idx-susu-test-decay/_search { "explain": true, "query": { "function_score": { "query": { "match": { "title": "scala" } }, "functions": [ { "gauss": { "createTime": { "origin": "now", "offset": "7d", "scale": "7d", "decay": 0.5 } } } ], "boost_mode": "sum" } } }
4.8 filter before match
理论上,非评分查询 先于 评分查询执行。非评分查询任务 旨在 降低那些将对评分查询计算带来更高成本的文档数量,从而达到快速搜索的目的
从概念上记住【非评分计算是首先执行的】,这将有助于写出高效又快速的搜索请求
下面的DSL就是先过滤后全文检索的示例
{ "from":0, "size":100, "query":{ "bool":{ "must":[ { "multi_match":{ "query":"指南", "fields":[ "articleName^10.0", "categoryDesc^8.0", "content^6.0", "createUserName^4.0", "ownerName^4.0" ], "type":"best_fields", "operator":"OR", "tie_breaker":0.3 } } ], "filter":[ { "bool":{ "must":[ { "term":{ "articleType":{ "value":"RULES" } } } ] } } ] } } }
4.9 constant_score
关于constant_score,可参考博客《ElasticSearch Query DSL之query context和filter context》
4.10 highLight高亮
# 1.为了防止index已存在,先删除一下 DELETE idx-susu-test-highlight # 2.手动设置index的mapping PUT idx-susu-test-highlight { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "refresh_interval": "1s" }, "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "standard" } } } } # 3. 往index中插入数据 POST idx-susu-test-highlight/_bulk?refresh=true { "index": { "_id": "1"} } {"id": 1, "title" : "Head for java"} { "index": { "_id": "2"} } {"id": 2, "title" : "Introduction to Java"} { "index": { "_id": "3"} } {"id": 3, "title" : "this is java thread"} # 4. 查询全部数据 GET idx-susu-test-highlight/_search # 5. 全文检索 GET idx-susu-test-highlight/_search { "query": { "match": { "title": "java" } }, "highlight":{ "pre_tags":[ ""], "post_tags":[""], "fields":{ "title":{} } } }
如上示例,就是高亮的使用示例。
接下来看index_options设置,可以看到,若将index_options设置为offsets,则在构建倒排索引的时候,就会将词项的offsets信息添加到倒排项中去,进而可以使得ES在进行高亮 *** 作的时候速度更快。
index_options官网:https://www.elastic.co/guide/en/elasticsearch/reference/7.1/index-options.html
4.11 深度分页问题对全文检索的影响
请参考博客:https://blog.csdn.net/gudejundd/article/details/104884464
五、参考文档
官方博客(相关度算法的介绍,注:这几篇博客若无法打开,则可以去我的百度网盘下载链接: https://pan.baidu.com/s/1Hv2ARbQ7Ggja398oj4bPhQ 密码: 5wum):
https://www.elastic.co/cn/elasticon/conf/2016/sf/improved-text-scoring-with-bm25
https://www.elastic.co/cn/blog/practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch
https://www.elastic.co/cn/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables
https://www.elastic.co/cn/blog/practical-bm25-part-3-considerations-for-picking-b-and-k1-in-elasticsearch
六、寄语
纸上得来终觉浅,绝知此事要躬行
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)