這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
不知不覺寫到第七篇了,按這個節奏,估計得寫到15到20篇左右才能寫完,希望自己能堅持下去,之前寫代碼的時候很多東西並沒有想得那麼細緻,現在每寫一篇文章還要查一些資料,確保文章的準確性,也相當於自己複習了一下吧,呵呵。
先說一下,關於倒排檔案,其實還有很多東西沒有講,到後面再統一補充一下吧,主要是倒排檔案的壓縮技術,這一部分因為目前的儲存空間不管是硬碟還是記憶體都是很大的,所以壓縮技術用得不是很多了。
今天我們來講講倒排索引的構建。
之前,我們瞭解到了,倒排索引在系統中是存成這個樣子
上面的B+樹是一個檔案,下面的倒排鏈是一個檔案,那麼,如何來構建這兩個檔案呢,本章我會說說一般的常規構建方法,然後說一下我是怎麼構建的。
一般情況下,搜尋引擎預設會認為索引是不會有太大的變化的,所以把索引分為全量索引和增量索引兩部分,全量索引一般是以天甚至是周,月為單位構建的,構建完了以後就匯入到引擎中進行檢索,而增量索引是即時的進入搜尋引擎的,很多就是儲存在記憶體中,搜尋的時候分別從全量索引和增量索引中檢索資料,然後把兩部分資料合併起來返回給請求方,所以增量索引不是我們這一篇的主要內容,在最後我的索引構建部分我會說一下我的增量索引構建方式。現在先看看全量索引。
全量索引構建一般有以下兩種方式
一次性構建索引
一種是一次性的構建索引,這種構建方法是全量掃描所有文檔,然後把所有的索引儲存到記憶體中,直到所有文檔掃描完畢,索引在記憶體中就構建完了,這時再一次性的寫入硬碟中。大概步驟如下:
初始化一個空map ,map的key用來儲存term,map的value是一個鏈表,用來儲存docid鏈
設定docid的值為0
讀取一個文檔內容,將文檔編號設定成docid
對文檔進行切詞操作,得到這個文檔的所有term(t1,t2,t3...)
將所有的<term,docid>索引值對的term插入到map的key中,docid追加到map的value中
docid加1
如果還有文檔未讀取,返回第三步,否則繼續
遍曆map中的<key,value>,將value寫入倒排檔案中,並記錄此value在檔案中的位移offset,然後將<key,offset>寫入B+樹中
索引構建完畢
用圖來表示就是下面幾個步驟
如果用虛擬碼來表示的話就是這樣
//初始化ivt的map 和 docid編號var ivt map[string][]intvar docid int = 0//依次讀取檔案的每一行資料for content := range DocumentsFileContents{ terms := segmenter.Cut(content) // 切詞 for _,term := range terms{ if _,ok:=ivt[term];!ok{ ivt[term]=[]int{docid} }else{ ivt[term]=append(ivt[term],docid) } docid++}//初始化一棵B+樹,字典bt:=InitBTree("./index.dic")//初始化一個倒排檔案ivtFile := InitFile("./index.ivt")//依次遍曆字典for k,v := range ivt{ //將value追加到倒排檔案中,並得到檔案位移[寫檔案] offset := ivtFile.Append(v) //將term和檔案位移寫入到B+樹中[寫檔案] bt.Add(term,offset)}ivtFile.Close()bt.Close() }
如此一來,倒排檔案就構建好了,這裡我直接使用了map這樣的描述,只是為了讓大家更加直觀的瞭解到一個倒排檔案的構建,在實際中可能不是用這種資料結構。
分批構建,依次合并
一次性構建的方式,由於是把所以文檔都載入到記憶體,如果機器的記憶體空間不夠大的話,會導致構建失敗,所以一般情況下不採用那種形式,很多索引構建的方式都用這種分批構建,依次合并的方式,這種方式主要按以下方式進行
申請一塊固定大小的記憶體空間,用來存放字典資料和文檔資料
在固定記憶體中初始化一個可排序的字典(可以是樹,也可以是跳躍表,也可以是鏈表,能排序就行)
設定docid的值為0
讀取一個文檔內容,將文檔編號設定成docid
對文檔進行切詞操作,得到這個文檔的所有term(t1,t2,t3...)
將term按順序插入到字典中,並且在記憶體中產生多個個<term,docid>的索引值對<t1,docid>,<t2,docid>,並且將這些索引值對存入到記憶體的文檔資料中,同時保證索引值對按照term進行排序
docid加1
如果記憶體空間用完了,將文檔資料寫入到磁碟上,清空記憶體中的文檔資料
如果還有文檔未讀取,返回第三步,否則繼續
由於各個磁碟檔案中的索引值對是按照term的順序排列的,通過多路歸併演算法將各個磁碟檔案進行合併作業,合并的過程中產生每一個term的倒排鏈,追加的寫一次倒排檔案,並配合詞典產生這個term的檔案位移,直到所有檔案合并完成,詞典也跟著構建完成了。
索引構建完畢
同樣,我們用一個圖來表示就是下面這個樣子
如果用虛擬碼表示的話,就是下面這個樣子,代碼流程也很簡單,結合上面的步驟和圖仔細看看就能明白
//初始化固定的記憶體空間,存放字典和資料dic := new DicMemory()data := new DataMemory()var docid int = 0//依次讀取檔案的每一行資料for content := range DocumentsFileContents{ terms := segmenter.Cut(content) // 切詞 for _,term := range terms{ //插入字典中 dic.Add(term) //插入到資料檔案中 data.Add(term,docid) //如果data滿了,寫入磁碟並清空記憶體 if data.IsFull() { data.WriteToDisk() data.Empty() } docid++}//初始化一個檔案描述符數組idxFiles := make([]*Fd,0)//依次讀取每一個磁碟檔案for idxFile := range ReadFromDisk { //擷取每一個磁碟檔案的檔案描述符,存到一個數組中 idxFiles.Append(idxFile)}//配合詞典進行多路歸併,並將結果寫入到一個新檔案中ivtFile:=InitFile("./index.ivt")dic.SetFilename("./index.dic")//多路歸併KWayMerge(idxFiles,ivtFile,dic)//構建完成ivtFile.Close()dic.Close() }
上面就是兩種構建全量索引的方法,對於第二種方法,還有一種特殊情況,就是當記憶體中的詞典也很巨大,將記憶體撐爆了怎麼辦,這是可以將詞典也分步的寫到磁碟,然後在進行詞典的合并,這裡就不說了,感興趣的可以自己去查一查。
我上面說的這些和一些搜尋引擎的書可能說的不太一樣,但是基本思想應該差不多,為了讓大家更直觀的抓到本質,很多特殊一點的情況我並沒有詳細說明,畢竟這不是一篇純理論的文章,如果大家真的感興趣肯定可以找到很多辦法來更深入的瞭解搜尋引擎的。
關於上面提到的多路歸併,是一個標準的外排序的方法,到處都能找到資料,這裡就不詳細展開了。
另外,在索引的構建過程中還有一些細節的東西,比如一般的索引構建都是兩次掃描文檔,第一次用來產生一些統計資訊,也就是上一篇說的詞的資訊,比如TF,DF之類的,第二次掃描才開始真正的構建,這樣的話,可以把term的相關性的計算放到構建索引的時候來進行,那麼在檢索的時候只需要進行排序而不用計算相關性了,可以極大提高檢索的效率。
我的構建方法
最後,我來說說我是怎麼構建索引的,由於我寫的這個搜尋引擎,是沒有明確的區分全量和增量索引概念的,把這個決定權交到了上層的引擎層來決定,所以在底層構建索引的時候不存在全量增量的概念,所以採用了第一種和第二種方法結合的方式進行索引的構建。
首先設定一個閾值,比如10000篇文檔,在這10000篇文檔的範圍內,按照第一種方式構建索引,產生一個字典檔案和一個倒排檔案,這一組檔案叫做一個段(segment)
每10000篇文檔產生一個段(segment),直到所有文檔構建完成,從而產生了多個段,並且在搜尋引擎啟動以後,增量資料也按這個方法進行構建,所以段會越來越多
每一個段就是索引的一部分,他有倒排索引的全部東西(詞典,倒排表),可以進行一次正常的檢索操作,每次檢索的時候依次搜尋各個段,然後把結果合并起來就是最終結果了
如果段的數量過多,按照第二種方式的思想,對多個段的詞典和倒排檔案進行多路合併作業,由於詞典是有序的,所以可以按照term的順序進行歸併操作,每次歸併的時候把倒排全拉出來,然後產生一個新的詞典和新的倒排檔案,當合并完了以後把老的都刪掉。
上面的合併作業策略完全交給上層的引擎層甚至業務層來完成,有些情境下增量索引少,那麼第一次構建完索引以後就可以把各個段合并到一起,增量索引每隔一定的時間合并一次,有些情境下資料一直不停的進入系統中,那麼可以通過一些策略,不停的在系統空閑時合并一部分索引,來保證檢索的效率。
OK,上面就是索引構建的方法,到這一篇完成,倒排索引的資料結構,構建方式都說完了,但是還是有很多零碎的東西沒有說,後面會統一的把一些沒提及到的地方整理一篇文章說一下,接下來,我會用一到兩篇的文章說一下正排索引,然後就可以跨到檢索層去了。
最後,歡迎掃一掃關注我的公眾號哈:)