Go實(shí)現(xiàn)全文搜索引擎

本文來源于Artem Krylysov的博客。
全文搜索是我們每天都在使用卻沒察覺的常用工具之一。如果您曾經(jīng)在谷歌上搜索過“Golang覆蓋率報(bào)告”或試圖在電子商務(wù)網(wǎng)站上查找“室內(nèi)無線攝像頭”,那么您使用的是某種全文搜索。
全文檢索(FTS)是一種用于在大量文檔中搜索特定文本的技術(shù)。文檔可以指網(wǎng)頁、報(bào)紙文章、電子郵件消息或任何結(jié)構(gòu)化文本。
今天我們將實(shí)現(xiàn)一個(gè)自己的FTS引擎。在這篇文章的結(jié)尾,我們將能夠在不到一毫秒的時(shí)間內(nèi)搜索數(shù)百萬個(gè)文檔。我們將從簡(jiǎn)單的搜索開始,比如“找出包含cat這個(gè)單詞的所有文檔”,然后我們將擴(kuò)展搜索引擎以支持更復(fù)雜的布爾查詢。

為什么需要FTS

在開始寫代碼之前,你可能會(huì)問“難道我們不能使用grep或循環(huán)檢查每個(gè)文件是否包含我們要搜索的內(nèi)容嗎?”。當(dāng)然可以。但不是最好的方式。

語料庫

我們將搜索英文維基百科的一部分摘要。最新的文檔集可以在dumps.wikimedia.org上找到。到目前為止,解壓后的文件大小為913 MB。XML文件包含超過600K個(gè)文檔。
文檔例子:

<title>Wikipedia: Kit-Cat Klock</title>
<url>https://en.wikipedia.org/wiki/Kit-Cat_Klock</url>
<abstract>The Kit-Cat Klock is an art deco novelty wall clock shaped like a grinning cat with cartoon eyes that swivel in time with its pendulum tail.</abstract>

加載文檔

我們需要將所有的文件加載到內(nèi)存。內(nèi)置的encoding/xml包非常方便可用:

import (
    "encoding/xml"
    "os"
)

type document struct {
    Title string `xml:"title"`
    URL   string `xml:"url"`
    Text  string `xml:"abstract"`
    ID    int
}

func loadDocuments(path string) ([]document, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    dec := xml.NewDecoder(f)
    dump := struct {
        Documents []document `xml:"doc"`
    }{}
    if err := dec.Decode(&dump); err != nil {
        return nil, err
    }

    docs := dump.Documents
    for i := range docs {
        docs[i].ID = I
    }
    return docs, nil
}

每個(gè)加載的文檔都被分配一個(gè)惟一的標(biāo)識(shí)符。為了簡(jiǎn)單起見,第一個(gè)加載的文檔被分配ID=0,第二個(gè)文檔被分配ID=1,以此類推。

第一次嘗試搜索

既然我們已經(jīng)把所有的文檔都加載到內(nèi)存中,我們就可以試著找到關(guān)于cat的文檔了。首先,讓我們遍歷所有文檔,檢查它們是否包含字符串cat:

func search(docs []document, term string) []document {
    var r []document
    for _, doc := range docs {
        if strings.Contains(doc.Text, term) {
            r = append(r, doc)
        }
    }
    return r
}

在我的筆記本電腦上,搜索階段需要103ms——不算太糟。如果您從輸出中抽查一些文檔,您可能會(huì)注意到該函數(shù)也匹配caterpillar和category,但不匹配大寫開頭的Cat。這不是我們想要的。
再進(jìn)行下一步之前,我們需要解決兩個(gè)事情:

  • 確保搜索大小寫不敏感(因此可以查找Cat)。
  • 在單詞邊界上匹配,而不是在子字符串上匹配(因此caterpillar和 communication不應(yīng)該匹配)。

使用正則表達(dá)式搜索

一個(gè)可以同時(shí)實(shí)現(xiàn)以上兩個(gè)需求的解決方案是正則表達(dá)式。
這里使用(?i)\bcat\b:

  • (?i)使正則表達(dá)式不區(qū)分大小寫。
  • \b匹配單詞邊界(一邊是單詞字符而另一邊不是單詞字符的位置)
func search(docs []document, term string) []document {
    re := regexp.MustCompile(`(?i)\b` + term + `\b`) // Don't do this in production, it's a security risk. term needs to be sanitized.
    var r []document
    for _, doc := range docs {
        if re.MatchString(doc.Text) {
            r = append(r, doc)
        }
    }
    return r
}

搜索過程花了兩秒多。正如您所看到的,即使只有60萬份文檔,搜索開始變得緩慢起來。雖然這種方法很容易實(shí)現(xiàn),但擴(kuò)展性不好。隨著數(shù)據(jù)集越來越大,我們需要搜索越來越多的文檔。該算法的時(shí)間復(fù)雜度是線性的——需要搜索的文檔數(shù)量等于文檔總數(shù)。如果我們有600萬份而不是60萬份文件,搜索將需要20秒。我們需要更好方法。

倒排索引

