ElasticSearch深入搜索
一、 结构化搜索
结构化搜索(Structured search) 是指有关探询那些具有内在结构数据的过程。比如日期、时间和数字都是结构化的:它们有精确的格式,我们可以对这些格式进行逻辑操作。比较常见的操作包括比较数字或时间的范围,或判定两个值的大小。
在结构化查询中,我们得到的结果 总是 非是即否,要么存于集合之中,要么存在集合之外。结构化查询不关心文件的相关度或评分;它简单的对文档包括或排除处理。
1、精确值查找
当进行精确值查找时, 我们会使用过滤器(filters)。过滤器很重要,因为它们执行速度非常快,不会计算相关度(直接跳过了整个评分阶段)而且很容易被缓存。请尽可能多的使用过滤式查询。
我们首先来看最为常用的 term
查询, 可以用它处理数字(numbers)、布尔值(Booleans)、日期(dates)以及文本(text)
首先,让我们以下面的例子开始介绍,创建并索引一些表示产品的文档,文档里有字段 `price` 和 `productID` ( `价格` 和 `产品ID` ):
POST /my_store/products/_bulk { "index": { "_id": 1 }} { "price" : 10, "productID" : "XHDK-A-1293-#fJ3" } { "index": { "_id": 2 }} { "price" : 20, "productID" : "KDKE-B-9947-#kL5" } { "index": { "_id": 3 }} { "price" : 30, "productID" : "JODL-X-1937-#pV7" } { "index": { "_id": 4 }} { "price" : 30, "productID" : "QQPX-R-3956-#aD8" }
在 Elasticsearch 的查询表达式(query DSL)中,我们可以使用 term
查询达到相同的目的。 term
查询会查找我们指定的精确值。作为其本身, term
查询是简单的。它接受一个字段名以及我们希望查找的数值:
{ "term" : { "price" : 20 } }
通常当查找一个精确值的时候,我们不希望对查询进行评分计算。只希望对文档进行包括或排除的计算,所以我们会使用 constant_score
查询以非评分模式来执行 term
查询并以一作为统一评分。
最终组合的结果是一个 constant_score
查询,它包含一个 term
查询:
GET /my_store/products/_search { "query" : { "constant_score" : { "filter" : { "term" : { "price" : 20 } } } } }
注:term查询文本时,要想查询其精确值,要将其设置成 not_analyzed
无需分析的属性。
2、布尔过滤器
一个 bool 过滤器由三部分组成:
{ "bool" : { "must" : [], "should" : [], "must_not" : [], } }
must
所有的语句都 必须(must) 匹配,与 AND
等价。
must_not
所有的语句都 不能(must not) 匹配,与 NOT
等价。
should
至少有一个语句要匹配,与 OR
等价
下面例子是查询price为20或productID为XHDK-A-1293-#fJ3,且price不等于30的查询,
用SQL语句表示如下:
SELECT product FROM products WHERE (price = 20 OR productID = "XHDK-A-1293-#fJ3") AND (price != 30)
用 Elasticsearch 语句如下:
GET /my_store/products/_search { "query" : { "filtered" : { "filter" : { "bool" : { "should" : [ { "term" : {"price" : 20}}, { "term" : {"productID" : "XHDK-A-1293-#fJ3"}} ], "must_not" : { "term" : {"price" : 30} } } } } } }
3、嵌套布尔过滤器
对于以下这个 SQL 语句:
SELECT document FROM products WHERE productID = "KDKE-B-9947-#kL5" OR ( productID = "JODL-X-1937-#pV7" AND price = 30 )
我们将其转换成一组嵌套的 bool
过滤器:
GET /my_store/products/_search { "query" : { "filtered" : { "filter" : { "bool" : { "should" : [ { "term" : {"productID" : "KDKE-B-9947-#kL5"}}, { "bool" : { "must" : [ { "term" : {"productID" : "JODL-X-1937-#pV7"}}, { "term" : {"price" : 30}} ] }} ] } } } } }
二、全文索引
全文搜索两个最重要的方面是:
- 相关性(Relevance)
- 它是评价查询与其结果间的相关程度,并根据这种相关程度对结果排名的一种能力,这种计算方式可以是 TF/IDF 方法(参见 相关性的介绍)、地理位置邻近、模糊相似,或其他的某些算法。
- 分析(Analysis)
- 它是将文本块转换为有区别的、规范化的 token 的一个过程,(参见 分析的介绍) 目的是为了(a)创建倒排索引以及(b)查询倒排索引
- 1、基于词项与基于全文
所有查询会或多或少的执行相关度计算,但不是所有查询都有分析阶段。 和一些特殊的完全不会对文本进行操作的查询(如 bool
或 function_score
)不同,文本查询可以划分成两大家族:
- 基于词项的查询
如 term
或 fuzzy
这样的底层查询不需要分析阶段,它们对单个词项进行操作。用 term
查询词项Foo
只要在倒排索引中查找 准确词项 ,并且用 TF/IDF 算法为每个包含该词项的文档计算相关度评分_score
。
term
查询只对倒排索引的词项精确匹配,这点很重要,它不会对词的多样性进行处理(如, foo
或 FOO
)。这里,无须考虑词项是如何存入索引的。如果是将 ["Foo","Bar"]
索引存入一个不分析的( not_analyzed
)包含精确值的字段。
基于全文的查询
像 match
或 query_string
这样的查询是高层查询,它们了解字段映射的信息:
一旦组成了词项列表,这个查询会对每个词项逐一执行底层的查询,再将结果合并,然后为每个文档生成一个最终的相关度评分。
假设想要查询关于 “full-text search(全文搜索)” 的文档, 但我们希望为提及 “Elasticsearch” 或 “Lucene” 的文档给予更高的 权重 ,这里 更高权重 是指如果文档中出现 “Elasticsearch” 或 “Lucene” ,它们会比没有的出现这些词的文档获得更高的相关度评分 _score
,也就是说,它们会出现在结果集的更上面。
一个简单的 bool
查询 允许我们写出如下这种非常复杂的逻辑:content 字段必须包含 full 、 text 和 search 所有三个词。如果 content 字段也包含 Elasticsearch 或 Lucene ,文档会获得更高的评分 _score
GET /_search { "query": { "bool": { "must": { "match": { "content": { "query": "full text search", "operator": "and" } } }, "should": [ { "match": { "content": "Elasticsearch" }}, { "match": { "content": "Lucene" }} ] } } }
但是如果我们想让包含 Lucene和
Elasticsearch的有更高的权重,并且包含 Elasticsearch
的语句比 Lucene
的权重更高,该如何处理?
我们可以通过指定 boost
来控制任何查询语句的相对的权重, boost
的默认值为 1
,大于 1
会提升一个语句的相对权重。所以下面重写之前的查询:
GET /_search { "query": { "bool": { "must": { "match": { "content": { "query": "full text search", "operator": "and" } } }, "should": [ { "match": { "content": { "query": "Elasticsearch", "boost": 3 } }}, { "match": { "content": { "query": "Lucene", "boost": 2 } }} ] } } }
boost
参数被用来提升一个语句的相对权重( boost
值大于 1
)或降低相对权重( boost
值处于 0
到 1
之间),但是这种提升或降低并不是线性的,换句话说,如果一个 boost
值为 2
,并不能获得两倍的评分 _score
。
如果我们知道 War and Peace 是标题,Leo Tolstoy 是作者,translator是译者,语句如下:
GET /_search { "query": { "bool": { "should": [ { "match": { "title": "War and Peace" }}, { "match": { "author": "Leo Tolstoy" }}, { "bool": { "should": [ { "match": { "translator": "Constance Garnett" }}, { "match": { "translator": "Louise Maude" }} ] }} ] } } }
为什么将译者条件语句放入另一个独立的 bool
查询中呢?所有的四个 match
查询都是 should
语句,所以为什么不将 translator 语句与其他如 title 、 author 这样的语句放在同一层呢?
答案在于评分的计算方式。 bool
查询运行每个 match
查询,再把评分加在一起,然后将结果与所有匹配的语句数量相乘,最后除以所有的语句数量。处于同一层的每条语句具有相同的权重。在上面这个例子中,包含 translator 语句的 bool
查询,只占总评分的三分之一。如果将 translator 语句与 title 和 author 两条语句放入同一层,那么 title 和 author 语句只贡献四分之一评分。
三、多字段搜索
1、单字符查询
bool
查询是多语句查询的主干。 它的适用场景很多,特别是当需要将不同查询字符串映射到不同字段的时候。
问题在于,目前有些用户期望将所有的搜索项堆积到单个字段中,并期望应用程序能为他们提供正确的结果。有意思的是多字段搜索的表单通常被称为 高级查询 (Advanced Search) —— 只是因为它对用户而言是高级的,而多字段搜索的实现却非常简单。
对于多词(multiword)、多字段(multifield)查询来说,不存在简单的 万能 方案。为了获得最好结果,需要 了解我们的数据 ,并了解如何使用合适的工具。
当用户输入了单个字符串查询的时候,通常会遇到以下三种情形:
- 最佳字段
- 当搜索词语具体概念的时候,比如 “brown fox” ,词组比各自独立的单词更有意义。像
title
和body
这样的字段,尽管它们之间是相关的,但同时又彼此相互竞争。文档在 相同字段 中包含的词越多越好,评分也来自于 最匹配字段 。 - 多数字段
-
为了对相关度进行微调,常用的一个技术就是将相同的数据索引到不同的字段,它们各自具有独立的分析链。
主字段可能包括它们的词源、同义词以及 变音词 或口音词,被用来匹配尽可能多的文档。
相同的文本被索引到其他字段,以提供更精确的匹配。一个字段可以包括未经词干提取过的原词,另一个字段包括其他词源、口音,还有一个字段可以提供 词语相似性 信息的瓦片词(shingles)。
其他字段是作为匹配每个文档时提高相关度评分的 信号 , 匹配字段越多 则越好。
- 混合字段
-
对于某些实体,我们需要在多个字段中确定其信息,单个字段都只能作为整体的一部分:
- Person:
first_name
和last_name
(人:名和姓) - Book:
title
、author
和description
(书:标题、作者、描述) - Address:
street
、city
、country
和postcode
(地址:街道、市、国家和邮政编码)
在这种情况下,我们希望在 任何 这些列出的字段中找到尽可能多的词,这有如在一个大字段中进行搜索,这个大字段包括了所有列出的字段。
- Person:
上述所有都是多词、多字段查询,但每个具体查询都要求使用不同策略。本章后面的部分,我们会依次介绍每个策略。
1、最佳字段
假设有个网站允许用户搜索博客的内容, 以下面两篇博客内容文档为例:
PUT /my_index/my_type/1 { "title": "Quick brown rabbits", "body": "Brown rabbits are commonly seen." } PUT /my_index/my_type/2 { "title": "Keeping pets healthy", "body": "My quick brown fox eats rabbits on a regular basis." }
用户输入词组 “Brown fox” 然后点击搜索按钮。事先,我们并不知道用户的搜索项是会在 title
还是在body
字段中被找到,但是,用户很有可能是想搜索相关的词组。用肉眼判断,文档 2 的匹配度更高,因为它同时包括要查找的两个词:
现在运行以下 bool
查询:
{ "query": { "bool": { "should": [ { "match": { "title": "Brown fox" }}, { "match": { "body": "Brown fox" }} ] } } }
但是我们发现查询的结果是文档 1 的评分更高:
{ "hits": [ { "_id": "1", "_score": 0.14809652, "_source": { "title": "Quick brown rabbits", "body": "Brown rabbits are commonly seen." } }, { "_id": "2", "_score": 0.09256032, "_source": { "title": "Keeping pets healthy", "body": "My quick brown fox eats rabbits on a regular basis." } } ] }
为了理解导致这样的原因, 需要回想一下 bool
是如何计算评分的:
- 它会执行
should
语句中的两个查询。 - 加和两个查询的评分。
- 乘以匹配语句的总数。
- 除以所有语句总数(这里为:2)。
文档 1 的两个字段都包含 brown
这个词,所以两个 match
语句都能成功匹配并且有一个评分。文档 2 的body
字段同时包含 brown
和 fox
这两个词,但 title
字段没有包含任何词。这样, body
查询结果中的高分,加上 title
查询中的 0 分,然后乘以二分之一,就得到比文档 1 更低的整体评分。
在本例中, title
和 body
字段是相互竞争的关系,所以就需要找到单个 最佳匹配 的字段。
如果不是简单将每个字段的评分结果加在一起,而是将 最佳匹配 字段的评分作为查询的整体评分,结果会怎样?这样返回的结果可能是: 同时 包含 brown
和 fox
的单个字段比反复出现相同词语的多个不同字段有更高的相关度。
不使用 bool
查询,可以使用 dis_max
即分离 最大化查询(Disjunction Max Query) 。分离(Disjunction)的意思是 或(or) ,这与可以把结合(conjunction)理解成 与(and) 相对应。分离最大化查询(Disjunction Max Query)指的是: 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回 :
{ "query": { "dis_max": { "queries": [ { "match": { "title": "Brown fox" }}, { "match": { "body": "Brown fox" }} ] } } }
得到我们想要的结果为:
{ "hits": [ { "_id": "2", "_score": 0.21509302, "_source": { "title": "Keeping pets healthy", "body": "My quick brown fox eats rabbits on a regular basis." } }, { "_id": "1", "_score": 0.12713557, "_source": { "title": "Quick brown rabbits", "body": "Brown rabbits are commonly seen." } } ] }
2、多数字段
全文搜索被称作是 召回率(Recall) 与 精确率(Precision) 的战场: 召回率 ——返回所有的相关文档;精确率 ——不返回无关文档。目的是在结果的第一页中为用户呈现最为相关的文档。
为了提高召回率的效果,我们扩大搜索范围 ——不仅返回与用户搜索词精确匹配的文档,还会返回我们认为与查询相关的所有文档。如果一个用户搜索 “quick brown box” ,一个包含词语 fast foxes
的文档被认为是非常合理的返回结果。
提高全文相关性精度的常用方式是为同一文本建立多种方式的索引, 每种方式都提供了一个不同的相关度信号 signal 。主字段会以尽可能多的形式的去匹配尽可能多的文档。举个例子,我们可以进行以下操作:
- 使用词干提取来索引
jumps
、jumping
和jumped
样的词,将jump
作为它们的词根形式。这样即使用户搜索jumped
,也还是能找到包含jumping
的匹配的文档。 - 将同义词包括其中,如
jump
、leap
和hop
。 - 移除变音或口音词:如
ésta
、está
和esta
都会以无变音形式esta
来索引。
尽管如此,如果我们有两个文档,其中一个包含词 jumped
,另一个包含词 jumping
,用户很可能期望前者能排的更高,因为它正好与输入的搜索条件一致。
为了达到目的,我们可以将相同的文本索引到其他字段从而提供更为精确的匹配。一个字段可能是为词干未提取过的版本,另一个字段可能是变音过的原始词,第三个可能使用 shingles 提供 词语相似性 信息。这些附加的字段可以看成提高每个文档的相关度评分的信号 signals ,能匹配字段的越多越好。
一个文档如果与广度匹配的主字段相匹配,那么它会出现在结果列表中。如果文档同时又与 signal 信号字段匹配,那么它会获得额外加分,系统会提升它在结果列表中的位置。
现在讨论一种普遍的搜索模式:跨字段实体搜索(cross-fields entity search)。 在如 person
、 product
或 address
(人、产品或地址)这样的实体中,需要使用多个字段来唯一标识它的信息。 person
实体可能是这样索引的:
{ "firstname": "Peter", "lastname": "Smith" }
或地址:
{ "street": "5 Poland Street", "city": "London", "country": "United Kingdom", "postcode": "W1V 3DG" }
我们的用户可能想搜索 “Peter Smith” 这个人,或 “Poland Street W1V” 这个地址,这些词出现在不同的字段中,所以如果使用 dis_max
或 best_fields
查询去查找 单个 最佳匹配字段显然是个错误的方式。
依次查询每个字段并将每个字段的匹配评分结果相加,听起来真像是 bool
查询:
{ "query": { "bool": { "should": [ { "match": { "street": "Poland Street W1V" }}, { "match": { "city": "Poland Street W1V" }}, { "match": { "country": "Poland Street W1V" }}, { "match": { "postcode": "Poland Street W1V" }} ] } } }
为每个字段重复查询字符串会使查询瞬间变得冗长,可以采用 multi_match
查询, 将 type
设置成most_fields
然后告诉 Elasticsearch 合并所有匹配字段的评分:
{ "query": { "multi_match": { "query": "Poland Street W1V", "type": "most_fields", "fields": [ "street", "city", "country", "postcode" ] } } }
most_fields 方式的问题
用 most_fields
这种方式搜索也存在某些问题,这些问题并不会马上显现:
- 它是为多数字段匹配 任意 词设计的,而不是在 所有字段 中找到最匹配的。
- 它不能使用
operator
或minimum_should_match
参数来降低次相关结果造成的长尾效应。 - 词频对于每个字段是不一样的,而且它们之间的相互影响会导致不好的排序结果。
四、近似匹配
1、短语匹配
什么是短语
一个被认定为和短语 quick brown fox
匹配的文档,必须满足以下这些要求:
quick
、brown
和fox
需要全部出现在域中。brown
的位置应该比quick
的位置大1
。fox
的位置应该比quick
的位置大2
。
如果以上任何一个选项不成立,则该文档不能认定为匹配。
像 match
查询对于标准全文检索是一种最常用的查询一样,当你想找到彼此邻近搜索词的查询方法时,就会想到 match_phrase
查询:
GET /my_index/my_type/_search { "query": { "match_phrase": { "title": "quick brown fox" } } }
鉴于一个短语查询仅仅排除了不包含确切查询短语的文档, 而 邻近查询 — 一个 slop
大于 0
— 的短语查询将查询词条的邻近度考虑到最终相关度 _score
中。 通过设置一个像 50
或者 100
这样的高 slop
值, 你能够排除单词距离太远的文档, 但是也给予了那些单词临近的的文档更高的分数。
下列对 quick dog
的邻近查询匹配了同时包含 quick
和 dog
的文档, 但是也给了与 quick 和 dog 更加临近的文档更高的分数 :
POST /my_index/my_type/_search { "query": { "match_phrase": { "title": { "query": "quick dog", "slop": 50 } } } }
注意高 slop 值:分数较高因为 quick 和 dog 很接近,分数较低因为 quick 和 dog 分开较远
{ "hits": [ { "_id": "3", "_score": 0.75, "_source": { "title": "The quick brown fox jumps over the quick dog" } }, { "_id": "2", "_score": 0.28347334, "_source": { "title": "The quick brown fox jumps over the lazy dog" } } ] }
我们可以将一个简单的 match
查询作为一个 must
子句。 这个查询将决定哪些文档需要被包含到结果集中。 我们可以用 minimum_should_match
参数去除长尾。 然后我们可以以 should
子句的形式添加更多特定查询。 每一个匹配成功的都会增加匹配文档的相关度。
GET /my_index/my_type/_search { "query": { "bool": { "must": { "match": { "title": { "query": "quick brown fox", "minimum_should_match": "30%" } } }, "should": { "match_phrase": { "title": { "query": "quick brown fox", "slop": 50 } } } } } }
must 子句从结果集中包含或者排除文档。 should 子句增加了匹配到文档的相关度评分。