ElasticSearch 2 (16) - 深入搜索系列之近似度匹配

摘要

标准的全文搜索使用TF/IDF处理文档、文档里的每个字段或一袋子词。match 查询可以告诉我们哪个袋子里面包含我们搜索的术语,但这只是故事的一部分。它并不能告诉我们词语之间的关系。

考虑下面句子的区别:

  • Sue ate the alligator.
  • The alligator ate sue.
  • Sue never goes anywhere without her alligator-skin purse.

一个 match 查询 “sue alligator”会匹配所有三个文档,但是它不会告诉我们这两个词组合在一起是否为同一个意思,甚至是否为同一个段落。

要理解词语之间是如何相关的是个非常复杂的问题,我们无法只是简单使用另外一个类型的查询来解决此问题,但是我们至少可以查找到相关的词,因为他们出现在邻近的地方,甚至相互紧接着。

每个文档都可能会比我们例子中给出的要长的多:Suealligator 可能被其他段落隔离,即使可能有的文档中这些词之间相距较远,我们仍然希望能够将他们找出来,但是我们希望为邻近出现的文档给出更高的相关度分数。

这个问题属于短语匹配(phrase matching)或相似度匹配(proximity matching)的领域。

版本

elasticsearch版本: elasticsearch-2.x

内容

短语匹配(Phrase Matching)

match 查询类似,match_phrase 查询是标准全文搜索的核心,当我们想要查找邻近出现的词语时会使用到它:

GET /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": "quick brown fox"
}
}
}

match 查询类似,match_phrase 查询首先分析查询字符串并生成一个术语列表,然后它会搜索所有术语,但是只将包含所有 all 查询术语的文档放在相对应的位置上。一个“quick box”短语查询不会与我们任何文档匹配,因为没有任何文档包含短语 quick box

match_phrase 查询也可以用一个phrase类型的match 来表示:

"match": {
"title": {
"query": "quick brown fox",
"type": "phrase"
}
}

术语位置(Term Positions)

当字符串被分析时,分析器不仅返回了一个术语列表,也包括每个术语在原字符串中的位置(position)、顺序(order)信息:

GET /_analyze?analyzer=standard
Quick brown fox

返回结果为:

{
"tokens": [
{
"token": "quick",
"start_offset": 0,
"end_offset": 5,
"type": "<ALPHANUM>",
"position": 1 #1
},
{
"token": "brown",
"start_offset": 6,
"end_offset": 11,
"type": "<ALPHANUM>",
"position": 2 #2
},
{
"token": "fox",
"start_offset": 12,
"end_offset": 15,
"type": "<ALPHANUM>",
"position": 3 #3
}
]
}
  • #1 #2 #3 是每个术语处于原字符串中的位置。

位置可以存储与反向索引中,像 match_phrase 这样与位置相关的查询,可以用它来搜索包含所有这些词且顺序一致的文档,没有中间状态。

何谓短语(What Is a Phrase)

对于一个和短语“quick brown fox”匹配的文档来说,必须满足一下条件:

  • quickbrownfox 必须所有都出现在字段里。
  • brown 的位置必须比 quick 的位置大1。
  • fox 的位置必须比 quick 的位置大2。

如果任意一个条件不满足,文档就是不匹配的。

在内部,match_phrase 查询使用低层次段(span)查询处理位置相关的匹配,段查询是一种术语层的查询,所以它没有分析阶段;他们对给定的术语进行精确搜索。

幸亏多数人都不直接使用 span 查询,因为 match_phrase 已经足够好了,但是对于某些特殊字段,如专利搜索,会使用低层次查询来处理需要仔细构建位置的细致搜索。

混合(Mixing It Up)

要求短语的准确匹配可能约束过于严格,我们能希望使用“quick fox”仍然能搜索出包含“quick brown fox”的文档,尽管它们的位置并不严格相等。

我们可以引入一个参数 slop 到短语匹配来表示这种自由度(degree of flexibility):

GET /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": {
"query": "quick fox",
"slop": 1
}
}
}
}

slop 参数告诉 match_phrase 查询在术语相距多远时,仍然会被认为是一个匹配的文档。这里的 相距多远 指的是使文档匹配所需将术语移动的次数。

