響應速度與智能化如何平衡,攜程酒店搜索實踐

概覽

隨着線上旅遊業務的不斷髮展,攜程酒店的數據量不斷增加,用戶對於搜索功能的要求也在不斷提高。攜程酒店搜索系統是一個基於Lucene開發的類似Solar的搜索引擎系統,本文將從四個部分描述對搜索引擎的優化。

第一部分,通過優化存儲來降低響應時延,提升用戶體驗,降低硬件成本。第二三部分,通過召回和糾錯的智能化來提升用戶體驗。第四部分,通過重新設計搜索DSL提高業務靈活性和研發效率。本文也描述了在優化過程中遇到的各種問題和解決方法。

一、存儲優化

1.1 數據壓縮

在Lucene 8中,long型的數據會被自動壓縮存儲。我們可以去除搜索shema中原有的byte、short、int類型,對整型字段統一使用long類型存儲,而不用擔心其佔用多餘的空間。這既降低了對內存和磁盤的需求,也降低了運維的人力成本。

1.2 空間索引

在地理查詢和存儲這塊,使用PointValues來替換原來的GeoHash索引。PointValues是從Lucene 6開始引入的一個新特性,使用kd樹作爲地理空間數據結構,來加速幾何圖形內點的過濾篩選。

踩過的坑

1)儘管Lucene官方極力宣傳PointValues的性能優勢,也許在二維地理搜索場景下是這樣,但是在一維數據中其性能還是遠遜於普通的倒排索引,甚至不如走逐個訪問過濾。究其原因是PointValue中KD樹的節點都是壓縮存儲,其CPU時間大部分消耗在對存儲的解壓和反序列化,造成浪費。

2)而對於高維空間的搜索,例如通過word2vec的詞向量搜索某個詞的相似詞,無論是KD樹還是VP樹,其時間複雜度都會退化到不可忍受的地步。

1.3 KV存儲

搜索流程不僅需要依靠倒排的索引,也需要正排的數據。在過濾和排序的搜索步驟中,需要根據主鍵來訪問doc的一些維度信息,來判斷該doc是否滿足過濾條件,或者用來計算這個doc的排序分數。

在早期Solar版本中,使用了FieldCache——一種內存中SST來保存這些KV數據。從Lucene 4開始,DocValues作爲KV數據的一種磁盤存儲方案。在Lucene 7版本中,使用倒排索引中的DISI作爲DocValues的索引,而FieldCache已經被移除。在Lucene 8版本中,DocVaues添加了jump table來增強其隨機訪問能力。

Lucene DocValues相對於FieldCache的優勢是:

1)存儲在磁盤,對內存需求減少。

2)存儲經過壓縮,消耗資源進一步減少。

Lucene內部的KV存儲有一定侷限性,例如:

1)使用磁盤的存儲時,需要將byte數組反序列化,還是略慢於內存中直接存儲的數據結構。

2)只能用docid作爲key,如果使用業務id來訪問,需要先查詢倒排索引獲取其docid,再訪問正排數據獲取值。

3)DISI存儲的docid範圍只能在32位整型內,當遇到單點幾十億級別的數據,就無法存儲了。

在某些場景下,給酒店打排序分時,需要獲取酒店到POI之間的關聯分數,此類分數不僅僅是通過直線距離計算得來,還需要考慮駕車步行距離的時間,以及距離篩選的酒店點擊量等等因素,所以需要一個酒店到POI之間關聯的KV存儲。酒店和POI數據量各自是百萬級別,而一個POI周邊的酒店數平均是千級別,這樣他們之間關聯數據條數可達數十億。

爲此,我們自研了一種Java內嵌KV存儲,和Lucene的索引中"mmap"模式一樣,利用JDK自帶的MappedDirectedBuffer,將數據存儲在磁盤上,將磁盤和內存的交換交給操作系統託管,也不會給堆內存造成壓力。不同於Lucene的DISI和LevelDB的SST,考慮到減少磁盤和內存的交換,已經提高TLB的命中率,其索引是固實化(compacted)的BTree,也就是一棵用數組表示的完全n叉樹,其查詢的時間複雜度爲對數,索引合併時間複雜度爲線性。相比使用排序數組的SST,空間佔用一樣,優勢是查詢時內存頁跳轉減少,劣勢是compact的時候需要隨機訪問磁盤,而不是順序訪問。

踩過的坑

1)雖然Lucene DocValues是一種磁盤存儲,但由於其實現和FieldCache有着諸多相似特性,部分元數據甚至是數據本身還是需要加載到內存的,這個加載的過程在DocValues的API中是懶加載的,並且會消耗一定的時間,需要注意其爭用引起的線程阻塞。最好在初次加載索引和之後,或者寫線程每次flush和compact之後,觸發一次DocValues的數據加載,再讓讀線程可見。

