您的位置:首页 > 其它

[Elasticsearch] 部分匹配 (一)ALL - 前缀查询

2017-08-04 00:00 671 查看
摘要: [Elasticsearch] 部分匹配 (一) - 前缀查询

部分匹配(Partial Matching)前缀查询

敏锐的读者可能已经发现到目前为止,介绍的查询都是在整个词条层面进行操作的。匹配的最小单元必须是一个词条。你只能找到存在于倒排索引(Inverted Index)中的词条。

但是如果你想匹配词条的一部分,而不是整个词条呢?部分匹配(Partial Matching)允许用户指定词条的一部分然后找到含有该部分的任何单词。

匹配词条一部分这一需求在全文搜索引擎领域比你想象的要不那么常见。如果你有SQL的背景,你可能有过使用下面的SQL语句来实现一个简单的全文搜索功能的经历:

WHERE text LIKE "*quick*"
AND text LIKE "*brown*"
AND text LIKE "*fox*"

当然,通过ES我们可以借助分析过程(Analysis Process)和倒排索引来避免这种"蛮力"技术。为了同时匹配"fox"和"foxes",我们可以简单地使用一个词干提取器,然后将词干进行索引。这样就没有必要进行部分匹配了。

即便如此,在某些场合下部分匹配还是有作用的。常见的用例比如:

匹配邮政编码,产品序列号,或者其它以某个特定前缀开头的或者能够匹配通配符甚至正则表达式的not_analyzed值。

即时搜索(Search-as-you-type) - 在用户完成搜索词条的输入前就展示最有可能的结果。

匹配德语或者荷兰语这一类语言,它们韩哟长复合单词,比如Weltgesundheitsorganisation(World Health Organization)。

我们以针对精确值not_analyzed字段的前缀匹配开始,介绍部分匹配的技术。

邮政编码和结构化数据

我们以英国的邮政编码来说明如何在结构化数据上使用部分匹配。英国邮政编码是一个定义清晰的结构。比如,W1V 3DG这个邮政编码可以被分解成以下几个部分:

W1V:这个部分表明了邮政地域和地区(Postal Area and District):

W 表明了地域(Area),使用一个或者两个字母。

1V 表明了地区(District),使用一个或者两个数字,可能跟随一个字母。

3DG:该部分表明了街道或者建筑:

3 表明了区域(Sector),使用一个数字。

DG 表明了单元,使用两个字母。

假设我们将邮政编码索引为精确值的not_analyzed字段,因此我们可以创建如下索引:

PUT /my_index
{
"mappings": {
"address": {
"properties": {
"postcode": {
"type":  "text",
"index": "not_analyzed"
}
}
}
}
}

然后索引一些邮政编码:

PUT /my_index/address/1
{ "postcode": "W1V 3DG" }

PUT /my_index/address/2
{ "postcode": "W2F 8HW" }

PUT /my_index/address/3
{ "postcode": "W1F 7HW" }

PUT /my_index/address/4
{ "postcode": "WC1N 1LZ" }

PUT /my_index/address/5
{ "postcode": "SW5 0BE" }

现在我们的数据就准备就绪了。

前缀查询(Prefix Query)

我们可以通过一个简单的prefix查询来得到所有以W1开头的邮政编码:

GET /my_index/address/_search
{
"query": {
"prefix": {
"postcode": "w1"
}
}
}

prefix查询是一个工作在词条级别的低级查询。它不会在搜索前对查询字符串进行解析。它假设用户会传入一个需要查询的精确前缀。

TIP

默认情况下,prefix查询不会计算相关度分值。它只是进行文档匹配,匹配的文档的分值为1。其实,相比查询它更像一个过滤器。prefix查询和prefix过滤器的唯一区别在于过滤器可以被缓存。

之前,我们提到过"你只能找到存在于倒排索引中的词条",但是对于这些邮政编码我们并没有进行任何特殊处理;每个邮政编码只是被当做精确值被简单地索引。那么prefix查询是如何工作的呢?

