使用阿里云试用Elasticsearch学习:2.3 深入搜索——多字段搜索

查询很少是简单一句话的 match 匹配查询。通常我们需要用相同或不同的字符串查询一个或多个字段,也就是说,需要对多个查询语句以及它们相关度评分进行合理的合并。

有时候或许我们正查找作者 Leo Tolstoy 写的一本名为 War and Peace(战争与和平)的书。或许我们正用 "minimum should match" (最少应该匹配)的方式在文档中对标题或页面内容进行搜索,或许我们正在搜索所有名字为 John Smith 的用户。

在本章,我们会介绍构造多语句搜索的工具及在特定场景下应该采用的解决方案。

多字符串查询

最简单的多字段查询可以将搜索项映射到具体的字段。如果我们知道 War and Peace 是标题,Leo Tolstoy 是作者,很容易就能把两个条件用 match 语句表示,并将它们用 bool 查询 组合起来:

bash 复制代码
GET /_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "title":  "War and Peace" }},
        { "match": { "author": "Leo Tolstoy"   }}
      ]
    }
  }
}

bool 查询采取 more-matches-is-better 匹配越多越好的方式,所以每条 match 语句的评分结果会被加在一起,从而为每个文档提供最终的分数 _score 。能与两条语句同时匹配的文档比只与一条语句匹配的文档得分要高。

当然,并不是只能使用 match 语句:可以用 bool 查询来包裹组合任意其他类型的查询,甚至包括其他的 bool 查询。我们可以在上面的示例中添加一条语句来指定译者版本的偏好:

bash 复制代码
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 查询中呢?所有的四个 match 查询都是 should 语句,所以为什么不将 translator 语句与其他如 title 、 author 这样的语句放在同一层呢?

答案在于评分的计算方式。 bool 查询运行每个 match 查询,再把评分加在一起,然后将结果与所有匹配的语句数量相乘,最后除以所有的语句数量。处于同一层的每条语句具有相同的权重。在前面这个例子中,包含 translator 语句的 bool 查询,只占总评分的三分之一。如果将 translator 语句与 title 和 author 两条语句放入同一层,那么 title 和 author 语句只贡献四分之一评分。

语句的优先级

前例中每条语句贡献三分之一评分的这种方式可能并不是我们想要的,我们可能对 title 和 author 两条语句更感兴趣,这样就需要调整查询,使 title 和 author 语句相对来说更重要。

在武器库中,最容易使用的就是 boost 参数。为了提升 title 和 author 字段的权重,为它们分配的 boost 值大于 1 :

bash 复制代码
GET /_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { 
            "title":  {
              "query": "War and Peace",
              "boost": 2
        }}},
        { "match": { 
            "author":  {
              "query": "Leo Tolstoy",
              "boost": 2
        }}},
        { "bool":  { 
            "should": [
              { "match": { "translator": "Constance Garnett" }},
              { "match": { "translator": "Louise Maude"      }}
            ]
        }}
      ]
    }
  }
}
  • title 和 author 语句的 boost 值为 2 。
  • 嵌套 bool 语句默认的 boost 值为 1 。

要获取 boost 参数 "最佳" 值,较为简单的方式就是不断试错:设定 boost 值,运行测试查询,如此反复。 boost 值比较合理的区间处于 1 到 10 之间,当然也有可能是 15 。如果为 boost 指定比这更高的值,将不会对最终的评分结果产生更大影响,因为评分是被 归一化的(normalized) 。

单字符串查询

bool 查询是多语句查询的主干。它的适用场景很多,特别是当需要将不同查询字符串映射到不同字段的时候。

问题在于,目前有些用户期望将所有的搜索项堆积到单个字段中,并期望应用程序能为他们提供正确的结果。有意思的是多字段搜索的表单通常被称为 高级查询 (Advanced Search) ------ 只是因为它对用户而言是高级的,而多字段搜索的实现却非常简单。

对于多词(multiword)、多字段(multifield)查询来说,不存在简单的 万能 方案。为了获得最好结果,需要 了解我们的数据 ,并了解如何使用合适的工具。

了解我们的数据

当用户输入了单个字符串查询的时候,通常会遇到以下三种情形:

最佳字段

当搜索词语具体概念的时候,比如 "brown fox" ,词组比各自独立的单词更有意义。像 title 和 body 这样的字段,尽管它们之间是相关的,但同时又彼此相互竞争。文档在 相同字段 中包含的词越多越好,评分也来自于 最匹配字段 。

