用 Golang 寫一個搜尋引擎(0x07)--- 正排索引

吳YH堅發表於2017-01-19

最近各種技術盛會太多,朋友圈各種刷屏,有廠商發的各種廣告,有講師發的各種自拍,各種參會的朋友們各種自拍,好不熱鬧,不知道你的朋友圈是不是也是這樣啊,去年還沒這麼多技術會議,今年感覺爆發了,呵呵,真是一個網際網路技術的好時代,而且還有各種撕B可看,真想八一八,怕得罪人,我們這種碼農還是專注技術專注寫程式碼吧。

你有什麼想了解的也可以給我留言哈,歡迎交流,我的工作之前主要做的是搜尋的,也做推薦和廣告,這部分的東西可能寫得多點,對了,嵌入式領域也行(跨得有點大,這個嵌入式不是iOS和Android,是真的嵌入式),沒什麼高階背景,也不是BAT這種大廠的,就是一小公司寫程式碼的,所以有很多東西還是不懂,你要是和我交流了發現我答不上來很正常啊,人艱不拆啊。。

本篇也比較長,但是乾貨不多,建議上廁所的時候看,或者在地鐵一邊聽歌一邊看。

前面幾篇,基本上把倒排索引的資料結構給講完了,並且簡單的說了一下排序,然後說了一下倒排索引的構建。這一篇主要寫一下正排索引以及倒排和正排怎麼配合起來形成一個完整的欄位索引。

正排索引

正排索引,也叫前向索引,和倒排索引(也叫反向索引)是相對的,正排索引相對倒排來說簡單多了,第二篇文章的時候有下面兩個表格(表1和表2)

這個是表1

文件編號 文件內容
1 這是一個Go語言實現的搜尋引擎
2 PHP是世界上最好的語言
3 Linux是C語言和組合語言實現的
4 谷歌是一個世界上最好的搜尋引擎公司

這個是表2

關鍵詞 文件編號
Go 1
語言 1,2,3
實現 1,3
搜尋引擎 1,4
PHP 2
世界 2,4
最好 2,4
彙編 3
公司 4

我們之前一直在說作為倒排索引的表2,對於表1,我們認為是資料的詳情(detail)資訊,最後用來做資料內容展示的,如果是放在一個只支援全文搜尋的搜尋引擎中的話,那確實表1只是用來做最後的資料展示,但是如果我們的搜尋引擎還想要一些複雜的功能,那麼表1就是一個正排索引,如果我們的搜尋引擎同時支援倒排索引和正排索引,我們可以簡單的認為這是一個資料庫系統(當然,和真正的資料庫還差得遠啊)。

首先,我們看什麼情況下要使用正排索引

很明顯,如果倒排索引滿足不了搜尋要求的時候,就需要引入正排索引,比如一個電商的搜尋引擎,那麼正排索引就是必須的了,假如我們有以下幾個商品需要上架:

商品編號 商品標題 釋出時間 價格 品牌
10001 錘子手機T9 2026-06-06 5000 錘子
10002 小米手機10 2020-02-02 1999 小米
10003 華為手機P20 2022-12-12 3999 華為

搜尋的時候我們可能需要搜尋價格在一個區間的手機,那麼僅僅用全文倒排索引就比較難完成任務了,而且我們在使用電商的搜尋引擎的時候,經常會在搜尋結果的上方看到一些彙總的資訊【比如品牌,型號,價格彙總】,這一部分的東西也是通過正排索引來實現的,像下面這個圖

用 Golang 寫一個搜尋引擎(0x07)--- 正排索引

所以說,如果我們的搜尋需求不僅僅是進行關鍵詞的匹配,還需要進行一些過濾操作(比如價格區間的過濾),彙總操作(比如結果集中每種品牌數量的統計),那麼就必須引入正排索引了。

第二,我們看看如何實現一個正排索引

實現正排索引有兩種方式:

