此項目是自己學習搜索引擎過程中的一些心得,在使用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。
索引結構體
// 索引器
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,執行如下命令直接運行
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
后,新建的一條數據就可以馬上被索引到。