多数字段

为了对相关度进行微调,常用的一个技术就是将相同的数据索引到不同的字段,它们各自具有独立的分析链。

主字段可能包括它们的词源、同义词以及 变音词 或口音词,被用来匹配尽可能多的文档。

相同的文本被索引到其他字段,以提供更精确的匹配。一个字段可以包括未经词干提取过的原词,另一个字段包括其他词源、口音,还有一个字段可以提供 词语相似性 信息的瓦片词(shingles)。

其他字段是作为匹配每个文档时提高相关度评分的 信号 , 匹配字段越多 则越好。

混合字段

对于某些实体,我们需要在多个字段中确定其信息,单个字段都只能作为整体的一部分:

Person: first_name 和 last_name (人:名和姓)

Book: title 、 author 和 description (书:标题、作者、描述)

Address: street 、 city 、 country 和 postcode (地址:街道、市、国家和邮政编码)

在这种情况下,我们希望在 任何 这些列出的字段中找到尽可能多的词,这有如在一个大字段中进行搜索,这个大字段包括了所有列出的字段。

上述所有都是多词、多字段查询,但每个具体查询都要求使用不同策略。本章后面的部分,我们会依次介绍每个策略。

最佳字段

假设有个网站允许用户搜索博客的内容,以下面两篇博客内容文档为例:

bash 复制代码
PUT /my_index/_doc/1
{
    "title": "Quick brown rabbits",
    "body":  "Brown rabbits are commonly seen."
}

PUT /my_index/_doc/2
{
    "title": "Keeping pets healthy",
    "body":  "My quick brown fox eats rabbits on a regular basis."
}

用户输入词组 "Brown fox" 然后点击搜索按钮。事先,我们并不知道用户的搜索项是会在 title 还是在 body 字段中被找到,但是,用户很有可能是想搜索相关的词组。用肉眼判断,文档 2 的匹配度更高,因为它同时包括要查找的两个词:

现在运行以下 bool 查询:

bash 复制代码
GET my_index/_search
{
    "query": {
        "bool": {
            "should": [
                { "match": { "title": "Brown fox" }},
                { "match": { "body":  "Brown fox" }}
            ]
        }
    }
}

但是我们发现查询的结果是文档 1 的评分更高:

bash 复制代码
"hits": [
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 0.90425634,
        "_source": {
          "title": "Quick brown rabbits",
          "body": "Brown rabbits are commonly seen."
        }
      },
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 0.77041256,
        "_source": {
          "title": "Keeping pets healthy",
          "body": "My quick brown fox eats rabbits on a regular basis."
        }
      }
    ]

为了理解导致这样的原因,需要回想一下 bool 是如何计算评分的:

  1. 它会执行 should 语句中的两个查询。
  2. 加和两个查询的评分。
  3. 乘以匹配语句的总数。
  4. 除以所有语句总数(这里为:2)。

文档 1 的两个字段都包含 brown 这个词,所以两个 match 语句都能成功匹配并且有一个评分。文档 2 的 body 字段同时包含 brown 和 fox 这两个词,但 title 字段没有包含任何词。这样, body 查询结果中的高分,加上 title 查询中的 0 分,然后乘以二分之一,就得到比文档 1 更低的整体评分。

在本例中, title 和 body 字段是相互竞争的关系,所以就需要找到单个 最佳匹配 的字段。

如果不是简单将每个字段的评分结果加在一起,而是将 最佳匹配 字段的评分作为查询的整体评分,结果会怎样?这样返回的结果可能是: 同时 包含 brown 和 fox 的单个字段比反复出现相同词语的多个不同字段有更高的相关度。

dis_max 查询

不使用 bool 查询,可以使用 dis_max 即分离 最大化查询(Disjunction Max Query) 。分离(Disjunction)的意思是 或(or) ,这与可以把结合(conjunction)理解成 与(and) 相对应。分离最大化查询(Disjunction Max Query)指的是: 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回 :

bash 复制代码
GET my_index/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Brown fox" }},
                { "match": { "body":  "Brown fox" }}
            ]
        }
    }
}

得到我们想要的结果为:

bash 复制代码
"hits": [
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 0.77041256,
        "_source": {
          "title": "Keeping pets healthy",
          "body": "My quick brown fox eats rabbits on a regular basis."
        }
      },
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 0.6931471,
        "_source": {
          "title": "Quick brown rabbits",
          "body": "Brown rabbits are commonly seen."
        }
      }
    ]

最佳字段查询调优

当用户搜索 "quick pets" 时会发生什么呢?在前面的例子中,两个文档都包含词 quick ,但是只有文档 2 包含词 pets ,两个文档中都不具有同时包含 两个词 的 相同字段 。

如下,一个简单的 dis_max 查询会采用单个最佳匹配字段,而忽略其他的匹配:

bash 复制代码
GET my_index/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Quick pets" }},
                { "match": { "body":  "Quick pets" }}
            ]
        }
    }
}

注意两个评分是完全相同的。

bash 复制代码
"hits": [
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 0.6931471,
        "_source": {
          "title": "Quick brown rabbits",
          "body": "Brown rabbits are commonly seen."
        }
      },
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 0.6931471,
        "_source": {
          "title": "Keeping pets healthy",
          "body": "My quick brown fox eats rabbits on a regular basis."
        }
      }
    ]

我们可能期望同时匹配 title 和 body 字段的文档比只与一个字段匹配的文档的相关度更高,但事实并非如此,因为 dis_max 查询只会简单地使用 单个 最佳匹配语句的评分 _score 作为整体评分。

tie_breaker 参数

可以通过指定 tie_breaker 这个参数将其他匹配语句的评分也考虑其中:

bash 复制代码
GET my_index/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "Quick pets" }},
                { "match": { "body":  "Quick pets" }}
            ],
            "tie_breaker": 0.3
        }
    }
}

结果如下:

bash 复制代码
"hits": [
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 0.876138,
        "_source": {
          "title": "Keeping pets healthy",
          "body": "My quick brown fox eats rabbits on a regular basis."
        }
      },
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 0.6931471,
        "_source": {
          "title": "Quick brown rabbits",
          "body": "Brown rabbits are commonly seen."
        }
      }
    ]

文档 2 的相关度比文档 1 略高。

tie_breaker 参数提供了一种 dis_max 和 bool 之间的折中选择,它的评分方式如下:

  1. 获得最佳匹配语句的评分 _score 。
  2. 将其他匹配语句的评分结果与 tie_breaker 相乘。
  3. 对以上评分求和并规范化。

有了 tie_breaker ,会考虑所有匹配语句,但最佳匹配语句依然占最终结果里的很大一部分。

tie_breaker 可以是 0 到 1 之间的浮点数,其中 0 代表使用 dis_max 最佳匹配语句的普通逻辑, 1 表示所有匹配语句同等重要。最佳的精确值需要根据数据与查询调试得出,但是合理值应该与零接近(处于 0.1 - 0.4 之间),这样就不会颠覆 dis_max 最佳匹配性质的根本。

multi_match 查询

multi_match 查询为能在多个字段上反复执行相同查询提供了一种便捷方式。

multi_match 多匹配查询的类型有多种,其中的三种恰巧与 了解我们的数据 中介绍的三个场景对应,即: best_fields 、 most_fields 和 cross_fields (最佳字段、多数字段、跨字段)。

默认情况下,查询的类型是 best_fields ,这表示它会为每个字段生成一个 match 查询,然后将它们组合到 dis_max 查询的内部,如下:

bash 复制代码
GET my_index/_search
{
  "query": {
    "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 重写成更简洁的形式:

bash 复制代码
GET my_index/_search
{
  "query": {
    "multi_match": {
      "query": "Quick brown fox",
      "fields": [
        "title",
        "body"
      ],
      "tie_breaker": 0.3,
      "minimum_should_match": "30%"
    }
  }
}
  • 如 minimum_should_match 或 operator 这样的参数会被传递到生成的 match 查询中。

查询字段名称的模糊匹配

字段名称可以用模糊匹配的方式给出:任何与模糊模式正则匹配的字段都会被包括在搜索条件中,例如可以使用以下方式同时匹配 book_title 、 chapter_title 和 section_title (书名、章名、节名)这三个字段:

bash 复制代码
GET my_index/_search
{
  "query": {
    "multi_match": {
      "query": "Quick brown fox",
      "fields": "*_title"
    }
  }
}

提升单个字段的权重

bash 复制代码
GET my_index/_search
{
  "query": {
    "multi_match": {
      "query": "Quick brown fox",
      "fields": [
        "*_title",
        "chapter_title^2"
      ]
    }
  }
}
  • chapter_title 这个字段的 boost 值为 2 ,而其他两个字段 book_title 和 section_title 字段的默认 boost 值为 1 。

多数字段

全文搜索被称作是 召回率(Recall) 与 精确率(Precision) 的战场: 召回率 ------返回所有的相关文档; 精确率 ------不返回无关文档。目的是在结果的第一页中为用户呈现最为相关的文档。

为了提高召回率的效果,我们扩大搜索范围------不仅返回与用户搜索词精确匹配的文档,还会返回我们认为与查询相关的所有文档。如果一个用户搜索 "quick brown box" ,一个包含词语 fast foxes 的文档被认为是非常合理的返回结果。

如果包含词语 fast foxes 的文档是能找到的唯一相关文档,那么它会出现在结果列表的最上面,但是,如果有 100 个文档都出现了词语 quick brown fox ,那么这个包含词语 fast foxes 的文档当然会被认为是次相关的,它可能处于返回结果列表更下面的某个地方。当包含了很多潜在匹配之后,我们需要将最匹配的几个置于结果列表的顶部。

提高全文相关性精度的常用方式是为同一文本建立多种方式的索引,每种方式都提供了一个不同的相关度信号 signal 。主字段会以尽可能多的形式的去匹配尽可能多的文档。举个例子,我们可以进行以下操作:

  • 使用词干提取来索引 jumps 、 jumping 和 jumped 样的词,将 jump 作为它们的词根形式。这样即使用户搜索 jumped ,也还是能找到包含 jumping 的匹配的文档。
  • 将同义词包括其中,如 jump 、 leap 和 hop 。
  • 移除变音或口音词:如 ésta 、 está 和 esta 都会以无变音形式 esta 来索引。

尽管如此,如果我们有两个文档,其中一个包含词 jumped ,另一个包含词 jumping ,用户很可能期望前者能排的更高,因为它正好与输入的搜索条件一致。

为了达到目的,我们可以将相同的文本索引到其他字段从而提供更为精确的匹配。一个字段可能是为词干未提取过的版本,另一个字段可能是变音过的原始词,第三个可能使用 shingles 提供 词语相似性 信息。这些附加的字段可以看成提高每个文档的相关度评分的信号 signals ,能匹配字段的越多越好。

一个文档如果与广度匹配的主字段相匹配,那么它会出现在结果列表中。如果文档同时又与 signal 信号字段匹配,那么它会获得额外加分,系统会提升它在结果列表中的位置。

我们会在本书稍后对同义词、词相似性、部分匹配以及其他潜在的信号进行讨论,但这里只使用词干已提取(stemmed)和未提取(unstemmed)的字段作为简单例子来说明这种技术。

多字段映射

首先要做的事情就是对我们的字段索引两次:一次使用词干模式以及一次非词干模式。为了做到这点,采用 multifields 来实现,已经在 multifields 有所介绍:

bash 复制代码
DELETE my_index

PUT /my_index
{
  "settings": {
    "number_of_shards": 1
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "english",
        "fields": {
          "std": {
            "type": "text",
            "analyzer": "standard"
          }
        }
      }
    }
  }
}
  • title 字段使用 english 英语分析器来提取词干。
  • title.std 字段使用 standard 标准分析器,所以没有词干提取。

接着索引一些文档:

bash 复制代码
PUT /my_index/_doc/1
{ "title": "My rabbit jumps" }

PUT /my_index/_doc/2
{ "title": "Jumping jack rabbits" }

这里用一个简单 match 查询 title 标题字段是否包含 jumping rabbits (跳跃的兔子):

bash 复制代码
GET /my_index/_search
{
   "query": {
        "match": {
            "title": "jumping rabbits"
        }
    }
}

因为有了 english 分析器,这个查询是在查找以 jump 和 rabbit 这两个被提取词的文档。两个文档的 title 字段都同时包括这两个词,所以两个文档得到的评分也相同:

bash 复制代码
"hits": [
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 0.36464313,
        "_source": {
          "title": "My rabbit jumps"
        }
      },
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 0.36464313,
        "_source": {
          "title": "Jumping jack rabbits"
        }
      }
    ]

