[toc]
基于词项和基于全文的搜索
¶基于 Term的查询
¶Term 的重要性
term 是表达语意的最小单位。搜索和利用统计语言模型进行自然语言处理都需要处理 term。
¶Es 中的特点
-
在 Es 中,Term 查询包括以下查询:
Term Query、Range Query、Exists Query、Prefix Query、Wildcard Query
-
在 Es 中,Term 查询,对输入不做分词。会将输入作为一个整体的词项,在倒排索引中查询准确的词项,并且使用相关度算分公式为每个包含该词项的文档进行相关度算分,例如"App Store"
-
可以通过 Constant Score 将查询转换成一个 Filtering,避免算法,并利用缓存,提高性能。
¶关于 Term 查询的例子
-
先插入以下索引
-
执行以下查询语句
当查询"iPhone"的时候是得不到结果的,查询"iphone"的时候就可以。
这是因为对于上面创建索引的时候认为 desc 字段是一个 text 类型的数据,所以会做默认的分词处理,即"iPhone"最终被转换成了小写。而我们这里指定了 term 查询,而 term 查询是一个精准词条查询,不会对数据做任何分词处理,直接进行 term 匹配,即"iPhone",所以是匹配不到经过分词处理之后的"iphone"的。
-
执行以下查询语句
我们会发现也不会返回任何结果。同理,那是因为 Es 也在创建这条索引的时候对这个字段进行了默认的分词,如下图所示,整个内容被切分成了好几个 term。而我们将该完整 ID到该字段中进行 term 检索是匹配不到数据的。
此时如果我们查询的值改成"xhdk",将得到匹配。但是如果我们就想对这个输入值进行精确匹配,应该怎么做呢?(我们不想返回其他不相关但是相似的数据)此时我们将检索字段改成"productId.keyword"即可返回精确匹配的文档,keyword 是Es 为每一个 text 类型的字段生成的子字段,它对 text 字段的原值进行了索引。
-
Es 的查询默认都会返回一个算分结果
如果我们希望跳过这个步骤,则可以利用:复合查询-Constant Score 转为 Filter
- 将 Query 转成 Filter,忽略 TF-IDF 计算,避免相关性算分的开销
- FIlter 可以有效利用缓存
¶基于全文的查询
¶Es 基于全文本的查找
Match Query、Match Phrase Query、Query String Query
¶特点:
- 索引和搜索时都会进行分词,查询字符串先传递到一个合适的分词器,然后生成一个供查询的词项列表到倒排索引中进行检索
- 查询时,先会对输入的查询进行分词,然后每个词项逐个进行底层的查询,最终将结果进行合并。并为每个文档生成一个算分。例如查"Matrix reloaded",会查到包括 Matrix 或者 reloaded 的所有结果。
¶Match Query Result
以下返回包含所有包含 Matrix 或者 reloaded 的文档
¶Operator
使用 AND 操作符,返回所有包含 Matrix 以及 reloaded 的文档
¶Minimum_shold_match
Minimum_should_match 参数可以调整 persicion 或者 recall 使我们返回的结果更加理想,该参s数可以控制应该匹配的词条的最少数量。
数字可以是负数,例如有4个term的匹配,当匹配度为-25%与75%,其意义是一样的,都是最少匹配三个,但处理5个term时,-25%表示至少匹配四个,而75%表示至少匹配三个term。
¶Match Phrase Query
match_phrase 查询的 slop 也可以对 persicion 和 recall 进行调整
¶查询过程
¶Kibana 测试请求
DELETE products
PUT products
{
"settings": {
"number_of_shards": 1
}
}
POST /products/_bulk
{ "index": { "_id": 1 }}
{ "productID" : "XHDK-A-1293-#fJ3","desc":"iPhone" }
{ "index": { "_id": 2 }}
{ "productID" : "KDKE-B-9947-#kL5","desc":"iPad" }
{ "index": { "_id": 3 }}
{ "productID" : "JODL-X-1937-#pV7","desc":"MBP" }
GET /products
POST /products/_search
{
"query": {
"term": {
"desc": {
//"value": "iPhone"
"value":"iphone"
}
}
}
}
POST /products/_search
{
"query": {
"term": {
"desc.keyword": {
//"value": "iPhone"
//"value":"iphone"
}
}
}
}
POST /products/_search
{
"query": {
"term": {
"productID": {
"value": "XHDK-A-1293-#fJ3"
}
}
}
}
POST /products/_search
{
//"explain": true,
"query": {
"term": {
"productID.keyword": {
"value": "XHDK-A-1293-#fJ3"
}
}
}
}
POST /products/_search
{
"explain": true,
"query": {
"constant_score": {
"filter": {
"term": {
"productID.keyword": "XHDK-A-1293-#fJ3"
}
}
}
}
}
#设置 position_increment_gap
DELETE groups
PUT groups
{
"mappings": {
"properties": {
"names":{
"type": "text",
"position_increment_gap": 0
}
}
}
}
GET groups/_mapping
POST groups/_doc
{
"names": [ "John Water", "Water Smith"]
}
POST groups/_search
{
"query": {
"match_phrase": {
"names": {
"query": "Water Water",
"slop": 100
}
}
}
}
POST groups/_search
{
"query": {
"match_phrase": {
"names": "Water Smith"
}
}
}
结构化搜索
结构化搜索(Structured search)是指对结构化数据的搜索。日期、布尔类型和数字都是结构化的。
文本也可以使结构化的。
- 如彩色笔可以有离散的颜色集合:红、绿、蓝
- 一个博客可能被标记了标签,例如:分布式、搜索
- 电商网站上的商品都有 UPCs(通用产品码 Universal Product Codes)或者其他的唯一标识,它们都需要遵从严格规定的、格式化的格式。
¶1、Es 中的结构化搜索
布尔、时间、日期和数字这类结构化数据:有精确的格式,我们可以对这些格式进行逻辑操作。包括比较数字或时间的范围,或判定两个值的大小。
结构化的文本可以做精确匹配或者部分匹配:Term 查询、Prefix 前缀查询
结构化结果只有"是"或者"否"两个值,根据场景需要,可以决定结构化搜索是否需要打分(如果不需要就转成 Constant Score Query)
¶2、布尔值Term 查询示例
右边使用 constant_score 去掉算分的过程
¶3、数字 Range查询示例
¶4、日期 Range 查询示例
右边是日期表达式,根据这个表达式可以构建我们想要的时间区间
¶5、空值相关查询
左边是查询 date 字段不是空的数据、右边是查询 date 字段是空的数据
¶6、精确查找多个值
前两个查询是对一个字段进行多值 term 查询,可以发现只要包含任一个值都会进行返回。而第三个精准查询也是,只要该文档中该字段的值有一个是匹配该规则的,都会进行返回,不管其他值是否匹配。如果我们想要该字段是内容和值个数完全匹配才返回的话,解决方案:增加一个 genre_count 字段进行计数。
¶7、Kibana 测试请求
#结构化搜索,精确匹配
DELETE products
POST /products/_bulk
{ "index": { "_id": 1 }}
{ "price" : 10,"avaliable":true,"date":"2018-01-01", "productID" : "XHDK-A-1293-#fJ3" }
{ "index": { "_id": 2 }}
{ "price" : 20,"avaliable":true,"date":"2019-01-01", "productID" : "KDKE-B-9947-#kL5" }
{ "index": { "_id": 3 }}
{ "price" : 30,"avaliable":true, "productID" : "JODL-X-1937-#pV7" }
{ "index": { "_id": 4 }}
{ "price" : 30,"avaliable":false, "productID" : "QQPX-R-3956-#aD8" }
GET products/_mapping
#对布尔值 match 查询,有算分
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"term": {
"avaliable": true
}
}
}
#对布尔值,通过constant score 转成 filtering,没有算分
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"constant_score": {
"filter": {
"term": {
"avaliable": true
}
}
}
}
}
#数字类型 Term
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"term": {
"price": 30
}
}
}
#数字类型 terms
POST products/_search
{
"query": {
"constant_score": {
"filter": {
"terms": {
"price": [
"20",
"30"
]
}
}
}
}
}
#数字 Range 查询
GET products/_search
{
"query" : {
"constant_score" : {
"filter" : {
"range" : {
"price" : {
"gte" : 20,
"lte" : 30
}
}
}
}
}
}
# 日期 range
POST products/_search
{
"query" : {
"constant_score" : {
"filter" : {
"range" : {
"date" : {
"gte" : "now-1y"
}
}
}
}
}
}
#exists查询
POST products/_search
{
"query": {
"constant_score": {
"filter": {
"exists": {
"field": "date"
}
}
}
}
}
#处理多值字段
POST /movies/_bulk
{ "index": { "_id": 1 }}
{ "title" : "Father of the Bridge Part II","year":1995, "genre":"Comedy"}
{ "index": { "_id": 2 }}
{ "title" : "Dave","year":1993,"genre":["Comedy","Romance"] }
#处理多值字段,term 查询是包含,而不是等于
POST movies/_search
{
"query": {
"constant_score": {
"filter": {
"term": {
"genre.keyword": "Comedy"
}
}
}
}
}
#字符类型 terms
POST products/_search
{
"query": {
"constant_score": {
"filter": {
"terms": {
"productID.keyword": [
"QQPX-R-3956-#aD8",
"JODL-X-1937-#pV7"
]
}
}
}
}
}
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"match": {
"price": 30
}
}
}
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"term": {
"date": "2019-01-01"
}
}
}
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"match": {
"date": "2019-01-01"
}
}
}
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"constant_score": {
"filter": {
"term": {
"productID.keyword": "XHDK-A-1293-#fJ3"
}
}
}
}
}
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"term": {
"productID.keyword": "XHDK-A-1293-#fJ3"
}
}
}
#对布尔数值
POST products/_search
{
"query": {
"constant_score": {
"filter": {
"term": {
"avaliable": "false"
}
}
}
}
}
POST products/_search
{
"query": {
"term": {
"avaliable": {
"value": "false"
}
}
}
}
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"term": {
"price": {
"value": "20"
}
}
}
}
POST products/_search
{
"profile": "true",
"explain": true,
"query": {
"match": {
"price": "20"
}
}
}
}
POST products/_search
{
"query": {
"constant_score": {
"filter": {
"bool": {
"must_not": {
"exists": {
"field": "date"
}
}
}
}
}
}
}
搜索的相关性算法
¶1、相关性(Relevance)
搜索的相关算分,描述了一个文档和查询语句匹配的程度。ES 会对每个匹配查询条件的解雇哦进行算分_score。
打分的本质是排序,需要把符合用户需求的文档排在前面。在 ES5之前,默认的相关性算分采用 TF-IDF,现在采用 BM 25。我们来看下面的例子:
我们搜索"区块链的应用"被拆分成了三个 term,可以看到 id 为2和3的文档都包含了这三个 term,它们三个的相关性较之其他文档来说肯定是最高的。现在的问题是这三个中谁的相关性最高呢?
¶2、TF-IDF
TF-IDF 被公认为是信息检索领域最重要的发明,除了在信息检索这方面,在文献分类和其他相关领域有着非常广泛的引用。
¶词频(TF)
Term Frequency:检索词在一篇文档中出现的频率,检索词出现的次数除以文档的总字数。
度量一条查询和结果文档相关性的简单方法,就是简单地将搜索中每一个词地 TF 进行相加:TF(区块链)+TF(的)+TF(应用)。另外,对于停用词(Stop word)"的"在文档中出现了很多次,但是对贡献相关度几乎没有用处,我们似乎不应该考虑它们地 TF。
¶逆文档频率(IDF)
-
DF:检索词在所有文档中出现地频率
- "区块链"在相对比较少的文档中出现
- "应用"在相对比较多的文档中出现
- "Stop word"在大量的文档中出现
-
Inverse Document Frequency:简单说=log(全部文档数/检索词出现过的文档总数),它可以认为是一个 term 在搜索语句中的权重。
如果某个 term 出现过的文档数越多,说明在计算整个搜索"语句"的文档相关性的时候,它的计算权重应该越低。所以我们可以通过式子"全部文档数/检索词 term 出现过的文档总数"可以计算到 term 的相对出现文档频率。但是为了可以将这个运算结果应用到整个文档的相关性中,我们可以对它"log"一下,这样我们可以使得运算结果变小可以乘到 term 的 TF 中,但是它的函数曲线趋势还是不变的,即随着 term 在所有文档中出现的次数越多,它的 IDF 就越低,只不过在次数越来越多的时候,它的 IDF 下降的速度越来越慢。
TF-IDF 本质上就是将 TF 求和变成了加权求和:搜索语句"XXXX"被分隔成 n 个 term,搜索出来的所有文档的_score 计算就是"当前文档中第一个 term 出现的频率*第一个 term 的计算权重+当前文档中第二个 term 出现的频率*第二个term 的计算权重+… …",而上面例子的计算公式就是:TF(区块链)*IDF(区块链)+TF(的)*IDF(的)+TF(应用)*IDF(应用)
-
IDF 的概念,最早是剑桥大学的"斯巴克*琼斯"提出
- 1972年-“关键词特殊性的统计解释和它在文献检索中的应用”
- 但是没有从理论上解释 IDF 应该是用 log(全部文档数/检索词出现过的文档总数),而不是其他函数。也没有做进一步的研究
-
1970、1980年代萨尔顿和罗宾逊,进行了进一步的证明和研究,并用香农信息论做了证明:http://www.staff.city.ac.uk/~sb317/papers/foundations_bm25_review.pdf
-
现代搜索引擎,对 TF-IDF 进行了大量细微的优化。
-
Lucene 中的 TF-IDF 公式
-
¶3、BM25
从 ES5 开始,默认算分算法改为 BM25。BM25做了一定的优化,它和经典的 TF-IDF 相比:当 TF 无限增加的时候,后者的 score 会不断增长;对于前者来说,随着 TF 的增长,TF 对于算法的影响会慢慢下降。
¶4、定制 Similarity(相似度)
在 ES 中创建索引的时候对于相似度算分的计算是可以定制的:
K 默认值是 1.2,数值越⼩小,饱和度越⾼高,b 默认值是 0.75(取值范围 0-1),0 代表禁⽌止 Normalization。
BM25公式:
¶4、通过 Explain API 查看 TF-IDF
-
建立索引并写入文档
-
搜索查看算分结果
我们看到搜索"elasticsearch"的时候,返回了两条记录"we like elasticsearch"和"we use elasticsearch to power the search",前者算分比后者高,其实两者中"elasticsearch"出现的次数都是一样的,但是前者更短,所以最终算分更高。
-
explain 查看算分过程,我们加上"expalin"参数之后,看到右边的结果为我们显示了每一个文档的算分过程。
¶5、通过 Boosting Relevance 控制相关度算分
Boosting 是控制相关度的一种手段,我们可以在索引的 mapping、字段 的 mapping或者查询条件中对 Boosting 进行设置。
- 当 boost > 1时,打分的相关度相对性提升
- 当0 < boost < 1时,打分的权重相对性降低
- 当 boost < 0时,贡献负分
另外 es 还提供了一个 Boosting 的复合查询,其中包含 positive 和 negative 子查询,下面就是这样一个例子。
- 我们将对于字段content 的"elasticsearch"词条的 term 查询纳入到 positive,表示这个查询会贡献正向分值。
- 将对于同一个字段 content 的"like"词条的 term 查询纳入到 negative,表示这个查询会贡献负分,并设置 boosting 的一个属性"negative_boost"来指定负分基础数值。
¶6、Kibana 测试请求
PUT testscore
{
"settings": {
"number_of_shards": 1
},
"mappings": {
"properties": {
"content": {
"type": "text"
}
}
}
}
PUT testscore/_bulk
{ "index": { "_id": 1 }}
{ "content":"we use Elasticsearch to power the search" }
{ "index": { "_id": 2 }}
{ "content":"we like elasticsearch" }
{ "index": { "_id": 3 }}
{ "content":"The scoring of documents is caculated by the scoring formula" }
{ "index": { "_id": 4 }}
{ "content":"you know, for search" }
POST /testscore/_search
{
//"explain": true,
"query": {
"match": {
"content":"you"
//"content": "elasticsearch"
//"content":"the"
//"content": "the elasticsearch"
}
}
}
POST testscore/_search
{
"query": {
"boosting" : {
"positive" : {
"term" : {
"content" : "elasticsearch"
}
},
"negative" : {
"term" : {
"content" : "like"
}
},
"negative_boost" : 0.2
}
}
}
POST tmdb/_search
{
"_source": ["title","overview"],
"query": {
"more_like_this": {
"fields": [
"title^10","overview"
],
"like": [{"_id":"14191"}],
"min_term_freq": 1,
"max_query_terms": 12
}
}
}
Query&Filtering 与多字符串多字段查询
¶Query Context & Filter Context
下面是高级搜索的功能:支持多项文本输入,针对多个字段进行搜索。
搜索引擎一般也提供基于时间,价格等条件的过滤。在 Elasticsearch 中,有 Query 和 Filter 两种不同的搜索 Context:
- Query Context:会进行相关性算分
- Filter Context:不需要进行算分(Yes or No),可以利用 Cache,获得更好的性能
¶条件组合
问题:假设要搜索一部电影,包含了以下一些条件:
- 评论中包含了 Guitar
- 用户打分高于3分
- 上映日期要在1993年与2000年之间
这个搜索包含了3段逻辑针对不同字段,我们可以使用复合查询:bool Query来同时实现这三个逻辑,并且有比较好的性能。
¶bool 查询
一个 bool 查询,是一个或者多个查询子句的组合,它有4种子句,其中2种会影响算分,2种不影响算分。
相关性打分并不只是全文本检索的专利。也适用于 yes|no 的子句,匹配的子句越多,相关性评分越高。如果多条查询子句被合并为一条复合查询语句,比如bool查询,则每个查询子句计算得出的评分会被合并到总的相关性评分中。
¶1、bool 查询语法
- 子查询可以任意顺序出现
- 可以嵌套多个查询
- 如果你的 bool 查询中,没有 must 条件,should 中必须至少满足一条查询
下面是一个 bool 查询例子:
- 第一个 must 子句表示第一个查询必须匹配到文档,并进行算分
- 最后一个 should 检索满足其中一个条件的文档,并进行算分
- filter 对匹配文档进行过滤,不进行算分
- 下面的 must_not 子句过滤价格小于等于10的文档,没有算分
¶2、解决结构化查询:"包含而不是相等"的问题
在"结构化搜索"章节的"精确查找多个值"小节中提到对于一个多值字段的结构化查询的语义是包含查询的,即这个字段只要是有一个值匹配了检索的 term,那么当前这个文档就是被命中了。
为了解决这个问题,我们可以针对这个字段引入一个"count"字段专门用来存储对这个字段的值个数,在查询的时候我们使用 bool 复合查询,在对某个字段检索我们需要的内容的同时,限制该字段的值的个数,从而实现精准匹配:
¶3、Query Context、should 和 Filter Context、must_not下的算分规则
示例一:Query Context 和 should:影响算分
前面我们提到,当前两个操作是会对匹配的文档进行算分的。
- 对于 es 来说,query context 的语义就是寻找最接近用户意愿的数据,固然会对 query context 的"查询条件"对商品进行匹配并进行匹配相关度的计算。
- 而 should 的语义也是寻找最接近用户意愿的数据,只不过 should 是包含一项仅匹配,且有它自己维度的算分规则。
可以看到经过下面的 should 条件 或者 term query context 匹配到的文档是有分值的。然后在最顶层属性会有一个"max_score"属性用来聚合当前查询所有文档的分值表示当前查询的分值。
示例二:Filter Context 和 must_not 查询不会进行算分
可以看到下面的查询中只有 filter 和 must_not 条件:
- filter 的语意就是过滤,es认为这个功能仅仅就是筛选出匹配的数据即可,所以经过这个条件命中出来的文档分值都是0。(当然,如果有一个 should 或者 must 条件也命中了这个文档,那么应该就是将这两个条件计算的分值相加,然后加上当前 filter 的分值0得到这个文档的最终分值)
- 而 must_not 的语义是用户想要排除这些数据,只要剩下的数据,那么返回的文档就是剩下的数据,理所当然的 must_not 条件并不是"匹配"了这些文档数据,也就没有"查询条件匹配相关度"这么个概念了,固然不会进行分值计算,所以经过排除 must_not 之后返回的文档没有被should 或者 must 条件命中,分值就是0。如果命中了,就按照上面提到的计算方式计算分值。
根据上面的规则对每个返回的文档的分值进行计算并返回分值。然后也会对当前查询的所有分值进行汇总。
¶4、bool 嵌套
另外,bool 复合查询是支持嵌套的。下面的例子是利用嵌套 bool 实现"shuold not"的逻辑
¶5、 bool 语句结构影响算分
bool 查询语句的结构是会对相关度算分产生影响的:
- 同一层级下的竞争字段,具有相同的权重
- 通过嵌套 bool 查询,可以改变对算分的影响
看下面的例子,我们通过将左边的颜色下放一层,导致关于颜色的匹配算分低于其他算分
¶6、控制字段的 Boosting
在"搜索的相关性算法"的章节我们提到可以通过 Boosting 控制算分的逻辑,其中可以索引mapping、字段 mapping 以及查询中进行boosting 控制。
下面是 bool 查询中进行 boosting 修改的一个例子。可以看到我们建立了一个索引写入了两个文档,其中包含两个字段 title 和 content,我们并没有对索引进行 mapping 定义,所以是动态生成的,boosting 的值都是默认的。
下面我们对这个索引进行一个 bool 查询,可以看到一个 bool 查询中包含了两个 match 查询,分别是针对 title 字段和 content 字段,而这两个字段的查询的内容都是一样的"apple ipad"。其中我们还对这两个查询分别定义了不同的 boost。
此时我们设置 title match 查询的 boost 是4,content match 查询的 boost 是1,那么我们插入的两个文档将都会被命中返回。因为 title match 的 boost 值较高,所以针对 title 字段的算分权重高,那么由于文档2的 title字段包含了两个"Apple iPad",固然返回结果中文档2会排在文档1前面。但是如果我们将它们的两个 boost 值互调,那么返回的排序就也会互调了。
¶7、Not Quite Not
我们看下面这样的一个例子。我们插入三条文档数据。然后通过 bool 复合 match 查询 content 中包含 apple 的新闻。可以看到三条文档数据都返回了。但是实际上我们只是想查询关于苹果公司产品新闻,排在第一位的新闻是"苹果公司员工喜欢苹果派和苹果汁",不是我们想要的数据。我们可以怎样优化呢?看下面操作
-
那么我们可以做以下修改,加入一个 must_not 的 bool 查询,这样我们就会过滤掉包含"派"的数据:
-
但是有时候我们并不是想直接过滤这些其他数据,而是想将它排在较后面的位置,那么我们可以使用 boosting 查询,将"派"纳入到贡献负分的 boosting-negative 查询中,并设置贡献分值为0.5,这样就能实现我们想要的效果了
¶8、相关阅读
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html
https://www.elastic.co/guide/en/elasticsearch/reference/7.1/query-dsl-boosting-query.html
¶9、Kibana 测试请求
POST /products/_bulk
{ "index": { "_id": 1 }}
{ "price" : 10,"avaliable":true,"date":"2018-01-01", "productID" : "XHDK-A-1293-#fJ3" }
{ "index": { "_id": 2 }}
{ "price" : 20,"avaliable":true,"date":"2019-01-01", "productID" : "KDKE-B-9947-#kL5" }
{ "index": { "_id": 3 }}
{ "price" : 30,"avaliable":true, "productID" : "JODL-X-1937-#pV7" }
{ "index": { "_id": 4 }}
{ "price" : 30,"avaliable":false, "productID" : "QQPX-R-3956-#aD8" }
#基本语法
POST /products/_search
{
"query": {
"bool" : {
"must" : {
"term" : { "price" : "30" }
},
"filter": {
"term" : { "avaliable" : "true" }
},
"must_not" : {
"range" : {
"price" : { "lte" : 10 }
}
},
"should" : [
{ "term" : { "productID.keyword" : "JODL-X-1937-#pV7" } },
{ "term" : { "productID.keyword" : "XHDK-A-1293-#fJ3" } }
],
"minimum_should_match" :1
}
}
}
#改变数据模型,增加字段。解决数组包含而不是精确匹配的问题
POST /newmovies/_bulk
{ "index": { "_id": 1 }}
{ "title" : "Father of the Bridge Part II","year":1995, "genre":"Comedy","genre_count":1 }
{ "index": { "_id": 2 }}
{ "title" : "Dave","year":1993,"genre":["Comedy","Romance"],"genre_count":2 }
#must,有算分
POST /newmovies/_search
{
"query": {
"bool": {
"must": [
{"term": {"genre.keyword": {"value": "Comedy"}}},
{"term": {"genre_count": {"value": 1}}}
]
}
}
}
#Filter。不参与算分,结果的score是0
POST /newmovies/_search
{
"query": {
"bool": {
"filter": [
{"term": {"genre.keyword": {"value": "Comedy"}}},
{"term": {"genre_count": {"value": 1}}}
]
}
}
}
#Filtering Context
POST _search
{
"query": {
"bool" : {
"filter": {
"term" : { "avaliable" : "true" }
},
"must_not" : {
"range" : {
"price" : { "lte" : 10 }
}
}
}
}
}
#Query Context
POST /products/_bulk
{ "index": { "_id": 1 }}
{ "price" : 10,"avaliable":true,"date":"2018-01-01", "productID" : "XHDK-A-1293-#fJ3" }
{ "index": { "_id": 2 }}
{ "price" : 20,"avaliable":true,"date":"2019-01-01", "productID" : "KDKE-B-9947-#kL5" }
{ "index": { "_id": 3 }}
{ "price" : 30,"avaliable":true, "productID" : "JODL-X-1937-#pV7" }
{ "index": { "_id": 4 }}
{ "price" : 30,"avaliable":false, "productID" : "QQPX-R-3956-#aD8" }
POST /products/_search
{
"query": {
"bool": {
"should": [
{
"term": {
"productID.keyword": {
"value": "JODL-X-1937-#pV7"}}
},
{"term": {"avaliable": {"value": true}}
}
]
}
}
}
#嵌套,实现了 should not 逻辑
POST /products/_search
{
"query": {
"bool": {
"must": {
"term": {
"price": "30"
}
},
"should": [
{
"bool": {
"must_not": {
"term": {
"avaliable": "false"
}
}
}
}
],
"minimum_should_match": 1
}
}
}
#Controll the Precision
POST _search
{
"query": {
"bool" : {
"must" : {
"term" : { "price" : "30" }
},
"filter": {
"term" : { "avaliable" : "true" }
},
"must_not" : {
"range" : {
"price" : { "lte" : 10 }
}
},
"should" : [
{ "term" : { "productID.keyword" : "JODL-X-1937-#pV7" } },
{ "term" : { "productID.keyword" : "XHDK-A-1293-#fJ3" } }
],
"minimum_should_match" :2
}
}
}
POST /animals/_search
{
"query": {
"bool": {
"should": [
{ "term": { "text": "brown" }},
{ "term": { "text": "red" }},
{ "term": { "text": "quick" }},
{ "term": { "text": "dog" }}
]
}
}
}
POST /animals/_search
{
"query": {
"bool": {
"should": [
{ "term": { "text": "quick" }},
{ "term": { "text": "dog" }},
{
"bool":{
"should":[
{ "term": { "text": "brown" }},
{ "term": { "text": "brown" }},
]
}
}
]
}
}
}
DELETE blogs
POST /blogs/_bulk
{ "index": { "_id": 1 }}
{"title":"Apple iPad", "content":"Apple iPad,Apple iPad" }
{ "index": { "_id": 2 }}
{"title":"Apple iPad,Apple iPad", "content":"Apple iPad" }
POST blogs/_search
{
"query": {
"bool": {
"should": [
{"match": {
"title": {
"query": "apple,ipad",
"boost": 1.1
}
}},
{"match": {
"content": {
"query": "apple,ipad",
"boost":
}
}}
]
}
}
}
DELETE news
POST /news/_bulk
{ "index": { "_id": 1 }}
{ "content":"Apple Mac" }
{ "index": { "_id": 2 }}
{ "content":"Apple iPad" }
{ "index": { "_id": 3 }}
{ "content":"Apple employee like Apple Pie and Apple Juice" }
POST news/_search
{
"query": {
"bool": {
"must": {
"match":{"content":"apple"}
}
}
}
}
POST news/_search
{
"query": {
"bool": {
"must": {
"match":{"content":"apple"}
},
"must_not": {
"match":{"content":"pie"}
}
}
}
}
POST news/_search
{
"query": {
"boosting": {
"positive": {
"match": {
"content": "apple"
}
},
"negative": {
"match": {
"content": "pie"
}
},
"negative_boost": 0.5
}
}
}
单字符串多字段查询
¶单字符串查询
例如像搜索引擎 google 只提供了一个输入框,底层是查询相关的多个字段,支持按照价格、时间等其他字段进行过滤。
¶Disjunction Max Query
¶1、单字符串多字段查询的算分排序问题
我们插入两个文档到索引中:
- 第一个文档的标题是"敏捷的灰兔子",内容是"灰兔子是很常见的"。
- 第二个文档的标题是"保持宠物的健康",内容是"我的敏捷的灰色狐狸会定期地吃兔子"。
此时我们同时对标题和内容搜索"Brown fox",希望优先搜索到关于"灰色狐狸"的内容,要不然搜索到"灰色"或者"狐狸"也是可以的。现在我们发出一个 bool_should query:
但是发现返回结果虽然两条内容都返回了,文档1却排在文档2的前面,这并不符合我们的预期,因为文档2的内容里面是拥有"brown fox"这个"连贯"(前后顺序一致)的 term 组合的。为什么会出现这样的情况呢? 请看以下内容。
¶2、Dis-Max Query解决该场景问题
我们通过"explain"分析 should 查询的算分过程,得知它的逻辑是将每一个获得匹配的 match 查询的算分进行相加,而 match 查询的算分则是将查询内容拆解出来的所有 term 在当前文档当前字段的 TF*IDF 之后进行相加。
- 查询 should 语句中的两个查询
- 加和两个查询的评分
- 乘以匹配语句的总数?
- 处理所有语句的总数?
简单来说,should 查询就是将所有 term 在所有匹配字段的算分进行相加。那么上面的查询对于第一个文档来说 brown 字段在 title 和 body 字段同时命中,而第二个文档只在 body 字段命中了 brown 字段和 fox 字段,因为 brown 字段长度比 fox 长,文档一的两个字段都比文档二的两个字段要短,最终相加出来的评分必然比文档二大。
经过上面的分析我们找到的问题的所在,根据我们的需求,查询"Brown fox"虽然是希望可以跨字段查询,但是我们想优先得到的文档是在标题或者内容中同时包含"Brown fox",此时它作为一个词组出现的意义相对更高。但是我们又希望可以查询到"brown"或者"fox"的内容,只不过放在后面而已。 所以我们需要的应该是这样一个查询语义:任何被检索到的文档将其匹配评分最高的字段作为该文档的评分进行返回,而不是 bool 的累加所有字段的评分。此时我们可以引进 Disjuction Max Query,它的查询语义就是如此。
看下面的操作,就可以得到我们想要的效果
以上关于"brown fox"的例子可能不是很恰当,我们的实际需求可能也不是这样子的。但是针对单个字段作为文档的最高评分返回这个需求肯定是有的。所以先了解、理解这个功能吧。遇到了需求自然就懂了。
¶3、通过 Tie Breaker 参数进行优化调整
通过上面的例子了解了 Disjuction Max Query 之后,我们来看下面的例子,我们还是基于上面的索引和文档进行查询,本次是同时对 title 和 body 检索"Quick pets",可以看到右边显示的结果是两个文档的分值都是一样的。这就引出一个新问题:
有一些情况下,所有文档都没有字段同时存在"Quick"和"pets",即我们所检索的内容作为一个词组存在与某个文档的字段中,现在我们的需求就退化了,希望优先得到"所有字段尽量匹配"的数据。但是我们发现,在下面的例子中,文档1的标题中匹配了一个 quick,而文档2的标题中匹配了一个 pets,内容中匹配了一个quick,文档2才是我们优先想得到的文档。而 Disjuction Max Query 仅仅是得到最优匹配字段的分值作为文档分值返回,那么文档1的最优匹配字段自然是匹配了 “quick” 的 title 了,而文档2也是匹配了"pets"的 title,所以导致它们分值一致。
针对上面的问题,我们引入了 Disjuction Max Query 的一个属性Tie Breaker进行优化,这个属性的语义是:
- 获得最佳匹配语句的评分_score,
- 将其他匹配语句的评分与 tie_breaker 相乘
- 对以上评分求和并规范化作为整个 Disjuction Max Query 的评分返回
Tie Breaker 是一个介于0-1之间的浮点数。0代表使用最佳匹配;1代表所有语句同等重要,即退化成了 bool_should 查询。
我们加上该属性进行查询,得到了想要的效果:
加上 expain 查看算分过程:
¶4、Kibana 测试请求
PUT /blogs/_doc/1
{
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
PUT /blogs/_doc/2
{
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
POST /blogs/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
]
}
}
}
POST blogs/_search
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Quick pets" }},
{ "match": { "body": "Quick pets" }}
]
}
}
}
POST blogs/_search
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Quick pets" }},
{ "match": { "body": "Quick pets" }}
],
"tie_breaker": 0.2
}
}
}
¶Multi Match Query
对于单字符串多字段查询有以下三种查询场景:
¶1、最佳字段(Best Fields)
当字段之间相互竞争,又相互关联。例如上面的 title 和 body 这样的字段。评分来自最佳匹配字段。该场景我们可以使用上面的 disjuction max query 实现,也可以使用这里介绍的 multi match query 实现。
在这种查询场景下,以我们上面的例子为例,multi_match 查询的实现调用如下(可以达到和 dis-max query 一样的效果):
- Best Fields 是默认类型,可以不用指定
- Minimum should Match 等参数可以传递到生成的 query 中
¶2、多数字段(Most Fileds)
在处理多字段检索英文内容的时,一种常见的手段是:
- 针对检索内容的"主要或者重要字段"字段设置分词器为 English Analyzer,该分词器会抽取英文词干(过去时、现在进行时、派生词等相关词语),将相同词干的词语加入同义词,这样我们对该字段进行检索的时候可以匹配更多的文档(提高 recall);然后为该字段增加一个子字段,该子字段使用 Standard Analyzer,该分词器仅提供简单的分词操作,匹配会更加精准(提高 precision)。
- 检索内容的其他字段作为匹配文档提高相关度的信号,匹配字段越多越好。
¶案例和问题
查看下面的截图,我们先建立包含一个 text 类型的 title 字段的索引,并为该字段指定英文分词器。然后输入两个文档。并执行了一个对 title 字段执行了一个"barking dogs"的 match 查询。右边是返回结果,我们看到,title 内容为"my dog barks"的文档的分值要比"I see a lot of barking dogs on the road"的分值要高,但是第二个文档中是包含"barking dogs"这个完整内容的,这显然不符合我们预期。这是因为英文分词器会对字段内容进行词干抽取加入同义词进行索引和检索,所以"barking"和"barks"都会映射到"bark"索引上,"dogs"会映射到"dog"上,即"barking dogs"最终被解释为"bark"和"dog"两个词条来进行索引和检索,而文档1和文档2都命中了 barking 和 dogs,但是文档1更短,固然分值更高。
那么我们现在遇到的问题是,我们想保证这两个文档都被命中返回,但是文档2的内容分值更高。也就是在保证尽量多地返回相关文档(recall)的同时保证更准确的文档排在最前面(precision)。
¶解决问题
此时我们就引入了我们讲到的 Most Field 了。如下图,我们重构了索引,为字段 title 增加了一个子字段"std",并为其指定类型为和 title 一样的 text,但是分词器指定为 standard。
此时我们在执行查询语句的时候,使用了 multi_match 的 most_fields 查询,指定检索内容"query"为"barking dogs",字段为"title"和"title.std"。这样就能达到我们想要的效果了,两个文档都返回,但是文档2排在前面。
下面两张截图是加了"explain"之后返回的结果,通过 explain 我们知道,对于检索内容"barking dogs",es 先对其在 title 字段上进行了检索,自然的两个文档都匹配了,所以必然两个文档都会返回;然后对该字符串在 title.std 字段上进行检索,此时只会有文档2匹配;后面就是算分环节了,文档1命中了字段 title,所以算分就是针对词条"bark"和"dog"的算分总和,而文档2同时命中了字段 title 和 title.std,所以其算分为针对词条"bark"、“dog”、"barking"和"dogs"算分的总和,所以文档2算分大于文档1。
¶总结
-
用广度匹配字段 title 包含尽可能多的文档以提升召回率,同时又使用 title.std 作为信号将相关度更高的问答那个置于结果顶部。
-
每个字段对于最终评分的贡献可以通过自定义值 boost 来控制。比如,我们的文档中可能不只 title 这么一个字段可能还有很多其他的字段,我们为了使得 title 字段更为重要,同时也降低了其他信号字段的作用。(设置 boost 如下所示在字段后面通过"^"符号紧跟一个 boost 值)
¶3、混合字段(Cross Field)
对于某些实体,例如"人"这个实体,拥有人名、地址、图书信息等这些字段信息。我们需要在这多个字段中检索我们想要查询的信息,而单个字段只能作为整体的一部分。希望在任何这些列出的字段中找到尽可能多的词。
¶案例和问题
下面是一个地址的文档信息:
当我们对于一个输入字符串想对这个包含多个字段的地址文档进行检索的时候,我们想到了可以使用 Multi Match-most_fields query实现。但是使用 most_fields 只能在一定程度上实现这个需求,即类似下面这样的搜索,我们认为用户的搜索的内容是一个非结构化的内容,会经过拆分之后形成一个个词条,这些词条允许在不同的字段上被检索到,只要所有词条都能被检索到我们就认为这个文档是匹配的,进行返回与算分:
但是如果我们的需求产生了变化,认为用户的输入无论是否一个结构化的内容,它最终形成的词条都只能在文档的某一个或者多个字段完全涵盖才算匹配,即使是多个字段分别涵盖这些词条我们也不能认为它是匹配,面对这样的场景most_fields 就不能满足了,我们也不能对它和 Match Query 一样加上一个And Operator 来解决。(为 most_fields 增加And Operator 调用查询 api 是不会报错的,但是返回结果却不是我们的预期效果,本实验返回空)
虽然针对上面的需求,我们可以用前面提到的 copy_to 将多字段内容复制到同一个字段上,但是这样需要花费额外的空间,且原本算分可能也会受到影响。
¶解决问题
此时我们就引入了 multi_match 的 coress_fields query,它可以完全解决这样的场景:
- 支持使用 Operator
- 与 copy_to 相比,其中一个优势就是它可以在搜索的时为单个字段提升权重
多语言及中文分词与检索
¶自然语言与查询 recall
- 当处理人类自然语言时,有些情况,尽管搜索和原文不完全匹配,但是希望搜到一些内容
- Quick brown fox 和 fast brown fox
- Jumping fox 和 Jumped foxes
- 一些可采取的优化
- 归一化词元:清除变音符号,如 ròle 的时候也会匹配role
- 抽取词根:清除单复数和时态的差异
- 包含同义词
- 拼写错误或者同音异形词
¶混合多语言的挑战
-
一些具体的多语言场景
- 不同的索引使用不同的语言
- 同一个索引中,不同的字段
- 一个文档的一个字段内混合不同的语言
-
混合语言存在的一些挑战
- 词干提取:以色列文档,包含了希伯来语、阿拉伯语、俄语和英文
- 不正确的文档频率:英文为主的文章中,德文算分高(因为德文的出现频率很低,一旦对德文进行检索,自然算分高)
- 需要判断用户搜索时使用的语言,语言识别(Compact Language Detector),然后根据语言查询不同的索引。
-
一个搜索条件(框)支持多语言查询方案
-
不同语言用不同索引,例如 orders-cn ,orders-en
-
可以通过设置mulfi field创建多个子字段,这个子字段可以使用不同的分词器。
至于用户在搜索的时候使用什么语言,可以让用户指定,或者通过http header中的accept language来判定。
至于索引的数据,如果你明确知道他所用的语言,用方案一会很简单。否则你需要使用一个学习算法对文档的语言进行归类。有一些现成的库可以使用,例如:chromiu-compact-language-detector ,基于google的CLD开发,支持160多种语言的detect。
-
¶分词的挑战
-
英文分词:You’re 分成一个还是多个?Half-baked 中间的横杠是否去掉。
-
中文分词
-
分词标准不同:哈工大标准中,姓和名是分开的;HanLP 分词是在一起的。具体情况需指定不同的标准
-
歧义(组合型歧义、交集型歧义、真歧义)
中华人民共和国、美国会通过对台售武法案、上海仁和服装厂
-
¶中文分词方法的演变-字典法
- 查字典:最容易想到的分词方法(北京航空大学的梁南元教授提出)
- 一个句子从左到右扫描一遍。遇到有的词就标示出来。找到复合词,就找最长的
- 不认识的字串就分隔成单字词
- 最小词数的分词理论:哈工大王晓龙博士把查字典的方法理论化
- 一句话应该分成数量最少的词串
- 遇到二义性的分割,无能为力(例如:“发展中国家”、“上海大学城书店”)
- 用各种文化规则来解决二义性都不成功
¶中文分词方法的演变-基于统计法的机器学习算法
-
统计语言模型:1990年前后,清华大学电子工程系郭进博士
解决了二义性问题,将中文分词的错误率降低了一个数量级。概率问题,动态规划+利用维特比算法快速找到最佳分词
-
基于统计的机器学习算法
- 这类目前常用的算法是 HMM、CRF、SVM、深度学习等算法。比如 HanLP 分词工具是基于 CRF 算法,以CRF 为例,基本思路是对汉子进行标注训练,不仅考虑了词语出现的频率,还考虑上下文,具备较好的学习能力,因此其对歧义词和未登录词的识别都具有良好的效果。
- 随着深度学习的兴起,也出现了基于神经网络的分词器,有人尝试使用双向 LSTM+CRF 实现分词器,其本质上是序列标注,据报道其分词器字符准确率可高达97.5%
¶中文分词器现状
- 中文分词器以统计语言模型为基础,经过几十年的发展,今天基本已经可以看做是一个已经解决的问题。
- 不同分词器的好坏,主要的差别在于数据的使用和工程使用的精度
- 常见的分词都是使用机器学习算法和词典相结合,一方面能够提高分词准确率,另一方面能够改善领域适应性。
¶一些中文分词器
-
HanLP:面相生产环境的自然语言处理工具包
-
IK 分词器
¶HanLP Analysis
Es 插件下载:
./elasticsearch-plugin install https://github.com/KennFalcon/elasticsearch-analysis- hanlp/releases/download/v7.1.0/elasticsearch-analysis-hanlp-7.1.0.zip
支持远程词典配置:
¶IK Analysis
Es 插件下载:
./elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-
ik/releases/download/v7.1.0/elasticsearch-analysis-ik-7.1.0.zip
支持字典热更更新
¶Pinyin Analysis
Es 插件下载:
./elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis- pinyin/releases/download/v7.1.0/elasticsearch-analysis-pinyin-7.1.0.zip
¶相关资源
一些分词工具,供参考:
¶Kibana 测试请求
#stop word
DELETE my_index
PUT /my_index/_doc/1
{ "title": "I'm happy for this fox" }
PUT /my_index/_doc/2
{ "title": "I'm not happy about my fox problem" }
POST my_index/_search
{
"query": {
"match": {
"title": "not happy fox"
}
}
}
#虽然通过使用 english (英语)分析器,使得匹配规则更加宽松,我们也因此提高了召回率,但却降低了精准匹配文档的能力。为了获得两方面的优势,我们可以使用multifields(多字段)对 title 字段建立两次索引: 一次使用 `english`(英语)分析器,另一次使用 `standard`(标准)分析器:
DELETE my_index
PUT /my_index
{
"mappings": {
"blog": {
"properties": {
"title": {
"type": "string",
"analyzer": "english"
}
}
}
}
}
PUT /my_index
{
"mappings": {
"blog": {
"properties": {
"title": {
"type": "string",
"fields": {
"english": {
"type": "string",
"analyzer": "english"
}
}
}
}
}
}
}
PUT /my_index/blog/1
{ "title": "I'm happy for this fox" }
PUT /my_index/blog/2
{ "title": "I'm not happy about my fox problem" }
GET /_search
{
"query": {
"multi_match": {
"type": "most_fields",
"query": "not happy foxes",
"fields": [ "title", "title.english" ]
}
}
}
#安装插件
./elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.1.0/elasticsearch-analysis-ik-7.1.0.zip
#安装插件
bin/elasticsearch install https://github.com/KennFalcon/elasticsearch-analysis-hanlp/releases/download/v7.1.0/elasticsearch-analysis-hanlp-7.1.0.zip
#ik_max_word
#ik_smart
#hanlp: hanlp默认分词
#hanlp_standard: 标准分词
#hanlp_index: 索引分词
#hanlp_nlp: NLP分词
#hanlp_n_short: N-最短路分词
#hanlp_dijkstra: 最短路分词
#hanlp_crf: CRF分词(在hanlp 1.6.6已开始废弃)
#hanlp_speed: 极速词典分词
POST _analyze
{
"analyzer": "hanlp_standard",
"text": ["剑桥分析公司多位高管对卧底记者说,他们确保了唐纳德·特朗普在总统大选中获胜"]
}
#Pinyin
PUT /artists/
{
"settings" : {
"analysis" : {
"analyzer" : {
"user_name_analyzer" : {
"tokenizer" : "whitespace",
"filter" : "pinyin_first_letter_and_full_pinyin_filter"
}
},
"filter" : {
"pinyin_first_letter_and_full_pinyin_filter" : {
"type" : "pinyin",
"keep_first_letter" : true,
"keep_full_pinyin" : false,
"keep_none_chinese" : true,
"keep_original" : false,
"limit_first_letter_length" : 16,
"lowercase" : true,
"trim_whitespace" : true,
"keep_none_chinese_in_first_letter" : true
}
}
}
}
}
GET /artists/_analyze
{
"text": ["刘德华 张学友 郭富城 黎明 四大天王"],
"analyzer": "user_name_analyzer"
}
平时开发及测试需要注意
¶思考与分析
- “精确值"还是"全文”
- 搜索是怎样的?不同字段需要配置怎样的分词器
- 测试不同的选项
- 分词器、多字段属性、是否要 g-grams、what are some critical synonyms、为字段设置不同的权重
- 测试不同的选项,测试不同的搜索条件
- 查看搜索结果和相关性算分、对搜索结果高亮显示
¶测试相关性:理解原理+多分析+多调整测试
技术分为道和术两种
- 道:原理和原则
- 术:具体的做法、具体的解法
关于搜索,为了有一个好的搜索结果。除了真正理解背后的原理,更需要多加实践与分析
- 单纯追求"术",会一直很辛苦。只有掌握了本质和精髓之"道,做事才游刃有余
- 要做好搜索,除了理解原理,也需要坚持去分析一些不好的搜索结果。只有通过一定时间的积累,才能真正所有感觉
- 总希望一个模型,一个算法,就能毕其功于一役,是不现实的
¶监控并理解用户行为
开发的时候不要过度调试相关度
而要监控搜索结果,监控用户点击最顶端结果的频次
将搜索结果提高到几高水平,唯一途径就是
- 需要具有度量用户行为的强大能力
- 可以在后台实现统计数据,比如:用户的查询和结果,有多少被点击了
- 哪些搜索,没有返回结果
使用 Search Template 和 Index Alias
¶Search Template
主要可用于解耦程序和搜索 DSL。Elasticsearch 的查询语句对相关性算分和查询性能都至关重要。
在开发初期,虽然可以明确查询参数,但是往往还不能最终定义查询的 DSL 的具体结构。
我们可以通过 Search Template 定义一个 Contract,解耦开发人员、搜索工程师、性能工程师之间的工作,让其各司其职。
- 使用_script api创建一个名为 "tmdb"的 Search Template,我们对DSL中的 multi_match 查询的 query 属性定义为一个template 的变量,这个属性也就是用户查询的时候输入的一个检索字符串
-
使用 Search Template 进行查询
详细语法参考:https://www.elastic.co/guide/en/elasticsearch/reference/7.1/search-template.html
¶Index Alias
主要用于实现零停机运维。当我们如果经常会修改一个索引的名称,但是我们elasticsearch 的上层应用又不想经常性地修改引用索引地名称,这时候我们就可以使用 Index Alias。上层应用对 alias 进行索引地访问。
另外我们还可以使用 Alias 创建不同地查询的视图,例如下面的动作在创建索引的同时使用了一个 range filter,设定对该 alias 的访问只能获得 rating字段大于等于4的文档数据。
Function Score Query 优化算分
Elasticsearch 默认会以文档的相关度算分进行排序,可以通过指定一个或者多个查询字段进行排序。但是在某些特定条件,无法针对相关度,对排序实现更多的控制,即使用相关度算分排序无法满足需求。
而 Function Score Query 可以在查询结束后,对每一个匹配的文档进行一系列的重新算分,根据新生成的分数进行排序。它提供了几种默认的计算分值的函数:
- Weight:为每一个文档设置一个简单而不被规范化的权重
- Field Value Factor:使用该数值来修改_score,例如将"热度"和"点赞数"作为算分的参考因素
- Random Score:对搜索结果进行随机的排序,使得我们可以在实现特定需求例如需要为每一个用户使用一个不同的随机算分
- 衰减函数:以某个字段的值为标准,举例某个值越近,得分越高
- Script Score:自定义脚本完全控制所需逻辑
¶案例使用 function score query
希望能够将点赞多的 blog,放在搜索列表相对靠前的位置。同时搜索的评分,还是要作为排序的主要依据。 以下就是一个例子,我们写入一个文档到博客索引中,这个文档包含一个标题、内容、投票数三个字段。
下面就是一个查询博客文档的请求,它使用了 function_score 的 query,我们指定了 votes 字段作为 field_value_factor对搜索结果进行重新算分。
以上 function_score 的算分规则其实就是:新的算分= 旧的算分 * 投票数;(上面的逻辑就是相关度算分 * 6)
¶使用 Modifier 平滑曲线
以上查询会存在以下问题,当用户投票数的差异非常大的时候,例如有三篇博客的投票数分别是0,1000,1000000000的时候,算分结果的差异也会非常大,因为它是简单的相乘。
这时候我们可以引进 function_score 的 field_value_factor 的另一个属性 modifier 了,它可以对算分的函数进行平滑处理,例如我们在下面的例子中使用了 log1p 的 modifiier,它将会导致我们的新算分逻辑产生变化:新的算分 = 旧的算分 * log(1 + 投票数);(即:相关度算分 * log(1 + 投票数))log 了一下,这样就能让我们的算分函数曲线在投票数越来越高的时候趋向平滑。
另外,modifier 还有以下平滑处理方式可以选择,根据实际的场景进行选择:
¶Factor
另外我们还能如下面例子这样使用另一个属性"factor",它也会影响到我们的算分:新的算分 = 旧的算分 * log(1 + factor * 投票数);即(相关度算分 * log(1 + factor * 投票数))。从而实现更精准的控制。下面是不同 factor下的算分函数曲线的平滑度:
¶Boost Mode 和 Max Boost
Function_score query 还可以通过Boost Mode来控制新的算分方式:
- Multiply:算分与log函数值(如果没有进行平滑处理就是该字段值本身)的乘积(默认,就是我们上面例子使用的)
- Sum:算分与log函数值(如果没有进行平滑处理就是该字段值本身)的和
- Min/Max:算分与log函数值(如果没有进行平滑处理就是该字段值本身)取最小/最大值
- Replace:使用log函数值(如果没有进行平滑处理就是该字段值本身)取代算分
另外Max Boost 可以将返回结果的新算分计算结果维持在一个最大值之内,下面例子没有加入 Max_Boost 之前最大算分为5点几,加入之后 max_boost=3之后变成了3点几:
¶一致性随机函数
使用场景:网站的广告需要提高展现率,让每个用户能看到不同的随机排名,但是也希望同一个用户访问时,其访问到的广告的顺序时一致的。
这样我们就可以使用 function_score 的 random_score 设置其属性 seed 值来实现,它会通过一个随机函数对搜索文档进行随机算分,同一个 seed 值每一个文档获得的随机算分是一致的,这也就保证了随机函数的一致性。
这样我们就可以通过为不同用户指定不同 seed 值实现随机排序了。
¶相关阅读
https://www.elastic.co/guide/en/elasticsearch/reference/7.1/query-dsl-function-score-query.html
¶Kibana 测试请求:
DELETE blogs
PUT /blogs/_doc/1
{
"title": "About popularity",
"content": "In this post we will talk about...",
"votes": 0
}
PUT /blogs/_doc/2
{
"title": "About popularity",
"content": "In this post we will talk about...",
"votes": 100
}
PUT /blogs/_doc/3
{
"title": "About popularity",
"content": "In this post we will talk about...",
"votes": 1000000
}
POST /blogs/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes"
}
}
}
}
POST /blogs/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p"
}
}
}
}
POST /blogs/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p" ,
"factor": 0.1
}
}
}
}
POST /blogs/_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": 3
}
}
}
POST /blogs/_search
{
"query": {
"function_score": {
"random_score": {
"seed": 911119
}
}
}
}
Elasticsearch Suggester API
现代的搜索引擎,一般都会提供 Suggest as type 的功能,帮助用户在输入搜索的过程中,进行自动补全或者纠错。通过协助用户输入更加精准的关键词,提高后续搜索阶段文档匹配的程度。
在 google 上搜索,一开始会自动补全。当输入到一定长度,如因为单词拼写错误无法补全,就会开始提示相似的词或者句子。
搜索引擎中类似的功能,在 Elasticsearch 中是通过 Suggester API 实现的,其原理时:将输入的文本分解为 Token,然后在索引的字段里找相似的 Term 并返回。
根据不同的使用场景,Elasticsearch 设计了4种类别的 Suggesters:
- Term & Phrase Suggester
- Complete & Context Suggester
下面我们来看几个案例,在此之前先建立一个测试索引和一些文档数据,这些文档都有一个 body 字段,该字段使用默认分词器standard,仅进行了大写转小写的动作,对于它来说"rocks"和"rock"是两个词:
¶Term Suggester
Term Suggester 下提供了三种 Suggestion Mode:
- Missing:如果索引中已经存在term,不提供建议
- Popular:推荐出现频率比当前检索词条更加高的词
- Always:无论出现频率是否比当前检索词条更高,都进行返回
¶Missing Mode
我们来看下下面的例子,调用_search 的时候我们指定了 suggest 而不是 query,这个就是 es 的 Suggester,这个例子中我们指定了一个 term-suggestion,其中 text 的内容就是用户输入的内容,其中我们指定了 body 作为我们检索建议词条的字段:
Suggester 其实就是一种特殊类型的搜索。"text"里是调用时提供的文本,通常来自于用户界面上用户输入的内容。
可以看到上面的用户输入的"lucen"是一个错误的拼写,应该是"lucene"。下面我们分析一下 suggestion 的工作流程:
-
该 term-suggestion 在接收到 text 的内容之后,会经过分词器的拆分成一个个 terms,然后到索引中进行 term 匹配,此时如果匹配不到或者匹配到了就要看我们配置的"suggest_mode"来决定后面的步骤怎么走
-
可以看到我们在上面的例子中配置了"missing"的"suggest_mode":当text 拆分之后的 term 在索引的 term 字典中无法匹配的时候, 就会将一些"相似的"词条在 options 中进行返回;如果匹配了,options 中就不会返回任何内容(最后一个文档"elasticsearch is rock solid"中包含)。
其中每个"相似的"词条中都包含了一个"score"字段,这个字段描述了当前返回词条和 text 中词条的相似度,这个相似度是通过 Levenshtein Edit DIstance 的算法实现的。核心思想就是一个词改动多少字符就可以和另外一个词一致。es 提供了很多可选参数来控制相似性的模糊程度。例如"max_edits",它的可输入范围是[1,2],具体控制逻辑待研究:
-
另外 options 中返回的建议词条默认是按照词频"frequence"进行排序的,我们也可以设定按照相似度"score"进行排序,通过设置一个 sort 属性即可:
¶Popular Mode
上面的 Missing Mode 的例子中可以看到对于输入"lucen rock"拆解之后的第二个词条 “rock” 已经在索引词条字段中存在了,就不在 suggestion 中返回,如果我们依然想对匹配的索引的字段进行返回,可以通过设置"popular" mode 来实现(但是需要注意的,它只会返回在所有文档中词频比当前匹配的检索词条更高的相似词条):
可以看到 es 的 popular mode 是按照出现频率(rocks 在两篇文档中出现了,更 popular)或者相似度给我们进行了返回(匹配的词条本身不会返回,上例中 “rock” 没有返回)。
¶Prefix Length
我们针对以上例子做一个改动,将搜索 text 中的"rock"改成了"hock", 可以看到即使是 popular mode,针对该 term 也没有返回相关的建议。
这是因为 es 在默认情况下,如果检索词条的第一个字符和被检索词条的第一个字符都不匹配,就认为它们是"不相似的",这个逻辑es 也提供了一个参数实现可配置化:“prefix_length”,当我们将这个参数设置为0的时候,es 就会跳过第一个字符的检查,即可获得返回"rock":
¶Always Mode
上面的例子中,我们使用了"popular mode",它在当前检索的词条在索引的词条字典中已经存在的情况下也可以进行返回,但是有个前提条件就是只返回词频比检索词条更高的其他相似词条。如果我们也想对词频比当前检索词条低的相似词条进行返回,可以通过设置"always mode"进行实现。
¶Phrase Suggester
Phrase Suggester 在 Term Suggester 上增加了一些额外的逻辑,还支持了一些额外的参数:
- Max Errors:最多可以拼错的 Terms 数
- Confidence:限制返回结果数,默认为1
下面是一个 Phrase Suggester 的例子:
详细参考https://www.elastic.co/guide/en/elasticsearch/reference/7.1/search-suggesters-phrase.html
¶Completion Suggester
Completion Suggester 提供了"自动完成"(Auto Complete)的功能。用户每输入一个字符,就需要即时发送一个查询请求到后端查找匹配项。这对性能要求比较苛刻。Elasticsearch 采用了不同的数据结构,并非通过倒排索引来完成。而是将 Analyze 的数据编码成 FST 和索引一起存放。FST 会被 ES 整个加载进内存,速度很快。
FST 只能用于前缀查找。
¶使用 Completion Suggester 的一些步骤
-
定义 mapping,对需要使用 Completion Suggester 实现自动完成的功能的字段使用"completion" type
-
索引数据
-
搜索数据
可以看到我们也是调用了_search api 然后指定了 suggest,并在它的一个子属性"article-suggest"下面指定了"completion"的 suggest并为其指定了"title_completion"字段,也就是我们在建立索引的时候指定为"completion"type 的字段,然后设置"prefix"为"el",这个 "prefix"就是用户目前输入的内容:
可以看到,es 为我们返回了所有以"el"为前缀的数据:
¶Context Suggester
它是 Completion Suggester 的扩展,可以在搜索中加入更多的上下文信息,例如,存在这么个需求,当用户输入"star" 的时候:
- 如果用户在咖啡频道,es 给出建议"Starbucks"
- 如果用户在电影频道:es 给出建议"star wars"
es 中 context suggester 可以实现两种 context:
- category:任意字符串
- geo:地理位置信息
¶实现 Context Suggester
-
定制一个 Mapping
如下图我们建立了一个 comments 索引,在其 mapping 中定义了一个字段为 completion_autocomplete,该字段类型也是"completion",然后为它设置了另外一个属性"context",然后设置上下文类型 type 为"category",并起名为" comment_category"上下文:
-
索引数据
如下图所示我们分别在该索引中设置了两条文档,文档内容分别是"I love the star war movies"和"Where can I find a Starbucks",我们需要实现上面讲的在不同频道进行输入前缀"sta"的时候提示不同的相应的内容,我们可以像下面这样进行索引数据:
-
为每个文档进行 comment_autocomplete 字段的设置(该字段类型是 completion,和上面的 Completion Suggester 一样),表示我们想让这个文档可以提供自动提示的内容
-
如果我们没有根据不同上下文提示不同内容的需求,完全可以像上面的 Completion Suggester 的设置那样直接将 Comment 的内容复制到 comment_autocomplete 字段中(或者干脆删掉 comment 字段即可,那么就和 completion suggester 一样了);但是现在我们现在需要实现这样的需求,所以我们还要对 comment_autocomplete 字段的内容进行定制:
-
通过设置 contexts 属性将当前文档与不同的上下文绑定,可以看到 contexts 属性是一个数组,也就是说支持设置多个值,而它的值又不是一个字符串类型,我们是可以对它再进行设置的,也就是说这里的上下文划分两个维度支持我们更灵活的数据分类和聚合(例如我们可以设置两层 context:第一层 context 通过在建立索引的时候为 mapping中的 contexts 属性中设置不同的属性来实现;第二层 context 通过在索引文档的时候通过给文档指定不通contexts 属性的值来实现)。
而在本案例中我们只给索引建立了一个上下文(我们只需要实现一层 context):comment_category。所以我们直接给不同频道的文档对"comment_category"进行不同的设值,同一频道的文档进行相同的设值,这样就可以实现文档和上下文绑定聚合的功能。对于上下文我们可以理解为 FST 的不同隔离空间。
-
通过设置 input 属性将 input 的内容绑定到指定的上下文,也就是 FST 的隔离空间,然后建立这些 input 值和当前文档的关联关系。(后续用户进行自动完成动作的时候就会到指定的 FST 隔离空间下进行词条检索匹配,一旦匹配到了,就会根据 input 值绑定的文档对文档进行返回)
-
-
-
查询数据
我们现在进行数据查询,可以看到我们设置用户输入的内容"sta"到 prefix 属性,然后指定 completion 的属性 field 为 comment_autocomplete,表示我们要到这个字段中进行"自动完成操作",然后指定 completion 的 contexts 字段的"comment_category"属性值为"coffee",表示我们要到comment_category 的一级上下文下的 coffee 二级上下文查找进行词条检索匹配。
es 将会完成这样的动作,根据我们指定的上下文 contexts 属性值将 sta 到指定的 FST 空间中进行前缀匹配(因为我们设置了comment_autocomplete 字段是上下文自动完成,这里的查询不设置上下文将会报错)。如果匹配到了就会返回和匹配字符串绑定的文档(因为可以在多个文档中设置相同的 input,所以可能会返回多个文档)。
根据上面的逻辑,如果我们没有为一个文档进行上下文绑定,那么我们在进行以上的上下文查询的时候将会无法匹配到该文档;另外,我们可以给一个文档设置和文档内容完全不相关的 input(这种情况下,即使不包含上下文功能的功能,也会和上面的 completion suggester 的实现存在差异)。
¶四种 suggestion 比较
-
精准度
Completion > Phrase > term
-
召回率
Term > Phrase > Completion
-
性能
Completion > Phrase > Term
¶Kibana 测试请求
DELETE articles
PUT articles
{
"mappings": {
"properties": {
"title_completion":{
"type": "completion"
}
}
}
}
POST articles/_bulk
{ "index" : { } }
{ "title_completion": "lucene is very cool"}
{ "index" : { } }
{ "title_completion": "Elasticsearch builds on top of lucene"}
{ "index" : { } }
{ "title_completion": "Elasticsearch rocks"}
{ "index" : { } }
{ "title_completion": "elastic is the company behind ELK stack"}
{ "index" : { } }
{ "title_completion": "Elk stack rocks"}
{ "index" : {} }
POST articles/_search?pretty
{
"size": 0,
"suggest": {
"article-suggester": {
"prefix": "elk ",
"completion": {
"field": "title_completion"
}
}
}
}
DELETE articles
POST articles/_bulk
{ "index" : { } }
{ "body": "lucene is very cool"}
{ "index" : { } }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { } }
{ "body": "Elasticsearch rocks"}
{ "index" : { } }
{ "body": "elastic is the company behind ELK stack"}
{ "index" : { } }
{ "body": "Elk stack rocks"}
{ "index" : {} }
{ "body": "elasticsearch is rock solid"}
POST _analyze
{
"analyzer": "standard",
"text": ["Elk stack rocks rock"]
}
POST /articles/_search
{
"size": 1,
"query": {
"match": {
"body": "lucen rock"
}
},
"suggest": {
"term-suggestion": {
"text": "lucen rock",
"term": {
"suggest_mode": "missing",
"field": "body"
}
}
}
}
POST /articles/_search
{
"suggest": {
"term-suggestion": {
"text": "lucen rock",
"term": {
"suggest_mode": "popular",
"field": "body"
}
}
}
}
POST /articles/_search
{
"suggest": {
"term-suggestion": {
"text": "lucen rock",
"term": {
"suggest_mode": "always",
"field": "body",
}
}
}
}
POST /articles/_search
{
"suggest": {
"term-suggestion": {
"text": "lucen hocks",
"term": {
"suggest_mode": "always",
"field": "body",
"prefix_length":0,
"sort": "frequency"
}
}
}
}
POST /articles/_search
{
"suggest": {
"my-suggestion": {
"text": "lucne and elasticsear rock hello world ",
"phrase": {
"field": "body",
"max_errors":2,
"confidence":0,
"direct_generator":[{
"field":"body",
"suggest_mode":"always"
}],
"highlight": {
"pre_tag": "<em>",
"post_tag": "</em>"
}
}
}
}
}
DELETE articles
PUT articles
{
"mappings": {
"properties": {
"title_completion":{
"type": "completion"
}
}
}
}
POST articles/_bulk
{ "index" : { } }
{ "title_completion": "lucene is very cool"}
{ "index" : { } }
{ "title_completion": "Elasticsearch builds on top of lucene"}
{ "index" : { } }
{ "title_completion": "Elasticsearch rocks"}
{ "index" : { } }
{ "title_completion": "elastic is the company behind ELK stack"}
{ "index" : { } }
{ "title_completion": "Elk stack rocks"}
{ "index" : {} }
POST articles/_search?pretty
{
"size": 0,
"suggest": {
"article-suggester": {
"prefix": "elk ",
"completion": {
"field": "title_completion"
}
}
}
}
DELETE comments
PUT comments
PUT comments/_mapping
{
"properties": {
"comment_autocomplete":{
"type": "completion",
"contexts":[{
"type":"category",
"name":"comment_category"
}]
}
}
}
POST comments/_doc
{
"comment":"I love the star war movies",
"comment_autocomplete":{
"input":["star wars"],
"contexts":{
"comment_category":"movies"
}
}
}
POST comments/_doc
{
"comment":"Where can I find a Starbucks",
"comment_autocomplete":{
"input":["starbucks"],
"contexts":{
"comment_category":"coffee"
}
}
}
POST comments/_search
{
"suggest": {
"MY_SUGGESTION": {
"prefix": "sta",
"completion":{
"field":"comment_autocomplete",
"contexts":{
"comment_category":"coffee"
}
}
}
}
}