概要
本篇我們介紹一下部分搜索的幾種玩法,我們經常使用的瀏覽器搜索框,輸入時會彈出下拉提示,也是基於局部搜索原理實現的。
前綴搜索
我們在前面瞭解的搜索,詞條是最小的匹配單位,也是倒排索引中存在的詞,現在我們來聊聊部分匹配的話題,只匹配一個詞條中的一部分內容,相當於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。
我們分析一下示例的搜索過程:
- 索引文檔時,先建立倒排索引,keyword沒有分詞的操作,直接建立索引,簡易示例表如下:
postcode | doc ids |
---|---|
510000 | 1 |
514000 | 2 |
527100 | 3 |
511500 | 4 |
511100 | 5 |
- 如果是全文搜索,搜索字符串"511",沒有匹配的結果,直接返回爲空。
- 如果是前綴搜索,掃描到一個"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
這樣的拆分特別符合我們的搜索習慣。
案例
- 創建一個索引,指定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。
- 在自定義分析器autocomplete中使用上面這個token過濾器
PUT /demo_index/_mapping/_doc
{
"properties": {
"title": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "standard"
}
}
}
- 我們可以測試一下效果
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
}
]
}
測試結果符合預期。
- 增加一點測試數據
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"}
- 使用簡單的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的也會出來,全文檢索,只是分數比較低。
- 使用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比任何一種基於詞的查詢都要快很多。
示例
- 指定title.fields字段爲completion類型
PUT /music
{
"mappings": {
"children" :{
"properties": {
"title": {
"type": "text",
"fields": {
"suggest": {
"type":"completion"
}
}
},
"content": {
"type": "text"
}
}
}
}
}
- 插入一些示例數據
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"}
- 搜索請求及響應
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架構社區微信羣共同探討技術