如果只是查询 title.std 字段,那么只有文档 2 是匹配的。尽管如此,如果同时查询两个字段,然后使用 bool 查询将评分结果 合并 ,那么两个文档都是匹配的( title 字段的作用),而且文档 2 的相关度评分更高( title.std 字段的作用):

bash 复制代码
GET /my_index/_search
{
   "query": {
        "multi_match": {
            "query":  "jumping rabbits",
            "type":   "most_fields", 
            "fields": [ "title", "title.std" ]
        }
    }
}

我们希望将所有匹配字段的评分合并起来,所以使用 most_fields 类型。这让 multi_match 查询用 bool 查询将两个字段语句包在里面,而不是使用 dis_max 查询。

bash 复制代码
"hits": [
      {
        "_index": "my_index",
        "_id": "2",
        "_score": 1.3862942,
        "_source": {
          "title": "Jumping jack rabbits"
        }
      },
      {
        "_index": "my_index",
        "_id": "1",
        "_score": 0.36464313,
        "_source": {
          "title": "My rabbit jumps"
        }
      }
    ]

文档 2 现在的评分要比文档 1 高。

用广度匹配字段 title 包括尽可能多的文档------以提升召回率------同时又使用字段 title.std 作为 信号 将相关度更高的文档置于结果顶部。

每个字段对于最终评分的贡献可以通过自定义值 boost 来控制。比如,使 title 字段更为重要,这样同时也降低了其他信号字段的作用:

bash 复制代码
GET /my_index/_search
{
   "query": {
        "multi_match": {
            "query":       "jumping rabbits",
            "type":        "most_fields",
            "fields":      [ "title^10", "title.std" ] 
        }
    }
}

title 字段的 boost 的值为 10 使它比 title.std 更重要。

跨字段实体搜索

现在讨论一种普遍的搜索模式:跨字段实体搜索(cross-fields entity search)。在如 person 、 product 或 address (人、产品或地址)这样的实体中,需要使用多个字段来唯一标识它的信息。 person 实体可能是这样索引的:

bash 复制代码
{
    "firstname":  "Peter",
    "lastname":   "Smith"
}

或地址:

bash 复制代码
{
    "street":   "5 Poland Street",
    "city":     "London",
    "country":  "United Kingdom",
    "postcode": "W1V 3DG"
}

这与之前描述的 多字符串查询 很像,但这存在着巨大的区别。在 多字符串查询 中,我们为每个字段使用不同的字符串,在本例中,我们想使用 单个 字符串在多个字段中进行搜索。

我们的用户可能想搜索 "Peter Smith" 这个人,或 "Poland Street W1V" 这个地址,这些词出现在不同的字段中,所以如果使用 dis_max 或 best_fields 查询去查找 单个 最佳匹配字段显然是个错误的方式。

简单的方式

依次查询每个字段并将每个字段的匹配评分结果相加,听起来真像是 bool 查询:

