此專案是自己學習搜尋引擎過程中的一些心得,在使用 Go 語言的時候,發現了悟空這個搜尋引擎專案,結合此專案程式碼以及《資訊檢索導論》,自己對搜尋引擎的原理是實現都有了一個初步的認識,然後結合工作中可能遇到的場景,做了一個簡單的 Demo。
寫下這篇文章,可能比較囉嗦,希望幫助到需要的人。專案程式碼地址: https://github.com/LiuRoy/sakura
基礎知識
一個簡單例子
假如有四個文件,分別代表四部電影的名字:
- The Shawshank Redemption
- Forrest Gump
- The Godfather
- The Dark Knight
如果我們想根據這四個文件建立資訊檢索,即輸入查詢詞就可以找到包含此詞的所有電影,最直觀的實現方式是建立一個矩陣,每一行代表一個詞,每一列代表一個文件,取值1/0代表該此是否在該文件中。如下:
如果輸入是Dark,只需要找到Dark對應的行,選出值為1對應的文件即可。當輸入是多個單詞的時候,例如:The Gump,我們可以分別找到The和Gump對應的行:1011和0100,如果是想做AND運算(既包括The也包括Gump的電影),1011和0100按位與操作返回0000,即沒有滿足查詢的電影;如果是OR運算(包括The或者包括Gump的電影),1011和0100按位與操作返回1111,這四部電影都滿足查詢。
實際情況是我們需要檢索的文件很多,一箇中等規模的bbs網站釋出的帖子可能也有好幾百萬,建立這麼龐大的一個矩陣是不現實的,如果我們仔細觀察這個矩陣,當資料量急劇增大的時候,這個矩陣是很稀疏的,也就是說某一個詞在很多文件中不存在,對應的值為0,因此我們可以只記錄每個詞所在的文件id即可,如下:
查詢的第一步還是找到每個查詢詞對應的文件列表,之後的AND或者OR操作只需要按照對應的文件id列表做過濾即可。實際程式碼中一般會保證此id列表有序遞增,可以極大的加快過濾操作。上圖中左邊的每一個詞叫做詞項,整張表稱作倒排索引。
實際搜尋過程
如果要實現一個搜尋功能,一般有如下幾個過程
- 蒐集要新增索引的文字,例如想要在知乎中搜尋問題,就需要蒐集所有問題的文字。
- 文字的預處理,把上述的收集的文字處理成為一個個詞項。不同語言的預處理過程差異很大,以中文為例,首先要把蒐集到的文字做分詞處理,變為一個個詞條,分詞的質量對最後的搜尋效果影響很大,如果切的粒度太大,一些短詞搜尋正確率就會很低;如果切的粒度太小,長句匹配效果會很差。針對分詞後的詞條,還需要正則化:例如濾除停用詞(例如:
的
把
並且
,一些幾乎所有中文文件都包含的一些詞,這些詞對搜尋結果沒有實質性影響),去掉形容詞後面的的
字等。 - 根據上一步的詞項和文件建立倒排索引。實際使用的時候,倒排索引不僅僅只是文件的id,還會有其他的相關的資訊:詞項在文件中出現的次數、詞項在文件中出現的位置、詞項在文件中的域(以文章搜尋舉例,域可以代表標題、正文、作者、標籤等)、文件元資訊(以文章搜尋舉例,元資訊可能是文章的編輯時間、瀏覽次數、評論個數等)等。因為搜尋的需求各種各樣,有了這些資料,實際使用的時候就可以把查詢出來的結果按照需求排序。
- 查詢,將查詢的文字做分詞、正則化的處理之後,在倒排索引中找到詞項對應的文件列表,按照查詢邏輯進行過濾操作之後可以得到一份文件列表,之後按照相關度、後設資料等相關資訊排序展示給使用者。
相關度
文件和查詢相關度是對搜尋結果排序的一個重要指標,不同的相關度演算法效果千差萬別,針對同樣一份搜尋,百度和谷歌會把相同的帖子展示在不同的位置,極有可能就是因為相關度計算結果不一樣而導致排序放在了不同的位置。
基礎的相關度計算演算法有:TF-IDF,BM25 等,其中BM25 詞項權重計算公式廣泛使用在多個文件集和多個搜尋任務中並獲得了成功。尤其是在TREC 評測會議上,BM25 的效能表現很好並被多個團隊所使用。由於此演算法比較複雜,我也是似懂非懂,只需要記住此演算法需要詞項在文件中的詞頻,可以用來計算查詢和文件的相關度,計算出來的結果是一個浮點數,這樣就可以將使用者最需要知道的文件優先返回給使用者。
搜尋引擎程式碼
悟空搜尋(專案地址: https://github.com/huichen/wukong)是一款小巧而又效能優異的搜尋引擎,核心程式碼不到2000行,帶來的缺點也很明顯:支援的功能太少。因此這是一個非常適合深入學習搜尋引擎的例子,作者不僅給出了詳細的中文文件,還在程式碼中標註了大量的中文註釋,閱讀原始碼不是太難,在此結合悟空搜尋程式碼和搜尋原理,深入的講解搜尋具體的實現。
索引
索引的核心程式碼在core/index.go。
索引結構體
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
// 索引器 type Indexer struct { // 從搜尋鍵到文件列表的反向索引 // 加了讀寫鎖以保證讀寫安全 tableLock struct { sync.RWMutex table map[string]*KeywordIndices docsState map[uint64]int // nil: 表示無狀態記錄,0: 存在於索引中,1: 等待刪除,2: 等待加入 } addCacheLock struct { sync.RWMutex addCachePointer int addCache types.DocumentsIndex } removeCacheLock struct { sync.RWMutex removeCachePointer int removeCache types.DocumentsId } initOptions types.IndexerInitOptions initialized bool // 這實際上是總文件數的一個近似 numDocuments uint64 // 所有被索引文字的總關鍵詞數 totalTokenLength float32 // 每個文件的關鍵詞長度 docTokenLengths map[uint64]float32 } // 反向索引表的一行,收集了一個搜尋鍵出現的所有文件,按照DocId從小到大排序。 type KeywordIndices struct { // 下面的切片是否為空,取決於初始化時IndexType的值 docIds []uint64 // 全部型別都有 frequencies []float32 // IndexType == FrequenciesIndex locations [][]int // IndexType == LocationsIndex } |
tableLock
中的table就是倒排索引,map中的key即是詞項,value就是該詞項所在的文件列表資訊,keywordIndices
包括三部分:文件id列表(保證docId有序)、該詞項在文件中的頻率列表、該詞項在文件中的位置列表,當initOptions
中的IndexType
被設定為FrequenciesIndex
時,倒排索引不會用到keywordIndices
中的locations,這樣可以減少記憶體的使用,但不可避免地失去了基於位置的排序功能。
由於頻繁的更改索引會造成效能上的急劇下降,悟空在索引中加入了快取功能。如果要新加一個文件至引擎,會將文件資訊加入addCacheLock
中的addCahe
中,addCahe
是一個陣列,存放新加的文件資訊。如果要刪除一個文件,同樣也是先將文件資訊放入removeCacheLock
中的removeCache
中,removeCache
也是一個陣列,存放需要刪除的文件資訊。只有在對應快取滿了之後或者觸發強制更新的時候,才會將快取中的資料更新至倒排索引。
新增刪除文件
新增新的文件至索引由函式AddDocumentToCache
和AddDocuments
實現,從索引中刪除文件由函式RemoveDocumentToCache
和RemoveDocuments
實現。因為程式碼較長,就不貼在文章裡面,感興趣的同學可以結合程式碼和下面的講解,更深入的瞭解實現方法。
刪除文件
RemoveDocumentToCache
首先檢查索引是否已經存在docId,如果存在,將文件資訊加入removeCache
中,並將此docId的文件狀態更新為1(待刪除);如果索引中不存在但是在addCahe
中,則只是把文件狀態更新為1(待刪除)。- 如果
removeCache
已滿或者是外界強制更新,則會呼叫RemoveDocuments
將removeCache
中要刪除的文件從索引中抹除。 RemoveDocuments
會遍歷整個索引,如果發現詞項對應的文件資訊出現在removeCache
中,則抹去table
和docState
中相應的資料。
備註:removeCache
和docIds
均已按照文件id排好序,所以RemoveDocuments
可以以較高的效率快速找到需要刪除的資料。
新增文件
AddDocumentToCache
首先會將需要新增的文件資訊放入到addCahe
中,如果快取已滿或者是強制更新,則會遍歷addCache
,如果索引中存在此文件,則把該文件狀態置為1(待刪除),否則置為2(新加)並將狀態為1(待刪除)的文件資料放在addCache
列表前面,addCache
列表後面都是需要直接更新的文件資料。- 呼叫
RemoveDocumentToCache
更新索引,如果更新成功,則把addCache
中所有的資料呼叫AddDocuments
新增至索引,否則只會把addCache
中狀態為2(新加)的文件呼叫AddDocuments
新增至索引。 AddDocuments
遍歷每個文件的詞項,更新對應詞項的KeywordIndices
資料,並保證KeywordIndices
文件id有序。
備註:第二步相同的文件只會將最後一條新增的文件更新至索引,避免了快取中頻繁新增刪除可能造成的問題。
搜尋實現
從上面新增刪除文件的操作可以發現,真正有效的資料是tableLock
中的table
和docState
,其他的資料結構均是出於效能方面的妥協而新增的一些快取。查詢的函式Lookup
也只是從這兩個map中找到相關資料並進行排序。
- 合併搜尋關鍵詞和標籤詞,從
table
中找到這些詞對應的所有KeywordIndices
資料 - 從上面的
KeywordIndices
資料中找出所有公共的文件,並根據文件詞頻和位置資訊計算bm25和位置資料。
程式碼架構
悟空使用了很多非同步的方式提高執行效率,針對我們開發高效的程式碼很有借鑑意義。專案文件裡面有一份粗略的架構圖,我根據engine原始碼,畫出了一份詳細的架構圖。下面就以介面為粒度講解具體的執行流程。
備註:圓柱體代表管道,矩形代表worker。
初始化引擎
這部分體現在圖最上面的persistentStorageInitWorker
和persistentStorageInitChannel
,如果指定了索引的持久化資料庫的資訊,在引擎啟動的時候,會非同步呼叫persistentStorageInitWorker
,這個routine會將持久化的索引資料(所有storage shard)載入到記憶體中,載入完畢後通過persistentStorageInitChannel
通知主routine.
新增文件
IndexDocument
是對外的新增文件的介面,當此介面執行的時候,先將需要分詞的文字放入管道segmenterChannel
,segmentWorker
從segmenterChannel
取出文字做分詞處理,然後將分詞的結果均勻的分配到各個shard對應的indexerAddDocChannels
和rankerAddDocChannels
,indexerAddDocumentWorker
和rankerAddDocWorker
分別從上面兩個管道中取出資料更新索引資料和排序資料。
如果設定了持久化資料,IndexDocument
還會將文件資料均勻的放入到各個storage shard的persistentStorageIndexDocumentChannels
中,persistentStorageIndexDocumentWorker
負責將管道中的文件資料持久化到檔案中。
刪除文件
RemoveDocument
是對外的刪除文件的介面,當介面執行的時候,找到文件所在的shard,然後將請求放入indexerRemoveDocChannels
和rankerRemoveDocChannels
,indexerRemoveDocWorker
和rankerRemoveDocWorker
分別監聽上面兩個管道,清除索引資料和排序資料。
查詢
search
是對外的搜尋介面,它會針對所有的shard裡的indexerLookupChannels
傳送請求資料,之後阻塞在監聽rankerReturnChannel
這一步,indexerLookupWorker
會呼叫函式Lookup
從倒排索引中找到制定的文件,如果不要求排序,直接將資料放入rankerReturnChannel
,否則將資料交給rankerRankChannels
,然後由rankerRankWorker
排完序再放入rankerReturnChannel
。當search
發現所有資料都返回之後,再將各個shard的資料做一次排序,然後返回。
總結
由架構圖可以很清晰地看出整個執行流程,同時知道此引擎無法分散式部署。如果需要做分散式部署,需要將每個shard作為一個獨立的程式,而且上層有一個類似網管的程式做資料分發和彙總操作。
例項講解
為了方便自己和大家的使用,我寫了一個比較簡單的例子,用orm的callback方式更新搜尋引擎。
資料準備
文件資料是我從知乎的戀愛和婚姻話題爬取的精品回覆,大概有1800左右回覆,包括問題標題,回覆正文,點贊個數以及問題標籤,下載連結:https://github.com/LiuRoy/sakura/blob/master/spider/tables.sqlite,儲存格式為sqlite,資料如下:
對如何爬取的同學可以參看程式碼https://github.com/LiuRoy/sakura/blob/master/spider/crawl.py,執行如下命令直接執行
1 2 3 |
cd sakura/spider/ pip install -r requirement python scrawl.py |
啟動引擎
用上一步爬取的資料構建一個搜尋引擎,程式碼參考server.go,在執行之前需要自己配置一下詞典以及資料路徑,悟空提供了一份分詞詞典和停用詞列表,配置完成後執行go run server.go
啟動服務,然後通過瀏覽器就可以使用搜尋服務了。
更新索引
一般搜尋服務的資料都是動態變化的,如何在資料頻繁變動的時候以最簡單的方式更新索引呢?我能想到的方法有如下幾種:
- 定時全量更新索引
- 定時查詢資料庫修改資料,將修改的資料更新至索引
- 讀取資料庫binlog,將資料變動實時更新到索引
- 每次資料庫變更時,通過介面呼叫或者佇列的方式通知搜尋引擎修改索引
我採用了第四種方式做了一個demo,程式碼參考sender.go,為了避免程式碼耦合,通過orm的callback方式將修改的資料通過zeromq訊息佇列傳送給搜尋服務,搜尋服務有一個goroutine來消費資料並更改索引,當執行go run sender.go
後,新建的一條資料就可以馬上被索引到。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!