1. 原理
全文搜索是ES的核心功能。ES中的数据按数据特性可分为两类:确切值及全文文本。ES中如keyword,date这些类型的值都可视为确切值。而text类型的值则视为全文文本数据。
为了对全文文本进行搜索,ES使用分析器(analyzer,根据不同自然语言、不同要求选择不同的分析器)将文本分析为单独的词(英文为terms或tokens,这里符合中国人的习惯,称为词),然后根据分词结果创建倒排索引(inverted index)。倒排索引以文本中的词为键,该词在文档中出现位置为值的一个数据结构,不同于常见的以文档序号(或标题)为键,文档内容为值的索引形式,所以称为倒排索引。ES中文档对象是一个结构化的JSON数据对象,每一个JSON文档中被索引的字段都有自己的倒排索引,全文搜索即在倒排索引中搜索。
ES中,分布式搜索操作需要分散到所有相关的分片中,然后收集所有的结果。对于一个搜索请求,搜索结果在不同分片中的分布密度可能是不同的,有可能出现一个分片包括了90%的记录,而其它分片却只包含极少记录的情况。当需要对搜索结果排序时,也需要从各独立的分片中收集到所有的搜索结果后再统一进行排序,这个过程类似于map-reduce的混洗(shuffle)过程。对于大数据集,这个过程可能会非常昂贵。因而在创建数据模型和设计搜索指令时我们应考虑搜索效率。
ES将搜索请求执行分为两阶段:在第一阶段查询(query)阶段,每个涉及搜索的分片执行搜索请求,获取本地搜索记录的排序后列表,并把这些可供协调节点(coordinating node)进行全局排序等操作的元数据信息返回给协调节点。在第二阶段取回(fetch)阶段,协调节点仅向包含搜索结果的分片请求具体的文档对象返回给用户。
2. 搜索API
ES提供了强大的数据搜索能力,在API级别提供基于URI和基于请求消息体两种搜索操作方式。
2.1. 基于URI的搜索
2.1.1. 基本格式
基于URI的搜索使用一个HTTP的GET请求携带搜索请求参数。搜索参数为(key,value)形式的名值对,以”&”连接。如果搜索请求中不带搜索参数则表示搜索所有记录。
仍以《编程随笔-ElasticSearch知识导图(3):映射》中第2节中的银行账号索引为例,先来看一个使用URI的搜索请求:
curl -iXGET 'localhost:9200/bank/_search?pretty&q=(firstname:Amber)AND(lastname:Duke)'
上面的请求中URI中为索引名(bank)后携带搜索路径(_search),并在URI的“?”之后的查询字符串中携带查询参数q,查询满足firstname为Amber且lastname为Duke'的记录(若要实现全文搜索,则不需要添加前面的域名),查询参数q在子查询表达式前(格式为”域名:值”)使用“+”表示应满足该条件, “-”表示不应满足该条件。q中子查询表达式使用”AND”或“OR”表示条件之间的逻辑与或关系。
2.1.2. 通配符与正则表达式
查询字符串支持“?”和“*”这样的简单通配符,上面的查询示例使用统配符之后可以变为如下形式:
curl -iXGET 'localhost:9200/bank/_search?pretty&q=(f\*name:A?ber)AND(lastname:Duke)'
查询条件字符串支持正则表达式(使用”/”包含起来),上面的查询可表达为如下形式:
curl -iXGET 'localhost:9200/bank/_search?pretty&q=(firstname:/A~r/)AND(lastname:Duke)'
2.1.3. 分页与排序
ES默认在一次搜索请求中只返回搜索结果的前10条记录。因而当搜索记录较多时,搜索结果需要进行分页。
ES提供搜索参数“from”和“size”用于指示返回结果开始的索引值与返回的数目。由于分页的结果往往基于记录的排序结果,因而使用“sort”参数实现搜索结果的排序。
考虑如下查询:
curl -iXGET 'localhost:9200/bank/_search?pretty&q=(balance:>5000)&sort=balance:desc&from=0&size=3'
查询余额(balance)大于5000的人群中最有钱的前三位。
如果不想搜索出所有字段,可以在搜索条件中使用” _source”参数指定在返回结果中指定的域。上面的搜索请求可转换为如下请求:
curl -iXGET 'localhost:9200/bank/_search?pretty&q=(balance:>5000)&sort=balance:desc&from=0&size=3&_source=balance,firstname,lastname'
在返回的搜索结果中可以看到“_source”对象中只有“balance,firstname,lastname”这三个字段。
现在我们发现基于URI的搜索格式已经非常类似于常用的SQL格式了。的确,ES支持SQL语言的查询(在xpack插件中支持)。
2.1.4. 搜索参数
简单列举一下常用的基于URI搜索的参数:
参数 | 描述 |
---|---|
q | 查询条件字符串 |
df | 查询的缺省域 |
analyzer | 分析查询字符串时使用的分析器 |
analyze_wildcard | 是否分析通配符 |
default_operator | 确定缺省逻辑关系是AND或OR,缺省是OR. |
explain | 在结果中包含对于命中记录得分的解释 |
_source | 选择在结果中展示的字段 |
sort | 排序条件 |
timeout | 搜索超时时间 |
terminate_after | 每个分片上采集的最大搜索记录数目 |
from | 搜索结果起始索引 |
size | 本次搜索结果的最大数目 |
search_type | 可以取值:dfs_query_then_fetch 或 query_then_fetch. 缺省为query_then_fetch |
参数中的search_type用来定义不同的搜索类型来应对不同场景,其取值可为:
- query_then_fetch:将搜索分为query和fetch两个阶段,如前面的银行账户查询例子,与搜索条件相关的所有分片在第一阶段返回给协调节点余额大于5000的记录元数据信息,协调节点排序后,发现余额最大的前3名都在1个分片中,那么第二阶段它只需向包含前3名的分片请求完整的搜索结果数据。
- dfs_query_then_fetch:dfs是 Distributed Frequency Search的简写。与query_then_fetch方式基本相同,只是增加了一个从所有相关分片中获取词频(本地IDF)以便计算全局词频(全局IDF)的预查询阶段,用于更精确的相关性评分(scoring)计算(文献2中不建议在生产环境下使用此选项)。
2.2. 基于请求消息体的搜索
2.2.1. 何为DSL
DSL(Domain Specific Language)旨在向目标用户提供人性化的界面,其要旨在于沟通。DSL拥有贴近用户思维的语法结构,这些语言抽象依赖于背后提供支撑的语义模型。
ES中定义用于搜索的DSL使用JSON格式,是一种外部DSL语言。
使用URI表示的搜索示例使用DLS改写后的形式如下:curl -iXPOST 'localhost:9200/bank/_search?pretty' -H 'Content-Type: application/json' -d' { "query": { "bool": { "must": { "match_all": {} }, "filter": { "range": { "balance": { "gte": 5000 } } } } }, "sort": { "balance": { "order": "desc" } }, "from": 0, "size": 3, "_source": ["balance","firstname","lastname"] } '
上例中将请求消息体分为两个部分,“query”子句和其它搜索参数元素,“query”子句使用Query DSL描述。其它搜索参数同2.1.4节中描述的参数。
搜索DSL提供更加丰富的搜索参数与功能,更多内容参见文献1。2.2.2. Query DSL
搜索请求中query子句可应用于查询上下文(Query context)和/或过滤上下文(Filter context)中。查询上下文中query子句用来回答文档对象的匹配程度(计算score值衡量相关性),过滤上下文中query子句用来回答文档对象是否符合过滤条件,只有是否两个选择。简单来说,需要全文搜索或不精确匹配(考察相关性)时应用查询上下文,需要精确过滤时应用过滤上下文。
上节的搜索示例既应用于查询上下文又应用于过滤上下文中, “must”子句指示了查询条件(示例中没有设置查询条件,其实可以省略);“filter”子句指示了过滤条件。
将示例中的“match_all”换上更具体的查询条件,查找姓“Barry”的最有钱的前三位同学:curl -iXPOST 'localhost:9200/bank/_search?pretty' -H 'Content-Type: application/json' -d' { "query": { "bool": { "must": { "match": {"lastname":"Barry"} }, "filter": { "range": { "balance": { "gte": 5000 } } } } }, "sort": { "balance": { "order": "desc" } }, "from": 0, "size": 3, "_source": ["balance","firstname","lastname"] } '
上例在查询子句中指定了域名为“lastname”,该域表示一个确切值,不需要全文搜索。因而将match子句转换为term子句,放到"filter"子句中,搜索出的结果一样:
curl -iXPOST 'localhost:9200/bank/_search?pretty' -H 'Content-Type: application/json' -d' { "query": { "bool": { "filter": [ { "term": { "lastname.keyword": "Barry" } }, { "range": { "balance": { "gte": 5000 } } } ] } }, "sort": { "balance": { "order": "desc" } }, "from": 0, "size": 3, "_source": [ "balance", "firstname", "lastname" ] } '
match和term子句常用来组建查询子句: term用于搜索域的确切值,即term指示的值在搜索时不经过分析器的处理,对于text类型的域,term子句访问的分析器产生的倒排索引,常常无法获得精确匹配的搜索结果(如term搜索值中有大写字母但不经过分析器转换,而待搜索的文本域在经分析器分析后倒排索引中都变为小写字符,则无法精确匹配)。term常用于搜索keyword、date、数字这些具有确切值类型的域(上面示例中使用的是lastname.keyword这个域),而对需要全文搜索的域的常使用match子句(搜索值与文本域都经分析器转换过,保持一致)。term子句搜索时不对搜索结果评分(score),因此效率要高。
实际应用 中,如果不涉及全文搜索需求,可在过滤上下文中使用term子句。match和term子句还衍生出诸多变化(如match_phrase,match_phrase_prefix,multi_match,terms,),这些衍生的语法可参考文献1。
搜索语句中通常会使用多个match或term子句构建组合查询,ES提供must,should, must_not等逻辑运算符来组合一个复杂查询,构成一个布尔表达式。Query DSL可使用如下格式表示一个布尔表达式:{ "query": { "bool": { "must": [], "must_not":[], "should":[], "filter":[] } } }
bool子句为真需要满足bool子句中各子句的查询条件。must、should、must_not、filter子句均可为数组格式(只有一个子元素时可以写为对象格式),数组中每个子对象可为match或term子句。其中:must约束子元素之间的逻辑与(AND)关系,should约束子元素之间的逻辑或(OR)关系,must_not约束各子元素的逻辑非(NOT)关系。bool子句之间是可以嵌套的,一个bool子句可做为另一个bool子句的子条件,以构建更复杂层次的查询。
将上面的搜索条件增加一个子条件,查找姓“Barry”或名为“Burton”的最有钱的前三位同学,查询命令为如下形式:curl -iXPOST 'localhost:9200/bank/_search?pretty' -H 'Content-Type: application/json' -d' { "query": { "bool": { "filter": [ { "bool": { "should": [ { "term": { "lastname.keyword": "Barry" } }, { "term": { "firstname.keyword": "Burton" } } ] } }, { "range": { "balance": { "gte": 5000 } } } ] } }, "sort": { "balance": { "order": "desc" } }, "from": 0, "size": 3, "_source": [ "balance", "firstname", "lastname" ] } '
Query DSL中对于通配符和正则表达式使用专用的子句“wildcard”和“regexp”,与基于URI的搜索方式有些区别,请大家注意。
Query DSL比较强大,语法也多。我的意见是:不用一次就全部弄清楚全部的子句语法,需要时再去文献1查一下即可;使用本节的bool子句格式能够解决80%以上的问题;查询子句的层次越少越好,因为能提高效率;对于确切值的查询条件尽量放到filter子句中。
本节最后使用一张图总结一下Query DSL的常用语法:2.2.3. 搜索模板
ES的搜索API支持使用mustache语言渲染的搜索模板,上节的搜索示例可以改写成如下模板形式:
curl -iXPOST 'localhost:9200/bank/_search/template?pretty' -H 'Content-Type: application/json' -d' { "source": { "query": { "bool": { "must": { "match_all": {} }, "filter": { "range": { "{{field_1}}": { "gte": "{{_base}}" } } } } }, "sort": { "{{field_1}}": { "order": "desc" } }, "from": 0, "size": 3, "_source": [ "{{field_1}}", "{{field_2}}", "{{field_3}}" ] }, "params": { "_base": 5000, "field_1":"balance", "field_2":"firstname", "field_3":"lastname" } } '
在使用模板的搜索中,URI变为“_search/template”,模板使用“source”子句描述,模板的变参使用“{{}}”来标识,实参在“params”被定义。
实际使用中,使用的模板的好处在于我们只需为相同模式的查询定义一次即可,运行时只需赋值对应的实参即可。设计应用系统时利用预置的搜索模板可以提升开发效率,对于需要二次开发的系统而言模板也对外则屏蔽了实现细节。2.2.4. 嵌套对象搜索
考虑对域“fullname”的映射:
"fullname": { "properties": { "age": { "type": "long" }, "firstname": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "lastname": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } }
要在该索引中搜索名为“zhang”的记录,可写为如下简单的查询语句:
{ "_source": [ "fullname" ], "query": { "match": { "fullname.firstname": "zhang" } } }
其中用于匹配的字段“firstname”,使用“.”表示法来表明该属性属于“fullname”这个对象,而并非索引中的顶层域。
对于未显式在映射中定义嵌套子对象的属性,只以“nested”类型定义的子对象,在查询中需要使用“nested”子句查询,将“fullname”的映射修改未如下形式:{ "fullname": { "type": "nested" } }
上面的查询语句可改写为如下形式:
{ "_source": [ "fullname" ], "query": { "nested": { "path": "fullname", "query": { "match": { "fullname.firstname": "zhang" } } } } }
3. 关联查询
真实世界中数据之间的关系总是如此复杂。
关系数据库提供多表联合(join)查询,使用键来关联表之间的关系。如同其它Nosql数据库一样,ES建议在数据建模时扁平化,使得一个领域本体中的数据关系“内聚”在一个索引中。
在ES中对一个集群的多个索引进行搜索非常方便,在请求URI中填写需要参与搜索的索引即可,如“localhost:9200/_search?pretty”表示搜索集群中所有索引。而对多个域进行同一条件搜索,Query DSL也提供了“multi_match”这样的子句。
ES中对于相同的域名在搜索时不区分其属于哪个索引,因此并不能直接支持关系数据库中“table1.field1=table2.field1”这样的索引间(相当于表间)关联查询,文献2中讨论了四种管理关联数据的方式:
1. 在应用层模拟关系数据库连接:这种方法实际是将关系数据库的一次接连查询操作分解为两次查询操作,要求关联的两个索引的数据对象之间保存关联的外键。假设有索引index1包含{id11,field11,field12}属性,index2包含{id21,field21,field2,id11}属性, id11为index2的外键,对于涉及index1和index2的应用级查询,将其分解为两次查询:第一次根据查询条件中index1的字段查询出符合条件的id11,第二次根据第一次查询出的id11值及查询条件中index2的字段查询出符合条件记录。
2. 增加冗余副本数据:在一个索引中增加需要关联的另一个索引的数据,这样增加了第一个索引的冗余数据,使得关联查询只在第一个索引中进行。
3. 使用嵌套对象来保存关联关系:这种方式的思路是从领域顶层建模,将需要关联的原本独立的领域本体合并为一个总的本体,然后根据这个总的本体来建立映射。原来独立的领域本体对应的数据对象在总的数据对象中成为了嵌套的子对象。
4. 在对象之间建立父子关联关系:还记得我们在《编程随笔-ElasticSearch知识导图(3):映射》一文中提到,ES中可以设置文档对象之间为父子关系的内容吗?建立数据对象之间的父子关系本质上是建立一颗对象层次树,将对象之间的关联关系转换为树上节点间的父子(祖先)关系。Query DSL中提供了has_child、has_parent、parent_id这样的查询子句。
综上所述,在ES实现关联查询本质上使用的方式无外乎两种:- 把所有数据都建模到一个索引中(6.0版本后一个索引中只有一个类型(type)),上面讲到的后三种方式都基于这个思路;
- 数据对象分布在多个索引中,但对象间有字段进行关联,通过将联合查询分解为多个分步查询(只查询一个索引)得到最终结果。
似乎在ES中无法再现关系数据库强大的关联查询,但请注意,在关系数据库中执行多表的join操作也是非常低效的。根据应用需求,可以在ES中设计合理的数据模型去尽量满足关联查询。4. 一个设计实例
4.1. GA/T 1400.3的查询指令
在《编程随笔-ElasticSearch知识导图(3):映射》一文中,介绍了遵循GA/T 1400.3的视频图像信息数据库(以下简称视图库)的数据模型,并示例定义了视图库中索引的映射。
视图库中各索引对应GA/T 1400.3定义的各数据对象类型,视图库的数据模型是一个扁平的数据模型。不同类型的对象之间具有关联关系,通过对象中的外键字段进行关联。如人、车、物对象的来源标识字段与图像对象的图像标识关联。
视图库提供的数据服务接口完成对视图库的查询功能,视图库的查询接口是标准的restful风格的GET接口。每个查询接口对应一个数据对象类型(使用资源URI指示数据对象类型),使用查询字符串指示查询条件。GA/T 1400.3提供的一个查询指令示例如下://查找身高在1.60m~1.70m 之间,携带红色包的人员记录,返回结果按年龄上限排序,且只返回PersonID、SourceID两个属性。 GET /VIID/Persons?((Person.HeightUpLimit <=170) AND (Person.HeightLowerLimit >=160))&(Person.BagColor=Red)&(Sort = Person.AgeUpLimit)& (Fields= (PersonID,SourceID))
GA/T 1400.3的附录F中定义了视图库的查询指令规范,主要参照了SQL语言规范:定义了算术运算符、逻辑运算符、比较运算符、聚合函数及分页参数等。除了算术运算符在ES中不支持之外,其它的规范要求都可在ES的DSL中找到对应语法(注:根据视图库的对象类型定义,实际应用中在查询时基本不会有需要进行加减乘除计算的字段,但如果一定要保证规范的完整性,需要考虑一些变通手段)。
如果在基于视图库中的应用系统中需要涉及到关联查询,可以使用上节提到的在应用层分步查询的方法。4.2. 将查询指令转换为搜索API
分析一下视图库查询字符串的特点:查询字符串由多个子查询条件组成,每个子查询条件使用“&”连接(为逻辑与关系),子查询条件可分为三类:
1. 针对对象域的布尔表达式,该表达式可为复合逻辑表达式。
2.返回结果字段表达式(使用“Fields”关键字),指示出现在查询结果中的返回字段,这些字段中可能会有聚合函数(如最大、最小值)。
3.分页排序表达式,指示分页记录和位置及排序方式等,使用Sort,PageRecordNum,RecordStartNo,MaxNumRecordReturn等关键字。
可在视图库中编写一个查询指令转换器,解析查询字符串中的各子查询条件,并将这些子查询条件组合为ES支持的查询URI或查询消息体。
上节中提到的查询指令示例,转换为URI后的查询格式如下:curl -iXGET 'localhost:9200/person/_search?pretty&q=((PersonObject.HeightUpLimit:<=170)AND(PersonObject.HeightLowerLimit:>=160))AND(PersonObject.BagColor:4)&sort=PersonObject.AgeUpLimit:asc&_source=PersonObject.PersonID,PersonObject.SourceID'
转换为DSL描述的消息体如下:
{ "query": { "bool": { "filter": [ { "bool": { "must": [ { "range": { "PersonObject.HeightUpLimit": { "lte": 170 } } }, { "range": { "PersonObject.HeightLowerLimit": { "gte": 160 } } } ] } }, { "term": { "PersonObject.BagColor": "4" } } ] } }, "sort": { " PersonObject.AgeUpLimit": { "order": "asc" } }, "_source": [ "PersonObject.PersonID", "PersonObject.SourceID" ] }
5. 参考文献
- https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html
- Clinton Gormley &Zachary Tong, Elasticsearch: The Definitive Guide,2015
- Debasish Ghosh,DSLs in action, 2013
- GA/T 1400.3 公安视频图像信息应用系统 第3部分:数据库技术要求,2017