2)雖然Lucene DocValues支持隨機訪問,但其API的實現還是相對滯後。在一次請求中,不允許訪問的docid大於或等於上次訪問的docid,強制整個打分過程是順序訪問的。這自然有他的道理:順序訪問的性能更好。但排序過程可能依據多個分數,多個分數的計算公式中可能引用同一維度的字段,這樣會造成重複訪問同一doc的同一字段的DocValues,使得API報錯。解決的方法是將之前查詢到的字段值緩存入當期的context中,下次訪問時直接獲取緩存。另外一種解決方案,直接修改Lucene源代碼,消除這個不必要的限制,代碼位置在MultiDocValues.NumericDocValues.advanceExact和MultiDocValues.SortedNumericDocValues.advanceExact。

3)雖然可以使用MappedDirectedBuffer將存儲移出JVM堆,減輕了堆GC的壓力,但是當堆外內存髒塊超過一定閾值,操作系統還是會觸發阻塞整個進程的flush工作。解決方法是將磁盤映射文件打開爲read-only,用作append-only數據庫的存儲。沒有對現有塊的修改就不會存在髒塊,而內部異步compact來實現增量更新。這樣,只會存在缺頁加載的IO操作,被淘汰的頁可以立即丟棄,而不用刷回磁盤。

二、查詢智能化

當今搜索系統中,單純的文本召回已經不能滿足用戶的要求。搜索引擎需要根據用戶的輸入,識別用戶輸入的語義和意圖,進而修改召回和排序方式。

2.1 語義查詢生成流程

1)第一步是實體標註。將實體名稱作爲詞庫給用戶輸入分詞以後,給分出的每一個詞標註實體,識別其類型和對應ID。

2)第二步是提取核心語義。例如,用戶 輸入” 浙江杭州西湖希爾頓”,需要識別出浙江是杭州的上級、杭州是西湖的上級,從而忽略掉” 浙江” 和” 杭州”,其核心語義就是” 西湖” 和” 希爾頓”。

3)第三步是查詢生成。根據上面的核心語義” 西湖” 和” 希爾頓”,通過規則系統,生成查詢,優先查找西湖周邊的希爾頓集團下的酒店,即使這些酒店文本中,看不出包含” 浙江”、” 杭州”、”西湖”、”希爾頓” 中的任意一個。

2.2 語義分析的常用算法

2.2.1 上下文無關句法分析(CFG)

1)優點:可以轉化爲自動機,計算速度快

2)缺點:語法規則固定,不適合分析比較靈活的自然語言

2.2.2 依存句法分析

依存圖的主要思想是連接短語的中心詞與其依存詞。用有向邊把中心詞與依存詞連接起來。依存分析中一個重要的概念是投射性,是由單詞之間依存的線性詞序決定的一種約束。投射性的的依存句法等價於CFG,非投射的依存句法的描述範圍比 CFG更廣。

1)優點:較爲靈活,規則簡單

2)缺點:有的情形,時間複雜度會退化到指數級別

2.2.3 酒店聯想引擎中使用的語義分析

爲了克服上述經典語法分析的一些弱點,酒店聯想使用一種依據知識圖譜分類分層的簡化依存分析方式。根據酒店的業務場景,將標註後的實體詞性放入不同的bucket中,進而進一步查詢bucket內部實體和bucket之間實體的關聯關係,進而去除修飾詞,提取核心語義。同一bucket中的實體類型可以進一步分層,例如區域類型中省份、國家、城市、景區都可以分爲單獨的一層,再去獲取彼此之間的關係。從而避免算法複雜度的爆炸。

三、智能糾錯

Lucene自帶的英文單詞相似度糾錯,是通過ngram分詞索引召回,從詞庫中粗篩出候選詞,進一步使用Levenshtein編輯距離精篩出相似度高的詞。

我們在Lucene糾錯的基礎上,做了更多的優化,我們的糾錯會考慮上下文,糾錯詞庫的數據來源也更加多元化,目標是使得我們的英文糾錯可以媲美Bing或者Google。

3.1 LSH 局部敏感哈希

隨着業務增長,作爲基礎語料的實體數量也在增長,糾錯詞庫的數據量隨之增長,Lucene默認的ngram召回的候選詞集合開始變得不那麼準確,很多的用戶目標詞在粗篩過後就不在候選集內,導致無法正確糾出。我們需要考慮加入不同的維度作爲Hash桶,來進一步縮小粗篩的範圍,比如詞長是一個比較好的維度;並且調整ngram中參數n的大小,以及分詞以後的查詢交併關係,使有限的粗篩召回結果更加精確。

3.2 上下文糾錯

