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

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

    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架构社区

  • 相关阅读:
    stop slave卡住
    ERROR 1193 (HY000): Unknown system variable ‘rpl_semi_sync_master_enabled‘
    sqlalchemy QueuePool limit of size 3 overflow 20 reached, connection timed out, timeout
    Orchestrator 集群扩容后新节点无法加入集群
    关于golang database_sql 包
    MySQL主从集群搭建实战
    数据库分库分表事务解决方案
    如何编写单元测试-基于Spring
    扩展Spring切面
    springboot自定义配置源
  • 原文地址:https://www.cnblogs.com/huangying2124/p/12544098.html
Copyright © 2020-2023  润新知