為了使搜索更快,我們將對(duì)文本進(jìn)行預(yù)處理并預(yù)先構(gòu)建索引。
FTS全文搜索的核心是一種稱為倒排索引的數(shù)據(jù)結(jié)構(gòu)。倒排索引將文檔中的每個(gè)單詞與包含該單詞的文檔關(guān)聯(lián)起來。
例如:

documents = {
    1: "a donut on a glass plate",
    2: "only the donut",
    3: "listen to the drum machine",
}

index = {
    "a": [1],
    "donut": [1, 2],
    "on": [1],
    "glass": [1],
    "plate": [1],
    "only": [2],
    "the": [2, 3],
    "listen": [3],
    "to": [3],
    "drum": [3],
    "machine": [3],
}

下面是一個(gè)倒排索引的真實(shí)例子。在一本書中的索引:


文本分析

在開始構(gòu)建索引之前,需要將原始文本分解為適合于索引和搜索的單詞(tokens)列表。文本分析器由一個(gè)分詞器和多個(gè)過濾器組成。


分詞器

分詞器是文本分析的第一步。它的工作是將文本轉(zhuǎn)換為字符串列表。在單詞邊界上分割文本,并刪除標(biāo)點(diǎn)符號(hào):

func tokenize(text string) []string {
    return strings.FieldsFunc(text, func(r rune) bool {
        // Split on any character that is not a letter or a number.
        return !unicode.IsLetter(r) && !unicode.IsNumber(r)
    })
}

結(jié)果:

> tokenize("A donut on a glass plate. Only the donuts.")

["A", "donut", "on", "a", "glass", "plate", "Only", "the", "donuts"]

過濾器

在大多數(shù)情況下,僅僅將文本轉(zhuǎn)換為字符串列表是不夠的。為了使文本更容易索引和被搜索,我們需要做額外的規(guī)范化。

小寫字母

為了使搜索不區(qū)分大小寫,小寫字母過濾器將字符串轉(zhuǎn)換為小寫。cAT,Cat和caT統(tǒng)一為cat。稍后,當(dāng)我們查詢索引時(shí),我們也會(huì)將搜索條件設(shè)置為小寫。這將使搜索詞cAt與文本cAt匹配。

func lowercaseFilter(tokens []string) []string {
    r := make([]string, len(tokens))
    for i, token := range tokens {
        r[i] = strings.ToLower(token)
    }
    return r
}

結(jié)果:

> lowercaseFilter([]string{"A", "donut", "on", "a", "glass", "plate", "Only", "the", "donuts"})

["a", "donut", "on", "a", "glass", "plate", "only", "the", "donuts"]
刪除常用詞

幾乎所有的英文文本都包含常用的單詞,如a, I, the或be。這樣的詞叫做停頓詞。我們將刪除它們,因?yàn)閹缀跛形臋n都會(huì)包含停頓詞。
沒有“官方”的停頓詞列表??筛鶕?jù)需要添加,下面過濾這些停頓詞:

var stopwords = map[string]struct{}{ // I wish Go had built-in sets.
    "a": {}, "and": {}, "be": {}, "have": {}, "i": {},
    "in": {}, "of": {}, "that": {}, "the": {}, "to": {},
}

func stopwordFilter(tokens []string) []string {
    r := make([]string, 0, len(tokens))
    for _, token := range tokens {
        if _, ok := stopwords[token]; !ok {
            r = append(r, token)
        }
    }
    return r
}

結(jié)果:

> stopwordFilter([]string{"a", "donut", "on", "a", "glass", "plate", "only", "the", "donuts"})

["donut", "on", "glass", "plate", "only", "donuts"]

詞根提取

由于單詞語法規(guī)則的原因,文檔可能包含同一個(gè)單詞的不同形式。詞根提取將單詞簡(jiǎn)化為其基本形式。例如,fishing、fishing和fisher可以簡(jiǎn)化為基本形式fish。實(shí)現(xiàn)一個(gè)詞根提取是一項(xiàng)艱巨的任務(wù),這篇文章沒有涉及。我們將使用一個(gè)現(xiàn)成庫來解析:

import snowballeng "github.com/kljensen/snowball/english"

func stemmerFilter(tokens []string) []string {
    r := make([]string, len(tokens))
    for i, token := range tokens {
        r[i] = snowballeng.Stem(token, false)
    }
    return r
}

結(jié)果:

> stemmerFilter([]string{"donut", "on", "glass", "plate", "only", "donuts"})

["donut", "on", "glass", "plate", "only", "donut"]

備注:詞根并不總是一個(gè)有效的單詞。例如,一些詞根提取可能會(huì)將airline縮減為airlin

組合分詞器

func analyze(text string) []string {
    tokens := tokenize(text)
    tokens = lowercaseFilter(tokens)
    tokens = stopwordFilter(tokens)
    tokens = stemmerFilter(tokens)
    return tokens
}

分詞器和過濾器將英文句子轉(zhuǎn)換為單詞列表。

> analyze("A donut on a glass plate. Only the donuts.")

["donut", "on", "glass", "plate", "only", "donut"]

這些單詞可以作為索引。

構(gòu)建索引