用一个简单的例子,为了使查询 quick fox 能与包含 quick brown fox 的文档匹配,我们需要的 slop 为1:

            Pos 1         Pos 2         Pos 3
-----------------------------------------------
Doc: quick brown fox
-----------------------------------------------
Query: quick fox
Slop 1: quick ↳ fox

尽管所有词都需要出现在短语匹配(phrase matching)中,在使用 slop 时,词的顺序不必完全一致。当 slop 的值足够高时,词可以处于任何位置。

如果要使 fox quick 能与我们的文档匹配,我们需要的 slop 值为 3:

            Pos 1         Pos 2         Pos 3
-----------------------------------------------
Doc: quick brown fox
-----------------------------------------------
Query: fox quick
Slop 1: fox|quick ↵ #1
Slop 2: quick ↳ fox
Slop 3: quick ↳ fox
  • #1 注意 这一步foxquick 处于同一位置,因此,将词语的顺序从 fox quick 变化成 quick fox 还需要2步。

多值字段(Multivalue Fields)

如果将短语匹配使用到多值字段上会十分有趣,假如我们有下面这个文档:

PUT /my_index/groups/1
{
"names": [ "John Abraham", "Lincoln Smith"]
}

执行下面短语查询 Abraham Lincoln

GET /my_index/groups/_search
{
"query": {
"match_phrase": {
"names": "Abraham Lincoln"
}
}
}

令人惊讶的是,尽管 AbrahamLincoln 属于两个不同的人名,这个文档仍然可以被匹配到,这样的结果与数组在ElasticSearch内的索引方式相关。

当分析 John Abraham 的时候,生成下面信息:

  • Position 1: john
  • Position 2: abraham

当分析 Lincoln Smith 的时候,生成下面信息:

  • Position 3: lincoln
  • Position 4: smith

换句话说,ElasticSearch 为数组生成的token列表与“John Abraham Lincoln Smith”这样单个字符串生成的token列表一样。在例子中,当我们要查询“abraham lincoln”的时候,这两个词正好存在,而且他们是相邻的,这样就能匹配到文档。

幸运的是我们对这种情况有种变通的解决办法,叫做 position_offset_gap,我们需要将其配置到字段映射中:

DELETE /my_index/groups/ #1

PUT /my_index/_mapping/groups #2
{
"properties": {
"names": {
"type": "string",
"position_offset_gap": 100
}
}
}
  • #1 首先删除groups的映射以及所有这种类型下的文档
  • #2 创建正确的groups

position_offset_gap 值告诉ElasticSearch它需要为在当前每个新的数组元素位置上增加 position_offset_gap 所给出的值,现在我们得到的名字数组对应的术语位置如下:

  • Position 1: john
  • Position 2: abraham
  • Position 103: lincoln
  • Position 104: smith

这样,我们的短语查询“abraham lincoln”与文档不再匹配,因为他们之间相距100个位置,如果现在要想匹配到这个文档,我们需要为其指定 slop 值100。

越近越好(Closer Is Better)

与短语查询简单的将不包含准确短语的文档排除在外不同,近似查询(proximity query) ——一种 slop 值大于0的短语查询 —— 将查询术语的相似度融合到最终相关度分数 _score 中。为 slop 设置 50 或 100 这样很高的值,可以帮助我们排除掉词语之间相距十分远的那些文档,同时也能给那些词语间相距非常近的文档以高分。

下面相似度查询 “quick dog” 与两个包含 quickdog 的文档都匹配,但是词语相距近的文档的分数更高:

POST /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": {
"query": "quick dog",
"slop": 50 #1
}
}
}
}
  • #1 注意这个 slop 值很高

    {

    "hits": [

    {

    "_id": "3",

    "_score": 0.75, #1

    "_source": {

    "title": "The quick brown fox jumps over the quick dog"

    }

    },

    {

    "_id": "2",

    "_score": 0.28347334, #2

    "_source": {

    "title": "The quick brown fox jumps over the lazy dog"

    }

    }

    ]

    }

  • #1 quickdog 更近,因此分数更高。

  • #2 quickdog 较远,因此分数较低。

