美文网首页
Go实现全文搜索引擎

Go实现全文搜索引擎

作者: Go语言由浅入深 | 来源:发表于2022-04-28 06:41 被阅读0次

    本文来源于Artem Krylysov的博客
    全文搜索是我们每天都在使用却没察觉的常用工具之一。如果您曾经在谷歌上搜索过“Golang覆盖率报告”或试图在电子商务网站上查找“室内无线摄像头”,那么您使用的是某种全文搜索。
    全文检索(FTS)是一种用于在大量文档中搜索特定文本的技术。文档可以指网页、报纸文章、电子邮件消息或任何结构化文本。
    今天我们将实现一个自己的FTS引擎。在这篇文章的结尾,我们将能够在不到一毫秒的时间内搜索数百万个文档。我们将从简单的搜索开始,比如“找出包含cat这个单词的所有文档”,然后我们将扩展搜索引擎以支持更复杂的布尔查询。

    为什么需要FTS

    在开始写代码之前,你可能会问“难道我们不能使用grep或循环检查每个文件是否包含我们要搜索的内容吗?”。当然可以。但不是最好的方式。

    语料库

    我们将搜索英文维基百科的一部分摘要。最新的文档集可以在dumps.wikimedia.org上找到。到目前为止,解压后的文件大小为913 MB。XML文件包含超过600K个文档。
    文档例子:

    <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>
    

    加载文档

    我们需要将所有的文件加载到内存。内置的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
    }
    

    每个加载的文档都被分配一个惟一的标识符。为了简单起见,第一个加载的文档被分配ID=0,第二个文档被分配ID=1,以此类推。

    第一次尝试搜索

    既然我们已经把所有的文档都加载到内存中,我们就可以试着找到关于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——不算太糟。如果您从输出中抽查一些文档,您可能会注意到该函数也匹配caterpillar和category,但不匹配大写开头的Cat。这不是我们想要的。
    再进行下一步之前,我们需要解决两个事情:

    • 确保搜索大小写不敏感(因此可以查找Cat)。
    • 在单词边界上匹配,而不是在子字符串上匹配(因此caterpillar和 communication不应该匹配)。

    使用正则表达式搜索

    一个可以同时实现以上两个需求的解决方案是正则表达式。
    这里使用(?i)\bcat\b:

    • (?i)使正则表达式不区分大小写。
    • \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万份文档,搜索开始变得缓慢起来。虽然这种方法很容易实现,但扩展性不好。随着数据集越来越大,我们需要搜索越来越多的文档。该算法的时间复杂度是线性的——需要搜索的文档数量等于文档总数。如果我们有600万份而不是60万份文件,搜索将需要20秒。我们需要更好方法。

    倒排索引

    为了使搜索更快,我们将对文本进行预处理并预先构建索引。
    FTS全文搜索的核心是一种称为倒排索引的数据结构。倒排索引将文档中的每个单词与包含该单词的文档关联起来。
    例如:

    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],
    }
    

    下面是一个倒排索引的真实例子。在一本书中的索引:


    文本分析

    在开始构建索引之前,需要将原始文本分解为适合于索引和搜索的单词(tokens)列表。文本分析器由一个分词器和多个过滤器组成。


    分词器

    分词器是文本分析的第一步。它的工作是将文本转换为字符串列表。在单词边界上分割文本,并删除标点符号:

    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)
        })
    }
    

    结果:

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

    过滤器

    在大多数情况下,仅仅将文本转换为字符串列表是不够的。为了使文本更容易索引和被搜索,我们需要做额外的规范化。

    小写字母

    为了使搜索不区分大小写,小写字母过滤器将字符串转换为小写。cAT,Cat和caT统一为cat。稍后,当我们查询索引时,我们也会将搜索条件设置为小写。这将使搜索词cAt与文本cAt匹配。

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

    结果:

    > lowercaseFilter([]string{"A", "donut", "on", "a", "glass", "plate", "Only", "the", "donuts"})
    
    ["a", "donut", "on", "a", "glass", "plate", "only", "the", "donuts"]
    
    删除常用词

    几乎所有的英文文本都包含常用的单词,如a, I, the或be。这样的词叫做停顿词。我们将删除它们,因为几乎所有文档都会包含停顿词。
    没有“官方”的停顿词列表。可根据需要添加,下面过滤这些停顿词:

    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
    }
    

    结果:

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

    词根提取

    由于单词语法规则的原因,文档可能包含同一个单词的不同形式。词根提取将单词简化为其基本形式。例如,fishing、fishing和fisher可以简化为基本形式fish。实现一个词根提取是一项艰巨的任务,这篇文章没有涉及。我们将使用一个现成库来解析:

    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
    }
    

    结果:

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

    备注:词根并不总是一个有效的单词。例如,一些词根提取可能会将airline缩减为airlin

    组合分词器

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

    分词器和过滤器将英文句子转换为单词列表。

    > analyze("A donut on a glass plate. Only the donuts.")
    
    ["donut", "on", "glass", "plate", "only", "donut"]
    

    这些单词可以作为索引。

    构建索引

    回到倒排索引。它将文档中的每个单词映射到文档ID。内置Map结构体是存储索引的很好选择。map中的键是一个字符串,值是文档ID列表:

    type index map[string][]int
    

    构建索引包括分析文档并将它们的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 {
                    // 不重复添加文档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中的每个单词都对应包含该单词的文档ID列表。

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

    查询

    要查询索引,我们将应用与索引时相同的分词器和过滤器:

    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
    }
    

    结果:

    > idx.search("Small wild cat")
    
    [[24, 173, 303, ...], [98, 173, 765, ...], [[24, 51, 173, ...]]
    

    最后,我们可以找到所有包含cat的文件。搜索600K文档用了不到一毫秒(18个µs)!
    使用倒排索引,搜索文本的时间复杂度与搜索单词的数量成线性关系。在上面的示例查询中,除了分析输入文本外,搜索只需要执行三次map查找。

    布尔查询

    上一节的查询为每个查询返回一个分离的文档列表。当我们在搜索框中输入“small wild cat”时,我们通常期望找到的结果是一个列表,其中同时包含了“small”、“wild”和“cat”。下一步是计算列表之间的交集。通过这种方式,我们将得到一个匹配所有单词的文档列表。



    幸运的是,倒排索引中的ID是按升序插入的。由于这些id是排序的,所以可以在线性时间内计算两个列表之间的交集。intersection函数同时对两个列表进行迭代,并收集两个列表中都存在的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方法分析给定的查询文本,查找单词并计算相应的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
    }
    

    下载的维基百科文件中只有两个文件同时匹配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.
    

    搜索结果符合预期。

    总结

    我们刚刚建立了一个全文搜索引擎。尽管它很简单,但它可以为更高级的项目提供坚实的基础。
    这里没有提到很多能够显著提高性能并让引擎更加友好的内容。以下是一些可以进一步改进的想法:

    • 扩展布尔查询以支持OR和NOT
    • 将索引存储在磁盘上:
      *在每次应用程序重启时重新构建索引可能需要一段时间。
      • 大量索引可能不适合内存。
    • 尝试使用内存和cpu高效的数据格式来存储文档ID集。可以考虑Roaring Bitmaps。
    • 支持索引多个文档字段。
      *根据相关性对结果排序。
      完整的源代码可以在GitHub上找到。

    相关文章

      网友评论

          本文标题:Go实现全文搜索引擎

          本文链接:https://www.haomeiwen.com/subject/yeolyrtx.html