ES搜索(三)-- 排序和分布式搜索

Published: 29 Oct 2019 Category: ES

一、排序

默认情况下,结果集会按照相关性进行排序–相关性越高,排名越靠前。

1.1 排序方式

为了使结果可以按照相关性进行排序,我们需要一个相关性的值。在ElasticSearch的查询结果中,相关性分值会用_score字段来给出一个浮点型的数值,所以默认情况下,结果集以_score进行倒序排列。

过滤语句与_score没有关系,但是有隐含的查询条件match_all为所有的文档的_score设值为1。也就相当于所有的文档相关性是相同的。

1.2 字段值排序

下面例子中,对结果集按照时间排序,这也是最常见的情形,将最新的文档排列靠前。我们使用sort参数进行排序:

GET /_search
{
    "query" : {
        "filtered" : {
            "filter" : { "term" : { "user_id" : 1 }}
        }
    },
    "sort": { "date": { "order": "desc" }}
}

你会发现这里有两个不同点:

"hits" : {
    "total" :           6,
    "max_score" :       null, <1>
    "hits" : [ {
        "_index" :      "us",
        "_type" :       "tweet",
        "_id" :         "14",
        "_score" :      null, <1>
        "_source" :     {
             "date":    "2014-09-24",
             ...
        },
        "sort" :        [ 1411516800000 ] <2>
    },
    ...
} <1> \_score字段没有经过计算,因为它没有用作排序。 <2> date字段被转为毫秒当作排序依据。

首先,在每个结果中增加了一个sort字段,它所包含的值是用来排序的。在这个例子当中date字段在内部被转为毫秒,即长整型数字1411516800000等同于日期字符串2014-09-24 00:00:00 UTC。

其次就是_score和max_score字段都为null。计算_score是比较消耗性能的,而且通常主要用作排序–我们不是用相关性进行排序的时候,就不需要统计其相关性。如果你想强制计算其相关性,可以设置track_scores为true。

1.3 多级排序

如果我们想要合并一个查询语句,并且展示所有匹配的结果集使用第一排序是date,第二排序是_score:

GET /_search
{
    "query" : {
        "filtered" : {
            "query":   { "match": { "tweet": "manage text search" }},
            "filter" : { "term" : { "user_id" : 2 }}
        }
    },
    "sort": [
        { "date":   { "order": "desc" }},
        { "_score": { "order": "desc" }}
    ]
}

排序是很重要的。结果集会先用第一排序字段来排序,当用用作第一字段排序的值相同的时候, 然后再用第二字段对第一排序值相同的文档进行排序,以此类推。

多级排序不需要包含_score–你可以使用几个不同的字段,如位置距离或者自定义数值。

字符查询也支持自定义排序,在查询字符串使用sort参数就可以:

GET /_search?sort=date:desc&sort=_score&q=search

对于数字和日期,你可以从多个值中取出一个来进行排序,你可以使用min, max, avg或sum这些模式。比说你可以在dates字段中用最早的日期来进行排序:

"sort": {
    "dates": {
        "order": "asc",
        "mode":  "min"
    }
}

1.4 相关性简介

评分的计算方式取决于不同的查询类型 – 不同的查询语句用于不同的目的:fuzzy 查询会计算与关键词的拼写相似程度,terms查询会计算 找到的内容与关键词组成部分匹配的百分比,但是一般意义上我们说的全文本搜索是指计算内容与关键词的类似程度。

ElasticSearch的相似度算法被定义为 TF/IDF,即检索词频率/反向文档频率,包括一下内容:

  • 检索词频率::检索词在该字段出现的频率?出现频率越高,相关性也越高。 字段中出现过5次要比只出现过1次的相关性高。
  • 反向文档频率::每个检索词在索引中出现的频率?频率越高,相关性越低。 检索词出现在多数文档中会比出现在少数文档中的权重更低, 即检验一个检索词在文档中的普遍重要性。
  • 字段长度准则::字段的长度是多少?长度越长,相关性越低。 检索词出现在一个短的 title 要比同样的词出现在一个长的 content 字段有更高的权重。

单个查询可以使用TF/IDF评分标准或其他方式,比如短语查询中检索词的距离或模糊查询里的检索词相似度。

相关性并不只是全文本检索的专利。也适用于yes no的子句,匹配的子句越多,相关性评分越高。

二、分布式搜索

由于不知道哪个文档会匹配查询(文档可能存放在集群中的任意分片上),所以搜索需要一个更复杂的模型。

搜索的执行过程分两个阶段,称为查询然后取回(query then fetch)。

2.1 查询阶段

向索引中的每个分片广播查询,每个分片在本地执行搜索并建立匹配document的优先队列。

举个例子:

GET /_search
{
    "from": 90,
    "size": 10
}

1.客户端发送一个search(搜索)请求给Node,Node创建了一个长度为from+size的空优先级队列。

2.Node转发这个搜索请求到索引中每个分片的原本或副本。每个分片在本地执行这个查询并且结果将结果到一个大小为from+size的有序本地优先队列里去。

3.每个分片返回document的ID和它优先队列里的所有document的排序值给协调节点Node。Node把这些值合并到自己的优先队列里产生全局排序结果。

当一个搜索请求被发送到一个节点Node,这个节点就变成了协调节点。这个节点的工作是向所有相关的分片广播搜索请求并且把它们的响应整合成一个全局的有序结果集。这个结果集会被返回给客户端。

第一步是向索引里的每个节点的分片副本广播请求。就像document的GET请求一样,搜索请求可以被每个分片的原本或任意副本处理。这就是更多的副本(当结合更多的硬件时)如何提高搜索的吞吐量的方法。对于后续请求,协调节点会轮询所有的分片副本以分摊负载。

每一个分片在本地执行查询和建立一个长度为from+size的有序优先队列——这个长度意味着它自己的结果数量就足够满足全局的请求要求。分片返回一个轻量级的结果列表给协调节点。只包含documentID值和排序需要用到的值,例如_score。

协调节点将这些分片级的结果合并到自己的有序优先队列里。这个就代表了最终的全局有序结果集。到这里,查询阶段结束。

整个过程类似于归并排序算法,先分组排序再归并到一起,对于这种分布式场景非常适用。

2.2 取回阶段

查询阶段辨别出那些满足搜索请求的document,但我们仍然需要取回那些document本身。

分发阶段由以下步骤构成:

1.协调节点辨别出哪个document需要取回,并且向相关分片发出GET请求。
2.每个分片加载document并且根据需要丰富(enrich)它们(如高亮),然后再将document返回协调节点。
3.一旦所有的document都被取回,协调节点会将结果返回给客户端。

协调节点先决定哪些document是实际(actually)需要取回的。例如,我们指定查询{ “from”: 90, “size”: 10 },那么前90条将会被丢弃,只有之后的10条会需要取回。这些document可能来自与原始查询请求相关的某个、某些或者全部分片。 协调节点为每个持有相关document的分片建立多点get请求然后发送请求到处理查询阶段的分片副本。

分片加载document主体——_source field。如果需要,还会根据元数据丰富结果和高亮搜索片断。一旦协调节点收到所有结果,会将它们汇集到单一的回答响应里,这个响应将会返回给客户端。

2.3 深分页

查询然后取回过程虽然支持通过使用from和size参数进行分页,但是要在有限范围内(within limited)。还记得每个分片必须构造一个长度为from+size的优先队列吧,所有这些都要传回协调节点。这意味着协调节点要通过对分片数量 * (from + size)个document进行排序来找到正确的size个document。

根据document的数量,分片的数量以及所使用的硬件,对10,000到50,000条结果(1,000到5,000页)深分页是可行的。但是对于足够大的from值,排序过程将会变得非常繁重,会使用巨大量的CPU,内存和带宽。因此,强烈不建议使用深分页。

在实际中,“深分页者”也是很少的一部人。一般人会在翻了两三页后就停止翻页,并会更改搜索标准。那些不正常情况通常是机器人或者网络爬虫的行为。它们会持续不断地一页接着一页地获取页面直到服务器到崩溃的边缘。

如果你确实需要从集群里获取大量documents,你可以通过设置搜索类型scan禁用排序,来高效地做这件事。这一点将在后面的章节讨论。

2.4 结果震荡问题(the bouncing results problem)

想像一下,你正在按照timestamp字段来对你的结果排序,并且有两个document有相同的timestamp。由于搜索请求是在所有有效的分片副本间轮询的,这两个document可能在原始分片里是一种顺序,在副本分片里是另一种顺序。

这就是被称为结果震荡(bouncing results)的问题:用户每次刷新页面,结果顺序会发生变化。避免这个问题方法是对于同一个用户总是使用同一个分片。方法就是使用一个随机字符串(例如用户的会话ID(session ID))来设置preference参数。

preference参数

preference参数允许你控制使用哪个分片或节点来处理搜索请求。它接受如下一些参数 _primary, _primary_first, _local, _only_node:xyz, _prefer_node:xyz和_shards:2,3。

2.5 一些参数(timeout/search_type)

timeout

通常,协调节点会等待接收所有分片的回答。如果有一个节点遇到问题,它会拖慢整个搜索请求。

timeout参数告诉协调节点最多等待多久,就可以放弃等待而将已有结果返回。返回部分结果总比什么都没有好。

搜索请求的返回将会指出这个搜索是否超时,以及有多少分片成功答复了:

...
"timed_out":     true,  (1)
"_shards": {
   "total":      5,
   "successful": 4,
   "failed":     1     (2)
},
... (1) 搜索请求超时。

(2) 五个分片中有一个没在超时时间内答复。

如果一个分片的所有副本都因为其他原因失败了——也许是因为硬件故障——这个也同样会反映在该答复的_shards部分里。

search_type(搜索类型)

虽然query_then_fetch是默认的搜索类型,但也可以根据特定目的指定其它的搜索类型,例如:

GET /_search?search_type=count

count(计数)

count(计数)搜索类型只有一个query(查询)的阶段。当不需要搜索结果只需要知道满足查询的document的数量时,可以使用这个查询类型。

query_and_fetch(查询并且取回)

query_and_fetch(查询并且取回)搜索类型将查询和取回阶段合并成一个步骤。这是一个内部优化选项,当搜索请求的目标只是一个分片时可以使用,例如指定了routing(路由选择)值时。虽然你可以手动选择使用这个搜索类型,但是这么做基本上不会有什么效果。

dfs_query_then_fetch 和 dfs_query_and_fetch

dfs搜索类型有一个预查询的阶段,它会从全部相关的分片里取回项目频数来计算全局的项目频数。我们将在relevance-is-broken(相关性被破坏)里进一步讨论这个。

scan(扫描)

scan(扫描)搜索类型是和scroll(滚屏)API连在一起使用的,可以高效地取回巨大数量的结果。它是通过禁用排序来实现的。我们将在下一节scan-and-scroll(扫描和滚屏)里讨论它。

2.6 扫描和滚屏

scan(扫描)搜索类型是和scroll(滚屏)API一起使用来从Elasticsearch里高效地取回巨大数量的结果而不需要付出深分页的代价。

scroll(滚屏)

一个滚屏搜索允许我们做一个初始阶段搜索并且持续批量从Elasticsearch里拉取结果直到没有结果剩下。这有点像传统数据库里的cursors(游标)。

滚屏搜索会及时制作快照。这个快照不会包含任何在初始阶段搜索请求后对index做的修改。它通过将旧的数据文件保存在手边,所以可以保护index的样子看起来像搜索开始时的样子。

scan(扫描)

深度分页代价最高的部分是对结果的全局排序,但如果禁用排序,就能以很低的代价获得全部返回结果。为达成这个目的,可以采用scan(扫描)搜索模式。扫描模式让Elasticsearch不排序,只要分片里还有结果可以返回,就返回一批结果。

为了使用scan-and-scroll(扫描和滚屏),需要执行一个搜索请求,将search_type 设置成scan,并且传递一个scroll参数来告诉Elasticsearch滚屏应该持续多长时间。

GET /old_index/_search?search_type=scan&scroll=1m (1)
{
    "query": { "match_all": {}},
    "size":  1000
}

(1)保持滚屏开启1分钟。
这个请求的应答没有包含任何命中的结果,但是包含了一个Base-64编码的_scroll_id(滚屏id)字符串。现在我们可以将_scroll_id 传递给_search/scroll末端来获取第一批结果:

GET /_search/scroll?scroll=1m      (1)
c2Nhbjs1OzExODpRNV9aY1VyUVM4U0NMd2pjWlJ3YWlBOzExOTpRNV9aY1VyUVM4U0 <2>
NMd2pjWlJ3YWlBOzExNjpRNV9aY1VyUVM4U0NMd2pjWlJ3YWlBOzExNzpRNV9aY1Vy
UVM4U0NMd2pjWlJ3YWlBOzEyMDpRNV9aY1VyUVM4U0NMd2pjWlJ3YWlBOzE7dG90YW
xfaGl0czoxOw==

(1) 保持滚屏开启另一分钟。

(2) _scroll_id 可以在body或者URL里传递,也可以被当做查询参数传递。

注意,要再次指定?scroll=1m。滚屏的终止时间会在我们每次执行滚屏请求时刷新,所以他只需要给我们足够的时间来处理当前批次的结果而不是所有的匹配查询的document。

这个滚屏请求的应答包含了第一批次的结果。虽然指定了一个1000的size ,但是获得了更多的document。当扫描时,size被应用到每一个分片上,所以我们在每个批次里最多或获得size * number_of_primary_shards(size*主分片数)个document。

注意:
滚屏请求也会返回一个新的_scroll_id。每次做下一个滚屏请求时,必须传递前一次请求返回的_scroll_id。

如果没有更多的命中结果返回,就处理完了所有的命中匹配的document。

REF

排序