食谱

edit

本节包含一些帮助解决常见问题的配方:

混合精确搜索与词干提取

edit

在构建搜索应用程序时,词干提取通常是必须的,因为对于查询skiing,匹配包含skiskis的文档是可取的。但如果用户想要专门搜索skiing呢?典型的做法是使用多字段,以便以两种不同的方式索引相同的内容:

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

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

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

POST index/_refresh

通过这样的设置,在body中搜索ski将返回两个文档:

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

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

GET index/_search
{
  "query": {
    "simple_query_string": {
      "fields": [ "body.exact" ],
      "query": "ski"
    }
  }
}
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped" : 0,
    "failed": 0
  },
  "hits": {
    "total" : {
        "value": 1,
        "relation": "eq"
    },
    "max_score": 0.8025915,
    "hits": [
      {
        "_index": "index",
        "_id": "1",
        "_score": 0.8025915,
        "_source": {
          "body": "Ski resort"
        }
      }
    ]
  }
}

这对于最终用户来说并不是一件容易暴露的事情,因为我们需要有一种方法来判断他们是否在寻找精确匹配,并相应地重定向到适当的字段。此外,如果只有部分查询需要精确匹配,而其他部分仍应考虑词干提取,该怎么办?

幸运的是,query_stringsimple_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": 1,
    "successful": 1,
    "skipped" : 0,
    "failed": 0
  },
  "hits": {
    "total" : {
        "value": 1,
        "relation": "eq"
    },
    "max_score": 0.8025915,
    "hits": [
      {
        "_index": "index",
        "_id": "1",
        "_score": 0.8025915,
        "_source": {
          "body": "Ski resort"
        }
      }
    ]
  }
}

在上面的例子中,由于 ski 位于引号之间,它根据 quote_field_suffix 参数在 body.exact 字段中进行了搜索,因此只有文档 1 匹配。这允许用户根据需要混合精确搜索和词干搜索。

如果传递的 quote_field_suffix 字段不存在,搜索将回退到使用查询字符串的默认字段。

获取一致的评分

edit

Elasticsearch 使用分片和副本进行操作,这给实现良好的评分带来了挑战。

分数不可重现

edit

假设同一个用户连续两次运行相同的请求,但文档返回的顺序并不相同,这种体验相当糟糕,不是吗?不幸的是,如果你有副本(index.number_of_replicas 大于 0),这种情况可能会发生。原因是 Elasticsearch 以轮询方式选择查询应发送到的分片,因此如果你连续两次运行相同的查询,它很可能会发送到同一个分片的不同的副本。

现在为什么这是一个问题?索引统计数据是评分的重要组成部分。 由于删除了文档,这些索引统计数据在同一分片的副本之间可能会有所不同。 正如您可能知道的那样,当文档被删除或更新时,旧文档不会立即从索引中删除, 它只是被标记为已删除,并且只有在下次合并该旧文档所属的段时才会从磁盘中删除。 然而,出于实际原因,这些已删除的文档会被计入索引统计数据。所以想象一下, 主分片刚刚完成了一个删除了大量已删除文档的大合并,那么它的索引统计数据可能与副本(仍然有很多已删除文档)有足够的差异,以至于评分也不同。

解决此问题的推荐方法是使用一个字符串来标识登录的用户(例如用户ID或会话ID)作为偏好。这确保了给定用户的所有查询总是命中相同的分片,从而使查询的分数在不同查询之间保持更加一致。

这个解决方案还有一个好处:当两个文档的得分相同时,它们将默认按照其内部Lucene文档ID(这与_id无关)进行排序。然而,这些文档ID在同一分片的副本之间可能会有所不同。因此,通过始终访问同一个分片,我们可以获得得分相同的文档的更一致的排序。

相关性看起来有问题

edit

如果你注意到两个内容相同的文档获得了不同的分数,或者一个完全匹配的文档没有排在第一位,那么问题可能与分片有关。默认情况下,Elasticsearch让每个分片负责生成自己的分数。然而,由于索引统计数据是分数的重要贡献者,只有当分片具有相似的索引统计数据时,这种方法才能很好地工作。假设由于文档默认均匀地路由到分片,索引统计数据应该非常相似,评分也会按预期工作。然而,如果你遇到以下情况之一:

  • 在索引时使用路由,
  • 查询多个索引
  • 或者在索引中数据太少

那么很有可能所有参与搜索请求的分片都没有相似的索引统计信息,相关性可能会很差。

如果你有一个小数据集,解决这个问题的最简单方法是 将所有内容索引到一个只有一个分片的索引中 (index.number_of_shards: 1),这是默认设置。然后索引统计信息 将对所有文档相同,并且分数将保持一致。

否则,解决此问题的推荐方法是使用 dfs_query_then_fetch 搜索类型。这将使 Elasticsearch 执行一个初始的往返,向所有涉及的分片请求它们相对于查询的索引统计信息,然后协调节点将合并这些统计信息,并在请求分片执行 query 阶段时,将合并的统计信息与请求一起发送,以便分片可以使用这些全局统计信息而不是它们自己的统计信息来进行评分。

在大多数情况下,这个额外的往返应该是非常廉价的。然而,如果您的查询包含非常大量的字段/术语或模糊查询,请注意仅收集统计信息可能并不廉价,因为所有术语都必须在术语字典中查找以查找统计信息。

将静态相关性信号纳入评分

edit

许多领域都有已知与相关性相关的静态信号。 例如,PageRank 和 URL 长度是 两个常用于网络搜索的特征,以便独立于查询调整网页的分数。

有两种主要的查询方式可以将静态分数贡献与文本相关性结合起来,例如使用BM25计算的相关性: - script_score 查询 - rank_feature 查询

例如,假设你有一个pagerank字段,你希望将其与BM25分数结合起来,使得最终分数等于score = bm25_score + pagerank / (10 + pagerank)

使用script_score查询,查询看起来像这样:

GET index/_search
{
  "query": {
    "script_score": {
      "query": {
        "match": { "body": "elasticsearch" }
      },
      "script": {
        "source": "_score * saturation(doc['pagerank'].value, 10)" 
      }
    }
  }
}

pagerank 必须映射为 Numeric

而使用rank_feature查询时,它看起来如下所示:

GET _search
{
  "query": {
    "bool": {
      "must": {
        "match": { "body": "elasticsearch" }
      },
      "should": {
        "rank_feature": {
          "field": "pagerank", 
          "saturation": {
            "pivot": 10
          }
        }
      }
    }
  }
}

pagerank 必须映射为 rank_feature 字段

虽然这两种选项都会返回类似的分数,但它们各有权衡: script_score 提供了很大的灵活性, 使您能够按照自己的喜好将文本相关性分数与静态信号结合起来。另一方面,rank_feature 查询 只提供了几种将静态信号纳入分数的方法。然而, 它依赖于 rank_featurerank_features 字段,这些字段以特殊方式索引值, 使得 rank_feature 查询 能够跳过非竞争性文档,并更快地获取查询的前几个匹配项。