elasticsearch之实例篇

本文上接《elasticsearch之搭建篇》,看看如何实现类似糯米的检索功能。

在商铺和商品的存储方面,有嵌套(Nested)和父子文档(Parent-Child)两种方式,下面将依次探索。

嵌套(Nested)

创建type

商品将嵌套在商铺的文档中,其创建方法如下:

可见,商品列表作为一个属性存储在商铺中(type=nested,嵌套的),一个商铺有多个商品。(嵌套文档可以在这里学习

通过curl查看刚刚建立的商铺type:

上述信息与我创建时传入的一致,表明:商铺type已经成功建立,它利用嵌套文档技术,在其内部直接保存商品列表。

录入数据

我们通过bulk API批量的插入测试数据:

  • 商铺ID作为ES文档的唯一_id。
  • 商品ID作为普通字段保存在嵌套文档的product_id字段。

嵌套查询

因为商铺和商品是嵌套关系,所以在查询时需要使用”嵌套查询”语法。

我的查找需求表达如下:

若某商铺的名称或者其售卖的”商品”的名称,能够匹配”搜索关键字”,那么返回该商铺的信息。

分析一下这个查询的组成部分(注意配合代码注释理解):

  • query:查询语句必须放在其内部。
  • bool:组合查询,可以表达多个子句之间的AND(must),OR(should),NOT(must_not)关系。
  • should:OR的意思,里面多个子句满足任意一个即匹配,这个例子有2个子句。
    • should的总相关性是这样计算的:所有子句的相关性和/子句的数量。
  • match:全文匹配,会对$keyword分词,然后分别进行倒排查找。
    • 该查询是should的第一个子句,会匹配得到一个相关性。
  • nested:嵌套查询,是should的第二个子句。
  • path:嵌套查询指向的子文档路径。
  • score_mode:嵌套文档有多个,该参数指定nested子句的总相关性是如何计算的。
    • 这里指定max,表示取多个商品中的最大相关性。
  • query:对于嵌套查询来说,查询语句必须放在其内部。
  • match:全文匹配,会对$keyword分词,然后分别进行倒排查找。
    • 该查询是nested query的唯一子句,产生的相关性是nested的总相关性,也是should第二个子句的相关性。

其结果如下:

查询结果出乎意料!

直观来看,”东方宫兰州拉面”应该更符合我的预期,为什么”鑫明明拉面”的相关性却高于”东方宫兰州拉面”呢?

首先,我们要了解相关性,它表示搜索词与文档的匹配程度。

搜索引擎一般采用『词频/逆向文档频率 (TF/IDF)』来计算相关性:

  • TF:单词(TERM)在一个文档内的出现比例,出现越多说明TERM对这篇文章更重要,比如本文的”ES”就多次出现。
  • IDF:单词(TERM)在所有文档中出现的比例,出现越多说明TERM越大众越不起眼,比如”的,了,吧”这些助词。
  • TF/IDF:TERM在文档内的TF越高,在所有文档中的IDF越小,说明TERM与该文档越相关。

通过肉眼分析,”东方宫兰州拉面”这家店无论从名字还是商品的名字都更贴近于我的搜索词”东方宫拉面”,是ES有BUG吗?

答案:并不是,出现这个现象的原因是因为不准确的IDF!

我的商铺表有3个分片Shard,通过查看获知”东方宫兰州拉面”独自在分片1中,而”鑫明明拉面”和”开海饭店”在分片2中。

ES在计算IDF的时候是基于分片内的数据统计的,分片1内的”拉面”只出现在”东方宫兰州拉面”内,相当于100%的IDF(在所有文档内出现);分片2内的”拉面”只出现在”鑫明明拉面”内,而”开海饭店”里并没有出现,相当于50%的IDF(在1/2的文档内出现),讲到这里我们就明白了:”东方宫兰州拉面”和”鑫明明拉面”中”拉面”都出现了1次,但是前者的IDF是1,而后者是1/2,经过TF/IDF计算显然是后者的值更大,也就是更相关了!

这个问题在数据规模较大的情况下可以忽略,在我们开发阶段可以通过指定一个参数解决:search_type=dfs_query_then_fetch,它将获取集群所有分片的IDF和之后再计算TF/IDF,因此更加准确。

 

这次结果正确:

最佳子句

接下来我替换搜索关键字为:”兰炒饭”,其实我本意是”兰州炒饭”,只不过我输错了(如果搜索仍旧可以给我理想的结果,我会爱上它)。

我们直接看查询结果:

从直接上来看,”开海饭店”应排在第一位,因为它正在售卖我的最爱:”兰州炒饭”,可为什么”东方宫兰州拉面”在第一位呢?

之前说过,bool的should会对其内部的2个子句(一个匹配商铺名称,一个匹配商品名称)的相关性加和并除以子句个数(这里有2个子句),其结果作为商铺文档的总相关性。

  • “开海饭店”是商铺标题,和”兰炒饭”没有一丁点相关性,因此第一个子句的相关性=0。
  • “兰州炒饭”完美匹配我的查询,因此第二个子句有很高的相关性。
  • 总相关性 = (0 + 一个很高的相关性)/ 2,变成了很高相关性的一半。

 

  • “东方宫兰州拉面”是商铺标题,出现了”兰”,因此第一个子句的相关性还不错。
  • “蛋炒饭”出现了”炒饭”,因此第二个子句的相关性还不错。
  • 总相关性 = (一个不错的相关性 + 一个不错的相关性)/2,还是一个不错的相关性。

上面的查询结果就是这样的情况下产生的,完全不符合预期!

我的初衷是找到最符合搜索关键字的字段,无论它是”商铺名称”还是”商品名称”。

最佳字段“就是解决这个问题的:它保留多个检索字段中最大的相关性作为总相关性,查询变化如下:

主要做了如下调整:

  • bool组合查询替换成了dis_max最佳字段查询。
  • should替换成了queries,下面同样包含多个查询子句。

现在结果正确!

过滤距离

通常,我们希望找到附近N公里内的商铺,因此必须利用坐标进行筛选,ES提供了索引地理位置的能力。

假设我的坐标是(X,Y),搜索范围是以它为圆心,半径为1公里的圆形,那么ES会怎么做呢?

ES首先为每个商铺的merchant_location建立了索引,(X,Y)坐标将被建立2个索引:

  • 按经度索引。
  • 按纬度索引。

ES在执行查询时,首先以(X,Y)为中心画一个矩形,它恰好能够包裹圆形,这样的目的是可以利用2个索引快速缩小范围:

  1. 矩形的x轴区间范围,可以使用经度索引筛选出一批X轴在1公里范围内的文档。
  2. 矩形的y轴区间范围,可以使用纬度索引筛选出一批Y轴在1公里范围内的文档。
  3. 两个文档集合求交集,可以得到矩形范围内的所有文档。
  4. 矩形比圆形要多一些区域,因此遍历所有文档计算它们和(X,Y)之间的距离,将圆形外的点删除。

这种工作方式叫做”地理坐标盒模型“,它是一种精度高,计算耗费资源比较多的一种手段(另外一种精度低,资源耗费少的方式是geohash)。

下面的请求,首先利用”距离”过滤出1KM内的商铺,之后再基于过滤的结果进行全文检索并计算相关性:

这里使用了过滤,它在全文检索之前对数据进行按条件筛选,过滤的结果可以被ES缓存。

在ES5.x版本中,过滤语法filter必须和全文检索放在一个bool组合中,而全文检索放在must中即可。

下面是结果,它们按相关性排序:

现在扩大距离范围distance为2km,可以看到三个”商铺”全部返回(我就不贴结果了,亲自动手试试吧)。

排序

在糯米搜索中,”综合排序”其实就是指相关性排序,是ES的默认排序方法。

但是仔细观察糯米检索会发现,它支持若干其他排序方式,比如:按距离排序。

新的查询需求如下:搜索2KM之内,与”拉面”相关的”店铺”,并且按照距离远近排序。

  • sort是必须要写的。
  • sort内部可以并列多个排序条件。
  • _geo_distance是坐标排序的系统关键字。

结果如下:

更多的过滤和排序

上面的几个例子里包含了这些知识点:相关性排序,距离排序,距离过滤…

为了体现ES的强大之处,我们继续丰富搜索请求:

2KM以内,与”拉面”相关,商铺评分>=4分,商铺均价<=20元,按商铺评分、商品总销量排序,并且返回结果中包含距离。

分析一下这个查询:

  • query:通过bool组合实现”带过滤filter的相关性查询”:
    • 即先通过filter过滤出一批满足条件的数据。
    • 再对这批数据进行must(AND关系)相关性计算。
  • sort:有2项排序规则:
    • 按商铺分数倒序。
    • 如果商铺分数一样,则按商品总销量倒序。
  • _source:控制返回的字段,为空表示返回所有字段。
  • script_fields:脚本计算生成额外的字段,这里定义了一个distance字段:
    • lang是标明脚本使用的语言(ES支持多种脚本语言)。
    • params是脚本输入参数。
    • inline是内联脚本,它计算出每个文档和我所在坐标之间的距离。

得到的结果如下:

  • 使用script_fields后_source不会返回,因此需要在请求里显式的指定_source。
  • fields里是脚本计算的独立字段。
  • sort里的2个数据是排序时所使用的字段值。

ES的脚本功能很丰富,可以自己学习扩展:painless脚本语言ES5.x废弃的脚本语法

查询还是过滤

在上面这个例子中,我们使用了”带过滤的全文检索”,最终却没有使用”相关性”作为排序标准。

我们知道”全文检索”是会计算相关性的,这岂不是白白浪费计算资源吗?

简单的说:没有使用”相关性”作为排序的时候,全文检索的用途仅仅是确认 『匹配还是不匹配』,至于匹配的程度就无关紧要了。

怎么跳过全文检索的相关性计算呢?

与之前的代码相比,must下面的全文检索子句挪到了constant_score下面,又被一层query包裹起来。

constant_score会让其下的query相关性打分将始终为常量1,因此ES将不再为内部的全文检索计算相关性。

聚合统计分析

ES支持对查询出来的结果集合进行进一步的聚合分析,支持类似Mysql中的max,min,count,sum等聚合操作,也支持类似group by的分桶,以及分桶后的聚合操作。

下面的例子单纯的为了演示聚合,并没有多少实际价值:

首先保持之前的查询请求不变,额外增加一个aggs聚合语句,它统计每种product_type的平均销量:

  • aggs用来容纳若干聚合项,它们将被分别计算并在查询结果中返回。
  • 每个聚合项可以应用2种子句:
    • 调用sum,avg,min,max等对字段进行聚合,它们被称为”指标”。
    • 调用terms将数据分成桶,然后通过嵌套的aggs对每个桶进一步聚合。

聚合(aggs)是在query完成后执行的,它的输入是query的输出,如果没有query语句,那么aggs将在全部文档上执行。

聚合只是用来分析数据用的,并不能把聚合的结果拿来作为过滤文档的条件,这是一定要注意的。

如果你感觉理解困难,对括号嵌套一塌糊涂,建议多次揣摩上面的例子和解释并动手实践,别忘了阅读官方文档

经过一系列的实例,应该对ES的常见用法有了一定的掌握。

我认为2个知识点是学以致用关键:

  • 掌握全文检索的基本原理(TF/IDF,相关性)。
  • 亲自动手实践ES的查询语法,揣摩编写的逻辑。

 


 父子文档(Parent-Child)

“parent-child父子文档”功能:它关联同一个index下的2个type形成父子关系,两个type可以各自独立更新,在查询的时候可以选择其中之一作为检索主表,另外一张作为辅表,从而实现用子文档筛选父文档或者用父文档筛选子文档的能力。

一定要注意,”父子文档”并不是数据库那样的JOIN操作,因此不会将匹配的父子记录一起返回,这也是为什么”糯米美食”同时检索”店铺”和”商品”信息,但最终只能返回”店铺”数据的原因。

“父子文档”要求父亲与孩子符合一定的存储要求:

  • 如果父亲与孩子不在同一个index中存储,那么不同index各自进行分布式存储,两者数据无法本地化。
  • 父亲与属于其的孩子不在同一个shard中存储,那么不同shard各自进行分布式存储,两者数据也无法本地化。

因为”父子文档”在技术上的这些限制,因此:

  • 父亲与孩子必须存储在同一个index下的不同type中。
  • 对于父亲A和属于A的孩子们,它们应该”路由”到同一个shard下。

无论如何,本节我们先创建一个index再说,它有3个分片shard,每个shard有2个备份,这样规划的原因是因为我有3个ES节点:

首先创建”商铺”表:

需要过滤,排序,检索的字段,需要根据其用途配置index项:

  • not_analyzed:不需要分词的将被作为整体索引,那么使用。
  • analyzed:需要分词的,先经过analyzer分词成很多单词再逐个被索引。
  • no:不需要索引,仅作为数据字段一起被保存。

同样的道理,我们现在创建”商品type”:

我通过给_parent指定了它的父亲为merchant,但是这条命令会报错:can’t add a _parent field that points to an already existing type, that isn’t already a parent。

ES的强制规定,表达父子关系的2个type必须在创建index的时候一起提交mapping。也就是说:假如父type已存在,希望让一个新的子type关联到父type是不可能的(详细可见说明)!

无论如何,我确定要使用”父子文档”,因此我重新执行这段代码:它删除现在的index,并在重建index的同时传入2个type的mapping定义:

通过curl看一下basic数据库的定义:

可以看到product表的_parent已经生效,_routing强制为true是因为之前说的原因:子文档必须和父文档路由到同一个shard内才能实现”父子文档”的联合查询,那么什么是_routing呢?

一个文档进入哪个shard是由hash(_routing)%分片个数 来决定的,而_routing默认等于文档_id(可以点这里了解)。

插入数据

下面通过bulk批量API,先添加3个店铺:

之后为每个店铺添加一些商品:

需要注意:

  • 通常来说,每个店铺的id可能来自于mysql中的自增ID,商品id也是同样的,上面可以看出它们独立自增。
  • 为了满足”父子文档”,商铺文档按商铺ID路由即可,而对应的商品在添加时不能用商品id路由而是应该使用_routing=其所属的商铺id,这样hash(product._routing)==hash(merchant._id)。另外,我们不必自己传递_routing值而是直接指定_parent即可,相关父子数据将自动进入同一个shard存储。
  • bulk API批量提交一堆请求,每个请求由”元数据”+”请求体”共2行组成,其中”元数据”应该指定操作的类型和表名等,而”请求体”则承载具体的请求参数。

父子查询筛选店铺

下面是一个简单的筛选,即搜索售卖”鸭血粉丝汤”的”店铺”有哪些:

这里score_mode表示:同一个”店铺”下有多个”商品”匹配关键字,那么取匹配程度最高的那个”商品”的打分作为”店铺”的打分依据。

结果很准确,”开海饭店”售卖”鸭血粉丝汤”,其匹配度打分_score高达10分+,而”东方宫兰州拉面”因为售卖”羊肉汤”而命中”汤”字,所以也出现在了结果集之中,不过匹配度打分_score才区区0.9分。

父子文档也支持同时筛选父亲和孩子,只需要把父亲和孩子的子句放在一个bool中即可:

上面代码在店铺名或商品名中寻找”拉面”,bool中使用should表示或的关系,而should中有2项子句,一个针对父文档merchant_name进行检索,一个针对子文档product_name进行检索,最后按照bool should的方式计算总体相关性(可以回顾一下上面的dis_max方式取代should)。

特别感谢Jasonsoso留言纠正了本文关于父子文档的错误描述。

父子文档当前最大的实战问题是无法根据孩子/父亲排序,使用时需谨慎,可以参考:https://github.com/elastic/elasticsearch/issues/2917

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~