用 Golang 寫一個搜尋引擎(0x06)--- 索引構建

吳YH堅發表於2017-01-19

不知不覺寫到第七篇了,按這個節奏,估計得寫到15到20篇左右才能寫完,希望自己能堅持下去,之前寫程式碼的時候很多東西並沒有想得那麼細緻,現在每寫一篇文章還要查一些資料,確保文章的準確性,也相當於自己複習了一下吧,呵呵。

先說一下,關於倒排檔案,其實還有很多東西沒有講,到後面再統一補充一下吧,主要是倒排檔案的壓縮技術,這一部分因為目前的儲存空間不管是硬碟還是記憶體都是很大的,所以壓縮技術用得不是很多了。

今天我們來講講倒排索引的構建。

之前,我們瞭解到了,倒排索引在系統中是存成下圖這個樣子

用 Golang 寫一個搜尋引擎(0x06)--- 索引構建

上面的B+樹是一個檔案,下面的倒排鏈是一個檔案,那麼,如何來構建這兩個檔案呢,本章我會說說一般的常規構建方法,然後說一下我是怎麼構建的。

一般情況下,搜尋引擎預設會認為索引是不會有太大的變化的,所以把索引分為全量索引和增量索引兩部分,全量索引一般是以天甚至是周,月為單位構建的,構建完了以後就匯入到引擎中進行檢索,而增量索引是實時的進入搜尋引擎的,很多就是儲存在記憶體中,搜尋的時候分別從全量索引和增量索引中檢索資料,然後把兩部分資料合併起來返回給請求方,所以增量索引不是我們這一篇的主要內容,在最後我的索引構建部分我會說一下我的增量索引構建方式。現在先看看全量索引。

全量索引構建一般有以下兩種方式

一次性構建索引

一種是一次性的構建索引,這種構建方法是全量掃描所有文件,然後把所有的索引儲存到記憶體中,直到所有文件掃描完畢,索引在記憶體中就構建完了,這時再一次性的寫入硬碟中。大概步驟如下:

  • 初始化一個空map ,map的key用來儲存term,map的value是一個連結串列,用來儲存docid鏈
  • 設定docid的值為0
  • 讀取一個文件內容,將文件編號設定成docid
  • 對文件進行切詞操作,得到這個文件的所有term(t1,t2,t3...)
  • 將所有的鍵值對的term插入到map的key中,docid追加到map的value中
  • docid加1
  • 如果還有文件未讀取,返回第三步,否則繼續
  • 遍歷map中的,將value寫入倒排檔案中,並記錄此value在檔案中的偏移offset,然後將寫入B+樹中
  • 索引構建完畢

用圖來表示就是下面幾個步驟

用 Golang 寫一個搜尋引擎(0x06)--- 索引構建

如果用虛擬碼來表示的話就是這樣

//初始化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加1
  • 如果記憶體空間用完了,將文件資料寫入到磁碟上,清空記憶體中的文件資料
  • 如果還有文件未讀取,返回第三步,否則繼續
  • 由於各個磁碟檔案中的鍵值對是按照term的順序排列的,通過多路歸併演算法將各個磁碟檔案進行合併操作,合併的過程中生成每一個term的倒排鏈,追加的寫一次倒排檔案,並配合詞典生成這個term的檔案偏移,直到所有檔案合併完成,詞典也跟著構建完成了。
  • 索引構建完畢

同樣,我們用一個圖來表示就是下面這個樣子

用 Golang 寫一個搜尋引擎(0x06)--- 索引構建

如果用虛擬碼表示的話,就是下面這個樣子,程式碼流程也很簡單,結合上面的步驟和圖仔細看看就能明白

//初始化固定的記憶體空間,存放字典和資料
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,上面就是索引構建的方法,到這一篇完成,倒排索引的資料結構,構建方式都說完了,但是還是有很多零碎的東西沒有說,後面會統一的把一些沒提及到的地方整理一篇文章說一下,接下來,我會用一到兩篇的文章說一下正排索引,然後就可以跨到檢索層去了。

最後,歡迎掃一掃關注我的公眾號哈:)

用 Golang 寫一個搜尋引擎(0x06)--- 索引構建

相關文章