問題背景
搜尋關鍵字智慧提示是一個搜尋應用的標配,主要作用是避免使用者輸入錯誤的搜尋詞,並將使用者引導到相應的關鍵詞上,以提升使用者搜尋體驗。
美團CRM系統中存在數以百萬計的商家,為了讓使用者快速查詢到目標商家,我們基於solrcloud實現了商家搜尋模組。使用者在查詢商家時主要輸入商戶名、商戶地址進行搜尋,為了提升使用者的搜尋體驗和輸入效率,本文實現了一種基於solr字首匹配查詢關鍵字智慧提示(Suggestion)實現。
需求分析
- 支援字首匹配原則
在搜尋框中輸入“海底”,搜尋框下面會以海底為字首,展示“海底撈”、“海底撈火鍋”、“海底世界”等等搜尋詞;輸入“萬達”,會提示“萬達影城”、“萬達廣場”、“萬達百貨”等搜尋詞。 - 同時支援漢字、拼音輸入
由於中文的特點,如果搜尋自動提示可以支援拼音的話會給使用者帶來更大的方便,免得切換輸入法。比如,輸入“haidi”提示的關鍵字和輸入“海底”提示的一樣,輸入“wanda”與輸入“萬達”提示的關鍵字一樣。 - 支援多音字輸入提示
比如輸入“chongqing”或者“zhongqing”都能提示出“重慶火鍋”、“重慶烤魚”、“重慶小天鵝”。 - 支援拼音縮寫輸入
對於較長關鍵字,為了提高輸入效率,有必要提供拼音縮寫輸入。比如輸入“hd”應該能提示出“haidi”相似的關鍵字,輸入“wd”也一樣能提示出“萬達”關鍵字。 - 基於使用者的歷史搜尋行為,按照關鍵字熱度進行排序
為了提供suggest關鍵字的準確度,最終查詢結果,根據使用者查詢關鍵字的頻率進行排序,如輸入[重慶,chongqing,cq,zhongqing,zq] —> [“重慶火鍋”(f1),“重慶烤魚”(f2),“重慶小天鵝”(f3),…],查詢頻率f1 > f2 > f3。
解決方案
- 關鍵字收集
當使用者輸入一個字首時,碰到提示的候選詞很多的時候,如何取捨,哪些展示在前面,哪些展示在後面?這就是一個搜尋熱度的問題。使用者在使用搜尋引擎查詢商家時,會輸入大量的關鍵字,每一次輸入就是對關鍵字的一次投票,那麼關鍵字被輸入的次數越多,它對應的查詢就比較熱門,所以需要把查詢的關鍵字記錄下來,並且統計出每個關鍵字的頻率,方便提示結果按照頻率排序。搜尋引擎會通過日誌檔案把使用者每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度為1-255位元組。 - 漢字轉拼音
使用者輸入的關鍵字可能是漢字、數字,英文,拼音,特殊字元等等,由於需要實現拼音提示,我們需要把漢字轉換成拼音,java中考慮使用pinyin4j元件實現轉換。 - 拼音縮寫提取
考慮到需要支援拼音縮寫,漢字轉換拼音的過程中,順便提取出拼音縮寫,如“chongqing”,”zhongqing”—>”cq”,”zq”。 - 多音字全排列
要支援多音字提示,對查詢串轉換成拼音後,需要實現一個全排列組合,字串多音字全排列演算法如下:
12345678910111213141516171819202122232425262728293031323334public static List getPermutationSentence(List > termArrays,int start) {if (CollectionUtils.isEmpty(termArrays))return Collections.emptyList();int size = termArrays.size();if (start = size) {return Collections.emptyList();}if (start == size-1) {return termArrays.get(start);}List strings = termArrays.get(start);List permutationSentences = getPermutationSentence(termArrays, start + 1);if (CollectionUtils.isEmpty(strings)) {return permutationSentences;}if (CollectionUtils.isEmpty(permutationSentences)) {return strings;}List result = new ArrayList();for (String pre : strings) {for (String suffix : permutationSentences) {result.add(pre+suffix);}}return result;} - 索引與字首查詢
方案一 Trie樹 + TopK演算法
Trie樹即字典樹,又稱單詞查詢樹或鍵樹,是一種樹形結構,是一種雜湊樹的變種。典型應用是用於統計和排序大量的字串(但不僅限於字串),所以經常被搜尋引擎系統用於文字詞頻統計。它的優點是:最大限度地減少無謂的字串比較,查詢效率比雜湊表高。Trie是一顆儲存多個字串的樹。相鄰節點間的邊代表一個字元,這樣樹的每條分支代表一則子串,而樹的葉節點則代表完整的字串。和普通樹不同的地方是,相同的字串字首共享同一條分支。例如,給出一組單詞inn, int, at, age, adv, ant, 我們可以得到下面的Trie:
從上圖可知,當使用者輸入字首i的時候,搜尋框可能會展示以i為字首的“in”,“inn”,”int”等關鍵詞,再當使用者輸入字首a的時候,搜尋框裡面可能會提示以a為字首的“ate”等關鍵詞。如此,實現搜尋引擎智慧提示suggestion的第一個步驟便清晰了,即用trie樹儲存大量字串,當字首固定時,儲存相對來說比較熱的字尾。
TopK演算法用於解決統計熱詞的問題。解決TopK問題主要有兩種策略:hashMap統計+排序、堆排序
hashmap統計: 先對這批海量資料預處理。具體方法是:維護一個Key為Query字串,Value為該Query出現次數的HashTable,即hash_map(Query,Value),每次讀取一個Query,如果該字串不在Table中,那麼加入該字串,並且將Value值設為1;如果該字串在Table中,那麼將該字串的計數加一即可,最終在O(N)的時間複雜度內用Hash表完成了統計。
堆排序:藉助堆這個資料結構,找出Top K,時間複雜度為N‘logK。即藉助堆結構,我們可以在log量級的時間內查詢和調整/移動。因此,維護一個K(該題目中是10)大小的小根堆,然後遍歷300萬的Query,分別和根元素進行對比。所以,我們最終的時間複雜度是:O(N) + N’ * O(logK),(N為1000萬,N’為300萬)。
該方案存在的問題是:
- 建索引和查詢的時候都要把漢字轉換成拼音,查詢完成後還得把拼音轉換成漢字顯示,且需要考慮數字和特殊字元。
- 需要維護拼音、縮寫兩棵Trie樹。
方案二 Solr自帶Suggest智慧提示
Solr作為一個應用廣泛的搜尋引擎系統,它內建了智慧提示功能,叫做Suggest模組。該模組可選擇基於提示詞文字做智慧提示,還支援通過針對索引的某個欄位建立索引詞庫做智慧提示。 (詳見solr的wiki頁面http://wiki.apache.org/solr/Suggester)
該方案存在的問題是:
- 返回的結果是基於索引中欄位的詞頻進行排序,不是使用者搜尋關鍵字的頻率,因此不能將一些熱門關鍵字排在前面。
- 拼音提示,多音字,縮寫還是要另外加索引欄位。
方案三 Solrcloud建立單獨的collection,利用solr字首查詢實現
如前所述,以上兩個方案在實施起來都存在一些問題,Trie樹+TopK演算法,在處理漢字suggest時不是很優雅,且需要維護兩棵Trie樹,實施起來比較複雜;Solr自帶的suggest智慧提示元件存在問題是使用freq排序演算法,返回的結果完全基於索引中字元的出現次數,沒有兼顧使用者搜尋詞語的頻率,因此無法將一些熱門詞排在更靠前的位置。於是,我們繼續尋找一種解決這個問題更加優雅的方案。
至此,我們考慮專門為關鍵字建立一個索引collection,利用solr字首查詢實現。solr中的copyField能很好解決我們同時索引多個欄位(漢字、pinyin, abbre)的需求,且field的multiValued屬性設定為true時能解決同一個關鍵字的多音字組合問題。配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
schema.xml: ------------------multiValued表示欄位是多值的-------------- kwsuggest 說明: kw為原始關鍵字 pinyin和abbre的multiValued=true,在使用solrj建此索引時,定義成集合型別即可:如關鍵字“重慶”的pinyin欄位為{chongqing,zhongqing}, abbre欄位為{cq, zq} kwfreq為使用者搜尋關鍵的頻率,用於查詢的時候排序 ---------suggest_text---------- <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true" /> |
KeywordTokenizerFactory:這個分詞器不進行任何分詞!整個字元流變為單個詞元。String域型別也有類似的效果,但是它不能配置文字分析的其它處理元件,比如大小寫轉換。任何用於排序和大部分Faceting功能的索引域,這個索引域只有能一個原始域值中的一個詞元。
字首查詢構造:
1 2 3 4 5 6 7 8 9 10 11 12 |
private SolrQuery getSuggestQuery(String prefix, Integer limit) { SolrQuery solrQuery = new SolrQuery(); StringBuilder sb = new StringBuilder(); sb.append(“suggest:").append(prefix).append("*"); solrQuery.setQuery(sb.toString()); solrQuery.addField("kw"); solrQuery.addField("kwfreq"); solrQuery.addSort("kwfreq", SolrQuery.ORDER.desc); solrQuery.setStart(0); solrQuery.setRows(limit); return solrQuery; } |
效果如下圖所示:
參考
- 從Trie樹談到字尾樹 http://blog.csdn.net/v_july_v/article/details/6897097
- 搜尋智慧提示suggestion,附近地點搜尋 http://blog.csdn.net/v_july_v/article/details/11288807
- solr suggester http://wiki.apache.org/solr/Suggester