第八章-复杂搜索

关注公众号:CoderBuff,回复“es”获取《ElasticSearch6.x实战教程》完整版PDF。

黑夜给了我黑色的眼睛,我却用它寻找光明。

经过了解简单的API和简单搜索,已经基本上能应付大部分的使用场景。可是非关系型数据库数据的文档数据往往又多又杂,各种各样冗余的字段,组成了一条"记录"。复杂的数据结构,带来的就是复杂的搜索。所以在进入本章节前,我们要构建一个尽可能"复杂"的数据结构。

下面分为两个场景,场景1偏向数据结构上的复杂并且介绍聚合查询、指定字段返回、深分页,场景2偏向搜索精度上的复杂。

场景1

存储一个公司的员工,员工信息包含姓名、工号、性别、出生年月日、岗位、上级、下级、所在部门、进入公司时间、修改时间、创建时间。其中员工工号作为主键ID全局唯一,员工只有一个直属上级,但有多个下级,可以通过父子文档实现。员工有可能属于多个部门(特别是领导可能兼任多个部门的负责人)。

数据结构

创建索引并定义映射结构:

PUT http://localhost:9200/company
{
"mappings":{
"employee":{
"properties":{
"id":{
"type":"keyword"
},
"name":{
"type":"text",
"analyzer":"ik_smart",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"sex":{
"type":"keyword"
},
"age":{
"type":"integer"
},
"birthday":{
"type":"date"
},
"position":{
"type":"text",
"analyzer":"ik_smart",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"level":{
"type":"join",
"relations":{
"superior":"staff",
"staff":"junior"
}
},
"departments":{
"type":"text",
"analyzer":"ik_smart",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"joinTime":{
"type":"date"
},
"modified":{
"type":"date"
},
"created":{
"type":"date"
}
}
}
}
}

数据

接下来是构造数据,我们构造几条关键数据。

  • 张三是公司的董事长,他是最大的领导,不属于任何部门。
  • 李四的上级是张三,他的下级是王五、赵六、孙七、周八,他同时是市场部和研发部的负责人,也就是隶属于市场部和研发部。
  • 王五、赵六的上级是张三,他没有下级,他隶属于市场部。
  • 孙七、周八的上级是李四,他没有下级,他隶属于研发部。

更为全面直观的数据如下表所示:

姓名 工号 性别 年龄 出生年月日 岗位 上级 下级 部门 进入公司时间 修改时间 创建时间
张三 1 男 49 1970-01-01 董事长 / 李四 / 1990-01-01 1562167817000 1562167817000
李四 2 男 39 1980-04-03 总经理 张三 王五、赵六、孙七、周八 市场部、研发部 2001-02-02 1562167817000 1562167817000
王五 3 女 27 1992-09-01 销售 李四 / 市场部 2010-07-01 1562167817000 1562167817000
赵六 4 男 29 1990-10-10 销售 李四 / 市场部 2010-08-08 1562167817000 1562167817000
孙七 5 男 26 1993-12-10 前端工程师 李四 / 研发部 2016-07-01 1562167817000 1562167817000
周八 6 男 25 1994-05-11 Java工程师 李四 / 研发部 2018-03-10 1562167817000 1562167817000

插入6条数据:

POST http://localhost:9200/company/employee/1?routing=1
{
"id":"1",
"name":"张三",
"sex":"男",
"age":49,
"birthday":"1970-01-01",
"position":"董事长",
"level":{
"name":"superior"
},
"joinTime":"1990-01-01",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/2?routing=1
{
"id":"2",
"name":"李四",
"sex":"男",
"age":39,
"birthday":"1980-04-03",
"position":"总经理",
"level":{
"name":"staff",
"parent":"1"
},
"departments":["市场部","研发部"],
"joinTime":"2001-02-02",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/3?routing=1
{
"id":"3",
"name":"王五",
"sex":"女",
"age":27,
"birthday":"1992-09-01",
"position":"销售",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["市场部"],
"joinTime":"2010-07-01",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/4?routing=1
{
"id":"4",
"name":"赵六",
"sex":"男",
"age":29,
"birthday":"1990-10-10",
"position":"销售",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["市场部"],
"joinTime":"2010-08-08",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/5?routing=1
{
"id":"5",
"name":"孙七",
"sex":"男",
"age":26,
"birthday":"1993-12-10",
"position":"前端工程师",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["研发部"],
"joinTime":"2016-07-01",
"modified":"1562167817000",
"created":"1562167817000"
}
POST http://localhost:9200/company/employee/6?routing=1
{
"id":"6",
"name":"周八",
"sex":"男",
"age":28,
"birthday":"1994-05-11",
"position":"Java工程师",
"level":{
"name":"junior",
"parent":"2"
},
"departments":["研发部"],
"joinTime":"2018-03-10",
"modified":"1562167817000",
"created":"1562167817000"
}

搜索

  1. 查询研发部的员工
GET http://localhost:9200/company/employee/_search
{
"query":{
"match":{
"departments":"研发部"
}
}
}
  1. 查询在研发部且在市场部的员工
GET http://localhost:9200/company/employee/_search
{
"query": {
"bool":{
"must":[{
"match":{
"departments":"市场部"
}
},{
"match":{
"departments":"研发部"
}
}]
}
}
}

*被搜索的字段是一个数组类型,但对查询语句并没有特殊的要求。

  1. 查询name="张三"的直接下属。
GET http://localhost:9200/company/employee/_search
{
"query": {
"has_parent":{
"parent_type":"superior",
"query":{
"match":{
"name":"张三"
}
}
}
}
}
  1. 查询name="李四"的直接下属。
GET http://localhost:9200/company/employee/_search

{
"query": {
"has_parent":{
"parent_type":"staff",
"query":{
"match":{
"name":"李四"
}
}
}
}
}
  1. 查询name="王五"的直接上级。
GET http://localhost:9200/company/employee/_search
{
"query": {
"has_child":{
"type":"junior",
"query":{
"match":{
"name":"王五"
}
}
}
}
}

聚合查询

ES中的聚合查询类似MySQL中的聚合函数(avg、max等),例如计算员工的平均年龄。

GET http://localhost:9200/company/employee/_search?pretty
{
"size": 0,
"aggs": {
"avg_age": {
"avg": {
"field": "age"
}
}
}
}

指定字段查询

指定字段返回值在查询结果中指定需要返回的字段。例如只查询张三的生日。

GET http://localhost:9200/company/employee/_search?pretty
{
"_source":["name","birthday"],
"query":{
"match":{
"name":"张三"
}
}
}

深分页

ES的深分页是一个老生常谈的问题。用过ES的都知道,ES默认查询深度不能超过10000条,也就是page * pageSize < 10000。如果需要查询超过1万条的数据,要么通过设置最大深度,要么通过scroll滚动查询。如果调整配置,即使能查出来,性能也会很差。但通过scroll滚动查询的方式带来的问题就是只能进行"上一页"、"下一页"的操作,而不能进行页码跳转。

scroll原理简单来讲,就是一批一批的查,上一批的最后一个数据,作为下一批的第一个数据,直到查完所有的数据。

首先需要初始化查询

GET http://localhost:9200/company/employee/_search?scroll=1m
{
"query":{
"match_all":{}
},
"size":1,
"_source": ["id"]
}

像普通查询结果一样进行查询,url中的scroll=1m指的是游标查询的过期时间为1分钟,每次查询就会更新,设置过长占会用过多的时间。

接下来就可以通过上述API返回的_scroll_id进行滚动查询,假设上面的结果返回"_scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAAFBFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABQhZNaTc3RVFYQ1N4cV91bUlRVHQyQVpRAAAAAAAAAUMWTWk3N0VRWENTeHFfdW1JUVR0MkFaUQAAAAAAAAFEFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABRRZNaTc3RVFYQ1N4cV91bUlRVHQyQVpR"。

GET http://localhost:9200/_search/scroll
{
"scroll":"1m",
"scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAAFBFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABQhZNaTc3RVFYQ1N4cV91bUlRVHQyQVpRAAAAAAAAAUMWTWk3N0VRWENTeHFfdW1JUVR0MkFaUQAAAAAAAAFEFk1pNzdFUVhDU3hxX3VtSVFUdDJBWlEAAAAAAAABRRZNaTc3RVFYQ1N4cV91bUlRVHQyQVpR"
}

这种方式有一个小小的弊端,如果超过过期时间就不能继续往下查询,这种查询适合一次全量查询所有数据。但现实情况有可能是用户在一个页面停留很长时间,再点击上一页或者下一页,此时超过过期时间页面不能再进行查询。所以还有另外一种方式,范围查询。

另一种深分页

假设员工数据中的工号ID是按递增且唯一的顺序,那么我们可以通过范围查询进行分页。

例如,按ID递增排序,第一查询ID>0的数据,数据量为1。

GET http://localhost:9200/company/employee/_search
{
"query":{
"range":{
"id":{
"gt":0
}
}
},
"size":1,
"sort":{
"id":{
"order":"asc"
}
}
}

此时返回ID=1的1条数据,我们再继续查询ID>1的数据,数据量仍然是1。

GET http://localhost:9200/company/employee/_search
{
"query":{
"range":{
"id":{
"gt":1
}
}
},
"size":1,
"sort":{
"id":{
"order":"asc"
}
}
}

这样我们同样做到了深分页的查询,并且没有过期时间的限制。

场景2

存储商品数据,根据商品名称搜索商品,要求准确度高,不能搜索洗面奶结果出现面粉。

由于这个场景主要涉及的是搜索的精度问题,所以并不会有复杂的数据结构,只有一个title字段。

定义一个只包含title字段且分词器默认为standard的索引:

PUT http://localhost:9200/ware_index
{
"mappings": {
"ware": {
"properties": {
"title":{
"type":"text"
}
}
}
}
}

插入两条数据:

POST http://localhost:9200/ware_index/ware
{
"title":"洗面奶"
}
POST http://localhost:9200/ware_index/ware
{
"title":"面粉"
}

搜索关键字"洗面奶":

POST http://localhost:9200/ware_index/ware/_search
{
"query":{
"match":{
"title":"洗面奶"
}
}
}

搜索结果出现了"洗面奶"和"面粉"两个风马牛不相及的结果,这显然不符合我们的预期。

原因在分词一章中已经说明,text类型默认分词器为standard,它会将中文字符串一个字一个字拆分,也就是将"洗面奶"拆分成了"洗"、"面"、"奶",将"面粉"拆分成了"面"、"粉"。而match会将搜索的关键词拆分,也就拆分成了"洗"、"面"、"奶",最后两个"面"都能匹配上,也就出现了上述结果。所以对于中文的字符串搜索我们需要指定分词器,而常用的分词器是ik_smart,它会按照最大粒度拆分,如果采用ik_max_word它会将词按照最小粒度拆分,也有可能造成上述结果。

DELETE http://localhost:9200/ware_index删除索引,重新创建并指定title字段的分词器为ik_smart。

PUT http://localhost:9200/ware_index
{
"mappings":{
"ware":{
"properties":{
"id":{
"type":"keyword"
},
"title":{
"type":"text",
"analyzer":"ik_smart"
}
}
}
}
}

这时如果插入“洗面奶”和“面粉”,搜索“洗面奶”是结果就只有一条。但此时我们插入以下两条数据:

POST http://localhost:9200/ware_index/ware
{
"id":"1",
"title":"新希望牛奶"
}
POST http://localhost:9200/ware_index/ware
{
"id":"2",
"title":"春秋上新短袖"
}

搜索关键字”新希望牛奶“:

POST http://localhost:9200/ware_index/ware/_search
{
"query":{
"match":{
"title":"新希望牛奶"
}
}
}

搜索结果出现了刚插入的2条,显然第二条”春秋上新短袖“并不是我们想要的结果。出现这种问题的原因同样是因为分词的问题,在ik插件的词库中并没有"新希望"一词,所以它会把搜索的关键词"新希望"拆分为"新"和"希望",同样在"春秋上新短袖"中"新"也并没有组合成其它词语,它也被单独拆成了"新",这就造成了上述结果。解决这个问题的办法当然可以在ik插件中新增"新希望"词语,如果我们在分词中所做的那样,但也有其它的办法。

短语查询

match_phrase,短语查询,它会将搜索关键字"新希望牛奶"拆分成一个词项列表"新 希望 牛奶",对于搜索的结果需要完全匹配这些词项,且位置对应,本例中的"新希望牛奶"文档数据从词项和位置上完全对应,故通过match_phrase短语查询可搜索出结果,且只有一条数据。

POST http://localhost:9200/ware_index/ware/_search
{
"query":{
"match_phrase":{
"title":"新希望牛奶"
}
}
}

尽管这能满足我们的搜索结果,但是用户实际在搜索中常常可能是"牛奶 新希望"这样的顺序,但遗憾的是根据match_phrase短语匹配的要求是需要被搜索的文档需要完全匹配词项且位置对应,关键字"牛奶 新希望"被解析成了"牛奶 新 希望",尽管它与"新希望牛奶"词项匹配但位置没有对应,所以并不能搜索出任何结果。同理,此时如果我们插入"新希望的牛奶"数据时,无论是搜索"新希望牛奶"还是"牛奶新希望"均不能搜索出"新希望的牛奶"结果,前者的关键字是因为词项没有完全匹配,后者的关键字是因为词项和位置没有完全匹配。

所以match_phrase也没有达到完美的效果。

短语前缀查询

match_phrase_prefix,短语前缀查询,类似MySQL中的like "新希望%",它大体上和match_phrase_prefix一致,也是需要满足文档数据和搜索关键字在词项和位置上保持一致,同样如果搜索"牛奶新希望"也不会出现任何结果。它也并没有达到我们想要的结果。

最低匹配度

前面两种查询中虽然能通过"新希望牛奶"搜索到我们想要的结果,但是对于"牛奶 新希望"却无能为力。接下来的这种查询方式能"完美"的达到我们想要的效果。

先来看最低匹配度的查询示例:

POST http://localhost:9200/ware_index/ware/_search
{
"query": {
"match": {
"title": {
"query": "新希望牛奶",
"minimum_should_match": "80%"
}
}
}
}

minimum_should_match即最低匹配度。"80%"代表什么意思呢?还是要从关键字"新希望牛奶"被解析成哪几个词项说起,前面说到"新希望牛奶"被解析成"新 希望 牛奶"三个词项,如果通过match搜索,则含有"新"的数据同样出现在搜索结果中。"80%"的含义则是3个词项必须至少匹配80% * 3 = 2.4个词项才会出现在搜索结果中,向下取整为2,即搜索的数据中需要至少包含2个词项。显然,"春秋上新短袖"只有1个词项,不满足最低匹配度2个词项的要求,故不会出现在搜索结果中。

同样,如果搜索"牛奶 新希望"也是上述的结果,它并不是短语匹配,所以并不会要求词项所匹配的位置相同。

可以推出,如果"minimum_should_match":"100%"也就是要求完全匹配,此时要求数据中包含所有的词项,这样会出现较少的搜索结果;如果"minimun_should_match:0"此时并不代表一个词项都可以不包含,而是只需要有一个词项就能出现在搜索结果,实际上就是默认的match搜索,这样会出现较多的搜索结果。

找到一个合适的值,就能有一个较好的体验,根据二八原则,以及实践表明,设置为"80%"能满足大部分场景,既不会多出无用的搜索结果,也不会少。

第九章-Java客户端(下)

基于Java客户端(上),本文不再赘述如何创建一个Spring Data ElasticSearch工程,也不再做过多文字叙述。更多的请一定配合源码使用,源码地址https://github.com/yu-linfeng/elasticsearch6.x_tutorial/tree/master/code/spring-data-elasticsearch,具体代码目录在complex包。

本章请一定结合代码重点关注如何如何通过Java API进行父子文档的数据插入,以及查询。

父子文档的数据插入

父子文档在ES中存储的格式实际上是以键值对方式存在,例如在定义映射Mapping时,我们将子文档定义为:

{
......
"level":{
"type":"join",
"relations":{
"superior":"staff",
"staff":"junior"
}
}
......
}

在写入一条数据时:

{
......
"level":{
"name":"staff",
"parent":"1"
}
......
}

对于于Java实体,我们可以把level字段设置为Map<String, Object>类型。关键注意的是,在使用Spring Data ElasticSearch时,我们不能直接调用sava或者saveAll方法。ES规定父子文档必须属于同一分片,也就是说在写入子文档时,需要定义routing参数。下面是代码节选:

BulkRequestBuilder bulkRequestBuilder = client.prepareBulk();
bulkRequestBuilder.add(client.prepareIndex("company", "employee", employeePO.getId()).setRouting(routing).setSource(mapper.writeValueAsString(employeePO), XContentType.JSON)).execute().actionGet();

一定参考源码一起使用。

ES实在是一个非常强大的搜索引擎。能力有限,实在不能将所有的Java API一一举例讲解,如果你在编写代码时,遇到困难也请联系作者邮箱hellobug at outlook.com,或者通过公众号coderbuff,解答得了的一定解答,解答不了的一起解答。

关注公众号:CoderBuff,回复“es”获取《ElasticSearch6.x实战教程》完整版PDF。

这是一个能给程序员加buff的公众号 (CoderBuff)

《ElasticSearch6.x实战教程》之复杂搜索、Java客户端(下)的更多相关文章

  1. 《ElasticSearch6.x实战教程》之简单搜索、Java客户端(上)

    第五章-简单搜索 众里寻他千百度 搜索是ES的核心,本节讲解一些基本的简单的搜索. 掌握ES搜索查询的RESTful的API犹如掌握关系型数据库的SQL语句,尽管Java客户端API为我们不需要我们去 ...

  2. 《ElasticSearch6.x实战教程》正式推出(附图书抽奖)

    经过接近1个月的时间,ElasticSearch6.x实战教程终于成册.这本实战教程小册有很多不足(甚至可能有错误),也是第一次完整推出一个系列的教程. 1年前,我开始真正接触ES,在此之前仅停留在知 ...

  3. 《ElasticSearch6.x实战教程》之分词

    第四章-分词 下雨天留客天留我不留 本打算先介绍"简单搜索",对ES的搜索有一个直观的感受.但在写的过程中发现分词无论如何都绕不过去.term查询,match查询都与分词息息相关, ...

  4. 《ElasticSearch6.x实战教程》之准备工作、基本术语

    第一章-准备工作 工欲善其事必先利其器 ElasticSearch安装 ElasticSearch6.3.2下载地址(Linux.mac OS.Windows通用,下载zip包即可):https:// ...

  5. 《ElasticSearch6.x实战教程》之简单的API

    第三章-简单的API 万丈高楼平地起 ES提供了多种操作数据的方式,其中较为常见的方式就是RESTful风格的API. 简单的体验 利用Postman发起HTTP请求(当然也可以在命令行中使用curl ...

  6. 《ElasticSearch6.x实战教程》之父-子关系文档

    第七章-父-子关系文档 打虎亲兄弟,上阵父子兵. 本章作为复杂搜索的铺垫,介绍父子文档是为了更好的介绍复杂场景下的ES操作. 在非关系型数据库数据库中,我们常常会有表与表的关联查询.例如学生表和成绩表 ...

  7. 《ElasticSearch6.x实战教程》之实战ELK日志分析系统、多数据源同步

    第十章-实战:ELK日志分析系统 ElasticSearch.Logstash.Kibana简称ELK系统,主要用于日志的收集与分析. 一个完整的大型分布式系统,会有很多与业务不相关的系统,其中日志系 ...

  8. .Net Core实战教程(一):Linux下搭建项目

    .Net Core实战教程(一):Linux下搭建项目 附言 .net core 1.0的时候就开始关注了,一直没有用于项目.真正用于项目我是2.0开始使用的.这几年也总结出一些经验.最近有空就写出来 ...

  9. 【ASP.NET实战教程】基于ASP.NET技术下多用户博客系统全程实战开发(NNblog)

    岁末主推:牛牛老师主讲,多用户博客系统,基于ASP.NET技术,年后将带来移动业务平台项目项目目标: 打造个性品牌Blogo,定制多用户博客 为每一个博客用户提供个性化的 blogo解决方案,打造精品 ...

随机推荐

  1. Window文件目录遍历 和 WIN32_FIND_DATA 结构(非常详细的中文注释)

    第一部分     *百度百科提供的内容总结:WIN32_FIND_DAT 第二部分   *程序实例 第三部分   *一篇使用FindFirstFile和FindNextFile函数的博文   第一部分 ...

  2. BI-学习之 商业智能项目工具安装

    首先咱们先需要下载一个工具,Microsoft SQL Server Data Tools - Business Intelligence for Visual Studio 2012并安装: 我目前 ...

  3. <iOS小技巧> 昵称格式判断

    一.使用方式 + 如下代码块功能:判断字体,判断字体输入格式       NSString *firstStr = [name substringToIndex:1];    NSArray *num ...

  4. win7访问部分win2003速度慢

    解决办法: 关闭TCPIP协议的自动优化调整功能,在win7上,以管理员身份运行cmd,输入 netsh interface tcp set global autotuninglevel=disabl ...

  5. 通过OSG实现对模型的日照模拟

    目录 1. 加载模型 2. 光照 1) 环境反射 2) 漫反射 3) 日照方向 (1) 太阳高度角和太阳方位角 (2) 计算过程 4) 改进实现 3. 阴影 4. 太阳高度角与太阳方位角的计算 1) ...

  6. CDMA子钟

    SYN6103型 CDMA子钟 产品概述 SYN6103型CDMA子钟是由西安同步电子科技有限公司精心设计.自行研发生产的一套从CDMA网络获取标准时间信号信息的子钟,能方便部署在任何有CDMA信号的 ...

  7. kafka笔记4(2)

    提交和偏移量 每次调用poll 方法,总是返回生产者写入Kafka但还没有被消费者读取过的记录我们因此可以追踪到哪些记录时被群组里的哪个消费者读取过的. 我们把更新分区当前位置的操作叫做提交. 那么消 ...

  8. 上不了名校?可以在 GitHub 上读他们的课程

    今天开始,全国各大区域的高考成绩陆续公布,又到了几家欢喜几家愁的时刻,如果你准备报考计算机相关专业,但是又由于分数不高而苦恼.别担心,在 GitHub 上有着大量的名校教学资源,即使上不了名校,也可以 ...

  9. 附006.Kubernetes RBAC授权

    一 RBAC 1.1 RBAC授权 基于角色的访问控制(RBAC)是一种基于个人用户的角色来管理对计算机或网络资源的访问的方法. RBAC使用rbac.authorization.k8s.io API ...

  10. 系统学习 Java IO (六)----管道流 PipedInputStream/PipedOutputStream

    目录:系统学习 Java IO---- 目录,概览 PipedInputStream 类使得可以作为字节流读取管道的内容. 管道是同一 JVM 内的线程之间的通信通道. 使用两个已连接的管道流时,要为 ...