Recipes(诀窍)

Mixing exact search with stemming(用 stemming(词干)进行精确搜索)

在构建搜索应用程序时,stemming (词干)经常是必须有的,因为它描述了针对 skiing 上的查询,以匹配包含 ski 或 skis 的文档。但是用户如果想要指定 skiing 来搜索呢?执行此操作的典型方式是使用 multi-field,以便以两种不同的方式对相同的内容进行索引。

PUT index
{
  "settings": {
    "analysis": {
      "analyzer": {
        "english_exact": {
          "tokenizer": "standard",
          "filter": [
            "lowercase"
          ]
        }
      }
    }
  },
  "mappings": {
    "type": {
      "properties": {
        "body": {
          "type": "text",
          "analyzer": "english",
          "fields": {
            "exact": {
              "type": "text",
              "analyzer": "english_exact"
            }
          }
        }
      }
    }
  }
}

PUT index/type/1
{
  "body": "Ski resort"
}

PUT index/type/2
{
  "body": "A pair of skis"
}

POST index/_refresh

按照这样的设置,在 body 中搜索 ski 这两个 documents(文档) 都将会被返回:

GET index/_search
{
  "query": {
    "simple_query_string": {
      "fields": [ "body" ],
      "query": "ski"
    }
  }
}
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 0.25811607,
    "hits": [
      {
        "_index": "index",
        "_type": "type",
        "_id": "2",
        "_score": 0.25811607,
        "_source": {
          "body": "A pair of skis"
        }
      },
      {
        "_index": "index",
        "_type": "type",
        "_id": "1",
        "_score": 0.25811607,
        "_source": {
          "body": "Ski resort"
        }
      }
    ]
  }
}

另一方面,搜索 body.exact 上的 ski 只会返回文档 1,因为 body.exact 的分析链不执行 stemming。

GET index/_search
{
  "query": {
    "simple_query_string": {
      "fields": [ "body.exact" ],
      "query": "ski"
    }
  }
}
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 0.25811607,
    "hits": [
      {
        "_index": "index",
        "_type": "type",
        "_id": "1",
        "_score": 0.25811607,
        "_source": {
          "body": "Ski resort"
        }
      }
    ]
  }
}

这还不是最终暴露给用户的东西,因为我们需要有一个方法来确定是否完全匹配他们正在寻找的东西,并重定向到相应的字段。如果只有部分查询需要匹配,而其他部分仍然应该考虑在内,该怎么办?

幸运的是,query_string 和 simple_query_string 查询具有解决这个问题的功能:quote_field_suffix。 这告诉 Elasticsearch,引号之间出现的单词将被重定向到一个不同的字段,如下所示:

GET index/_search
{
  "query": {
    "simple_query_string": {
      "fields": [ "body" ],
      "quote_field_suffix": ".exact",
      "query": "\"ski\""
    }
  }
}
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 0.25811607,
    "hits": [
      {
        "_index": "index",
        "_type": "type",
        "_id": "1",
        "_score": 0.25811607,
        "_source": {
          "body": "Ski resort"
        }
      }
    ]
  }
}

在上述情况下,由于 ski 是在引号之间,所以在 body.exact 字段上搜索,由于 quote_field_suffix 参数存在,所以只有文档1匹配。这样,用户可以根据需要将精确搜索与搜索结合在一起。

Getting consistent scoring(获得一致的得分)

实际上,Elasticsearch 运行 shards(分片) 和 replicas(副本)的时候会增加压力,同时也会获得一个比较好的得分。

Scores are not reproducible(得分不可重现)

同一个用户连续两次运行相同的请求,documents(文档) 不会在同一个 order 中返回【将会返回两次】,这是一个非常糟糕的体验,不是吗?不幸的是,如果您开启了副本(index.number_of_replicas 大于 0),这些可能会发生。原因是 Elasticsearch 以循环方式选择查询应该进行的 shards(分片),因此如果您连续运行相同的查询两次,那么它将转到相同 shards(分片)的不同 replicas(副本)去查询。

为什么会出现这种情况? index(索引)统计信息是得分的一个重要组成部分。由于已删除的文档,这些index(索引)统计数据可能因同一分片的副本而异。documents(文档)被删除或更新时,旧的 documents(文档)不会立即从索引中删除,它只是被标记为已删除,并且只有在下一次合并该旧 documents(文档)所属段时才会从磁盘中删除 。但是由于实际的原因,这些已删除的 documents(文档)被考虑在 index(索引)统计上。所以假设主 shards(分片)刚刚完成了一个大的合并,删除了大量被标记为已删除的 documents(文档),那么它可能具有与 replicas(副本)(仍然有大量已删除的文档)有很大不同的 index(索引)统计信息,因此分数也不同。

解决此问题推荐的方法是使用标识被记录的用户(例如,用户 ID 或会话 ID)作为首选项的字符串。这样可以确保给定用户的所有查询都将始终打到相同的 shards(分片),因此分数在查询之间保持更加一致。

这样做有另一个好处:当两个 documents(文档)具有相同的分数时,它们将按照 Lucene 内部 doc id(与_id或_uid无关)排序。但是,这些 documents(文档)在同一个 shards(分片)的 replicas(副本)上可能不同。 所以总是碰到同样的 shards(分片),我们会得到更一致的顺序的 documents(文档)并具有相同的分数。

Relevancy looks wrong(相关性错误)

如果您注意到具有相同内容的两个 documents(文档)获得不同的分数,或者完全匹配不排在第一位,则该问题可能与 shards(分片)有关。默认情况下,Elasticsearch 使每个 shards(分片)都生成自己的分数。然而,由于 index(索引)统计数据是分数的重要贡献者,所以如果 shards(分片)具有相似的 index(索引)统计信息,这只能使它运行的更好。假设默认情况下 documents(文档)均匀分配到 shards(分片),所以 index(索引)统计数据应该非常相似,评分将按预期方式工作。但是,如果您在索引时使用 routing(路由), - 查询多个 index(索引),或者索引中的数据太少,在搜索请求中所涉及的所有碎片都没有类似的索引统计信息,相关性可能很差。

如果您有一个小数据集,解决此问题的最简单的方法是将所有内容索引到具有单个 shards(分片)(index.number_of_shards:1)的 index(索引)中。 那么所有文件的 index(索引)统计将是一样的,分数将是一致的。

否则,解决这个问题的推荐方式来是使用 dfs_query_then_fetch 搜索类型。这将使 Elasticsearch 对所有相关的 shards(分片)进行初始化往返,询问它们相对于查询的 index(索引)统计信息,然后协调节点将合并这些统计信息,并在请求 shards(分片)执行查询阶段时发送合并的统计信息, 因此 shards(分片)可以使用这些全局统计数据而不是自己的统计数据来进行评分。

在大多数情况下,这次额外的往返操作资源消耗不大。但是,如果您的查询包含大量的 fileds / terms 或  fuzzy queries(模糊查询),注意,只统计数据的话可能资源消耗会比较高,因为所有 term 都必须在 terms dictionaries 中才能查找统计信息。