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架構社區

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章