相关的近似度(Proximity for Relevance)

尽管近似查询很有用,要求所有术语都必须存在这点使之过于严苛,这与我们在全文搜索的控制精度(Controlling Precision)里谈到的问题一样:如果7个术语里面匹配6个,这个文档很有可能有足够的相关度需要展示给用户,但是 match_phrase 查询会将其排除在外。

与其将近似匹配作为一种绝对的要求,不如将其作为一种信号(signal)来使用——即作为潜在的多查询,每个查询都会对最终分数有贡献。

事实上,当我们想把多个查询的结果加在一起的时候,往往就预示着我们可以用 bool 查询将它们组合起来。

我们可以把一个简单的 match 查询作为 must 语句,这个查询可以决定哪个文档会被包括在结果集中,我们也可以使用 minimum_should_match 参数来剪掉长尾,然后我们可以加入其他更具体的查询,比如 should 语句。每个匹配到的词都会增加匹配文档的相关度。

GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": {
"match": { #1
"title": {
"query": "quick brown fox",
"minimum_should_match": "30%"
}
}
},
"should": {
"match_phrase": { #2
"title": {
"query": "quick brown fox",
"slop": 50
}
}
}
}
}
}
  • #1 must 语句包括或排除结果集中的文档。
  • #2 should 语句增加匹配文档的相关度。

我们当然也可以将其他查询加入到 should 语句中,每个查询都对应这某个方面的相关度。

性能提升(Improving Performance)

短语和近似查询比简单的 match 要昂贵许多,因为一个 match 查询只需要在反向索引中对术语进行查找,而一个 match_phrase 查询需要计算和比较多个(可能重复的)术语的位置。

Lucene的性能测评 一个简单的术语查询比一个短语查询快10倍,比一个近似查询(带有 slop 的短语查询)要快20倍,当然,这些代价来自于搜索时而非索引时。

通常情况下,短语查询的额外消耗并不像上面说的这些数字这样吓人,这些区别只说明一个简单的术语查询是相当快的,短语查询在典型的全文搜索下通常消耗的时间在毫秒级,无论在实际中,还是在一个繁忙的集群下都十分有用。

在某些变态的场景下,短语查询非常消耗资源,但这种情况并不常见。一个比较变态的例子是DNA测序,有许多许多完全相同的术语反复出现在不同位置。使用更高的 slop 值会大大增加位置的计算量。

所以我们可以通过何种方式来限制短语查询和近似查询对系统性能的消耗呢?一个有用的方法就是减少短语查询需要检查的文档总数。

重算分数(Rescoring Result)

在之前部分,我们讨论了使用近似查询来满足相关度的需求,而不是用它来包含或排除结果集中的文档。一个查询可能有百万个结果,但是我们的用户通常只对最前面的几页内容感兴趣。

简单的match查询以及将包含所有搜索术语的文档排在了结果的顶部,我们需要做的只是将排序好的结果与短语查询的匹配结果进行额外的相关度重排。

search API 用 rescoring 来支持这种功能。重算分数的过程使我们可以为每个shard里首 K 个值采用代价更高的计分算法——如短语查询,然后将这些结果根据他们的新分数进行重新排序。

请求如下:

GET /my_index/my_type/_search
{
"query": {
"match": { #1
"title": {
"query": "quick brown fox",
"minimum_should_match": "30%"
}
}
},
"rescore": {
"window_size": 50, #2
"query": { #3
"rescore_query": {
"match_phrase": {
"title": {
"query": "quick brown fox",
"slop": 50
}
}
}
}
}
}
  • #1 match 查询决定最终结果集中的数据以及对结果进行 TF/IDF 排名。
  • #2 window_size 是每个 shard 里参与重算分数的结果数。
  • #3 目前重算分的算法需要在另一个查询中进行,不过未来有计划增加更多的算法。

相关词查找(Finding Associated Words)

尽管短语查询和近似查询非常有用,它们也有不足的一面,它们过于严格:所有术语都必须以短语查询的方式去匹配,即使使用 slop 也不例外。

slop 那里获得的词序的灵活性是需要付出代价的,因为这样会丢失词语之间的关联。我们可以找到 suealligatorate 相近出现的文档,但是我们无法区分到底是 sue ate 还是 alligator ate

