Elasticsearch系列---前缀搜索和模糊搜索
### 概要
本篇我们介绍一下部分搜索的几种玩法,我们经常使用的浏览器搜索框,输入时会弹出下拉提示,也是基于局部搜索原理实现的。
### 前缀搜索
我们在前面了解的搜索,词条是小的匹配单位,也是倒排索引中存在的词,现在我们来聊聊部分匹配的话题,只匹配一个词条中的一部分内容,相当于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"时,会有这样的提示框出现:
![](https://imgkr.cn-bj.ufileos.com/449ebb87-7791-456c-bf16-89d8c058e5ea.png)
浏览器捕捉每一个输入事件,每输入一个字符,向后台发一次请求,将你搜索的内容作为搜索前缀,搜索相关的当前热点的前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架构社区
相关文章