此項目是本身學習搜索引擎過程當中的一些心得,在使用go語言的時候,發現了悟空這個搜索引擎項目,結合此項目代碼以及《信息檢索導論》,本身對搜索引擎的原理是實現都有了一個初步的認識,而後結合工做中可能遇到的場景,作了一個簡單的demo。寫下這篇文章,可能比較囉嗦,但願幫助到須要的人。項目代碼地址: https://github.com/LiuRoy/sakurapython
假若有四個文檔,分別表明四部電影的名字:git
若是咱們想根據這四個文檔創建信息檢索,即輸入查找詞就能夠找到包含此詞的全部電影,最直觀的實現方式是創建一個矩陣,每一行表明一個詞,每一列表明一個文檔,取值1/0表明該此是否在該文檔中。以下:github
若是輸入是Dark,只須要找到Dark對應的行,選出值爲1對應的文檔便可。當輸入是多個單詞的時候,例如:The Gump,咱們能夠分別找到The和Gump對應的行:1011和0100,若是是想作AND運算(既包括The也包括Gump的電影),1011和0100按位與操做返回0000,即沒有知足查詢的電影;若是是OR運算(包括The或者包括Gump的電影),1011和0100按位與操做返回1111,這四部電影都知足查詢。算法
實際狀況是咱們須要檢索的文檔不少,一箇中等規模的bbs網站發佈的帖子可能也有好幾百萬,創建這麼龐大的一個矩陣是不現實的,若是咱們仔細觀察這個矩陣,當數據量急劇增大的時候,這個矩陣是很稀疏的,也就是說某一個詞在不少文檔中不存在,對應的值爲0,所以咱們能夠只記錄每一個詞所在的文檔id便可,以下:sql
查詢的第一步仍是找到每一個查詢詞對應的文檔列表,以後的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
啓動服務,而後經過瀏覽器就可使用搜索服務了。
通常搜索服務的數據都是動態變化的,如何在數據頻繁變更的時候以最簡單的方式更新索引呢?我能想到的方法有以下幾種:
我採用了第四種方式作了一個demo,代碼參考sender.go,爲了不代碼耦合,經過orm的callback方式將修改的數據經過zeromq消息隊列發送給搜索服務,搜索服務有一個goroutine來消費數據並更改索引,當執行go run sender.go
後,新建的一條數據就能夠立刻被索引到。