记住倒排索引是由唯一词条得有序列表构成的(此种情况下,即邮政编码)。对于每个词条,它会列举所有含有该词条的文档ID。对于我们的示例文档,倒排索引如下所示:

Term:          Doc IDs:
-------------------------
"SW5 0BE"    |  5
"W1F 7HW"    |  3
"W1V 3DG"    |  1
"W2F 8HW"    |  2
"WC1N 1LZ"   |  4
-------------------------

为了支持前缀匹配,查询会执行以下的步骤:

遍历词条列表并找到以W1开头的词条。

收集对应的文档ID。

移动到下一个词条。

如果该词条也以W1开头,那么重复步骤2;否则结束操作。

尽管以上的步骤对于我们的小例子而言能很好地工作,想象一下当倒排索引含有一百万个以W1开头的邮政编码时的情景,prefix查询需要访问一百万个词条来得到结果。

而前缀越短,就意味着需要访问越多的词条。如果我们查询前缀为W,而不是W1的词条,可能会匹配多达一千万个词条。

注意

prefix查询和过滤器对于即时(Ad-hoc)的前缀匹配是有用处的,但是在使用它们的时候需要小心。对于拥有少量词条的字段可以随意地使用,但是它们的扩展性较差,可能会让你的集群承受过多的压力。可以通过使用一个较长的前缀来限制它们对于集群的影响;这能够减少需要访问的词条的数量。

在本章的稍后部分,我们会介绍一种让前缀匹配更具效率的索引期间解决方案。但是首先,让我们看看两个相关的查询:wildcard以及regexp查询。

通配符正则表达式查询

wildcard查询和prefix查询类似,也是一个基于词条的低级别查询。但是它能够让你指定一个模式(Pattern),而不是一个前缀(Prefix)。它使用标准的shell通配符:?用来匹配任意字符,*用来匹配零个或者多个字符。

以下查询能够匹配包含W1F 7HW和W2F 8HW的文档:

GET /my_index/address/_search
{
"query": {
"wildcard": {
"postcode": "W?F*HW"
}
}
}

假设现在你想匹配在W地域(Area)的所有邮政编码。使用前缀匹配时,以WC开头的邮政编码也会被匹配,在使用通配符查询时也会遇到类似的问题。我们只想匹配以W开头,紧跟着数字的邮政编码。使用regexp查询能够让你写下更复杂的模式:

GET /my_index/address/_search
{
"query": {
"regexp": {
"postcode": "W[0-9].+"
}
}
}

这个正则表达式的规定了词条需要以W开头,紧跟着一个0到9的数字,然后是一个或者多个其它字符。

wildcard和regexp查询的工作方式和prefix查询完全一样。它们也需要遍历倒排索引中的词条列表来找到所有的匹配词条,然后逐个词条地收集对应的文档ID。它们和prefix查询的唯一区别在于它们能够支持更加复杂的模式。

这也意味着使用它们存在相同的风险。对一个含有很多不同词条的字段运行这类查询是非常消耗资源的。避免使用一个以通配符开头的模式(比如,*foo或者正则表达式: .*foo)。

尽管对于前缀匹配,可以在索引期间准备你的数据让它更加高效,通配符和正则表达式匹配只能在查询期间被完成。虽然使用场景有限,但是这些查询也有它们的用武之地。

注意

prefix,wildcard以及regexp查询基于词条进行操作。如果你在一个analyzed字段上使用了它们,它们会检查字段中的每个词条,而不是整个字段。

比如,假设我们的title字段中含有"Quick brown fox",它会产生词条quick,brown和fox。

这个查询能够匹配:

{ "regexp": { "title": "br.*" }}

而不会匹配:

{ "regexp": { "title": "Qu.*" }}
{ "regexp": { "title": "quick br*" }}