一種還是基於倒排索引,之前的倒排索引不是通過B+樹構建的麼,B+樹天然的帶排序功能,所以是可以進行範圍查詢的,比如上面那個表格,我們要搜尋的關鍵詞為手機,價格區間在1500–4000之間

  • 我們把價格欄位和商品標題欄位分別建立一個倒排。
  • 首先,通過標題的倒排索引,檢索出所有的帶手機這個關鍵詞的商品的結果集,他們是【1,2,3】
  • 然後進行價格區間的檢索,因為B+樹最下面的葉子節點是通過指標連在一起的,我們只需要通過指標遍歷葉子節點,就可以遍歷出價格區間中所有價格的倒排鏈,然後把這些鏈求並集,得到的結果集是【2,3】,就是滿足這個價格區間的所以商品了。
  • 最後再和關鍵詞查出來的商品求交集,就是最後的結果了。

這是第一種實現方式,彙總操作大家可以自己想想怎麼做,也能做,就是麻煩點。這種實現方式有下面幾個特點

  • 沒有單獨的正排檔案,和倒排檔案合在一起了,同時也不佔用額外的空間。
  • 但是它限制了倒排索引的實現方式只能是B+樹這種帶排序的字典,如果倒排檔案使用雜湊表來實現的話,就不能這麼幹了。
  • 檢索的時候如果是區間搜尋的話,需要進行多次求並集操作,效率上需要進行優化。
  • 由於只有倒排檔案,那麼最後用來做資料展示的時候還需要一個輔助的Detail檔案或者和資料庫繫結在一起才能進行最終的結果展示。

除了上面那個,還有一種實現方式,就是通過一個陣列來實現,陣列的下表就是文件編號(docid,不是商品編號,商品編號是主鍵),由於在搜尋引擎中,docid是自增的,而且不會進行刪除,所以也是唯一的,正好可以和一個一維陣列的下標對上,所以可以用一個陣列來儲存正排索引,就像下面這個表格,分別表示價格和品牌建立的正排索引,其實就是把表1的資料拆開來進行儲存了而已。(為了節省空間,我把兩個寫在一起了)

DOCID 價格 DOCID 品牌
0 5000 0 錘子
1 1999 1 小米
2 3999 2 華為

這麼存的話,檢索的時候怎麼做呢?如果還是上面那個檢索條件要搜尋的關鍵詞為手機,價格區間在1500–4000之間

  • 只把標題建立倒排,價格欄位建立一個一維陣列的正排
  • 首先,通過標題的倒排索引,檢索出所有的帶手機這個關鍵詞的商品的結果集,他們的DOCID是【1,2,3】
  • 遍歷結果集,每遍歷一個docid,直接通過那個一維陣列和對應的正排檔案進行比對,看是否滿足條件,滿足的留下,不滿足的丟棄。
  • 遍歷完成以後,得到最終的結果集【2,3】

如果是彙總操作的話,和上述類似,在第二步遍歷結果集的時候順便就可以進行統計了,遍歷完了也就統計完了。

條條大路通羅馬,通過兩種不同的資料結構,最後得到了一樣的結果,第二方式有以下幾個特點

  • 要為需要進行範圍查詢的欄位單獨建立正排索引,不能和倒排的資料結構合併。
  • 通過倒排獲取到結果集以後需要對結果集進行一次遍歷,然後得到一個新的結果集作為最後的結果,如果結果集特別巨大,那麼也需要時間進行遍歷。
  • 因為是一維陣列來實現的正排,如果文件數非常多的話,記憶體中是裝不下這麼多正排檔案的,需要在磁碟上來實現這個一維陣列。
  • 如果我們將每一個欄位都建立一個正排索引的話,那就不需要單獨的detail檔案或者和資料庫對接了,直接正排檔案合起來就是一個完整的文件資訊,少了外部依賴。

上面就是正排索引的兩種實現方式,使用哪一種要看具體的業務需求,比如像百度這種全文搜尋引擎,主要的需求其實就是查詢關鍵字,很少用到過濾,彙總操作,那麼不用單獨來實現正排索引,用第一種方式就行了,而如果是電商型別的搜尋引擎的話,有大量的過濾啊,彙總操作,那麼通過第二種方式來實現正排索引還是比較必要的。

我的程式碼裡面就是用的第二種方式,並且實現的時候是用mmap的方式在磁碟上實現的,如果記憶體夠大,可以全載入到記憶體提高檢索速度。

索引設計管理

正排索引和倒排索引終於都說完了,這要是搜尋引擎最關鍵的資料結構了,其他所有的東西都是在這個基礎上發展起來的,我們已經有了正排和倒排索引的結構,那麼如果來構建一個索引系統的,我是這麼來做的。

