這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
近期項目中有一個全文索引和全文檢索搜尋的業務需求,組內同事在這方面都沒啥經驗,找一個滿足我們需求的開源的全文檢索搜尋引擎勢在必行。我們這一期對全文檢索搜尋引擎的需求並不複雜,最主要的是引擎可以很好的支援中文分詞、索引和搜尋,並能快速實現功能。在全文檢索搜尋領域,基於Apache lucene的Elasticsearch捨我其誰,其強大的分布式系統能力、對超大規模資料的支援、友好的Restful API以及近即時的搜尋效能都是業內翹楚,並且其開發社區也是相當活躍,資料眾多。但也正式由於其體量較大,我們並沒有在本期項目中選擇使用Elasticsearch,而是挑選了另外一個“fame”不是那麼響亮的引擎:wukong。
一、wukong簡介
wukong,是一款golang實現的高效能、支援中文分詞的全文檢索搜尋引擎。我個人覺得它最大的特點恰恰是不像Elasticsearch那樣龐大和功能完備,而是可以以一個Library的形式快速整合到你的應用或服務中去,這可能也是在當前階段選擇它的最重要原因,當然其golang技術棧也是讓我垂涎於它的另外一個原因:)。
第一次知道wukong,其實是在今年的GopherChina大會上,其作者陳輝作為第一個演講嘉賓在大會上分享了“Go與人工智慧”。在這個presentation中,chen hui詳細講解了wukong搜尋引擎以及其他幾個關聯的開源項目,比如:sego等。
在golang世界中,做full text search的可不止wukong一個。另外一個比較知名的是bleve,但預設情況下,bleve並不支援中文分詞和搜尋,需要結合中文分詞外掛程式才能支援,比如:gojieba。
wukong基本上是陳輝一個人打造的項目,在陳輝在阿里任職期間,他將其用於阿里內部的一些項目中,但總體來說,wukong的應用還是很小眾的,相關資料也不多,基本都集中在其github網站上。關於wukong源碼的分析,倒是在國外網站上發現一篇:《Code reading: wukong full-text search engine》。
本文更多聚焦於應用wukong引擎,而不是來分析wukong代碼。
二、全文索引和檢索
1、最簡單的例子
我們先來看一個使用wukong引擎編寫的最簡單的例子:
//example1.gopackage mainimport ( "fmt" "github.com/huichen/wukong/engine" "github.com/huichen/wukong/types")var ( searcher = engine.Engine{} docId uint64)const ( text1 = `在蘇黎世的FIFA頒獎典禮上,巴薩球星、阿根廷國家隊隊長梅西贏得了生涯第5個金球獎,繼續創造足壇的新紀錄` text2 = `12月6日,網上出現照片顯示國產第五代戰鬥機殲-20的尾翼已經塗上五位元部隊編號`)func main() { searcher.Init(types.EngineInitOptions{ IndexerInitOptions: &types.IndexerInitOptions{ IndexType: types.DocIdsIndex, }, SegmenterDictionaries: "./dict/dictionary.txt", StopTokenFile: "./dict/stop_tokens.txt", }) defer searcher.Close() docId++ searcher.IndexDocument(docId, types.DocumentIndexData{Content: text1}, false) docId++ searcher.IndexDocument(docId, types.DocumentIndexData{Content: text2}, false) searcher.FlushIndex() fmt.Printf("%#v\n", searcher.Search(types.SearchRequest{Text: "巴薩 梅西"})) fmt.Printf("%#v\n", searcher.Search(types.SearchRequest{Text: "戰鬥機 金球獎"}))}
在這個例子中,我們建立的wukong engine索引了兩個doc:text1和text2,建立好索引後,我們利用引擎進行關鍵詞查詢,我們來看看查詢結果:
$go run example1.go2016/12/06 21:40:04 載入sego詞典 ./dict/dictionary.txt2016/12/06 21:40:08 sego詞典載入完畢types.SearchResponse{Tokens:[]string{"巴薩", "梅西"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x1, Scores:[]float32{0}, TokenSnippetLocations:[]int(nil), TokenLocations:[][]int(nil)}}, Timeout:false, NumDocs:1}types.SearchResponse{Tokens:[]string{"戰鬥機", "金球獎"}, Docs:[]types.ScoredDocument{}, Timeout:false, NumDocs:0}
可以看出當查詢“巴薩 梅西”時,引擎正確匹配到了第一個文檔(DocId:0×1)。而第二次查詢關鍵片語合“戰鬥機 金球獎”則沒有匹配到任何文檔。從這個例子我們也可以看出,wukong引擎對關鍵詞查詢支援的是關鍵詞的AND查詢,只有文檔中同時包含所有關鍵詞,才能被匹配到。這也是目前wukong引擎唯一支援的一種關鍵詞搜尋組合模式。
wukong引擎的索引key是一個uint64值,我們需要保證該值的唯一性,否則將導致已建立的索引被override。
另外我們看到:在初始化IndexerInitOptions時,我們傳入的IndexType是types.DocIdsIndex,這將指示engine在建立的索引和搜尋結果中只保留匹配到的DocId資訊,這將最小化wukong引擎對記憶體的佔用。
如果在初始化EngineInitOptions時不給StopTokenFile賦值,那麼當我們搜尋”巴薩 梅西”時,引擎會將keywords分成三個關鍵詞:”巴薩”、空格和”梅西”分別搜尋並Merge結果:
$go run example1.go2016/12/06 21:57:47 載入sego詞典 ./dict/dictionary.txt2016/12/06 21:57:51 sego詞典載入完畢types.SearchResponse{Tokens:[]string{"巴薩", " ", "梅西"}, Docs:[]types.ScoredDocument{}, Timeout:false, NumDocs:0}types.SearchResponse{Tokens:[]string{"戰鬥機", " ", "金球獎"}, Docs:[]types.ScoredDocument{}, Timeout:false, NumDocs:0}
2、FrequenciesIndex和LocationsIndex
wukong Engine的IndexType支援的另外兩個類型是FrequenciesIndex和LocationsIndex,分別對應的是保留詞頻資訊以及關鍵詞在文檔中出現的位置資訊,這兩類IndexType對記憶體的消耗量也是逐漸增大的,畢竟保留的資訊是遞增的:
當IndexType = FrequenciesIndex時:
$go run example1.go2016/12/06 22:03:47 載入sego詞典 ./dict/dictionary.txt2016/12/06 22:03:51 sego詞典載入完畢types.SearchResponse{Tokens:[]string{"巴薩", "梅西"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x1, Scores:[]float32{3.0480049}, TokenSnippetLocations:[]int(nil), TokenLocations:[][]int(nil)}}, Timeout:false, NumDocs:1}types.SearchResponse{Tokens:[]string{"戰鬥機", "金球獎"}, Docs:[]types.ScoredDocument{}, Timeout:false, NumDocs:0}
當IndexType = LocationsIndex時:
$go run example1.go2016/12/06 22:04:31 載入sego詞典 ./dict/dictionary.txt2016/12/06 22:04:38 sego詞典載入完畢types.SearchResponse{Tokens:[]string{"巴薩", "梅西"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x1, Scores:[]float32{3.0480049}, TokenSnippetLocations:[]int{37, 76}, TokenLocations:[][]int{[]int{37}, []int{76}}}}, Timeout:false, NumDocs:1}types.SearchResponse{Tokens:[]string{"戰鬥機", "金球獎"}, Docs:[]types.ScoredDocument{}, Timeout:false, NumDocs:0}
3、分詞對結果的影響
在前面,當不給StopTokenFile賦值時,我們初步看到了分詞對搜尋結果的影響。wukong的中文分詞完全基於作者的另外一個開源項目sego實現的。分詞的準確程度直接影響著索引的建立和關鍵詞的搜尋結果。sego的詞典和StopTokenFile來自於網路,如果你需要更加準確的分詞結果,那麼是需要你定期更新dictionary.txt和stop_tokens.txt。
舉個例子,如果你的來源文件內容為:”你們高度興趣的 .NET Core 1.1 來了哦”,你的搜尋關鍵詞為:興趣。按照我們的預期,應該可以搜尋到這個來源文件。但實際輸出卻是:
types.SearchResponse{Tokens:[]string{"興趣"}, Docs:[]types.ScoredDocument{}, Timeout:false, NumDocs:0}
其原因就在於sego對”你們高度興趣的 .NET Core 1.1 來了哦”這句話的分詞結果是:
你們/r 高度興趣/l 的/uj /x ./x net/x /x core/x /x 1/x ./x 1/x /x 來/v 了/ul 哦/zg
sego並沒有將“興趣”分出來,而是將“高度興趣”四個字放在了一起,wukong引擎自然就不會單獨為“興趣”單獨建立文檔索引了,搜尋不到也就能理解了。因此,sego可以被用來檢驗wukong引擎分詞情況,這將有助於你瞭解wukong對文檔索引的建立情況。
三、持久化索引和啟動恢複
上面的例子中,wukong引擎建立的文檔索引都是存放在記憶體中的,程式退出後,這些資料也就隨之消失了。每次啟動程式都要根據來源文件重建立立索引顯然是一個很不明智的想法。wukong支援將已建立的索引持久化到磁碟檔案中,並在程式重啟時從檔案中間索引資料恢複出來,並在後續的關鍵詞搜尋時使用。wukong底層支援兩種持久化引擎,一個是boltdb,另外一個是cznic/kv。預設採用boltdb。
我們來看一個持久化索引的例子(考慮文章size,省略一些代碼):
// example2_index_create.go... ...func main() { searcher.Init(types.EngineInitOptions{ IndexerInitOptions: &types.IndexerInitOptions{ IndexType: types.DocIdsIndex, }, UsePersistentStorage: true, PersistentStorageFolder: "./index", SegmenterDictionaries: "./dict/dictionary.txt", StopTokenFile: "./dict/stop_tokens.txt", }) defer searcher.Close() os.MkdirAll("./index", 0777) docId++ searcher.IndexDocument(docId, types.DocumentIndexData{Content: text1}, false) docId++ searcher.IndexDocument(docId, types.DocumentIndexData{Content: text2}, false) docId++ searcher.IndexDocument(docId, types.DocumentIndexData{Content: text3}, false) searcher.FlushIndex() log.Println("Created index number:", searcher.NumDocumentsIndexed())}
這是一個建立持久化索引的源檔案。可以看出:如果要持久化索引,只需在engine init時顯式設定UsePersistentStorage為true,並設定PersistentStorageFolder,即索引持久化檔案存放的路徑。執行一下該源檔案:
$go run example2_index_create.go2016/12/06 22:41:49 載入sego詞典 ./dict/dictionary.txt2016/12/06 22:41:53 sego詞典載入完畢2016/12/06 22:41:53 Created index number: 3
執行後,我們會在./index路徑下看到持久化後的索引資料檔案:
$tree indexindex├── wukong.0├── wukong.1├── wukong.2├── wukong.3├── wukong.4├── wukong.5├── wukong.6└── wukong.70 directories, 8 files
現在我們再建立一個程式,該程式從持久化的索引資料恢複索引到記憶體中,並針對搜尋關鍵詞給出搜尋結果:
// example2_index_search.go... ...var ( searcher = engine.Engine{})func main() { searcher.Init(types.EngineInitOptions{ IndexerInitOptions: &types.IndexerInitOptions{ IndexType: types.DocIdsIndex, }, UsePersistentStorage: true, PersistentStorageFolder: "./index", SegmenterDictionaries: "./dict/dictionary.txt", StopTokenFile: "./dict/stop_tokens.txt", }) defer searcher.Close() searcher.FlushIndex() log.Println("recover index number:", searcher.NumDocumentsIndexed()) fmt.Printf("%#v\n", searcher.Search(types.SearchRequest{Text: "巴薩 梅西"}))}
執行這個程式:
$go run example2_index_search.go2016/12/06 22:48:37 載入sego詞典 ./dict/dictionary.txt2016/12/06 22:48:41 sego詞典載入完畢2016/12/06 22:48:42 recover index number: 3types.SearchResponse{Tokens:[]string{"巴薩", "梅西"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x1, Scores:[]float32{0}, TokenSnippetLocations:[]int(nil), TokenLocations:[][]int(nil)}}, Timeout:false, NumDocs:1}
該程式成功從前面已經建立好的程式中恢複了索引資料,並針對Search request給出了正確的搜尋結果。
需要注意的是:boltdb採用了flock保證互斥訪問底層檔案資料的,因此當一個程式開啟了boltdb,此時如果有另外一個程式嘗試開啟相同的boltdb,那麼後者將阻塞在open boltdb的環節。
四、動態增加和刪除索引
wukong引擎支援運行時動態增刪索引,並即時影響搜尋結果。
我們以上一節建立的持久化索引為基礎,啟動一個支援索引動態增加的程式:
//example3.gofunc main() { searcher.Init(types.EngineInitOptions{ IndexerInitOptions: &types.IndexerInitOptions{ IndexType: types.DocIdsIndex, }, UsePersistentStorage: true, PersistentStorageFolder: "./index", PersistentStorageShards: 8, SegmenterDictionaries: "./dict/dictionary.txt", StopTokenFile: "./dict/stop_tokens.txt", }) defer searcher.Close() searcher.FlushIndex() log.Println("recover index number:", searcher.NumDocumentsIndexed()) docId = searcher.NumDocumentsIndexed() os.MkdirAll("./source", 0777) go func() { for { var paths []string //update index dynamically time.Sleep(time.Second * 10) var path = "./source" err := filepath.Walk(path, func(path string, f os.FileInfo, err error) error { if f == nil { return err } if f.IsDir() { return nil } fc, err := ioutil.ReadFile(path) if err != nil { fmt.Println("read file:", path, "error:", err) } docId++ fmt.Println("indexing file:", path, "... ...") searcher.IndexDocument(docId, types.DocumentIndexData{Content: string(fc)}, true) fmt.Println("indexed file:", path, " ok") paths = append(paths, path) return nil }) if err != nil { fmt.Printf("filepath.Walk() returned %v\n", err) return } for _, p := range paths { err := os.Remove(p) if err != nil { fmt.Println("remove file:", p, " error:", err) continue } fmt.Println("remove file:", p, " ok!") } if len(paths) != 0 { // 等待索引重新整理完畢 fmt.Println("flush index....") searcher.FlushIndex() fmt.Println("flush index ok") } } }() for { var s string fmt.Println("Please input your search keywords:") fmt.Scanf("%s", &s) if s == "exit" { break } fmt.Printf("%#v\n", searcher.Search(types.SearchRequest{Text: s})) }}
example3這個程式啟動了一個goroutine,定期到source目錄下讀取要建立索引的來源文件,並即時更新索引資料。main routine則等待使用者輸入關鍵詞,並通過引擎搜尋返回結果。我們來Run一下這個程式:
$go run example3.go2016/12/06 23:07:17 載入sego詞典 ./dict/dictionary.txt2016/12/06 23:07:21 sego詞典載入完畢2016/12/06 23:07:21 recover index number: 3Please input your search keywords:梅西types.SearchResponse{Tokens:[]string{"梅西"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x1, Scores:[]float32{0}, TokenSnippetLocations:[]int(nil), TokenLocations:[][]int(nil)}}, Timeout:false, NumDocs:1}Please input your search keywords:戰鬥機types.SearchResponse{Tokens:[]string{"戰鬥機"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x2, Scores:[]float32{0}, TokenSnippetLocations:[]int(nil), TokenLocations:[][]int(nil)}}, Timeout:false, NumDocs:1}Please input your search keywords:
可以看到:基於當前已經恢複的索引,我們可以正確搜尋到”梅西”、”戰鬥機”等關鍵詞所在的文檔。
這時我們如果輸入:“球王”,我們得到的搜尋結果如下:
Please input your search keywords:球王types.SearchResponse{Tokens:[]string{"球王"}, Docs:[]types.ScoredDocument{}, Timeout:false, NumDocs:0}
沒有任何文檔得以匹配。
沒關係,現在我們就來增加一個文檔,裡麵包含球王等關鍵字。我們建立一個文檔: soccerking.txt,內容為:
《球王馬拉多納》是一部講述世界上被公認為現代足球壇上最偉大的傳奇足球明星迭戈·馬拉多納的影片。他出身於清貧家庭,九歲展露過人才華,十一歲加入阿根廷足球青少年隊,十六歲便成為阿根廷甲級聯賽最年輕的>球員。1986年世界盃,他為阿根廷隊射入足球史上最佳進球,並帶領隊伍勇奪金杯。他的一生充滿爭議、大起大落,球迷與人們對他的熱愛卻從未減少過,生命力旺盛的他多次從人生穀底重生。
將soccerking.txt移動到source目錄中,片刻後,可以看到程式輸出以下日誌:
indexing file: source/soccerking.txt ... ...indexed file: source/soccerking.txt okremove file: source/soccerking.txt ok!flush index....flush index ok
我們再嘗試搜尋”球王”、”馬拉多納”等關鍵詞:
Please input your search keywords:球王types.SearchResponse{Tokens:[]string{"球王"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x4, Scores:[]float32{0}, TokenSnippetLocations:[]int(nil), TokenLocations:[][]int(nil)}}, Timeout:false, NumDocs:1}Please input your search keywords:馬拉多納types.SearchResponse{Tokens:[]string{"馬拉多納"}, Docs:[]types.ScoredDocument{types.ScoredDocument{DocId:0x4, Scores:[]float32{0}, TokenSnippetLocations:[]int(nil), TokenLocations:[][]int(nil)}}, Timeout:false, NumDocs:1}
可以看到,這回engine正確搜尋到了對應的Doc。
五、分布式索引和搜尋
從前面的章節內容,我們大致瞭解了wukong的工作原理。wukong將索引儲存於boltdb中,每個wukong instance獨佔一份資料,無法共用給其他wukong instance。當一個node上的記憶體空間不足以滿足資料量需求時,需要將wukong引擎進行分布式部署以實現分布式索引和搜尋。關於這點,wukong官方提供了一段方案描述:
分布式搜尋的原理如下:當文檔數量較多無法在一台機器記憶體中索引時,可以將文檔按照常值內容的hash值裂分(sharding),不同塊交由不同伺服器索引。在尋找時同一請求分發到所有裂分伺服器上,然後將所有伺服器返回的結果歸併重排序作為最終搜尋結果輸出。為了保證裂分的均勻性,建議使用Go語言實現的Murmur3 hash函數:https://github.com/huichen/murmur按照上面的原理很容易用悟空引擎實現分布式搜尋(每個裂分伺服器運行一個悟空引擎),但這樣的分布式系統多數是高度定製的,比如任務的調度依賴於分布式環境,有時需要添加額外層的伺服器以均衡負載
實質就是索引和搜尋的分區處理。目前我們項目所在階段尚不需這樣一個分布式wukong,因此,這裡也沒有實戰經驗可供分享。
六、wukong引擎的局限
有了上面的內容介紹,你基本可以掌握和使用wukong引擎了。不過在選用wukong引擎之前,你務必要瞭解wukong引擎的一些局限:
1、開發不活躍,資料較少,社區較小
wukong引擎基本上是作者一個人的項目,社區參與度不高,資料很少。另外由於作者正在創業,忙於造輪子^_^,因此wukong項目更新的頻度不高。
2、缺少計劃和願景
似乎作者並沒有持續將wukong引擎持續改進和發揚光大的想法和動力。Feature上也無增加。這點和bleve比起來就要差很多。
3、查詢功能簡單,僅支援關鍵詞的AND查詢
如果你要支援靈活多樣的全文檢索索引的查詢方式,那麼目前的版本的wukong很可能不適合你。
4、搜尋的準確度基於dictionary.txt的規模
前面說過,wukong的索引建立和搜尋精確度一定程度上取決於分詞引擎的分詞精確性,這樣dictionary.txt檔案是否全面,就會成為影響搜尋精確度的重要因素。
5、缺少將索引儲存於關係DB中的外掛程式支援
當前wukong引擎只能將索引持久化儲存於檔案中,尚無法和MySQL這樣的資料庫配合索引的儲存和查詢。
總之,wukong絕非一個完美的全文檢索搜尋引擎,是否選用,要看你所處的context。
七、小結
選用wukong引擎和我們的項目目前所處的context情況不無關係:我們需要快速實現出一個功能簡單卻可用的全文檢索搜尋服務。也許在後續版本中,對查詢方式、資料規模有進一步要求時,就是可能考慮更換引擎的時刻了。bleve、elasticsearch到時候就都會被我們列為考慮對象了。
本文代碼在可在這裡下載。
2016, bigwhite. 著作權.