bash 复制代码
{
  "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 查询,将 type 设置成 most_fields 然后告诉 Elasticsearch 合并所有匹配字段的评分:

bash 复制代码
{
  "query": {
    "multi_match": {
      "query":       "Poland Street W1V",
      "type":        "most_fields",
      "fields":      [ "street", "city", "country", "postcode" ]
    }
  }
}

most_fields 方式的问题

用 most_fields 这种方式搜索也存在某些问题,这些问题并不会马上显现:

它是为多数字段匹配 任意 词设计的,而不是在 所有字段 中找到最匹配的。

它不能使用 operator 或 minimum_should_match 参数来降低次相关结果造成的长尾效应。

词频对于每个字段是不一样的,而且它们之间的相互影响会导致不好的排序结果。

字段中心式查询

以上三个源于 most_fields 的问题都因为它是 字段中心式(field-centric) 而不是 词中心式(term-centric) 的:当真正感兴趣的是匹配词的时候,它为我们查找的是最匹配的 字段 。

best_fields 类型也是字段中心式的,它也存在类似的问题。

首先查看这些问题存在的原因,再想如何解决它们。

问题 1 :在多个字段中匹配相同的词

回想一下 most_fields 查询是如何执行的:Elasticsearch 为每个字段生成独立的 match 查询,再用 bool 查询将他们包起来。

可以通过 validate-query API 查看:

bash 复制代码
GET /_validate/query?explain
{
  "query": {
    "multi_match": {
      "query":   "Poland Street W1V",
      "type":    "most_fields",
      "fields":  [ "street", "city", "country", "postcode" ]
    }
  }
}

生成 explanation 解释:

bash 复制代码
(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 :剪掉长尾

在 匹配精度 中,我们讨论过使用 and 操作符或设置 minimum_should_match 参数来消除结果中几乎不相关的长尾,或许可以尝试以下方式:

bash 复制代码
{
    "query": {
        "multi_match": {
            "query":       "Poland Street W1V",
            "type":        "most_fields",
            "operator":    "and", 
            "fields":      [ "street", "city", "country", "postcode" ]
        }
    }
}

但是对于 best_fields 或 most_fields 这些参数会在 match 查询生成时被传入,这个查询的 explanation 解释如下:

bash 复制代码
(+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 :词频

在 什么是相关 中,我们解释过每个词默认使用 TF/IDF 相似度算法计算相关度评分:

词频

一个词在单个文档的某个字段中出现的频率越高,这个文档的相关度就越高。
逆向文档频率

一个词在所有文档某个字段索引中出现的频率越高,这个词的相关度就越低。

当搜索多个字段时,TF/IDF 会带来某些令人意外的结果。

想想用字段 first_name 和 last_name 查询 "Peter Smith" 的例子, Peter 是个平常的名 Smith 也是平常的姓,这两者都具有较低的 IDF 值。但当索引中有另外一个人的名字是 "Smith Williams" 时, Smith 作为名来说很不平常,以致它有一个较高的 IDF 值!

下面这个简单的查询可能会在结果中将 "Smith Williams" 置于 "Peter Smith" 之上,尽管事实上是第二个人比第一个人更为匹配。

bash 复制代码
{
    "query": {
        "multi_match": {
            "query":       "Peter Smith",
            "type":        "most_fields",
            "fields":      [ "*_name" ]
        }
    }
}

这里的问题是 smith 在名字段中具有高 IDF ,它会削弱 "Peter" 作为名和 "Smith" 作为姓时低 IDF 的所起作用。

解决方案

存在这些问题仅仅是因为我们在处理着多个字段,如果将所有这些字段组合成单个字段,问题就会消失。可以为 person 文档添加 full_name 字段来解决这个问题:

bash 复制代码
{
    "first_name":  "Peter",
    "last_name":   "Smith",
    "full_name":   "Peter Smith"
}

当查询 full_name 字段时:

  • 具有更多匹配词的文档会比只有一个重复匹配词的文档更重要。
  • minimum_should_match 和 operator 参数会像期望那样工作。
  • 姓和名的逆向文档频率被合并,所以 Smith 到底是作为姓还是作为名出现,都会变得无关紧要。

这么做当然是可行的,但我们并不太喜欢存储冗余数据。取而代之的是 Elasticsearch 可以提供两个解决方案------一个在索引时,而另一个是在搜索时------随后会讨论它们。

自定义 _all 字段

在 all-field 字段中,我们解释过 _all 字段的索引方式是将所有其他字段的值作为一个大字符串索引的。然而这么做并不十分灵活,为了灵活我们可以给人名添加一个自定义 _all 字段,再为地址添加另一个 _all 字段。

Elasticsearch 在字段映射中为我们提供 copy_to 参数来实现这个功能:

bash 复制代码
PUT /my_index
{
  "mappings": {
    "properties": {
      "first_name": {
        "type": "text",
        "copy_to": "full_name"
      },
      "last_name": {
        "type": "text",
        "copy_to": "full_name"
      },
      "full_name": {
        "type": "text"
      }
    }
  }
}

first_name 和 last_name 字段中的值会被复制到 full_name 字段。

有了这个映射,我们可以用 first_name 来查询名,用 last_name 来查询姓,或者直接使用 full_name 查询整个姓名。

first_name 和 last_name 的映射并不影响 full_name 如何被索引, full_name 将两个字段的内容复制到本地,然后根据 full_name 的映射自行索引。

cross-fields 跨字段查询

自定义 _all 的方式是一个好的解决方案,只需在索引文档前为其设置好映射。不过, Elasticsearch 还在搜索时提供了相应的解决方案:使用 cross_fields 类型进行 multi_match 查询。 cross_fields 使用词中心式(term-centric)的查询方式,这与 best_fields 和 most_fields 使用字段中心式(field-centric)的查询方式非常不同,它将所有字段当成一个大字段,并在 每个字段 中查找 每个词 。

为了说明字段中心式(field-centric)与词中心式(term-centric)这两种查询方式的不同,先看看以下字段中心式的 most_fields 查询的 explanation 解释:

bash 复制代码
GET /_validate/query?explain
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "most_fields",
            "operator":    "and", 
            "fields":      [ "first_name", "last_name" ]
        }
    }
}