首先,我們需要定一個規矩,所謂規矩就是我們的這個搜尋引擎哪些操作我支援,哪些操作我不支援,比如,我為了簡單,我就支援全文檢索,其他都不支援,那麼只需要好好的實現一個倒排索引結構,那資料結構部分就設計的差不多了。而我在做這個搜尋引擎的時候,想實現的是下面這些個功能。

  • 支援關鍵詞的倒排,也支援完全匹配型別的倒排。
  • 支援過濾操作,但是隻支援整數型別(如果是浮點數根據保留的小數位數轉成整數)和日期型別的過濾,對於字串只提供檢索操作,不提供過濾操作。
  • 對於過濾操作,支援大於,小於,等於,不等於,區間的過濾。
  • 支援欄位的彙總。
  • 不要外接資料庫系統進行資料詳情的展示。

既然是這麼來實現,那對於每個欄位,他可能的型別就是

欄位型別 行為 備註舉例
完整匹配的字串 建立倒排,正排(正排只展示,不進行過濾操作) 主鍵,型號
關鍵詞字串 建立倒排,正排(正排只展示,不進行過濾操作) 標題,描述
數字 只建立正排 價格,庫存
日期 只建立正排 上架
僅展示 只建立正排(正排只展示,不進行過濾操作) 商品詳情描述

這樣,我們實現的時候,首先實現一個倒排索引(src/FalconIndex/segment/invert.go),然後實現一個正排索引(src/FalconIndex/segment/profile.go),然後實現一個欄位類(src/FalconIndex/segment/field.go)用來管理倒排和正排,那麼搜尋引擎最最基本的資料結構就OK了,對外來說倒排和正排是隱藏的,只有Field類對外暴露,對檢索操作來說主要提供幾個介面方法:

  • addDocument 新增文件(建立正排或者倒排)
  • query 通過倒排檢索文件
  • filter 通過正排過濾文件
  • getValue 通過正排檔案獲取這個欄位的值

文章中我儘量少列或者不列程式碼,主要是對搜尋引擎的原理有了解,原理了解了可以自己來實現程式碼,實在不會可以自己去參考參考我的程式碼,畢竟程式設計這東西只要知道了原理和演算法,怎麼實現並不是麻煩事。

寫在後面的話

之前我一直做C++開發的,寫的搜尋程式碼也是C++的,現在用Golang,也沒啥特別的難度,當然因為我對Golang的特性並不是很熟悉,所以基本沒有用Golang的高階功能,寫出來的程式碼當然不夠Golang範,但這也不影響我的實現。

OK,欄位部分介紹完了,搜尋引擎的核心資料結構也介紹完了,後面接下來會繼續往上走,先到段層,然後到索引層,然後會說一下檢索邏輯實現,合併邏輯之類的,索引之上會繼續說一下搜尋引擎的引擎部分,後面還會遇到一些資料結構,比如bitmap,哦,還會單獨寫一到兩篇來介紹分詞,至於排序和索引結構優化也會單獨拿出來說。

另外,我的程式碼基本完成了,包括分散式的部分,會在最近提交到github上去,所以後面也會有幾篇來說搜尋引擎的分散式實現,還是本著原生的原則,沒用第三方庫,所以分散式部分沒有PAXOS這種高階的理論,也沒有ZooKeeper這種高階玩意,到時候大家看吧。

目前我的程式碼初步測試,8G,24核的機器中,1000萬條資料(微博資料,每條不超過140個字,我不是微博的人哈,不存在資料洩密,資料是某號稱亞二爬的博士爬來的,我只是下下來用而已),單個term的平均檢索時間在5ms,用AB進行單個URL測試,QPS大概在7000,如果是隨機關鍵詞測試,QPS大約在2000,基本達到我之前自己定的目標了,而且還有優化空間。下次測測ElasticSearch,目前感覺比它報出來的資料要快,但是環境不一樣,下次部一個比較一下,而且功能上還完全達不到ElasticSearch的水平,不過它那一套要實現出來也是沒什麼問題的,需要的是堅持,我會把這個專案維護下去,不過最近實在是太忙了,苦逼啊。。。

最後,繼續發個二維碼,你懂的,關注一下唄:)

用 Golang 寫一個搜尋引擎(0x07)--- 正排索引

相關文章