搜索引擎原理及使用


此項目是自己學習搜索引擎過程中的一些心得,在使用go語言的時候,發現了悟空這個搜索引擎項目,結合此項目代碼以及《信息檢索導論》,自己對搜索引擎的原理是實現都有了一個初步的認識,然后結合工作中可能遇到的場景,做了一個簡單的demo。寫下這篇文章,可能比較啰嗦,希望幫助到需要的人。項目代碼地址: https://github.com/LiuRoy/sakura

基礎知識

一個簡單例子

假如有四個文檔,分別代表四部電影的名字:

  1. The Shawshank Redemption
  2. Forrest Gump
  3. The Godfather
  4. 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也是一個數組,存放需要刪除的文檔信息。只有在對應緩存滿了之后或者觸發強制更新的時候,才會將緩存中的數據更新至倒排索引。

添加刪除文檔

添加新的文檔至索引由函數AddDocumentToCacheAddDocuments實現,從索引中刪除文檔由函數RemoveDocumentToCacheRemoveDocuments實現。因為代碼較長,就不貼在文章里面,感興趣的同學可以結合代碼和下面的講解,更深入的了解實現方法。

刪除文檔
  1. RemoveDocumentToCache首先檢查索引是否已經存在docId,如果存在,將文檔信息加入removeCache中,並將此docId的文檔狀態更新為1(待刪除);如果索引中不存在但是在addCahe中,則只是把文檔狀態更新為1(待刪除)。
  2. 如果removeCache已滿或者是外界強制更新,則會調用RemoveDocumentsremoveCache中要刪除的文檔從索引中抹除。
  3. RemoveDocuments會遍歷整個索引,如果發現詞項對應的文檔信息出現在removeCache中,則抹去tabledocState中相應的數據。

備注:removeCachedocIds均已按照文檔id排好序,所以RemoveDocuments可以以較高的效率快速找到需要刪除的數據。

添加文檔
  1. AddDocumentToCache首先會將需要添加的文檔信息放入到addCahe中,如果緩存已滿或者是強制更新,則會遍歷addCache,如果索引中存在此文檔,則把該文檔狀態置為1(待刪除),否則置為2(新加)並將狀態為1(待刪除)的文檔數據放在addCache列表前面,addCache列表后面都是需要直接更新的文檔數據。
  2. 調用RemoveDocumentToCache更新索引,如果更新成功,則把addCache中所有的數據調用AddDocuments添加至索引,否則只會把addCache中狀態為2(新加)的文檔調用AddDocuments添加至索引。
  3. AddDocuments遍歷每個文檔的詞項,更新對應詞項的KeywordIndices數據,並保證KeywordIndices文檔id有序。

備注:第二步相同的文檔只會將最后一條添加的文檔更新至索引,避免了緩存中頻繁添加刪除可能造成的問題。

搜索實現

從上面添加刪除文檔的操作可以發現,真正有效的數據是tableLock中的tabledocState,其他的數據結構均是出於性能方面的妥協而添加的一些緩存。查詢的函數Lookup也只是從這兩個map中找到相關數據並進行排序。

  1. 合並搜索關鍵詞和標簽詞,從table中找到這些詞對應的所有KeywordIndices數據

  2. 從上面的KeywordIndices數據中找出所有公共的文檔,並根據文檔詞頻和位置信息計算bm25和位置數據。

代碼架構

悟空使用了很多異步的方式提高運行效率,針對我們開發高效的代碼很有借鑒意義。項目文檔里面有一份粗略的架構圖,我根據engine源碼,畫出了一份詳細的架構圖。下面就以接口為粒度講解具體的執行流程。

備注:圓柱體代表管道,矩形代表worker。

初始化引擎

這部分體現在圖最上面的persistentStorageInitWorkerpersistentStorageInitChannel,如果指定了索引的持久化數據庫的信息,在引擎啟動的時候,會異步調用persistentStorageInitWorker,這個routine會將持久化的索引數據(所有storage shard)加載到內存中,加載完畢后通過persistentStorageInitChannel通知主routine.

添加文檔

IndexDocument是對外的添加文檔的接口,當此接口執行的時候,先將需要分詞的文本放入管道segmenterChannelsegmentWorkersegmenterChannel取出文本做分詞處理,然后將分詞的結果均勻的分配到各個shard對應的indexerAddDocChannelsrankerAddDocChannelsindexerAddDocumentWorkerrankerAddDocWorker分別從上面兩個管道中取出數據更新索引數據和排序數據。

如果設置了持久化數據,IndexDocument還會將文檔數據均勻的放入到各個storage shard的persistentStorageIndexDocumentChannels中,persistentStorageIndexDocumentWorker負責將管道中的文檔數據持久化到文件中。

刪除文檔

RemoveDocument是對外的刪除文檔的接口,當接口執行的時候,找到文檔所在的shard,然后將請求放入indexerRemoveDocChannelsrankerRemoveDocChannelsindexerRemoveDocWorkerrankerRemoveDocWorker分別監聽上面兩個管道,清除索引數據和排序數據。

查詢

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啟動服務,然后通過瀏覽器就可以使用搜索服務了。

更新索引

一般搜索服務的數據都是動態變化的,如何在數據頻繁變動的時候以最簡單的方式更新索引呢?我能想到的方法有如下幾種:

  1. 定時全量更新索引
  2. 定時查找數據庫修改數據,將修改的數據更新至索引
  3. 讀取數據庫binlog,將數據變動實時更新到索引
  4. 每次數據庫變更時,通過接口調用或者隊列的方式通知搜索引擎修改索引

我采用了第四種方式做了一個demo,代碼參考sender.go,為了避免代碼耦合,通過orm的callback方式將修改的數據通過zeromq消息隊列發送給搜索服務,搜索服務有一個goroutine來消費數據並更改索引,當執行go run sender.go后,新建的一條數據就可以馬上被索引到。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM