不知不覺寫到第六篇了,按這個節奏,估計得寫到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][]int
var 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,上面就是索引構建的方法,到這一篇完成,倒排索引的數據結構,構建方式都說完了,但是還是有很多零碎的東西沒有說,后面會統一的把一些沒提及到的地方整理一篇文章說一下,接下來,我會用一到兩篇的文章說一下正排索引,然后就可以跨到檢索層去了。