当多个词语一同出现的时候,它们所表达的内容要比单个词独立出现时更有意义。有这么两句话,I'm not happy I'm workingI’m happy I’m not working,它们包括相同的词,从词语相似度上说是非常接近的,但是其所表达的意思却大相径庭。

如果我们对词组索引,而不是每个单词独立索引,这样我们就能够保留更多词语使用时的语境。

句子 Sue ate the alligator ,我们不仅会索引单个词(unigram)作为术语

["sue", "ate", "the", "alligator"]

而且会将与之邻近的词组成单个术语:

["sue ate", "ate the", "the alligator"]

这样的词对(或 bigrams)被称作 瓦片词(shingles)

瓦片词(Shingles) 不一定要是成对出现的,它也可以是个三元组(trigrams),

["sue ate the", "ate the alligator"]

三元组(Trigram)为我们带来了更高的准确度,但是也大大增加了索引的数量。Bigram 在多数情况下就够用了。

当然 shingles 只在用户输入的词序与文档内容中的词序一致时有用;一个 sue alligator 查询会与单个词匹配,但无法与 shingles 里的术语匹配。

幸运的是,用户倾向使用与数据结果中结构相似的词语来表达他们想要搜索的内容。但是有点非常重要,即:仅仅索引 bigram 是不够的,我们仍然需要对 unigram 进行索引,而 bigram 可以作为信号词来使用以提高相关度分数。

生成Shingles

Shingles 可以作为分析过程的一部分在索引时创建,我们可以将 unigrambigram 索引到同一字段中,但是将他们分开索引会更加清楚,查询也可以独立开来。

首先,我们需要创建一个使用 shingle token的过滤器的分析器:

DELETE /my_index

PUT /my_index
{
"settings": {
"number_of_shards": 1, #1
"analysis": {
"filter": {
"my_shingle_filter": {
"type": "shingle",
"min_shingle_size": 2, #2
"max_shingle_size": 2, #3
"output_unigrams": false #4
}
},
"analyzer": {
"my_shingle_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"my_shingle_filter" #5
]
}
}
}
}
}
  • #1 参照被破坏的相关度(Relevance Is Broken)
  • #2 #3 shingle 的大小默认为 2,我们不需要显式设置。
  • #4 shingle的token过滤器默认输出 unigram,但是我们希望将 unigrambigram 分开。
  • #5 my_shingle_analyzer 使用我们自定义的 my_shingles_filter 作为 token 过滤器。

首先我们使用 analyze API来测试,确保我们的分析器如我们期望一样工作:

GET /my_index/_analyze?analyzer=my_shingle_analyzer
Sue ate the alligator

返回结果为三个术语:

  • sue ate
  • ate the
  • the alligator

现在我们可以使用新的分析器设置一个字段。

多字段(Multifields)

我们之前提到过,将 unigrambigram 分开索引会更清楚,所以我们为 title 字段作为多字段:

PUT /my_index/_mapping/my_type
{
"my_type": {
"properties": {
"title": {
"type": "string",
"fields": {
"shingles": {
"type": "string",
"analyzer": "my_shingle_analyzer"
}
}
}
}
}
}

有了这个映射,title里的JSON文档会同时以 unigram 的形式在title字段索引,还会以 bigrams 的形式在 title.shingles 字段索引,这样我们就能分别查询这两个字段。

最后,我们对例子中的文档进行索引:

POST /my_index/my_type/_bulk
{ "index": { "_id": 1 }}
{ "title": "Sue ate the alligator" }
{ "index": { "_id": 2 }}
{ "title": "The alligator ate Sue" }
{ "index": { "_id": 3 }}
{ "title": "Sue never goes anywhere without her alligator skin purse" }

Shingles查询(Searching for Shingles)

为了理解 shingles 带来的好处,我们先看看一个简单 match 查询“The hungry alligator ate Sue”的结果:

GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "the hungry alligator ate sue"
}
}
}

这个查询返回3个文档,注意到文档1和2有着相同的相关度分数,因为他们包含一样的词:

{
"hits": [
{
"_id": "1",
"_score": 0.44273707, #1
"_source": {
"title": "Sue ate the alligator"
}
},
{
"_id": "2",
"_score": 0.44273707, #2
"_source": {
"title": "The alligator ate Sue"
}
},
{
"_id": "3", #3
"_score": 0.046571054,
"_source": {
"title": "Sue never goes anywhere without her alligator skin purse"
}
}
]
}
  • #1 #2 两个文档都包含单词Suethealligatorate,所以他们分数相同。
  • 我们可以通过设置 minimum_should_match 参数来排除文档3。参照 控制精度(Controlling Precision)

现在我们将 shingles 字段加入到查询中,记住,我们这里的查询是将 shingles 字段作为信号来提升相关度分数的,所以我们仍然需要将 title 字段包含其中:

GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": {
"match": {
"title": "the hungry alligator ate sue"
}
},
"should": {
"match": {
"title.shingles": "the hungry alligator ate sue"
}
}
}
}
}

这里我们仍然匹配到3个文档,但是文档2被排在了第一位,因为它与 shingles里的术语 ate sue 相匹配。

{
"hits": [
{
"_id": "2",
"_score": 0.4883322,
"_source": {
"title": "The alligator ate Sue"
}
},
{
"_id": "1",
"_score": 0.13422975,
"_source": {
"title": "Sue ate the alligator"
}
},
{
"_id": "3",
"_score": 0.014119488,
"_source": {
"title": "Sue never goes anywhere without her alligator skin purse"
}
}
]
}

尽管如此,我们查询中包含词语 hungry,它没有出现在任何文档中,我们仍然可以通过词的相似度来找到最相关的文档。

性能(Performance)

shingles比短语查询更灵活,它的性能也非常好。与短语查询每次搜索都需要付出代价不同,使用shingles可以让我们的查询如简单 match 查询一样高效。使用shingles需要在索引时付出一点代价,因为需要索引更多术语,这也意味着需要更多的磁盘空间用来存储shingles。但是,多数应用只需要写入一次读取多次,这样也就优化了查询。

这个话题会在ElasticSearch中经常碰到:不需要显式的设置,就能让我们在搜索时提升速度。当我们对需求更明确时,我们就能通过在对索引阶段数据的模型进行设计,从而得到更好的结果以及达到更优的性能。

参考

elastic.co: Proximity Matching