回到倒排索引。它將文檔中的每個(gè)單詞映射到文檔ID。內(nèi)置Map結(jié)構(gòu)體是存儲(chǔ)索引的很好選擇。map中的鍵是一個(gè)字符串,值是文檔ID列表:

type index map[string][]int

構(gòu)建索引包括分析文檔并將它們的ID添加到Map:

func (idx index) add(docs []document) {
    for _, doc := range docs {
        for _, token := range analyze(doc.Text) {
            ids := idx[token]
            if ids != nil && ids[len(ids)-1] == doc.ID {
                // 不重復(fù)添加文檔ID.
                continue
            }
            idx[token] = append(ids, doc.ID)
        }
    }
}

func main() {
    idx := make(index)
    idx.add([]document{{ID: 1, Text: "A donut on a glass plate. Only the donuts."}})
    idx.add([]document{{ID: 2, Text: "donut is a donut"}})
    fmt.Println(idx)
}

Map中的每個(gè)單詞都對(duì)應(yīng)包含該單詞的文檔ID列表。

map[donut:[1 2] glass:[1] is:[2] on:[1] only:[1] plate:[1]]

查詢

要查詢索引,我們將應(yīng)用與索引時(shí)相同的分詞器和過濾器:

func (idx index) search(text string) [][]int {
    var r [][]int
    for _, token := range analyze(text) {
        if ids, ok := idx[token]; ok {
            r = append(r, ids)
        }
    }
    return r
}

結(jié)果:

> idx.search("Small wild cat")

[[24, 173, 303, ...], [98, 173, 765, ...], [[24, 51, 173, ...]]

最后,我們可以找到所有包含cat的文件。搜索600K文檔用了不到一毫秒(18個(gè)μs)!
使用倒排索引,搜索文本的時(shí)間復(fù)雜度與搜索單詞的數(shù)量成線性關(guān)系。在上面的示例查詢中,除了分析輸入文本外,搜索只需要執(zhí)行三次map查找。

布爾查詢

上一節(jié)的查詢?yōu)槊總€(gè)查詢返回一個(gè)分離的文檔列表。當(dāng)我們?cè)谒阉骺蛑休斎搿皊mall wild cat”時(shí),我們通常期望找到的結(jié)果是一個(gè)列表,其中同時(shí)包含了“small”、“wild”和“cat”。下一步是計(jì)算列表之間的交集。通過這種方式,我們將得到一個(gè)匹配所有單詞的文檔列表。



幸運(yùn)的是,倒排索引中的ID是按升序插入的。由于這些id是排序的,所以可以在線性時(shí)間內(nèi)計(jì)算兩個(gè)列表之間的交集。intersection函數(shù)同時(shí)對(duì)兩個(gè)列表進(jìn)行迭代,并收集兩個(gè)列表中都存在的ID:

func intersection(a []int, b []int) []int {
    maxLen := len(a)
    if len(b) > maxLen {
        maxLen = len(b)
    }
    r := make([]int, 0, maxLen)
    var i, j int
    for i < len(a) && j < len(b) {
        if a[i] < b[j] {
            i++
        } else if a[i] > b[j] {
            j++
        } else {
            r = append(r, a[i])
            i++
            j++
        }
    }
    return r
}

更新后的search方法分析給定的查詢文本,查找單詞并計(jì)算相應(yīng)的ID列表之間的交集:

func (idx index) search(text string) []int {
    var r []int
    for _, token := range analyze(text) {
        if ids, ok := idx[token]; ok {
            if r == nil {
                r = ids
            } else {
                r = intersection(r, ids)
            }
        } else {
            // Token doesn't exist.
            return nil
        }
    }
    return r
}

下載的維基百科文件中只有兩個(gè)文件同時(shí)匹配small、wild和cat:

> idx.search("Small wild cat")

130764  The wildcat is a species complex comprising two small wild cat species, the European wildcat (Felis silvestris) and the African wildcat (F. lybica).
131692  Catopuma is a genus containing two Asian small wild cat species, the Asian golden cat (C. temminckii) and the bay cat.

搜索結(jié)果符合預(yù)期。

總結(jié)

我們剛剛建立了一個(gè)全文搜索引擎。盡管它很簡(jiǎn)單,但它可以為更高級(jí)的項(xiàng)目提供堅(jiān)實(shí)的基礎(chǔ)。
這里沒有提到很多能夠顯著提高性能并讓引擎更加友好的內(nèi)容。以下是一些可以進(jìn)一步改進(jìn)的想法:

  • 擴(kuò)展布爾查詢以支持OR和NOT
  • 將索引存儲(chǔ)在磁盤上:
    *在每次應(yīng)用程序重啟時(shí)重新構(gòu)建索引可能需要一段時(shí)間。
    • 大量索引可能不適合內(nèi)存。
  • 嘗試使用內(nèi)存和cpu高效的數(shù)據(jù)格式來存儲(chǔ)文檔ID集??梢钥紤]Roaring Bitmaps。
  • 支持索引多個(gè)文檔字段。
    *根據(jù)相關(guān)性對(duì)結(jié)果排序。
    完整的源代碼可以在GitHub上找到。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容