使用阿里云试用Elasticsearch学习:2.4 深入搜索——近似匹配

时间:2024-04-07 10:06:26

使用 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 这两个词可能会分散在其他的段落文字中,我们可能会希望得到尽可能包含这两个词的文档,但我们也同样需要这些文档与分词有很高的相关度。

这就是短语匹配或者近似匹配的所属领域。

在这一章节,我们还是使用在match 查询中使用过的文档作为例子。

短语匹配

就像 match 查询对于标准全文检索是一种最常用的查询一样,当你想找到彼此邻近搜索词的查询方法时,就会想到 match_phrase 查询。

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

类似 match 查询, match_phrase 查询首先将查询字符串解析成一个词项列表,然后对这些词项进行搜索,但只保留那些包含 全部 搜索词项,且 位置 与搜索词项相同的文档。 比如对于 quick fox 的短语搜索可能不会匹配到任何文档,因为没有文档包含的 quick 词之后紧跟着 fox 。

词项的位置

当一个字符串被分词后,这个分析器不但会返回一个词项列表,而且还会返回各词项在原始字符串中的 位置 或者顺序关系:

GET /_analyze
{
  "text": "Quick brown fox",
  "analyzer": "standard"
}

返回信息如下:

{
  "tokens": [
    {
      "token": "quick",
      "start_offset": 0,
      "end_offset": 5,
      "type": "<ALPHANUM>",
      "position": 0
    },
    {
      "token": "brown",
      "start_offset": 6,
      "end_offset": 11,
      "type": "<ALPHANUM>",
      "position": 1
    },
    {
      "token": "fox",
      "start_offset": 12,
      "end_offset": 15,
      "type": "<ALPHANUM>",
      "position": 2
    }
  ]
}
  • position 代表各词项在原始字符串中的位置。

位置信息可以被存储在倒排索引中,因此 match_phrase 查询这类对词语位置敏感的查询, 就可以利用位置信息去匹配包含所有查询词项,且各词项顺序也与我们搜索指定一致的文档,中间不夹杂其他词项。

什么是短语

一个被认定为和短语 quick brown fox 匹配的文档,必须满足以下这些要求:

  • quick 、 brown 和 fox 需要全部出现在域中。
  • brown 的位置应该比 quick 的位置大 1 。
  • fox 的位置应该比 quick 的位置大 2 。

如果以上任何一个选项不成立,则该文档不能认定为匹配。

本质上来讲,match_phrase 查询是利用一种低级别的 span 查询族(query family)去做词语位置敏感的匹配。 Span 查询是一种词项级别的查询,所以它们没有分词阶段;它们只对指定的词项进行精确搜索。
值得庆幸的是,match_phrase 查询已经足够优秀,大多数人是不会直接使用 span 查询。 然而,在一些专业领域,例如专利检索,还是会采用这种低级别查询去执行非常具体而又精心构造的位置搜索。

混合起来

精确短语匹配 或许是过于严格了。也许我们想要包含 “quick brown fox” 的文档也能够匹配 “quick fox,” , 尽管情形不完全相同。
我们能够通过使用 slop 参数将灵活度引入短语匹配中:

GET /my_index/_search
{
  "query": {
    "match_phrase": {
      "body": {
        "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

尽管在使用了 slop 短语匹配中所有的单词都需要出现, 但是这些单词也不必为了匹配而按相同的序列排列。 有了足够大的 slop 值, 单词就能按照任意顺序排列了。

为了使查询 fox quick 匹配我们的文档, 我们需要 slop 的值为 3:

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

注意 fox 和 quick 在这步中占据同样的位置。 因此将 fox quick 转换顺序成 quick fox 需要两步, 或者值为 2 的 slop 。

越近越好

鉴于一个短语查询仅仅排除了不包含确切查询短语的文档, 而 邻近查询 — 一个 slop 大于 0— 的短语查询将查询词条的邻近度考虑到最终相关度 _score 中。 通过设置一个像 50 或者 100 这样的高 slop 值, 你能够排除单词距离太远的文档, 但是也给予了那些单词临近的的文档更高的分数。

下列对 quick dog 的邻近查询匹配了同时包含 quick 和 dog 的文档, 但是也给了与 quick 和 dog 更加临近的文档更高的分数 :

POST my_index/_search
{
   "query": {
      "match_phrase": {
         "body": {
            "query": "quick dog",
            "slop":  50 
         }
      }
   }
}

注意高 slop 值。

"hits": [
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 0.38019663,
        "_source": {
          "title": "Keeping pets healthy",
          "body": "The quick brown fox jumps over the quick dog."
        }
      },
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 0.06361735,
        "_source": {
          "title": "Keeping pets healthy",
          "body": "My quick brown fox eats rabbits on a regular basis dog."
        }
      }
    ]
  • 分数较高因为 quick 和 dog 很接近
  • 分数较低因为 quick 和 dog 分开较远

使用邻近度提高相关度

虽然邻近查询很有用, 但是所有词条都出现在文档的要求过于严格了。 我们讨论 全文搜索 一章的 控制精度 也是同样的问题: 如果七个词条中有六个匹配, 那么这个文档对用户而言就已经足够相关了, 但是 match_phrase 查询可能会将它排除在外。

相比将使用邻近匹配作为绝对要求, 我们可以将它作为 信号— 使用, 作为许多潜在查询中的一个, 会对每个文档的最终分值做出贡献 (参考 多数字段)。

实际上我们想将多个查询的分数累计起来意味着我们应该用 bool 查询将它们合并。

我们可以将一个简单的 match 查询作为一个 must 子句。 这个查询将决定哪些文档需要被包含到结果集中。 我们可以用 minimum_should_match 参数去除长尾。 然后我们可以以 should 子句的形式添加更多特定查询。 每一个匹配成功的都会增加匹配文档的相关度。

GET /my_index/_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 子句增加了匹配到文档的相关度评分。

我们当然可以在 should 子句里面添加其它的查询, 其中每一个查询只针对某一特定方面的相关度。

性能优化

短语查询和邻近查询都比简单的 query 查询代价更高。 一个 match 查询仅仅是看词条是否存在于倒排索引中,而一个 match_phrase 查询是必须计算并比较多个可能重复词项的位置。

Lucene nightly benchmarks 表明一个简单的 term 查询比一个短语查询大约快 10 倍,比邻近查询(有 slop 的短语 查询)大约快 20 倍。当然,这个代价指的是在搜索时而不是索引时。

通常,短语查询的额外成本并不像这些数字所暗示的那么吓人。事实上,性能上的差距只是证明一个简单的 term 查询有多快。标准全文数据的短语查询通常在几毫秒内完成,因此实际上都是完全可用,即使是在一个繁忙的集群上。
在某些特定病理案例下,短语查询可能成本太高了,但比较少见。一个典型例子就是DNA序列,在序列里很多同样的词项在很多位置重复出现。在这里使用高 slop 值会到导致位置计算大量增加。

那么我们应该如何限制短语查询和邻近近查询的性能消耗呢?一种有用的方法是减少需要通过短语查询检查的文档总数。

结果集重新评分

在先前的章节中 ,我们讨论了而使用邻近查询来调整相关度,而不是使用它将文档从结果列表中添加或者排除。一个查询可能会匹配成千上万的结果,但我们的用户很可能只对结果的前几页感兴趣。

一个简单的 match 查询已经通过排序把包含所有含有搜索词条的文档放在结果列表的前面了。事实上,我们只想对这些 顶部文档 重新排序,来给同时匹配了短语查询的文档一个额外的相关度升级。

search API 通过 重新评分 明确支持该功能。重新评分阶段支持一个代价更高的评分算法—​比如 phrase 查询—​只是为了从每个分片中获得前 K 个结果。 然后会根据它们的最新评分 重新排序。

该请求如下所示:

GET /my_index/_search
{
  "query": {
    "match": {
      "title": {
        "query": "quick brown fox",
        "minimum_should_match": "30%"
      }
    }
  },
  "rescore": {
    "window_size": 50,
    "query": {
      "rescore_query": {
        "match_phrase": {
          "title": {
            "query": "quick brown fox",
            "slop": 50
          }
        }
      }
    }
  }
}
  • match 查询决定哪些文档将包含在最终结果集中,并通过 TF/IDF 排序。
  • window_size 是每一分片进行重新评分的顶部文档数量。
  • 目前唯一支持的重新打分算法就是另一个查询,但是以后会有计划增加更多的算法。

寻找相关词

短语查询和邻近查询都很好用,但仍有一个缺点。它们过于严格了:为了匹配短语查询,所有词项都必须存在,即使使用了 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”]
Trigrams 提供了更高的精度,但是也大大增加了索引中唯一词项的数量。在大多数情况下,Bigrams 就够了。

当然,只有当用户输入的查询内容和在原始文档中顺序相同时,shingles 才是有用的;对 sue alligator 的查询可能会匹配到单个单词,但是不会匹配任何 shingles 。

幸运的是,用户倾向于使用和搜索数据相似的构造来表达搜索意图。但这一点很重要:只是索引 bigrams 是不够的;我们仍然需要 unigrams ,但可以将匹配 bigrams 作为增加相关度评分的信号。

生成 Shingles

Shingles 需要在索引时作为分析过程的一部分被创建。我们可以将 unigrams 和 bigrams 都索引到单个字段中, 但将它们分开保存在能被独立查询的字段会更清晰。unigrams 字段将构成我们搜索的基础部分,而 bigrams 字段用来提高相关度。

PUT /my_index
{
    "settings": {
        "number_of_shards": 1,  
        "analysis": {
            "filter": {
                "my_shingle_filter": {
                    "type":             "shingle",
                    "min_shingle_size": 2, 
                    "max_shingle_size": 2, 
                    "output_unigrams":  false   
                }
            },
            "analyzer": {
                "my_shingle_analyzer": {
                    "tokenizer":        "standard",
                    "filter": [
                        "lowercase",
                        "my_shingle_filter" 
                    ]
                }
            }
        }
    }
}
  • 默认最小/最大的 shingle 大小是 2 ,所以实际上不需要设置。
  • shingle 语汇单元过滤器默认输出 unigrams ,但是我们想让 unigrams 和 bigrams 分开。
  • my_shingle_analyzer 使用我们常规的 my_shingles_filter 语汇单元过滤器。

首先,用 analyze API 测试下分析器:

GET /my_index/_analyze
{
  "text": "Sue ate the alligator",
  "analyzer": "my_shingle_analyzer"
}

果然, 我们得到了 3 个词项:

{
  "tokens": [
    {
      "token": "sue ate",
      "start_offset": 0,
      "end_offset": 7,
      "type": "shingle",
      "position": 0
    },
    {
      "token": "ate the",
      "start_offset": 4,
      "end_offset": 11,
      "type": "shingle",
      "position": 1
    },
    {
      "token": "the alligator",
      "start_offset": 8,
      "end_offset": 21,
      "type": "shingle",
      "position": 2
    }
  ]
}
  • sue ate
  • ate the
  • the alligator

现在我们可以继续创建一个使用新的分析器的字段。

多字段

我们曾谈到将 unigrams 和 bigrams 分开索引更清晰,所以 title 字段将创建成一个多字段(参考 字符串排序与多字段 ):

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

通过这个映射, JSON 文档中的 title 字段将会被以 unigrams (title)和 bigrams (title.shingles)被索引,这意味着可以独立地查询这些字段。

最后,我们可以索引以下示例文档:

POST /my_index/_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

为了理解添加 shingles 字段的好处,让我们首先来看 The hungry alligator ate Sue 进行简单 match 查询的结果:

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

这个查询返回了所有的三个文档, 但是注意文档 1 和 2 有相同的相关度评分因为他们包含了相同的单词:

"hits": [
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 1.3721708,
        "_source": {
          "title": "Sue ate the alligator"
        }
      },
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 1.3721708,
        "_source": {
          "title": "The alligator ate Sue"
        }
      },
      {
        "_index": "my_index",
        "_id": "3",
        "_score": 0.21526179,
        "_source": {
          "title": "Sue never goes anywhere without her alligator skin purse"
        }
      }
    ]
  • 两个文档都包含 the 、 alligator 和 ate ,所以获得相同的评分。
  • 我们可以通过设置 minimum_should_match 参数排除文档 3 ,参考 控制精度 。