查询期间的即时搜索(Query-time Search-as-you-type)

现在让我们来看看前缀匹配能够如何帮助全文搜索。用户已经习惯于在完成输入之前就看到搜索结果了 - 这被称为即时搜索(Instant Search, 或者Search-as-you-type)。这不仅让用户能够在更短的时间内看到搜索结果,也能够引导他们得到真实存在于我们的索引中的结果。

比如,如果用户输入了johnnie walker bl,我们会在用户输入完成前显示Johnnie Walker Black Label和Johnnie Walker Blue Label相关的结果。

和往常一样,有多种方式可以达到我们的目的!首先我们从最简单的方式开始。你不需要以任何的方式准备你的数据,就能够在任何全文字段(Full-text Field)上实现即时搜索

短语匹配(Phrase Matching)中,我们介绍了match_phrase查询,它能够根据单词顺序来匹配所有的指定的单词。对于查询期间的即时搜索,我们可以使用该查询的一个特例,即match_phrase_prefix查询:

{
"match_phrase_prefix" : {
"brand" : "johnnie walker bl"
}
}

次查询和match_phrase查询的工作方式基本相同,除了它会将查询字符串中的最后一个单词当做一个前缀。换言之,前面的例子会查找以下内容:

johnnie

紧接着的是walker

紧接着的是以bl开头的单词

如果我们将该查询通过validate-query API执行,它会产生如下的解释:

"johnnie walker bl*"

和match_phrase查询一样,它能够接受一个slop参数(参见这里)来让单词间的顺序和相对位置不那么严格:

{
"match_phrase_prefix" : {
"brand" : {
"query": "walker johnnie bl",
"slop":  10
}
}
}

但是,查询字符串中的最后一个单词总是会被当做一个前缀。

在之前介绍prefix查询的时候,我们谈到了prefix查询的一些需要注意的地方 - prefix查询时如何消耗资源的。在使用match_phrase_prefix查询的时候,也面临着同样的问题。一个前缀a你能够匹配非常非常多的词条。匹配这么多的词条不仅会消耗很多资源,同时对于用户而言也是没有多少用处的。

我们可以通过将参数max_expansions设置成一个合理的数值来限制前缀扩展(Prefix Expansion)的影响,比如50:

{
"match_phrase_prefix" : {
"brand" : {
"query":          "johnnie walker bl",
"max_expansions": 50
}
}
}

max_expansions参数会控制能够匹配该前缀的词条的数量。它会找到首个以bl开头的词条然后开始收集(以字母表顺序)直到所有以bl开头的词条都被遍历了或者得到了比max_expansions更多的词条。

不要忘了在用户每敲入一个字符的时候,该查询就要被执行一次,因此它的速度需要快。如果第一个结果集不符合用户的期望,那么他们就会继续输入直到得到他们需要的结果。

索引期间优化ngrams及索引期间的即时搜索

目前我们讨论的所有方案都是在查询期间的。它们不需要任何特殊的映射或者索引模式(Indexing Patterns);它们只是简单地工作在已经存在于索引中的数据之上。

查询期间的灵活性是有代价的:搜索性能。有时,将这些代价放到查询之外的地方是有价值的。在一个实时的Web应用中,一个额外的100毫秒的延迟会难以承受。

通过在索引期间准备你的数据,可以让你的搜索更加灵活并更具效率。你仍然付出了代价:增加了的索引大小和稍微低一些的索引吞吐量,但是这个代价是在索引期间付出的,而不是在每个查询的执行期间。

你的用户会感激你的。

部分匹配(Partial Matching)的ngrams

我们说过:"你只能找到存在于倒排索引中的词条"。尽管prefix,wildcard以及regexp查询证明了上面的说法并不是一定正确,但是执行一个基于单个词条的查询会比遍历词条列表来得到匹配的词条要更快是毫无疑问的。为了部分匹配而提前准备你的数据能够增加搜索性能。

