Elasticsearch系列---前缀搜索和模糊搜索

2020-05-22 00:00:00 索引 前缀 匹配 性能 长度

### 概要


本篇我们介绍一下部分搜索的几种玩法,我们经常使用的浏览器搜索框,输入时会弹出下拉提示,也是基于局部搜索原理实现的。


### 前缀搜索


我们在前面了解的搜索,词条是小的匹配单位,也是倒排索引中存在的词,现在我们来聊聊部分匹配的话题,只匹配一个词条中的一部分内容,相当于mysql的"where content like '%love%'",在数据库里一眼就能发现这种查询是不走索引的,效率非常低。


Elasticsearch对这种搜索有特殊的拆分处理,支持多种部分搜索格式,这次重点在于not_analyzed值字段的前缀匹配。


#### 前缀搜索语法


我们常见的可能有前缀搜需求的有邮编、产品序列号、快递单号、证件号的搜索,这些值的内容本身包含一定的逻辑分类含义,如某个前缀表示地区、年份等信息,我们以邮编为例子:


```java


# 只创建一个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"}

```


前缀搜索示例:

```java

GET /demo_index/address/_search

{

"query": {

"prefix": {

"postcode": {

"value": "511"

}

}

}

}

```


搜索结果可以看到两条,符合预期:

```java

{

"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。


我们分析一下示例的搜索过程:

1. 索引文档时,先建立倒排索引,keyword没有分词的操作,直接建立索引,简易示例表如下:


| postcode | doc ids |

| :---- | -----: |

| 510000 | 1 |

| 514000 | 2 |

| 527100 | 3 |

| 511500 | 4 |

| 511100 | 5 |


2. 如果是全文搜索,搜索字符串"511",没有匹配的结果,直接返回为空。

3. 如果是前缀搜索,扫描到一个"511500",继续搜索,又得到一个"511100",继续搜,直到整个倒排索引全部搜索完,返回结果结束。


从这个过程我们可以发现:match的搜索性能还是非常高的,前缀搜索由于要遍历索引,性能相对低一些,但有些场景,却是只有前缀搜索才能胜任。如果前缀的长度越长,那么能匹配的文档相对越少,性能会好一些,如果前缀太短,只有一个字符,那么匹配数据量太多,会影响性能,这点要注意。


#### 通配符和正则搜索


通配符搜索和正则表达式搜索跟前缀搜索类型,只是功能更丰富一些。


##### 通配符


常规的符号:?任意一个字符,0个或任意多个字符,示例:

```java

GET /demo_index/address/_search

{

"query": {

"wildcard": {

"postcode": {

"value": "*110*"

}

}

}

}

```


##### 正则搜索


即搜索字符串是用正则表达式来写的,也是常规的格式:


```java

GET /demo_index/address/_search

{

"query": {

"regexp": {

"postcode": {

"value": "[0-9]11.+"

}

}

}

}

```


这两种算是一种语法介绍,可以让我们编写更灵活的查询请求,但性能都不怎么好,所以用得也不多。


### 即时搜索


我们使用搜索引擎时,会发现搜索框里会有相关性的词语提示,如下我们在google网站上搜索"Elasticsearch"时,会有这样的提示框出现:


![](imgkr.cn-bj.ufileos.com)



浏览器捕捉每一个输入事件,每输入一个字符,向后台发一次请求,将你搜索的内容作为搜索前缀,搜索相关的当前热点的前10条数据,返回给你,用来辅助你完成输入,baidu也有类似的功能。


这种实现原理是基于前缀搜索来完成的,只是google/baidu的后台实现更复杂,我们可以站在Elasticsearch的视角上来模拟即时搜索:


```java

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,如下示例:


```java

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


这样的拆分特别符合我们的搜索习惯。


#### 案例

1. 创建一个索引,指定filter

```java

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。


2. 在自定义分析器autocomplete中使用上面这个token过滤器


```java

PUT /demo_index/_mapping/_doc

{

"properties": {

"title": {

"type": "text",

"analyzer": "autocomplete",

"search_analyzer": "standard"

}

}

}

```


3. 我们可以测试一下效果


```java

GET /demo_index/_analyze

{

"analyzer": "autocomplete",

"text": "love you"

}

```


响应结果:


```java

{

"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

}

]

}

```


测试结果符合预期。


4. 增加一点测试数据


```java

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"}

```


5. 使用简单的match查询

```java

GET /demo_index/_doc/_search

{

"query": {

"match": {

"title": "love ev"

}

}

}

```


响应结果:

```java

{

"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的也会出来,全文检索,只是分数比较低。




6. 使用match_phrase


推荐使用match_phrase,要求每个term都有,而且position刚好靠着1位,符合我们的期望的。


```java

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比任何一种基于词的查询都要快很多。


#### 示例


1. 指定title.fields字段为completion类型

```java

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"}

```


3. 搜索请求及响应


```java

GET /music/children/_search

{

"suggest": {

"my-suggest": {

"prefix": "children music",

"completion": {

"field":"title.suggest"

}

}

}

}

```


响应如下,有删节:

```java

{

"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搜索可以针对输入拼写错误的单词,有一定的纠错功能,示例:

```java

GET /music/children/_search

{

"query": {

"fuzzy": {

"name": {

"value": "teath",

"fuzziness": 2

}

}

}

}

```


fuzziness:多纠正的字母个数,默认是2,有限制,设置太大也是的,不能无限加大,错误太多了也纠正不了。


常规用法:match内嵌套一个fuzziness,设置为auto。

```java

GET /music/children/_search

{

"query": {

"match": {

"name": {

"query": "teath",

"fuzziness": "AUTO",

"operator": "and"

}

}

}

}

```


了解一下即可。


### 小结


本篇介绍了前缀搜索,通配符搜索和正则搜索的基本玩法,对前缀搜索的性能影响和控制手段做了简单讲解,ngram在索引时局部搜索和搜索提示是非常经典的做法,后顺带介绍了一下模糊搜索的常规用法,可以了解一下。

专注Java高并发、分布式架构,更多技术干货分享与心得,请关注公众号:Java架构社区

相关文章