使用wukong全文搜索引擎

zzsyg0306 8年前發布 | 30K 次閱讀 中文分詞 分布式系統 搜索引擎

近期項目中有一個全文索引和全文搜索的業務需求,組內同事在這方面都沒啥經驗,找一個滿足我們需求的開源的 全文搜索引擎 勢在必行。我們這一期對全文搜索引擎的需求并不復雜,最主要的是引擎可以很好的支持中文分詞、索引和搜索,并能快速實現功能。在全文搜索領域,基于 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引擎,而不是來分析wukong代碼。

二、全文索引和檢索

1、最簡單的例子

我們先來看一個使用wukong引擎編寫的最簡單的例子:

//example1.go

package main

import (
    "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.go
2016/12/06 21:40:04 載入sego詞典 ./dict/dictionary.txt
2016/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.go
2016/12/06 21:57:47 載入sego詞典 ./dict/dictionary.txt
2016/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.go
2016/12/06 22:03:47 載入sego詞典 ./dict/dictionary.txt
2016/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.go
2016/12/06 22:04:31 載入sego詞典 ./dict/dictionary.txt
2016/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.go
2016/12/06 22:41:49 載入sego詞典 ./dict/dictionary.txt
2016/12/06 22:41:53 sego詞典載入完畢
2016/12/06 22:41:53 Created index number: 3

執行后,我們會在./index路徑下看到持久化后的索引數據文件:

$tree index
index
├── wukong.0
├── wukong.1
├── wukong.2
├── wukong.3
├── wukong.4
├── wukong.5
├── wukong.6
└── wukong.7

0 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.go
2016/12/06 22:48:37 載入sego詞典 ./dict/dictionary.txt
2016/12/06 22:48:41 sego詞典載入完畢
2016/12/06 22:48:42 recover index number: 3
types.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.go

func 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.go
2016/12/06 23:07:17 載入sego詞典 ./dict/dictionary.txt
2016/12/06 23:07:21 sego詞典載入完畢
2016/12/06 23:07:21 recover index number: 3
Please 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  ok
remove 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到時候就都會被我們列為考慮對象了。

 

 

來自:http://tonybai.com/2016/12/06/an-intro-to-wukong-fulltext-search-engine/

 

 本文由用戶 zzsyg0306 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!