ElasticSearch 2 (16) - 深入搜索系列之近似度匹配的更多相关文章

  1. ElasticSearch 2 (17) - 深入搜索系列之部分匹配

    ElasticSearch 2 (17) - 深入搜索系列之部分匹配 摘要 到目前为止,我们介绍的所有查询都是基于完整术语的,为了匹配,最小的单元为单个术语,我们只能查找反向索引中存在的术语. 但是, ...

  2. ElasticSearch 2 (18) - 深入搜索系列之控制相关度

    ElasticSearch 2 (18) - 深入搜索系列之控制相关度 摘要 处理结构化数据(比如:时间.数字.字符串.枚举)的数据库只需要检查一个文档(或行,在关系数据库)是否与查询匹配. 布尔是/ ...

  3. ElasticSearch 2 (13) - 深入搜索系列之结构化搜索

    ElasticSearch 2 (13) - 深入搜索系列之结构化搜索 摘要 结构化查询指的是查询那些具有内在结构的数据,比如日期.时间.数字都是结构化的.它们都有精确的格式,我们可以对这些数据进行逻 ...

  4. ElasticSearch 2 (15) - 深入搜索系列之多字段搜索

    ElasticSearch 2 (15) - 深入搜索系列之多字段搜索 摘要 查询很少是简单的一句话匹配(one-clause match)查询.很多时候,我们需要用相同或不同的字符串查询1个或多个字 ...

  5. ElasticSearch 2 (14) - 深入搜索系列之全文搜索

    ElasticSearch 2 (14) - 深入搜索系列之全文搜索 摘要 在看过结构化搜索之后,我们看看怎样在全文字段中查找相关度最高的文档. 全文搜索两个最重要的方面是: 相关(relevance ...

  6. ElasticSearch 2 (37) - 信息聚合系列之内存与延时

    ElasticSearch 2 (37) - 信息聚合系列之内存与延时 摘要 控制内存使用与延时 版本 elasticsearch版本: elasticsearch-2.x 内容 Fielddata ...

  7. Elasticsearch java api 基本搜索部分详解

    文档是结合几个博客整理出来的,内容大部分为转载内容.在使用过程中,对一些疑问点进行了整理与解析. Elasticsearch java api 基本搜索部分详解 ElasticSearch 常用的查询 ...

  8. ElasticSearch 2 (38) - 信息聚合系列之结束与思考

    ElasticSearch 2 (38) - 信息聚合系列之结束与思考 摘要 版本 elasticsearch版本: elasticsearch-2.x 内容 本小节涵盖了许多基本理论以及很多深入的技 ...

  9. ElasticSearch 2 (36) - 信息聚合系列之显著项

    ElasticSearch 2 (36) - 信息聚合系列之显著项 摘要 significant_terms(SigTerms)聚合与其他聚合都不相同.目前为止我们看到的所有聚合在本质上都是简单的数学 ...

随机推荐

  1. BZOJ5334:[TJOI2018]数学计算(线段树)

    Description 小豆现在有一个数x,初始值为1. 小豆有Q次操作,操作有两种类型:  1 m: x = x  *  m ,输出 x%mod; 2 pos: x = x /  第pos次操作所乘 ...

  2. InnerClass annotations are missing corresponding EnclosingMember annotations. Such InnerClas...

    如果 你的项目中使用了注解插件 比如butterknife   升级3.1之后打包编译  出现以下错误提示 InnerClass annotations are missing correspondi ...

  3. AES块加密与解密

    AES块加密与解密 解密目标 在CBC和CTR两种模式下分别给出十篇加密的样例密文,求解密一篇特定的密文 解密前提 全部密文及其加密使用的key都已给出 加密的方法遵循AES的标准 解密过程分析 实验 ...

  4. 向大家推荐一个在.Net下使用C#语言和Managed DirectX 9开发游戏的视频教程

    视频教程:3D游戏开发步步高系列课程(微软课堂).美中不足的是视频的声音和画面不太对应.专心的听声音,听老师讲解吧. PPT和源码下载:3D游戏开发步步高系列课程-PPT和源码 网址链接:3D游戏开发 ...

  5. JS输入框邮箱自动提示(带有demo和源码)

    今天在javascriptQQ群里面 有童鞋问到 有没有 "JS输入框邮箱自动提示"插件,即说都找遍了github上源码 都没有看到这样类似的插件,然后我想了下 "JS输 ...

  6. JavaScript中的箭头函数

    1.定义 箭头函数相当于匿名函数,并且简化了函数定义.箭头函数有两种格式,一种像上面的,只包含一个表达式,连{ ... }和return都省略掉了.还有一种可以包含多条语句,这时候就不能省略{ ... ...

  7. C++ 怎么让静态变量只初始化一次

    童鞋们在学习C++的时候,往往只是按照书本上的原文去强行记忆各种特性,比方说,静态变量只初始化一次.你心中一定在默念:一定要记住,static只会初始化一次云云,希望自己能够记住.告诉你,你为什么总是 ...

  8. 20155318 《网络攻防》Exp6 信息搜集与漏洞扫描

    20155318 <网络攻防>Exp6 信息搜集与漏洞扫描 基础问题 哪些组织负责DNS,IP的管理. 互联网名称与数字地址分配机构,ICANN机构.其下有三个支持机构,其中地址支持组织( ...

  9. # 2017-2018-2 20155319『网络对抗技术』Exp6:信息收集与漏洞扫描

    2017-2018-2 20155319『网络对抗技术』Exp6:信息收集与漏洞扫描 实践内容 (1)各种搜索技巧的应用 (2)DNS IP注册信息的查询 (3)基本的扫描技术:主机发现.端口扫描.O ...

  10. [CF1063F]String Journey[后缀数组+线段树]

    题意 在 \(S\) 中找出 \(t\) 个子串满足 \(t_{i+1}\) 是 \(t_{i}\) 的子串,要让 \(t\) 最大. \(|S| \leq 5\times 10^5\). 分析 定义 ...