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

2,931

概要

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

前缀搜索

我们在前面了解的搜索,词条是最小的匹配单位,也是倒排索引中存在的词,现在我们来聊聊部分匹配的话题,只匹配一个词条中的一部分内容,相当于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。

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

  1. 索引文档时,先建立倒排索引,keyword没有分词的操作,直接建立索引,简易示例表如下:
postcode doc ids
510000 1
514000 2
527100 3
511500 4
511100 5
  1. 如果是全文搜索,搜索字符串"511",没有匹配的结果,直接返回为空。
  2. 如果是前缀搜索,扫描到一个"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"时,会有这样的提示框出现:

浏览器捕捉每一个输入事件,每输入一个字符,向后台发一次请求,将你搜索的内容作为搜索前缀,搜索相关的当前热点的前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

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

案例

  1. 创建一个索引,指定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。

  1. 在自定义分析器autocomplete中使用上面这个token过滤器
PUT /demo_index/_mapping/_doc
{
  "properties": {
      "title": {
          "type":     "text",
          "analyzer": "autocomplete",
          "search_analyzer": "standard"
      }
  }
}
  1. 我们可以测试一下效果
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
    }
  ]
}

测试结果符合预期。

  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"} 
  1. 使用简单的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的也会出来,全文检索,只是分数比较低。

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

示例

  1. 指定title.fields字段为completion类型
PUT /music
{
  "mappings": {
    "children" :{
      "properties": {
        "title": {
          "type": "text",
          "fields": {
            "suggest": {
              "type":"completion"
            }
          }
        },
        "content": {
          "type": "text"
        }
      }
    }
  }
}

  1. 插入一些示例数据
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"} 
  1. 搜索请求及响应
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架构社区微信群共同探讨技术 ![Java架构社区]

(p1-jj.byteimg.com/tos-cn-i-t2…)