ElasticSearch 2 (15) - 深入搜索系列之多字段搜索
ElasticSearch 2 (15) - 深入搜索系列之多字段搜索
摘要
查询很少是简单的一句话匹配(one-clause match)查询。很多时候,我们需要用相同或不同的字符串查询1个或多个字段,也就是说,我们需要对多个查询语句以及他们相关分数(relevance scores)进行有意义的合并。
有时候或许我们正查找一本名为战争与和平(War and Peace)而作者叫Leo Tolstoy的书,或许我们正用“最少匹配”(“minimum should match”)的方式在文档中进行查找(可能是页面标题,也可能是页面的内容),或许我们正搜索所有名字为 John Smith 的用户。
本篇文章中,我们会介绍构造多语句搜索的工具以及不同场景下不同的适合解决方案。
版本
elasticsearch版本: elasticsearch-2.x
内容
多字符串查询(Multiple Query Strings)
最简单的多字段(multifield)查询是可以将搜索术语与具体字段映射的。如果我们知道 War and Peace 是标题,Leo Tolstoy 是作者,我们只需要将两个条件写成 match 语句,然后将他们用 bool 查询组合起来即可:
GET /_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "War and Peace" }},
{ "match": { "author": "Leo Tolstoy" }}
]
}
}
}
bool 查询的采用越多匹配越好 (more-matches-is-better)的方式,所以每个 match 语句的分数结果会加和在一起,为每个文档得到一个最终的分数,能与两个语句同时匹配的文档比只与一个语句匹配的文档得分要高。
当然,我们并不是只能使用 match 语句:可以用 bool 查询组合任意其他类型的查询,甚至其他的 bool 查询。我们可以为上面的例子添加特定译者版本的偏好:
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 查询中呢?所有的4个 match 查询都是 should 语句,我们为什么不将translator语句与其他语句(如 title、author)放在同一层呢?
答案在于分数的计算方式。bool 查询执行每个 match 查询,然后把他们加在一起,然后将结果与所有匹配的语句数量相乘,再除以所有的语句数量。处于同一层的每条语句具有相同的权重。在上面这个例子中,包含translator语句的 bool 查询,只占总分数的三分之一,如果我们将translator语句与 title 和 author 两个语句放入同一层,那么 title 和 author 语句只贡献四分之一。
句子的优先级排序
有可能上面这个例子中每个语句贡献三分之一的分数并不是我们想要的,我们很可能对 title 和 author 两个句子更感兴趣,这样我们就需要调整查询,使 title 和 author 语句更重要。
在我们军械库中,最容易使用的武器就是 boost 参数。为了提高 title 和 author 字段的权重,我们为他们分配高于1的 boost 值。
GET /_search
{
"query": {
"bool": {
"should": [
{ "match": { #1
"title": {
"query": "War and Peace",
"boost": 2
}}},
{ "match": { #2
"author": {
"query": "Leo Tolstoy",
"boost": 2
}}},
{ "bool": { #3
"should": [
{ "match": { "translator": "Constance Garnett" }},
{ "match": { "translator": "Louise Maude" }}
]
}}
]
}
}
}
- #1 #2 title 和 author 语句的 boost 值为2。
- 嵌套的 bool 语句的 boost 值为1。
获取 boost 参数最佳值的一个比较简单的方式就是需要不断试错:设定 boost 值,运行测试查询,如此反复。boost 值比较合理的一个区间是1到10,当然也有可能是15。如果为 boost 分配比这更高的值将不会对最终的结果产生更大影响,因为分数最后会被规范化(normalized)。
单字符串查询(Single Query String)
bool 查询是多语句查询的主干。它的适用场景很多,特别是当我们需要将不同查询字符串与不同字段建立映射的时候。
有些用户期望将所有的搜索术语堆积到一个字段中,然后期望ElasticSearch能理解这些搜索,并为他们提供正确的结果。有意思的是多字段搜索的表单通常被称为 高级查询 (Advanced Search)- 因为它对用户而言是高级的,但是,多字段搜索在实现上却非常简单。
对于多词(multiword)、多字段(multifield)查询没有简单的一刀切方案。为了得到最佳结果,我们除了需要了解如何使用合适的工具外,还需要了解我们的数据。
了解我们的数据(Know Your Data)
当我们的用户输入了一个单字符串查询的时候,我们通常会遇到一下情形:
-
最佳字段
当我们搜索具有具体概念的词的时候,比如“brown fox”,词组比它们各自更有意义。像 title 和 body 这样的字段,尽管他们是相关的,但是他们也彼此相互竞争。当文档在相同字段中具有更多词的时候,最终的分数来自于最匹配字段(best-matching field)。
-
多数字段
为了对相关度进行微调,一个常用的技术是将相同的数据索引到不同的字段中,它们各自具有独立的分析链。
主字段(main field)可能包括他们的词源、同义词,以及变音词或口音词。用它们来匹配尽可能多的文档。
相同的文本被索引到其他字段,以提供更精确的匹配。一个字段可以包括原词,其他词源、口音,以及可以提供词语相似性的 瓦片词 (shingles)。
其他字段是作为匹配每个文档时提高相关度分数的信号词,越多字段能匹配则越好。
-
混合字段
对于某些实体,我们需要在多个字段中确定其信息,单个字段都只能作为整体的一部分:
- Person: first_name 和 last_name
- Book: title,author 和 description
- Address: street,city,country 和 postcode
在这种情况下,我们希望在所有这些列出的字段中找到尽可能多的词,这有如在一个大的字段中进行搜索,这个大的字段包括所有列出字段。
上述所有的所有都是多词、多字段查询(mutiword,multifield queries),但是每个具体查询都需要使用不同的策略。在后面章节中,我们会依次介绍这些策略。
最佳字段(Best Fields)
如果我们有个网站并为用户提供博客内容搜索的功能,以下面两个博客内容文档为例:
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 中被找到,但是,用户很有可能是想对“Brown fox”这个相关词组进行搜索。以肉眼判断,文档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更低的整体分数。
注:
以公式表示文档1的分数为:
(score_of_doc_1_title_match + score_of_doc_1_body_match) * total_number_of_match_clause / total_number_of_clause
其中:
score_of_doc_1_body_match = 0
total_number_of_match_clause = 1
total_number_of_clause = 2
在这个例子中,title 和 body 两个字段处于竞争地位,所以我们就需要找到单个最佳匹配(best-matching)字段。
如果我们不是简单将每个字段的分数结果加在一起,而是将最佳匹配(best-matching)字段的分数作为整体查询的分数,会有怎样的结果?这样返回的结果可能是:同时包含两个词的单个字段 比 相同词语反复出现的多个不同字段 相关度更高。
dis_max 查询
我们可以使用 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."
}
}
]
}
最佳字段查询调优(Tuning Best Fields Queries)
当用户搜索“quick pets”时会发生什么呢?使用之前的例子,两个文档都包含词 quick,但是只有文档2包含词 pets,两个文档中都不具有同时包含两个词的字段。
如下,一个简单的 dis_max 查询会采用单个最佳匹配(best matching)字段,然后忽略其他的匹配:
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Quick pets" }},
{ "match": { "body": "Quick pets" }}
]
}
}
}
结果是:
{
"hits": [
{
"_id": "1",
"_score": 0.12713557, #1
"_source": {
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
},
{
"_id": "2",
"_score": 0.12713557, #2
"_source": {
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
}
]
}
- #1 #2 注意这两个分数是相同的。
我们可能期望在这个例子中,能够同时匹配 title 和 body 字段的文档比只与一个字段匹配的文档的相关度更高,但事实并非如此,因为 dis_max 查询只会使用单个最佳匹配语句的分数(_score)作为整体分数。
打破平衡(tie_breaker)
我们可以使用 tie_breaker 这个参数将其他匹配语句的分数也考虑其中:
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Quick pets" }},
{ "match": { "body": "Quick pets" }}
],
"tie_breaker": 0.3
}
}
}
这个查询的结果如下:
{
"hits": [
{
"_id": "2",
"_score": 0.14757764, #1
"_source": {
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
},
{
"_id": "1",
"_score": 0.124275915, #2
"_source": {
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
}
]
}
- #1 #2 可以看到,文档2比文档1在相关度上有微弱优势。
tie_breaker 参数的出现实际上是提供了一种处于 dis_max 和 bool 中间状态的查询,它分数计算的方式如下:
- 获得最佳匹配(best-matching)语句的分数 _score
- 将其他匹配语句的得分与 tie_breaker 相乘
- 将以上分数求和并规范化(normalize)
由于tie_breaker的作用,所有匹配语句都会被考虑其中,但是最佳匹配语句仍然占最终结果的大头。
注意:
tie_breaker 可以是 0 到 1 之间的浮点数,其中,如果数值为0,即代表使用dis_max最佳匹配语句的普通逻辑,如果数值为1,即表示所有匹配语句同等重要。最佳的准确值需要根据数据与查询进行调试得出,但是合理的值通常与零接近(处于 0.1 - 0.4 之间),这样的合理值不会改变 dis_max 使用最佳匹配的本质。
多配查询(multi_match查询)
multi_match 查询为反复执行在多个字段上的查询提供了一种简便的方式。
注意:
multi_match 查询的类型有多种,其中的三种恰巧与 了解我们的数据(Know YOur Data) 中介绍的三个场景对应,即:best_fields,most_fields,cross_fields。
默认情况下,下面这个查询的类型是 best_fields,这表示它会为每个字段生成一个查询,然后将他们组合到dis_max 查询的内部:
{
"dis_max": {
"queries": [
{
"match": {
"title": {
"query": "Quick brown fox",
"minimum_should_match": "30%"
}
}
},
{
"match": {
"body": {
"query": "Quick brown fox",
"minimum_should_match": "30%"
}
}
},
],
"tie_breaker": 0.3
}
}
上面这个查询以 multi_match 重写更为简洁:
{
"multi_match": {
"query": "Quick brown fox",
"type": "best_fields", #1
"fields": [ "title", "body" ],
"tie_breaker": 0.3,
"minimum_should_match": "30%" #2
}
}
- #1 这个 best_fields 类型是默认值,可以不指定。
- #2 如 minimum_should_match 或 operator 这样的参数会被传递到生成的 match 查询中。
查询字段名称的模糊匹配(Using Wildcards in Field Names)
字段名称可以用模糊匹配的方式给出:任何与模糊匹配(wildcard)正则匹配的字段都会被包括在搜索中,比如,我们可以使用一下方式同时匹配 book_title、chapter_title和 section_title 这三个字段:
{
"multi_match": {
"query": "Quick brown fox",
"fields": "*_title"
}
}
增加单个字段的权重(Boosting Individual Fields)
可以使用脱字号(caret ^ )的语法为单个字段增加权重:只需要在字段末尾添加 ^boost,其中 boost 是一个浮点数:
{
"multi_match": {
"query": "Quick brown fox",
"fields": [ "*_title", "chapter_title^2" ] #1
}
}
- #1 chapter_title 这个字段的boost值为2,而其他两个字段 book_title 和 section_title 字段具有默认的boost值为1。
多数字段(Most Fields)
全文搜索被称作是召回(Recall)与准确(Precision)的战场:召回(Recall)指的是返回结果中的所有文档都是相关的;准确(Precision)指的是返回结果中没有不相关的文档。目的是,在结果的第一页中,为用户呈现最相关的文档。
为了提高召回(Recall)的效果,我们在全网中进行搜索,不仅返回与用户搜索术语精确匹配的文档,还会返回我们认为与查询相关的所有文档。如果一个用户搜索“quick brown box”,一个包含词语fast foxes的文档出现在结果集中会被认为是非常合理的。
当然,如果包含词语fast foxes的文档是我们找到的唯一相关文档,那么它会出现在结果集的顶端,但是,如果有100个文档都出现了词语“quick brown fox”,那么这个包含词语fast foxes的文档会被认为是次相关的,它可能处于返回结果列表的下面某个地方。当包含了很多潜在匹配之后,我们需要将最匹配的几个放在结果集的顶端。
对全文相关度提高精度的一个常用方式是为同一文本建立不同方式的索引,每种方式都提供了一个不同的相关度信号(signal)。主字段(main field)会包括最宽匹配(broadest-matching)形式的术语去尽可能的匹配更多的文档。举个例子,我们可以进行一下操作:
- 使用词jump作为根(root)来索引 jumps、jumping和jumped样的词。这样,无论用户使用 jumped 还是 jumping 进行搜索,都能找到匹配的文档。
- 将同义词包括其中,如jump、leap和hop。
- 移除变音或口音词:如ésta、 está和esta都会以无变音形式建立索引。
尽管如此,如果我们有两个文档,其中一个包含词jumped,另一个包含词jumping,如果我们使用jumped进行搜索的时候,当前期望前者能有更高的排名。
为了解决这个问题,我们可以将相同的文本索引到其他字段中去以提供更精确的匹配。一个字段可能是为非词根的版本,另一个字段可能是变音过的原始词,还有一个字段可能使用瓦片词(shingles)以提供词语相似性的信息。这些其他的字段作为提高每个文档的相关度分数的信号(signals),匹配的字段越多越好。
一个文档如果与宽匹配的主字段匹配,那么它会出现在结果列表中,如果它同时与信号(signal)字段匹配,它会得到加分,系统会上提它在结果列表中的位置。
我们会稍后讨论同义词、词相似性、半匹配以及其他潜在的信号,这里我们只使用词干(stemmed)和非词干(unstemmed)字段作为简单例子来说明这种技术。
多字段映射(Multifield Mapping)
第一件要做的事情就是要对我们的字段索引两次:一次词干模式和一次非词干模式。我们使用 multifields 来实现(multifields 在String Sorting and Multifields中介绍过)。
DELETE /my_index
PUT /my_index
{
"settings": { "number_of_shards": 1 }, #1
"mappings": {
"my_type": {
"properties": {
"title": { #2
"type": "string",
"analyzer": "english",
"fields": {
"std": { #3
"type": "string",
"analyzer": "standard"
}
}
}
}
}
}
}
- #1 参考被破坏的相关度
- #2 title 字段使用 english 分析器进行词干分析。
- #3 title.std 字段使用 standard 标准分析器进行非词干分析。
接着我们索引一些文档:
PUT /my_index/my_type/1
{ "title": "My rabbit jumps" }
PUT /my_index/my_type/2
{ "title": "Jumping jack rabbits" }
这里用一个简单 match 查询 title 字段是否包含 jumping rabbits:
GET /my_index/_search
{
"query": {
"match": {
"title": "jumping rabbits"
}
}
}
由于使用 english 分析器,这个查询是在查找以 jump 和 rabbit 这两个词干作为术语的文档。两个文档的 title 字段都同时包括两个术语,所以两个文档得到的分数相同:
{
"hits": [
{
"_id": "1",
"_score": 0.42039964,
"_source": {
"title": "My rabbit jumps"
}
},
{
"_id": "2",
"_score": 0.42039964,
"_source": {
"title": "Jumping jack rabbits"
}
}
]
}
如果我们只是对 title.std 字段进行查询,那么只有文档2是匹配的。尽管如此,如果我们对两个字段同时查询,然后使用 bool 查询将分数结果合并,则两个文档都是匹配的(title 字段的作用),而且文档2的相关度分数更高(title.std 字段的作用):
GET /my_index/_search
{
"query": {
"multi_match": {
"query": "jumping rabbits",
"type": "most_fields", #1
"fields": [ "title", "title.std" ]
}
}
}
-
#1 我们希望将所有匹配字段的分数合并起来,所以我们使用 most_fields 类型。这使 multi_match 查询用 bool 查询将两个字段语句包在里面,而非 dis_max 查询。
{
"hits": [
{
"_id": "2",
"_score": 0.8226396, #1
"_source": {
"title": "Jumping jack rabbits"
}
},
{
"_id": "1",
"_score": 0.10741998, #2
"_source": {
"title": "My rabbit jumps"
}
}
]
} -
#1 #2 文档2现在的分数要比文档1高。
我们用宽匹配字段 title 包括尽可能多的文档——以增加召回(Recall)——同时又使用字段 title.std 作为信号(signal)将相关度更高的文档置入结果集的顶部。
每个字段对于最终分数的贡献可以通过自定义值 boost 来控制。比如,我们使 title 字段更为重要,这样同时也降低了其他信号字段的作用:
GET /my_index/_search
{
"query": {
"multi_match": {
"query": "jumping rabbits",
"type": "most_fields",
"fields": [ "title^10", "title.std" ] #1
}
}
}
- #1 title 字段的 boost 的值为10使它比 title.std 更重要。
跨字段实体搜索(Cross-fields Entity Search)
现在我们讨论一种普遍的搜索模式:跨字段实体搜索(cross-fields entity search)。在如人 (person)、产品(product) 或 地址(address) 这样的实体中,我们需要使用多个字段来唯一辨认它的信息。一个 人(person) 实体可能是这样索引的:
{
"firstname": "Peter",
"lastname": "Smith"
}
而一个 地址(address) 可能是这样
{
"street": "5 Poland Street",
"city": "London",
"country": "United Kingdom",
"postcode": "W1V 3DG"
}
这与我们之前说的多字符串查询很像,但是这里有一个巨大的区别。在多字符串查询中,我们为每个字段使用不同的字符串,在这个例子中,我们想使用单个字符串在多个字段中进行搜索。
我们的用户可能想要查找 “Peter Smith”这个人 或 “Poland Street W1V”这个地址,这些词都出现在不同的字段中,所以如果使用 dis_max / best_fields 查询去查找单个最佳匹配字段显然是错误的。
一种弱弱的方式(A Naive Approach)
我们依次查询每个字段,然后每个字段的匹配结果相加,这看起来像个 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 查询,将类型设置成 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 来减少此相关结果中的长尾。
- 词频对于每个字段是不一样的,而且它们之间相互影响可能会生成一个不好的排序结果。
以字段为中心的查询(Field-Centric Queries)
上面这三个来自于 most_fields 的问题都是因为它是 字段为中心的(field-centric)而不是 术语为中心的(term-centric)。当我们对术语(terms)匹配真正感兴趣时,它为我们查找的是最匹配的字段(fields)。
注意:
best_fields 类型也是字段为中心的(field-centric)有着类似的问题。
首先我们来看看这些问题存在的原因,然后再来解决它们。
问题 1:多个字段从匹配相同词(Matching the Same Word in Multiple Fields)
回想一下 most_fields 查询是如何执行的:ElasticSearch为每个字段生成一个查询,然后用 bool 查询将他们包裹起来。
我们可以通过 validate-query API来查看:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "Poland Street W1V",
"type": "most_fields",
"fields": [ "street", "city", "country", "postcode" ]
}
}
}
生成的解释(explanation)为:
(street:poland street:street street:w1v)
(city:poland city:street city:w1v)
(country:poland country:street country:w1v)
(postcode:poland postcode:street postcode:w1v)
得到的结果中,一个两个字段与 poland 匹配的文档比一个字段内同时匹配 poland 与 street 的文档分数要高。
问题 2:剪掉长尾(Trimming the Long Tail)
在精度控制(Controlling Precision)中,我们讨论了使用 and 操作符 或者 设置minimum_should_match 参数来消除结果中不相关的长尾:
{
"query": {
"multi_match": {
"query": "Poland Street W1V",
"type": "most_fields",
"operator": "and", #1
"fields": [ "street", "city", "country", "postcode" ]
}
}
}
- #1 所有词必须呈现。
但是对于 best_fields 或 most_fields 这样的参数会在 match 查询生成时被传入,这个查询的 explaination 如下:
(+street:poland +street:street +street:w1v)
(+city:poland +city:street +city:w1v)
(+country:poland +country:street +country:w1v)
(+postcode:poland +postcode:street +postcode:w1v)
换句话说,使用 and 操作符要求所有词都必须存在于相同字段,显然这样是不对的!可能没有任何文档能与这个查询匹配。
问题 3:词频(Term Frequencies)
在什么是相关(What is Relevance)中,我们解释了每个术语使用默认相似度算法是 TF/IDF:
-
词频(Term frequency)
一个词在单个文档的某个字段中出现的频率越高,这个文档的相关度越高。
-
逆向文件频率(Inverse document frequency)
一个词在所有文档索引中出现的频率越高,这个词的相关度越低。
当我们在多个字段中进行搜索时,TF/IDF可以为我们带来某些意外的结果。
考虑我们用字段 first_name 和 last_name 字段查询 “Peter Smith”的例子,Peter是一个普通的名(first name)同时Smith也是个一个非常普通的姓(last name),他们都具有较低的IDF值。但是当我们索引中有另外一个人的名字是 “Smith Williams”时,Smith作为姓(first name)就变得非常不普通以致它有一个较高的IDF值。
下面这个简单的查询可能会在结果中将 “Smith Williams” 置于 “Peter Smith”之上,尽管事实上第二个人比第一个人更匹配。
{
"query": {
"multi_match": {
"query": "Peter Smith",
"type": "most_fields",
"fields": [ "*_name" ]
}
}
}
这里的问题是 Smith 在名字段中有着高IDF,它会削弱 “Peter”作为名和“Smith”作为姓时较低的IDF的作用。
解决方案(Solution)
这些问题存在的原因在于它处理着多个字段,如果我们将所有这些字段组合成单个字段,这个问题就会不复存在。我们可以为Person文档添加一个 full_name 字段:
{
"first_name": "Peter",
"last_name": "Smith",
"full_name": "Peter Smith"
}
当对 full_name 进行查询时:
- 具有更多匹配词的文档会比只有一个重复匹配词的文档更重要。
- 参数 minimum_should_match 和 operator 会如我们期望那样工作。
- 姓和名的逆向文件频率被合并,所以 Smith 到底是作为姓出现,还是作为名出现?这个问题会变得无关紧要。
这么做当然是可行的,但是我们不太喜欢存储冗余的数据。ElasticSearch为我们提供了两个解决方案——一个是索引时的,另一个是搜索时的——我们会在稍后讨论这两个方案。
自定义_all字段(Custom _all Fields)
在元数据_all 字段中(Metadata:_all Field),我们解释过:_all 字段的索引方式是将所有其他字段的值作为一个巨大的字符串进行索引的。尽管这么做并不是十分灵活,但是我们可以为人的姓名添加一个自定义 _all 字段,然后再为地址添加另一个 _all 字段。
ElasticSearch在字段映射中,为我们提供了一个 copy_to 参数来实现这个功能。
PUT /my_index
{
"mappings": {
"person": {
"properties": {
"first_name": {
"type": "string",
"copy_to": "full_name" #1
},
"last_name": {
"type": "string",
"copy_to": "full_name" #2
},
"full_name": {
"type": "string"
}
}
}
}
}
- #1 #2 first_name 和 last_name 字段中的值会被复制到 full_name 字段中。
有了这个映射,我们可以使用 first_name 查询名,使用 last_name 查询名,或者直接使用 full_name 查询姓名。
注意:
映射中 first_name 和 last_name 并不知道 full_name是如何被索引的,full_name将两个字段的内容复制到本地,然后自行索引。
跨字段查询(cross_fields Queries)
自定义 _all 的方式是一个好的解决方案,我们只需要在索引文件之前为其设置好映射即可。不过,ElasticSearch还在搜索时(search-time)提供了相应的解决方案:使用类型 cross_fields 进行multi_match 查询。cross_fields 使用以术语为中心(term-centric)的查询方式,这与 best_fields 和 most_fields 使用的字段为中心(field-centric)的查询方式非常不同。它将所有字段看成一个大的字段,然后在里面查找每个术语(each term)。
为了说明这两个查询方式(field-centric和term-centric)的不同,我们先看看下面这个以字段为中心的 most_fields 查询的 explanation:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "most_fields",
"operator": "and", #1
"fields": [ "first_name", "last_name" ]
}
}
}
- #1 所有术语都是必须的。
对于一个匹配的文档,peter 和 smith 都必须同时出现在同一字段中,要么是 first_name字段中,要么是 last_name 字段中:
(+first_name:peter +first_name:smith)
(+last_name:peter +last_name:smith)
但是以术语为中心的方式会是下面这样:
+(first_name:peter last_name:peter)
+(first_name:smith last_name:smith)
换句话说,术语 peter 和 smith 都必须出现,但是可以出现在任意字段中。
cross_fields 类型首先分析查询字符串并生成一个术语列表,然后它在所有字段从依次搜索每个术语。这种不同的搜索方式很自然的解决了字段中心式查询(Field-Centric Queries)三个问题中的二个。留给我们的问题只是:逆向文件频率不同。
幸运的是,cross_fields 同样可以解决这个问题,通过 validate-query 查看:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "cross_fields", #1
"operator": "and",
"fields": [ "first_name", "last_name" ]
}
}
}
- #1 用cross_fields 术语中心式查询。
它通过将不同字段的逆向索引文件频率(inverse document frequency)混合的方式解决词频(term-frequency)的问题:
+blended("peter", fields: [first_name, last_name])
+blended("smith", fields: [first_name, last_name])
换句话说,它会同时在 first_name 和 last_name 两个字段中查找 smith 的IDF,然后用两者的最小值作为两个字段的IDF。结果实际上就是:smith 会被认为既是一个普通的姓,同时也是一个普通的名。
注意:
为了让 cross_fields 查询以最优方式工作,所有的字段都需要使用相同的分析器,具有相同分析器的字段会被分组在一起作为混合字段使用。
如果包括了不同分析链的字段,它们会以 best_fields 的相同方式加到查询结果中。例如:我们将 title 字段加到之前的查询中(假设他们使用的是不同的分析器),explaination 的结果如下:
(+title:peter +title:smith) ( +blended("peter", fields: [first_name, last_name]) +blended("smith", fields: [first_name, last_name]) )
提高字段权重(Per-Field Boosting)
cross_fields 查询与 自定义_all 相比的一个优势就是它可以在搜索时,为单个字段提升权重。
我们不需要为像 first_name 和 last_name这样具有相同值的字段这么做,但是如果要用 title 和 description 字段搜索图书,我们可能希望为 title 分配更多的权重,这同样可以使用前面介绍过的 脱字号(caret ^)语法来实现:
GET /books/_search
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "cross_fields",
"fields": [ "title^2", "description" ] #1
}
}
}
- \1 title 字段的 boost 为 2,description 字段的boost 为默认值 1。
能够为单个字段指定boost值所带来的好处需要权衡多字段查询与单字段自定义_all之间的代价,即那种方案会给我们带来更大的(性能)压力。
准确值字段(Exact-Value Fields)
在结束多字段查询这个话题之前,我们最后需要讨论的是准确值 not_analyzed 字段。将 not_analyzed 字段与 multi_match 中 analyzed 字段混在一起没有多大用处。
原因可以通过查看查询的explaination得到,假设我们将 title 字段设置成 not_analyzed:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "cross_fields",
"fields": [ "title", "first_name", "last_name" ]
}
}
}
因为 title 字段是未分析过的,ElasticSearch会将“peter smith”这个完整的字符串作为查询术语进行搜索:
title:peter smith
(
blended("peter", fields: [first_name, last_name])
blended("smith", fields: [first_name, last_name])
)
显然这个术语不在title的反向索引中,所以需要在 multi_match 查询中避免使用 not_analyzed 字段。