Elasticsearch系列---前缀搜索和模糊搜索
本篇我们介绍一下部分搜索的几种玩法,我们经常使用的浏览器搜索框,输入时会弹出下拉提示,也是基于局部搜索原理实现的。
前缀搜索
我们在前面了解的搜索,词条是最小的匹配单位,也是倒排索引中存在的词,现在我们来聊聊部分匹配的话题,只匹配一个词条中的一部分内容,相当于mysql的"where content like ‘%love%‘",在数据库里一眼就能发现这种查询是不走索引的,效率非常低。
Elasticsearch对这种搜索有特殊的拆分处理,支持多种部分搜索格式,这次重点在于not_analyzed精确值字段的前缀匹配。
前缀搜索语法
我们常见的可能有前缀搜需求的有邮编、产品序列号、快递单号、证件号的搜索,这些值的内容本身包含一定的逻辑分类含义,如某个前缀表示地区、年份等信息,我们以邮编为例子:
# 只创建一个postcode字段,类型为keyword PUT /demo_index { "mappings": { "address": { "properties": { "postcode": { "type": "keyword" } } } } } # 导入一些示例的邮编 POST /demo_index/address/_bulk { "index": { "_id": 1 }} { "postcode" : "510000"} { "index": { "_id": 2 }} { "postcode" : "514000"} { "index": { "_id": 3 }} { "postcode" : "527100"} { "index": { "_id": 4 }} { "postcode" : "511500"} { "index": { "_id": 5 }} { "postcode" : "511100"}
前缀搜索示例:
GET /demo_index/address/_search { "query": { "prefix": { "postcode": { "value": "511" } } } }
搜索结果可以看到两条,符合预期:
{ "took": 3, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 2, "max_score": 1, "hits": [ { "_index": "demo_index", "_type": "address", "_id": "5", "_score": 1, "_source": { "postcode": "511100" } }, { "_index": "demo_index", "_type": "address", "_id": "4", "_score": 1, "_source": { "postcode": "511500" } } ] } }
前缀搜索原理
prefix query不计算relevance score,_score固定为1,与prefix filter唯一的区别就是,filter会cache bitset。
我们分析一下示例的搜索过程:
- 索引文档时,先建立倒排索引,keyword没有分词的操作,直接建立索引,简易示例表如下:
postcode | doc ids |
---|---|
510000 | 1 |
514000 | 2 |
527100 | 3 |
511500 | 4 |
511100 | 5 |
- 如果是全文搜索,搜索字符串"511",没有匹配的结果,直接返回为空。
- 如果是前缀搜索,扫描到一个"511500",继续搜索,又得到一个"511100",继续搜,直到整个倒排索引全部搜索完,返回结果结束。
从这个过程我们可以发现:match的搜索性能还是非常高的,前缀搜索由于要遍历索引,性能相对低一些,但有些场景,却是只有前缀搜索才能胜任。如果前缀的长度越长,那么能匹配的文档相对越少,性能会好一些,如果前缀太短,只有一个字符,那么匹配数据量太多,会影响性能,这点要注意。
通配符和正则搜索
通配符搜索和正则表达式搜索跟前缀搜索类型,只是功能更丰富一些。
通配符
常规的符号:?任意一个字符,0个或任意多个字符,示例:
GET /demo_index/address/_search { "query": { "wildcard": { "postcode": { "value": "*110*" } } } }
正则搜索
即搜索字符串是用正则表达式来写的,也是常规的格式:
GET /demo_index/address/_search { "query": { "regexp": { "postcode": { "value": "[0-9]11.+" } } } }
这两种算是一种高级语法介绍,可以让我们编写更灵活的查询请求,但性能都不怎么好,所以用得也不多。
即时搜索
我们使用搜索引擎时,会发现搜索框里会有相关性的词语提示,如下我们在google网站上搜索"Elasticsearch"时,会有这样的提示框出现:
【图26】
浏览器捕捉每一个输入事件,每输入一个字符,向后台发一次请求,将你搜索的内容作为搜索前缀,搜索相关的当前热点的前10条数据,返回给你,用来辅助你完成输入,baidu也有类似的功能。
这种实现原理是基于前缀搜索来完成的,只是google/baidu的后台实现更复杂,我们可以站在Elasticsearch的视角上来模拟即时搜索:
GET /demo_index/website/_search { "query": { "match_phrase_prefix": { "title": "Elasticsearch q" } } }
原理跟match_phrase,只是最后一个term是作前缀来搜索的。
即搜索字符串"Elasticsearch q",Elasticsearch做普通的match查询,而"q"作前缀搜索,会去扫描整个倒排索引,找到所有q开头的文档,然后找到所有文档中,既包含Elasticsearch,又包含以q开头字符的文档。
当然这个查询支持slop参数。
max_expansions参数
前缀查询时我们提到了前缀太短会有性能的风险,此时我们可以通过max_expansions参数来降低前缀过短带来的性能问题,建议的值是50,如下示例:
GET /demo_index/website/_search { "query": { "match_phrase_prefix": { "postcode": { "query": "Elasticsearch q", "max_expansions": 50 } } } }
max_expansions的作用是控制与前缀匹配的词的数量,它会先查找第一个与前缀"q" 匹配的词,然后依次查找搜集与之匹配的词(按字母顺序),直到没有更多可匹配的词或当数量超过max_expansions时结束。
我们使用google搜索资料时,关键是输一个字符请求一次,这样我们就可以使用max_expansions去控制匹配的文档数量,因为我们会不停的输入,直到想要搜索的内容输入完毕或挑到合适的提示语之后,才会点击搜索按钮进行网页的搜索。
所以使用match_phrase_prefix记得一定要带上max_expansions参数,要不然输入第一个字符的时候,性能实在是太低了。
ngram的应用
前面我们用的部分查询,没有作索引做过特殊的设置,这种解决方案叫做查询时(query time)实现,这种无侵入性和灵活性通常以牺牲搜索性能为代价,还有一种方案叫索引时(index time),对索引的设置有侵入,提前完成一些搜索的准备工作,对性能提升有非常大的帮助。如果某些功能的实时性要求比较高,由查询时转为索引时是一个非常好的实践。
前缀搜索功能看具体的使用场景,如果是在一级功能的入口处,承担着大部分的流量,建议使用索引时,我们先来了解一下ngram。
ngrams是什么
前缀查询是通过挨个匹配来达到查找目的的,整个过程有些盲目,搜索量又大,所以性能比较低,但如果我事先把这些关键词,按照一定的长度拆分出来,就又可以回到match查询这种高效率的方式了。ngrams其实就是拆分关键词的一个滑动窗口,窗口的长度可以设置,我们拿"Elastic"举例,7种长度下的ngram:
- 长度1:[E,l,a,s,t,i,c]
- 长度2:[El,la,as,st,ti,ic]
- 长度3:[Ela,las,ast,sti,tic]
- 长度4:[Elas,last,asti,stic]
- 长度5:[Elast,lasti,astic]
- 长度6:[Elasti,lastic]
- 长度7:[Elastic]
可以看到,长度越长,拆分的词越少。
每个拆分出来的词都会加入到倒排索引中,这样就可以进行match搜索了。
还有一种特殊的edge ngram,拆词时它只留下首字母开头的词,如下:
- 长度1:E
- 长度2:El
- 长度3:Ela
- 长度4:Elas
- 长度5:Elast
- 长度6:Elasti
- 长度7:Elastic
这样的拆分特别符合我们的搜索习惯。
案例
- 创建一个索引,指定filter
PUT /demo_index { "settings": { "analysis": { "filter": { "autocomplete_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20 } }, "analyzer": { "autocomplete": { "type": "custom", "tokenizer": "standard", "filter": [ "lowercase", "autocomplete_filter" ] } } } } }
filter的意思是对于这个token过滤器接收的任意词项,过滤器会为之生成一个最小固定值为1,最大为20的n-gram。
- 在自定义分析器autocomplete中使用上面这个token过滤器
PUT /demo_index/_mapping/_doc { "properties": { "title": { "type": "text", "analyzer": "autocomplete", "search_analyzer": "standard" } } }
- 我们可以测试一下效果
GET /demo_index/_analyze { "analyzer": "autocomplete", "text": "love you" }
响应结果:
{ "tokens": [ { "token": "l", "start_offset": 0, "end_offset": 4, "type": "<ALPHANUM>", "position": 0 }, { "token": "lo", "start_offset": 0, "end_offset": 4, "type": "<ALPHANUM>", "position": 0 }, { "token": "lov", "start_offset": 0, "end_offset": 4, "type": "<ALPHANUM>", "position": 0 }, { "token": "love", "start_offset": 0, "end_offset": 4, "type": "<ALPHANUM>", "position": 0 }, { "token": "y", "start_offset": 5, "end_offset": 8, "type": "<ALPHANUM>", "position": 1 }, { "token": "yo", "start_offset": 5, "end_offset": 8, "type": "<ALPHANUM>", "position": 1 }, { "token": "you", "start_offset": 5, "end_offset": 8, "type": "<ALPHANUM>", "position": 1 } ] }
测试结果符合预期。
- 增加一点测试数据
PUT /demo_index/_doc/_bulk { "index": { "_id": "1"} } { "title" : "love"} { "index": { "_id": "2"}} {"title" : "love me"} } { "index": { "_id": "3"}} {"title" : "love you"} } { "index": { "_id": "4"}} {"title" : "love every one"}
- 使用简单的match查询
GET /demo_index/_doc/_search { "query": { "match": { "title": "love ev" } } }
响应结果:
{ "took": 1, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 2, "max_score": 0.83003354, "hits": [ { "_index": "demo_index", "_type": "_doc", "_id": "4", "_score": 0.83003354, "_source": { "title": "love every one" } }, { "_index": "demo_index", "_type": "_doc", "_id": "1", "_score": 0.41501677, "_source": { "title": "love" } } ] } }
如果用match,只有love的也会出来,全文检索,只是分数比较低。
- 使用match_phrase
推荐使用match_phrase,要求每个term都有,而且position刚好靠着1位,符合我们的期望的。
GET /demo_index/_doc/_search { "query": { "match_phrase": { "title": "love ev" } } }
我们可以发现,大多数工作都是在索引阶段完成的,所有的查询只需要执行match或match_phrase即可,比前缀查询效率高了很多。
搜索提示
Elasticsearch还支持completion suggest类型实现搜索提示,也叫自动完成auto completion。
completion suggest原理
建立索引时,要指定field类型为completion,Elasticsearch会为搜索字段生成一个所有可能完成的词列表,然后将它们置入一个有限状态机(finite state transducer 内,这是个经优化的图结构。
执行搜索时,Elasticsearch从图的开始处顺着匹配路径一个字符一个字符地进行匹配,一旦它处于用户输入的末尾,Elasticsearch就会查找所有可能结束的当前路径,然后生成一个建议列表,并且把这个建议列表缓存在内存中。
性能方面completion suggest比任何一种基于词的查询都要快很多。
示例
- 指定title.fields字段为completion类型
PUT /music { "mappings": { "children" :{ "properties": { "title": { "type": "text", "fields": { "suggest": { "type":"completion" } } }, "content": { "type": "text" } } } } }
2. 插入一些示例数据 ```java PUT /music/children/_bulk { "index": { "_id": "1"} } { "title":"children music London Bridge", "content":"London Bridge is falling down"} { "index": { "_id": "2"}} {"title":"children music Twinkle", "content":"twinkle twinkle little star"} { "index": { "_id": "3"}} {"title":"children music sunshine", "content":"you are my sunshine"}
- 搜索请求及响应
GET /music/children/_search { "suggest": { "my-suggest": { "prefix": "children music", "completion": { "field":"title.suggest" } } } }
响应如下,有删节:
{ "took": 26, "timed_out": false, "suggest": { "my-suggest": [ { "text": "children music", "offset": 0, "length": 14, "options": [ { "text": "children music London Bridge", "_index": "music", "_type": "children", "_id": "1", "_score": 1, "_source": { "title": "children music London Bridge", "content": "London Bridge is falling down" } }, { "text": "children music Twinkle", "_index": "music", "_type": "children", "_id": "2", "_score": 1, "_source": { "title": "children music Twinkle", "content": "twinkle twinkle little star" } }, { "text": "children music sunshine", "_index": "music", "_type": "children", "_id": "3", "_score": 1, "_source": { "title": "children music sunshine", "content": "you are my sunshine" } } ] } ] } }
这样返回的值,就可以作为提示语补充到前端页面上,如数据填充到浏览器的下拉框里。
模糊搜索
fuzzy搜索可以针对输入拼写错误的单词,有一定的纠错功能,示例:
GET /music/children/_search { "query": { "fuzzy": { "name": { "value": "teath", "fuzziness": 2 } } } }
fuzziness:最多纠正的字母个数,默认是2,有限制,设置太大也是无效的,不能无限加大,错误太多了也纠正不了。
常规用法:match内嵌套一个fuzziness,设置为auto。
GET /music/children/_search { "query": { "match": { "name": { "query": "teath", "fuzziness": "AUTO", "operator": "and" } } } }
了解一下即可。
小结
本篇介绍了前缀搜索,通配符搜索和正则搜索的基本玩法,对前缀搜索的性能影响和控制手段做了简单讲解,ngram在索引时局部搜索和搜索提示是非常经典的做法,最后顺带介绍了一下模糊搜索的常规用法,可以了解一下。
专注Java高并发、分布式架构,更多技术干货分享与心得,请关注公众号:Java架构社区
可以扫左边二维码添加好友,邀请你加入Java架构社区微信群共同探讨技术
相关推荐
另外一部分,则需要先做聚类、分类处理,将聚合出的分类结果存入ES集群的聚类索引中。数据处理层的聚合结果存入ES中的指定索引,同时将每个聚合主题相关的数据存入每个document下面的某个field下。