对于匹配的文档, peter 和 smith 都必须同时出现在相同字段中,要么是 first_name 字段,要么 last_name 字段:

bash 复制代码
(+first_name:peter +first_name:smith)
(+last_name:peter  +last_name:smith)

词中心式 会使用以下逻辑:

bash 复制代码
+(first_name:peter last_name:peter)
+(first_name:smith last_name:smith)

换句话说,词 peter 和 smith 都必须出现,但是可以出现在任意字段中。

cross_fields 类型首先分析查询字符串并生成一个词列表,然后它从所有字段中依次搜索每个词。这种不同的搜索方式很自然的解决了 字段中心式 查询三个问题中的二个。剩下的问题是逆向文档频率不同。

bash 复制代码
GET /_validate/query?explain
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "cross_fields", 
            "operator":    "and",
            "fields":      [ "first_name", "last_name" ]
        }
    }
}

用 cross_fields 词中心式匹配。

它通过 混合 不同字段逆向索引文档频率的方式解决了词频的问题:

bash 复制代码
+blended("peter", fields: [first_name, last_name])
+blended("smith", fields: [first_name, last_name])

换句话说,它会同时在 first_name 和 last_name 两个字段中查找 smith 的 IDF ,然后用两者的最小值作为两个字段的 IDF 。结果实际上就是 smith 会被认为既是个平常的姓,也是平常的名。

按字段提高权重

采用 cross_fields 查询与 自定义 _all 字段 相比,其中一个优势就是它可以在搜索时为单个字段提升权重。

这对像 first_name 和 last_name 具有相同值的字段并不是必须的,但如果要用 title 和 description 字段搜索图书,可能希望为 title 分配更多的权重,这同样可以使用前面介绍过的 ^ 符号语法来实现:

bash 复制代码
GET /books/_search
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "cross_fields",
            "fields":      [ "title^2", "description" ] 
        }
    }
}

title 字段的权重提升值为 2 , description 字段的权重提升值默认为 1 。

自定义单字段查询是否能够优于多字段查询,取决于在多字段查询与单字段自定义 _all 之间代价的权衡,即哪种解决方案会带来更大的性能优化就选择哪一种。

Exact-Value 精确值字段

在结束多字段查询这个话题之前,我们最后要讨论的是精确值 keyword 未分析字段。将 keyword 字段与 multi_match 中 analyzed 字段混在一起没有多大用处。

原因可以通过查看查询的 explanation 解释得到,设想将 title 字段设置成 keyword:

bash 复制代码
GET /_validate/query?explain
{
    "query": {
        "multi_match": {
            "query":       "peter smith",
            "type":        "cross_fields",
            "fields":      [ "title", "first_name", "last_name" ]
        }
    }
}

因为 title 字段是未分析过的,Elasticsearch 会将 "peter smith" 这个完整的字符串作为查询条件来搜索!

bash 复制代码
title:peter smith
(
    blended("peter", fields: [first_name, last_name])
    blended("smith", fields: [first_name, last_name])
)

显然这个项不在 title 的倒排索引中,所以需要在 multi_match 查询中避免使用 keyword 字段。

相关推荐
PP东19 分钟前
ES学习class类用法(十一)
javascript·学习
努力的布布42 分钟前
Elasticsearch-模糊查询
大数据·elasticsearch·搜索引擎
百流3 小时前
scala基础学习_运算符
开发语言·学习·scala
m0_748237053 小时前
Monorepo pnpm 模式管理多个 web 项目
大数据·前端·elasticsearch
百流3 小时前
scala基础学习(数据类型)-数组
开发语言·学习·scala
虾球xz3 小时前
游戏引擎学习第61天
java·学习·游戏引擎
三万棵雪松4 小时前
3.系统学习-熵与决策树
学习·算法·决策树
无涯学徒19984 小时前
J9学习打卡笔记
笔记·学习