注:本模塊會大量用到protobuf庫、cppjieba庫,不瞭解的讀者可以先去簡單瞭解一下這兩個庫怎麼使用,這裏就不贅述了。
(爲了避免本模塊的內容過長,筆者分兩篇完成,第一篇講搜索引擎索引的基礎知識和這個項目中索引模塊的基礎架構,第二篇講索引模塊的具體技術實現細節。)
一、對Boost官網網頁的預處理
注:這個過程用什麼語言處理都可以
附:code
由於Boost官網的數據都是以網頁的形式呈現的,但是製作索引的時候直接對網頁進行操作就比較麻煩,所以這個階段就是對Boost官網的網頁數據先做一個預處理,將它變成製作索引的時候容易處理的形式。
這裏採用將每一個網頁處理成一行數據,然後每個網頁的標題、url以及正文用一個分隔符隔開的形式:url\3title\3content
。這個分隔符之所以選擇一個不可見字符(\3
)是因爲,如果分隔符選擇可見字符,可能會和文檔中包含的內容衝突。
二、有關索引結構的設計
在實現之前,先介紹一下“索引”的概念。
2.1 單詞-文檔矩陣
單詞文檔矩陣時表達兩者之間所具有的一種包含關係的概念模型。通常邏輯上就像這樣:
文檔1 | 文檔2 | 文檔3 | 文檔4 | |
---|---|---|---|---|
詞彙1 | √ | √ | ||
詞彙2 | √ | √ | ||
詞彙3 | √ | √ | ||
詞彙4 | √ |
從縱向即文檔這個維度來看,每列表示文檔包含了哪些單詞,比如:文檔1包含詞彙1和詞彙3,而不包含其他單詞。從橫向即詞彙這個維度來看,每行代表了哪些文檔包含這個單詞。比如對於詞彙1來說,文檔1和文檔3包含這個詞彙,而其他文檔不包含這個詞彙。矩陣的其他行列同上解讀。
搜索引擎的索引其實就是實現單詞-文檔矩陣的具體數據結構。這個可以有不同的方式來實現上述概念模型,倒排索引是經過實驗數據驗證表示單詞到文檔的映射關係的最佳實現方式。除了倒排索引外,還可以用簽名文件、後綴樹等方式實現,這裏主要講用倒排索引實現的技術細節(TODO:這裏後面會有一個鏈接~)。
2.2 倒排索引相關的基本概念
- 文檔:這裏的文檔就是Boost網站的HTML格式的網頁;
- 文檔編號:爲了方便內部處理,爲文檔集合內每一個文檔賦予一個唯一的內部編號,以此編號作爲這個文檔的唯一標識。
- 倒排列表:倒排列表記載了出現過某個單詞的所有文檔的文檔列表。
2.3 倒排索引的基本概念
倒排索引是實現單詞-文檔矩陣映射關係的一種具體存儲形式,通過倒排索引,可以根據單詞快速獲取包含這個單詞的文檔列表。
通過概念可以看出倒排索引由兩部分構成:單詞和倒排列表。
下面通過一個例子來詳細說明倒排索引的結構:
假設有一個文檔集合包含5個文檔:
文檔編號 | 文檔內容 |
---|---|
1 | 谷歌地圖之父跳槽Facebook |
2 | 谷歌地圖之父加盟Facebook |
3 | 谷歌地圖創始人拉斯離開谷歌加盟Facebook |
4 | 谷歌地圖之父跳槽Facebook與Wave項目取消有關 |
5 | 谷歌地圖之父拉斯加盟社交網站Facebook |
由於文檔內容都是由很多單詞構成的,而我們在建立倒排索引的時候首先要將其切分爲很多單詞,這裏就涉及到分詞技術,這裏不詳述,後面會介紹。經過一系列構建過程後,建成的倒排索引結構就是下面這樣子的:
下面這個是最簡單的倒排索引結構,但是足以說明問題。
細心的讀者在對照上面的原始文檔集合和下面的倒排索引時會發現文檔中有一部分內容不見了,這裏涉及到分詞技術的具體細節,我後面會講,這裏先重點理解倒排索引結構。
單詞 | 倒排列表 |
---|---|
谷歌 | 1,2,3,4,5 |
地圖 | 1,2,3,4,5 |
之父 | 1,2,4,5 |
跳槽 | 1,4 |
1,2,3,4,5 | |
加盟 | 2,3,5 |
創始人 | 3 |
拉斯 | 3,5 |
離開 | 3 |
Wave | 4 |
項目 | 4 |
取消 | 4 |
有關 | 4 |
社交 | 5 |
網站 | 5 |
2.4 正排索引的基本概念
正排索引也稱爲"前向索引",它是創建倒排索引的基礎。正排索引由文檔集合組成,文檔集合中的文檔包含文檔的部分信息。由於設計開發這個Boost搜索引擎的初衷是理解一個搜索引擎的大體設計流程,所以一些地方從簡考慮,這裏的文檔包含的信息有:文檔ID、文檔標題、文檔正文、跳轉url、展示url以及標題和正文的分詞結果。
詳細的正排索引相關知識可以參考其他資料:正排索引
2.5 具體設計
對於海量網頁數據,爲其建立倒排索引往往需要耗費較大的磁盤空間,尤其是一些比較常見的單詞,其對應的倒排列表可能大小有好幾百M。如果搜索引擎在響應用戶查詢的時候,用戶查詢請求中包含常見詞彙,就需要將大量的倒排列表信息從磁盤讀入內存,之後進行查詢處理給出搜索結果。由於磁盤I/O速度往往是性能瓶頸,所以包含常用詞的用戶查詢,其響應速度可能會受到嚴重影響。
一方面爲了減少索引佔用的磁盤空間資源,另一方面爲了儘量減少磁盤I/O數據量,加快用戶查詢的響應速度,我們需要對索引數據進行壓縮。在這裏用protobuf
這個庫,因爲相比較XML
和json
它的壓縮效率是最高的,不過相對應的就是經過protobuf
壓縮的數據可讀性非常差,不過這問題不大, 因爲這個並不影響我們的開發。
通過前面關於索引相關概念的介紹,這裏直接用protobuf
定義數據格式:
syntax="proto2"
package doc_index_proto;
//定義分詞結果,分詞結果結構的設計見後面索引構建過程講解,這裏讀者先不用管爲什麼要定義分詞結果
message Pair {
//每個分詞結果都是前閉後開區間
required uint32 beg = 1;
required uint32 end = 2;
}
//定義正排索引結構
message DocInfo {
required uint64 id = 1;
required string title = 2;
required string content = 3;
required string show_url = 4;
required string jump_url = 5;
//保存分詞結果,這兩項先定義在這裏,具體含義見後面索引構建過程講解,這裏讀者先不用管這兩項
repeated Pair title_token = 6;
repeated Pair content_token = 7;
}
//定義倒排列表結構
message Weight {
required uint64 doc_id = 1;
//文檔權重
required int32 weight = 2;
//該單詞在正文中第一次出現的位置
required int32 first_pos = 3;
}
//定義倒排索引結構
message KwdInfo {
//單詞的字面值
required string key = 1;
//倒排列表
repeated Weight doc_list = 2;
}
//定義索引結構
message Index {
//正排索引
repeated DocInfo forward_index = 1; //repeated可以理解爲一個數組結構,表示這個數據結構包含多個元素
//倒排索引
repeated KwdInfo inverted_index = 2;
}
注:由於Boost官網的數據量並不是很大,所以簡單起見,這裏的doc_id並不以文檔編號差值(D-Gap)的形式存儲,而是直接存儲倒排索引項中的實際文檔編號,在實際比較成熟的搜索引擎系統中,doc_id都是存儲文檔編號差值(D-Gap)。而這樣做的原因是爲了更好地對數據進行壓縮,原始文檔編號一般都是大數值,通過差值計算,就有效地將大數值轉換爲了小數值,而這有助於增加數據的壓縮率。
定義完prorobuf數據格式,生成相應的cpp代碼:
protoc index.proto --cpp_out=. # 這個操作後就會生成index.pb.h和index.pb.cc文件
三、設計索引核心類
3.1 構思
3.1.1 對外接口
首先我們要構思這個類對外要提供什麼方法,由於它是搜索引擎的索引類,所以至少要包含以下方法:
- 對之前預處理的數據進行解析,在內存中構建出供搜索模塊使用的索引。簡稱構建功能;
- 給定關鍵詞,獲取到和關鍵詞相關的倒排列表。簡稱查倒排功能;
- 給定文檔ID,獲取到文檔的詳細信息。簡稱查正排功能。
爲了對索引進行壓縮存儲,還需要包含以下方法:
- 把內存中創建的索引結構進行序列化,保存到磁盤上。簡稱保存功能;
- 把磁盤上的索引文件讀取到內存中,進行反序列化,生成內存中的索引結構。簡稱加載功能。
爲了更好的檢驗我們前面方法實現的正確性,還需要提供一個調試接口:
- 將內存中的索引結構按照一定格式打印出來,方便我們觀察。簡稱反解功能。
綜上可以得出結論,這個索引類對外提供的接口至少要包含六個:構建、查倒排、查正排、保存、加載、反解。
//TODO:這裏其他的構思後面還會加
3.2 設計
由於索引類的對象包含的數據量很大,如果在內存中有很多對象,就會造成很大的內存開銷,尤其是頻繁的創建和銷燬,所以這個類可以用設計模式中的 單例模式 來創建,這樣就可以確定這個類的基礎架構。
懶漢模式:
class Index{
public:
Index();
static Index* Instance() {
if (NULL == inst_) {
inst_ = new Index();
}
}
//TODO
private:
static Index* inst_;
//TODO
}
然後來看這個類都需要包含哪些成員對象。首先肯定是要包含正排索引和倒排索引的,除此之外,因爲要對標題和正文進行分詞,所以還需要包含cppjieba分詞的一個分詞句柄。現在考慮到的成員對象就先這些。根據前面構思的結果,再把對外接口(公有成員對象)加上,索引類的基本結構就成型了:
namespace doc_index { //把索引模塊的邏輯代碼都放到自定義的命名空間doc_index裏
typedef doc_index_proto::DocInfo DocInfo; // 定義正排索引中文檔信息結構的數據類型別名
typedef doc_index_proto::Weight Weight; // 定義倒排列表中文檔信息結構的數據類型別名
typedef std::vector<DocInfo> ForwardIndex; // 定義正排索引數據類型
typedef std::vector<Weight> InvertedList // 定義倒排列表數據類型
typedef std::unordered_map<std::string, InvertedList> InvertedIndex; // 定義倒排索引數據類型
class Index{
public:
Index();
static Index* Instance() {
if (NULL == inst_) {
inst_ = new Index();
}
}
bool Build(const std::string& input_path); // 構建
const InvertedList* GetInvertedList(const std::string& key) const;// 查倒排
const DocInfo* GetDocInfo(uint64_t doc_id) const; // 查正排
bool Save(const std::string& ouput_path); // 保存
bool Load(const std::string& index_path); // 加載
bool Dump(const std::string& forward_dump_path, const std::string& inverted_dump_path); // 反解
private:
static Index* inst_;
ForwardIndex forward_index_; //正排索引
InvertedIndex inverted_index_; //倒排索引
cppjieba::Jieba jieba_; //cppjieba句柄
//TODO
};
}