只考慮單詞而不考慮上下文的糾錯,就像只考慮單詞熱度而不考慮上下文的分詞,有諸多侷限性。例如真詞糾錯case,用戶輸入把le meridien(艾美酒店)錯輸入爲let meridien,單看let這個單詞是並沒有錯的,即使認爲它是錯的,那麼let和le直接的相似度最高也只有66.7%,看起來也不高,不一定能達到精篩過濾的相似度閾值。

所以我們在糾錯的時候也需要考慮上下文。通過現有實體語料以及其熱度,統計出熱門的二元詞組及其熱度。然後在糾錯詞,將二元詞組作爲單詞來進行糾錯。這樣也可以對用戶少輸入或者多輸入的空格進行糾錯,並且可以解決空格問題和拼寫錯誤同時存在的場景。例如:用戶輸入southcoase,通過一次糾錯就可以糾出south coast這個詞組。

通過二元詞組庫的糾錯,只能往前/後多看一個詞的上下文,有的情況下這麼短的上下文並不能判斷出最佳的糾錯詞。這時候可以將所有實體名稱作爲詞庫來糾錯,由於其數據量龐大,粗篩的桶參數調整難度更大。另一方面,由於Lucene倒排索引下都是按docid排序的,docid是按數據插入順序自增,所以我們可以先按熱度排好序建入索引,再使用totalHitsThreshold=n限制召回的匹配條數,確保粗篩召回的是最熱的n條記錄。

3.3 優化編輯距離算法

經典的Levenshtein編輯距離算法,其狀態轉移發生在矩陣的2x2的範圍內,無法識別出字符交換的操作。如果我們把其狀態轉移方程擴充到3x3的方格內,根據行和列上各自前兩個字母來計算本單元格內的距離,即可識別出字符交換的操作。除此以外還能識別出字符雙寫漏寫爲單寫,以及單寫漏寫爲雙寫等場景,分別根據不同場景配置不同的距離權重,可以更加精細地計算兩個詞的相似度。

如果把根據前兩個字母算的編輯距離稱爲2階編輯距離,那麼2階可以擴展到n階,n越大,能覆蓋的情形越豐富,相似度越準確,糾錯效果更好。但是算法的時間複雜度也隨着n幾何增加。實際使用時,按場景需求選擇n。這種擴充到n階的想法來自於Damerau-Levenshtein編輯距離,Damerau-Levenshtein編輯距離是一種2階編輯距離。

編輯距離加權的思想也是在很多NLP論文中有提到,除了處理雙寫、調換等場景以外,也可以處理音近詞特別是一些從別的語言翻譯而來的音近詞,特別是旅遊業務背景下,很多地名都是按當地語言翻譯過來的。舉箇中文的例子,從英文翻譯而來的亞馬遜和亞馬孫,從"遜"到"孫"的編輯距離權重幾乎可以配置爲0,意味着亞馬遜和亞馬孫相似度100%,類似的case在作爲表音語言的韓文和俄文的翻譯文本中更多。

四、搜索DSL

DSL(Domain Specific Language),中文翻譯爲領域特定語言,相對於GPL(General Purpose Language)通用編程語言,DSL指的是專注於某個應用程序領域的計算機語言。

James Gosling曾經說過:每個配置文件最終都會變成一門編程語言。搜索系統的複雜化導致其配置的複雜化,根據不同的用戶輸入核心語義、不同的用戶偏好、不同的搜索上下文,生成搜索查詢和排序,這樣的規則系統需要複雜的配置。Lucene原本也有自帶的查詢語言,類似SQL,可以定義召回、排序、分頁等邏輯,但這樣的查詢語言已經不能滿足我們日益複雜的需求,嚴重製約了開發效率,我們需要將搜索語言擴展甚至重寫,就像從SQL擴展到PL/SQL那樣。

4.1 設計考量

4.1.1 降低學習成本

設計查詢語言的時候,需要儘量向SQL語言看齊。SQL是大家已經廣泛熟知的查詢語言,語法越和SQL一致,越是降低學習難度。

在ElasticSearch的結構化DSL中,使用的是must、should、must not查詢方式,這樣的查詢方式雖然貼合lucene底層查詢方式,但是從一個沒有接觸過類似搜索產品的開發看來需要學習成本。在Lucene自帶的查詢語言中,雖然可以使用AND、OR這些交併條件,但其實現是有bug的,其運算符優先級有問題,導致一些場景優先執行OR再執行AND,需要開發小心翼翼地給所有的子表達是添加合適的括號,更不幸的是,lucene的查詢語言編譯器通過JavaCC自動生成,不是人手寫的代碼,可讀性很差,很難修改。

SQL和其他GPL相比,最顯著的特徵是其邏輯運算符的優先級,需要低於比較運算符。另外一個特徵是兩個整型相除,一般數據庫實現默認返回的是浮點型數據,而不是整型,對於整數相除,另外使用內置函數實現。