在索引期间准别数据意味着选择正确的分析链(Analysis Chain),为了部分匹配我们选择的工具叫做n-gram。一个n-gram可以被想象成一个单词上的滑动窗口(Moving Window)。n表示的是长度。如果我们对单词quick得到n-gram,结果取决于选择的长度:

长度1(unigram): [ q, u, i, c, k ]

长度2(bigram): [ qu, ui, ic, ck ]

长度3(trigram): [ qui, uic, ick ]

长度4(four-gram):[ quic, uick ]

长度5(five-gram):[ quick ]

单纯的n-grams对于匹配单词中的某一部分是有用的,在复合单词的ngrams中我们会用到它。然而,对于即时搜索,我们使用了一种特殊的n-grams,被称为边缘n-grams(Edge n-grams)。边缘n-grams会将起始点放在单词的开头处。单词quick的边缘n-gram如下所示:

q

qu

qui

quic

quick

你也许注意到它遵循了用户在搜索"quick"时的输入形式。换言之,对于即时搜索而言它们是非常完美的词条。

索引期间的即时搜索(Index-time Search-as-you-type)

建立索引期间即时搜索的第一步就是定义你的分析链(Analysis Chain)(在配置解析器中讨论过),在这里我们会详细阐述这些步骤:

准备索引

第一步是配置一个自定义的edge_ngram词条过滤器,我们将它称为autocomplete_filter:

{
"filter": {
"autocomplete_filter": {
"type":     "edge_ngram",
"min_gram": 1,
"max_gram": 20
}
}
}

以上配置的作用是,对于此词条过滤器接受的任何词条,它都会产生一个最小长度为1,最大长度为20的边缘ngram(Edge ngram)。

然后我们将该词条过滤器配置在自定义的解析器中,该解析器名为autocomplete。

{
"analyzer": {
"autocomplete": {
"type":      "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"autocomplete_filter"
]
}
}
}

以上的解析器会使用standard分词器将字符串划分为独立的词条,将它们变成小写形式,然后为它们生成边缘ngrams,这要感谢autocomplete_filter。

创建索引,词条过滤器和解析器的完整请求如下所示:

PUT /my_index
{
"settings": {
"number_of_shards": 1,
"analysis": {
"filter": {
"autocomplete_filter": {
"type":     "edge_ngram",
"min_gram": 1,
"max_gram": 20
}
},
"analyzer": {
"autocomplete": {
"type":      "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"autocomplete_filter"
]
}
}
}
}
}

你可以通过下面的analyze API来确保行为是正确的:

GET /my_index/_analyze?analyzer=autocomplete
quick brown

返回的词条说明解析器工作正常:

q

qu

qui

quic

quick

b

br

bro

brow

brown

为了使用它,我们需要将它适用到字段中,通过update-mapping API:

PUT /my_index/_mapping/my_type
{
"my_type": {
"properties": {
"name": {
"type":     "string",
"analyzer": "autocomplete"
}
}
}
}

现在,让我们索引一些测试文档:

POST /my_index/my_type/_bulk
{ "index": { "_id": 1            }}
{ "name": "Brown foxes"    }
{ "index": { "_id": 2            }}
{ "name": "Yellow furballs" }


查询该字段

如果你使用一个针对"brown fo"的简单match查询:

GET /my_index/my_type/_search
{
"query": {
"match": {
"name": "brown fo"
}
}
}

你会发现两份文档都匹配了,即使Yellow furballs既不包含brown,也不包含fo:

{

"hits": [
{
"_id": "1",
"_score": 1.5753809,
"_source": {
"name": "Brown foxes"
}
},
{
"_id": "2",
"_score": 0.012520773,
"_source": {
"name": "Yellow furballs"
}
}
]
}

通过validate-query API来发现问题:

GET /my_index/my_type/_validate/query?explain
{
"query": {
"match": {
"name": "brown fo"
}
}
}

