ElasticSearch 2 (16) - 深入搜索系列之近似度匹配
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”会匹配所有三个文档,但是它不会告诉我们这两个词组合在一起是否为同一个意思,甚至是否为同一个段落。
要理解词语之间是如何相关的是个非常复杂的问题,我们无法只是简单使用另外一个类型的查询来解决此问题,但是我们至少可以查找到相关的词,因为他们出现在邻近的地方,甚至相互紧接着。
每个文档都可能会比我们例子中给出的要长的多:Sue 和 alligator 可能被其他段落隔离,即使可能有的文档中这些词之间相距较远,我们仍然希望能够将他们找出来,但是我们希望为邻近出现的文档给出更高的相关度分数。
这个问题属于短语匹配(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”匹配的文档来说,必须满足一下条件:
- quick 、brown 和 fox 必须所有都出现在字段里。
- 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 注意 这一步fox 和 quick 处于同一位置,因此,将词语的顺序从 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"
}
}
}
令人惊讶的是,尽管 Abraham 和 Lincoln 属于两个不同的人名,这个文档仍然可以被匹配到,这样的结果与数组在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” 与两个包含 quick 和 dog 的文档都匹配,但是词语相距近的文档的分数更高:
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 quick 和 dog 更近,因此分数更高。
-
#2 quick 和 dog 较远,因此分数较低。
相关的近似度(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 那里获得的词序的灵活性是需要付出代价的,因为这样会丢失词语之间的关联。我们可以找到 sue、alligator 和 ate 相近出现的文档,但是我们无法区分到底是 sue ate 还是 alligator ate。
当多个词语一同出现的时候,它们所表达的内容要比单个词独立出现时更有意义。有这么两句话,I'm not happy I'm working 与 I’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 可以作为分析过程的一部分在索引时创建,我们可以将 unigram 和 bigram 索引到同一字段中,但是将他们分开索引会更加清楚,查询也可以独立开来。
首先,我们需要创建一个使用 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,但是我们希望将 unigram 与 bigram 分开。
- #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)
我们之前提到过,将 unigram 和 bigram 分开索引会更清楚,所以我们为 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 两个文档都包含单词Sue、the、alligator和ate,所以他们分数相同。
- 我们可以通过设置 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中经常碰到:不需要显式的设置,就能让我们在搜索时提升速度。当我们对需求更明确时,我们就能通过在对索引阶段数据的模型进行设计,从而得到更好的结果以及达到更优的性能。