除了向SQL看齊,其數字類型和字符串類型的表達方式向EMCAScript看齊,因爲當前JSON作爲最常用的序列化方式被大家廣泛熟知,JS的字符串轉義也比Java更加方便。當然,EMCAScript不支持64位整型,而我們需要支持,特別是當日期時間轉化爲long參與計算的時候。

4.1.2 面向高性能場景

一次搜索請求中需要對召回的數以萬計的doc去做過濾和計算排序分,但又對響應時間比較敏感,特別是在聯想推薦的場景中,用戶每輸入一個字,就要立時修改推薦的內容。所以在設計語言時,需要保留對CPU和內存友好的特性:

1)基於性能考慮保留primitive type,借鑑基於C的腳本語言lua,只保留兩種數值類型——整型的long和浮點型的double,並且強轉系統。基礎類型是現階段ElasticSearch script的諸多實現中仍沒有實現的功能。

2)查詢過濾,比較字段和值時,使用lucene列式存儲,即DocValues,而不是去獲取行數據。

3)去除CBO(基於成本的優化器)。如果開發對執行計劃瞭然於胸,就會發現在一些複雜場景下傳統數據庫中的CBO經常幫倒忙,導致我們不得不使用use index這種語法。去除CBO的同時,用不同的語法讓開發可以自定義執行計劃是走索引還是走過濾,降低執行計劃的不確定性,也可以降低查詢編譯期的耗時。而RBO(基於規則的優化器)中的一些規則可以保留,比如任何條件和false取交集,默認就返回false,而不是真的去執行其查詢。

4.1.3 多態

搜索語言需要支持編譯時的多態,提高用戶友好性。

1)函數多態,例如max函數,如果傳入的是整型那麼返回的也是整型,如果傳入的是浮點型,返回的也是浮點型。

2)運算符多態,例如加號"+"運算,如果兩邊都是數值類型,那麼按數值相加,並且設計合適的隱式轉換規則;如果一邊是字符串,那麼就把兩邊按字符串concat起來。

支持更多的地理搜索功能

從語言層面支持地理搜索,而不需要編寫各種語法糖。

除了支持常用的距離範圍搜索,還利用了計算圖形學的算法和KD樹,支持多邊形內的點的搜索、點到多邊形的距離搜索,用於查詢多邊形區域範圍內以及周邊的召回。

4.1.4 安全性

搜索語言需要支持查詢參數化,來避免查詢腳本注入。這一點和SQL一樣,ElasticSearch也已經支持參數化的script。我們對參數化進行了擴展,使其參數本身可以爲一個表達式,在查詢編譯時預執行,實現類似Shell或者是JS中eval的功能。

4.1.5 支持描述業務流程

上文中所說的在查詢編譯時預執行的表達式,是一種doc無關的表達式。相比而言,查詢執行時的表達式都需要傳入一個docid來獲取當前doc。

上文中描述的語義分析提取核心詞以後,需要通過核心詞以及規則系統生成新的查詢和排序。這種doc無關的表達式,我們正可以用來支持規則系統這種和具體doc無關的業務邏輯,類似PL/SQL這種面向存儲過程的語言,這也是ElasticSearch中暫未實現的功能。

踩過的坑

上設計一門新的語言時,不要一開始就設計爲詞法分析和語法分析雙層編譯結構,也不要一開始就設計action表,因爲在設計新語言的一開始可能並不清楚詞法和語法的邊界在哪裏,即使事先明確定義,做到一半的時候可能還會再做修改。對於語法簡單的DSL,使用基於字符的遞歸下推自動機實現編譯功能是更好的選擇,對於後續的語法修改會更加靈活。

總結

搜索引擎本身對數據庫事務要求不強,數據計算量比較大,是一種CPU密集型的、對響應時間敏感的信息檢索系統。 一方面是用戶對於其智能化的需求,一方面又是用戶對於其響應速度的需求,保持兩者之間的平衡一直是個難題。

所幸業界有很多較爲成熟的搜索產品:Solar/Lucene、ElasticSearch,也有很多可供借鑑的算法,還有很多或新或舊的存儲,例如HBase、LevelDB、RocksDB等等。他山之石可以攻玉,只要我們不迷信權威,充分了解這些產品或者算法背後的實現原理,就可以站在巨人的肩膀上,更加靈活地找到適合當前場景的技術方案,甚至創造出全新的算法和工具,不斷提升用戶的搜索體驗。

作者介紹

mczhao,攜程資深軟件工程師,關注自然語言處理、搜索引擎和數據庫內核開發。

本文轉載自公衆號攜程技術(ID:ctriptech)。

原文鏈接

響應速度與智能化如何平衡,攜程酒店搜索實踐

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