现在在查询里添加 shingles 字段。不要忘了在 shingles 字段上的匹配是充当一 种信号—​为了提高相关度评分—​所以我们仍然需要将基本 title 字段包含到查询中:

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

仍然匹配到了所有的 3 个文档, 但是文档 2 现在排到了第一名因为它匹配了 shingled 词项 ate sue.

"hits": [
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 3.6694741,
        "_source": {
          "title": "The alligator ate Sue"
        }
      },
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 1.3721708,
        "_source": {
          "title": "Sue ate the alligator"
        }
      },
      {
        "_index": "my_index",
        "_id": "3",
        "_score": 0.21526179,
        "_source": {
          "title": "Sue never goes anywhere without her alligator skin purse"
        }
      }
    ]

即使查询包含的单词 hungry 没有在任何文档中出现,我们仍然使用单词邻近度返回了最相关的文档。

Performance性能

shingles 不仅比短语查询更灵活,而且性能也更好。 shingles 查询跟一个简单的 match 查询一样高效,而不用每次搜索花费短语查询的代价。只是在索引期间因为更多词项需要被索引会付出一些小的代价, 这也意味着有 shingles 的字段会占用更多的磁盘空间。 然而,大多数应用写入一次而读取多次,所以在索引期间优化我们的查询速度是有意义的。

这是一个在 Elasticsearch 里会经常碰到的话题:不需要任何前期进行过多的设置,就能够在搜索的时候有很好的效果。 一旦更清晰的理解了自己的需求,就能在索引时通过正确的为你的数据建模获得更好结果和性能。