得到的解释说明了查询会寻找查询字符串中每个单词的边缘ngrams:

name:b name:br name:bro name:brow name:brown name:f name:fo

name:f这一条件满足了第二份文档,因为furballs被索引为f,fu,fur等。因此,得到以上的结果也没什么奇怪的。autocomplete解析器被同时适用在了索引期间和搜索期间,通常而言这都是正确的行为。但是当前的场景是为数不多的不应该使用该规则的场景之一。

我们需要确保在倒排索引中含有每个单词的边缘ngrams,但是仅仅匹配用户输入的完整单词(brown和fo)。我们可以通过在索引期间使用autocomplete解析器,而在搜索期间使用standard解析器来达到这个目的。直接在查询中指定解析器就是一种改变搜索期间分析器的方法:

GET /my_index/my_type/_search
{
"query": {
"match": {
"name": {
"query":    "brown fo",
"analyzer": "standard"
}
}
}
}

另外,还可以在name字段的映射中分别指定index_analyzer和search_analyzer。因为我们只是想修改search_analyzer,所以可以在不对数据重索引的前提下对映射进行修改:

PUT /my_index/my_type/_mapping
{
"my_type": {
"properties": {
"name": {
"type":            "string",
"index_analyzer":  "autocomplete",
"search_analyzer": "standard"
}
}
}
}

此时再通过validate-query API得到的解释如下:

name:brown name:fo

重复执行查询后,也仅仅会得到Brown foxes这份文档。

因为大部分的工作都在索引期间完成了,查询需要做的只是查找两个词条:brown和fo,这比使用match_phrase_prefix来寻找所有以fo开头的词条更加高效。

完成建议(Completion Suggester)

使用边缘ngrams建立的即时搜索是简单,灵活和迅速的。然而,有些时候它还是不够快。延迟的影响不容忽略,特别当你需要提供实时反馈时。有时最快的搜索方式就是没有搜索。

ES中的完成建议采用了一种截然不同的解决方案。通过给它提供一个完整的可能完成列表(Possible Completions)来创建一个有限状态转换器(Finite State Transducer),该转换器是一个用来描述图(Graph)的优化数据结构。为了搜索建议,ES会从图的起始处开始,对用户输入逐个字符地沿着匹配路径(Matching Path)移动。一旦用户输入被检验完毕,它就会根据当前的路径产生所有可能的建议。

该数据结构存在于内存中,因此对前缀查询而言是非常迅速的,比任何基于词条的查询都要快。使用它来自动完成名字和品牌(Names and Brands)是一个很不错的选择,因为它们通常都以某个特定的顺序进行组织,比如"Johnny Rotten"不会被写成"Rotten Johnny"。

当单词顺序不那么容易被预测时,边缘ngrams就是相比完成建议更好的方案。

边缘ngrams和邮政编码

边缘ngrams这一技术还可以被用在结构化数据上,比如本章前面提到过的邮政编码。当然,postcode字段也许需要被设置为analyzed,而不是not_analyzed,但是你仍然可以通过为邮政编码使用keyword分词器来让它们和not_analyzed字段一样。

TIP

keyword分词器是一个没有任何行为(no-operation)的分词器。它接受的任何字符串会被原样输出为一个词条。所以对于一些通常被当做not_analyzed字段,然而需要某些处理(如转换为小写)的情况下,是有用处的。

这个例子使用keyword分词器将邮政编码字符串转换为一个字符流,因此我们就能够利用边缘ngram词条过滤器了:

{
"analysis": {
"filter": {
"postcode_filter": {
"type":     "edge_ngram",
"min_gram": 1,
"max_gram": 8
}
},
"analyzer": {
"postcode_index": {
"tokenizer": "keyword",
"filter":    [ "postcode_filter" ]
},
"postcode_search": {
"tokenizer": "keyword"
}
}
}
}
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  ElasticSearch