Blev:如何构建一个速度极快的搜索引擎?

Go/Golang 是我最喜欢的语言之一;我喜欢它的简约和干净,它的语法非常紧凑,并努力保持简单(我是 KISS 原则的忠实粉丝)。

我最近面临的一个主要挑战是构建一个快速的搜索引擎。当然,还有 SOLR 和 ElasticSearch 等选项;它们都运行良好,并且具有高度可扩展性,但是,我需要简化搜索,使其更快、更易于部署,并且几乎没有依赖性。

我需要进行足够的优化,以便快速返回结果,以便对它们进行重新排序。虽然 C/Rust 可能很适合,但我更看重开发速度和生产力。我想 Golang 是两全其美的选择。

在本文中,我将通过一个简单的示例介绍如何使用 Go 构建自己的搜索引擎,您会感到惊讶:它并不像您想象的那么复杂。

Golang:Python 的升级版

我不知道为什么,但 Golang 在某种程度上感觉像 Python。语法非常容易掌握,也许是因为到处都没有分号和括号,或者没有丑陋的 try-catch 语句。也许是因为 Go 格式化程序很棒,我不知道。

无论如何,由于 Golang 生成一个独立的二进制文件,因此可以非常轻松地将其部署到任何生产服务器。您只需“go build”并替换可执行文件即可。

这正是我所需要的。

你相信吗?

不,这不是打字错误 🙂。Bleve 是一个功能强大、易于使用且非常灵活的 Golang 搜索库。

虽然作为一名 Go 开发人员,您通常会尽量避免使用第三方软件包;但有时使用第三方软件包也是有道理的。Bleve 速度快、设计精良,并且提供足够的价值来证明使用它的合理性。

此外,这就是我“Bleve”的原因:

  • 自包含,Golang 的一大优势是单一二进制文件,所以我想保留这种感觉,不需要外部数据库或服务来存储和查询文档。Bleve 在内存中运行并写入磁盘,类似于 Sqlite。
  • 易于扩展。由于它只是 Go 代码,因此我可以根据需要在代码库中轻松调整库或扩展它。
  • 快速:搜索超过 1000 万份文档的结果仅需 50-100 毫秒,包括过滤。
  • 分面:如果没有一定程度的分面支持,您就无法构建现代搜索引擎。Bleve 完全支持常见的分面类型:例如范围或简单类别计数。
  • 快速索引:Bleve 比 SOLR 稍慢。SOLR 可以在 30 分钟内索引 1000 万个文档,而 Bleve 需要一个多小时,不过,一个小时左右的时间仍然相当不错,足以满足我的需求。
  • 高质量的结果。Bleve 在关键字结果方面表现良好,而且一些语义类型的搜索在 Bleve 中也表现良好。
  • 快速启动:如果您需要重新启动或部署更新,只需几毫秒即可重新启动 Bleve。重建内存中的索引不会阻塞读取,因此重新启动后几毫秒内即可顺利搜索索引。
  • 设置索引?

    在 Bleve 中,“索引”可以视为数据库表或集合 (NoSQL)。与常规 SQL 表不同,您无需指定每一列,基本上大多数用例都可以使用默认架构。

    要初始化 Blev 索引,您可以执行以下操作:

    mappings := bleve.NewIndexMapping()
    index, err = bleve.NewUsing("/some/path/index.bleve", mappings, "scorch", "scorch", nil)
    if err != nil {
        log.Fatal(err)
    }

    Bleve 支持几种不同的索引类型,但经过多次尝试后我发现“scorch”索引类型性能最佳。如果您不传入最后 3 个参数,Bleve 将默认使用 BoltDB。

    添加文档

    向 Bleve 添加文档非常简单。您基本上可以在索引中存储任何类型的结构:

    type Book struct {
        ID    int    `json:"id"`
        Name  string `json:"name"`
        Genre string `json:"genre"`
    }
    
    b := Book{
        ID:    1234,
        Name:  "Some creative title",
        Genre: "Young Adult",
    }
    idStr := fmt.Sprintf("%d", b.ID)
    // index(string, interface{})
    index.index(idStr, b)

    如果要索引大量文档,最好使用批处理:

    // You would also want to check if the batch exists already
    // - so that you don't recreate it.
    batch := index.NewBatch()
    if batch.Size() >= 1000 {
        err := index.Batch(batch)
        if err != nil {
            // failed, try again or log etc...
        }
        batch = index.NewBatch()
    } else {
        batch.index(idStr, b)
    }

    您会注意到,使用“index.NewBatch”可以简化诸如批处理记录并将其写入索引之类的复杂任务,它创建一个容器来临时索引文档。

    此后,您只需在循环时检查大小,并在达到批量大小限制后刷新索引。

    搜索索引

    Bleve 提供了多种不同的搜索查询解析器,您可以根据自己的搜索需求进行选择。为了让这篇文章简短而精炼,我将只使用标准查询字符串解析器。

    searchParser := bleve.NewQueryStringQuery("chicken reciepe books")
    maxPerPage := 50
    ofsset := 0
    searchRequest := bleve.NewSearchRequestOptions(searchParser, maxPerPage, offset, false)
    // By default bleve returns just the ID, here we specify
    // - all the other fields we would like to return.
    searchRequest.Fields = []string{"id", "name", "genre"}
    searchResults, err := index.Search(searchResult)

    只需这几行,您现在就拥有一个强大的搜索引擎,它可以以较低的内存和资源占用提供良好的结果。

    以下是搜索结果的 JSON 表示,“hits”将包含匹配的文档:

    {
        "status": {
            "total": 5,
            "failed": 0,
            "successful": 5
        },
        "request": {},
        "hits": [],
        "total_hits": 19749,
        "max_score": 2.221337297308545,
        "took": 99039137,
        "facets": null
    }

    刻面

    如前所述,Bleve 提供开箱即用的全面分面支持,无需在您的架构中进行设置。例如,要对书籍“类型”进行分面,您可以执行以下操作:

    //... build searchRequest -- see previous section.
    // Add facets
    genreFacet := bleve.NewFacetRequest("genre", 50)
    searchRequest.AddFacet("genre", genreFacet)
    searchResults, err := index.Search(searchResult)

    我们仅用 2 行代码扩展了之前的 **searchRequest**。“NewFacetRequest”接受 2 个参数:

  • 字段:索引中要分面的字段(字符串)。
  • 大小:要计数的条目数(整数)。因此,在我们的示例中,它只会计算前 50 个类型。
  • 以上内容现在将填充我们搜索结果中的“方面”。

    接下来,我们只需将我们的方面添加到搜索请求中即可。它包含“方面名称”和实际方面。“方面名称”是在我们的搜索结果中找到此结果集的“关键”。

    高级查询和过滤

    虽然“QueryStringQuery”解析器可以为您带来相当多的益处;但有时您需要更复杂的查询,例如“必须匹配”,您希望将搜索词与多个字段进行匹配,只要至少有一个字段匹配就返回结果。

    您可以使用“Disjunction”和“Conjunction”查询类型来实现这一点。

  • 连接查询:基本上,它允许您将多个查询链接在一起以形成一个巨型查询。所有子查询必须至少匹配一个文档。
  • 分离查询:这将允许您执行上面提到的“必须匹配一个”查询。您可以传入 x 个查询,并设置有多少个子查询必须匹配至少一个文档。
  • 分离查询示例:

    djQuery := bleve.NewDisjunctionQuery()
    fields := []string{"title", "description", "author"}
    // At least 1 of the queries below must match at least one document.
    djQuery.Min = 1
    for _, field := range fields {
        query := bleve.NewMatchQuery(searchTerm)
        // Tell the query which field to match against.
        query.setField(field)
        djQuery.AddQuery(query)
    }
    searchRequest := bleve.NewSearchRequestOptions(djQuery, maxPerPage, offset, false)

    与我们之前使用“searchParser”的方式类似,我们现在可以将“Disjunction Query”传递到“searchRequest”的构造函数中。

    虽然不完全相同,但它类似于以下 SQL:

    SELECT docs FROM docs 
    WHERE title LIKE '%x%' 
    OR description LIKE '%x%'
    OR author LIKE '%x%';

    您还可以通过设置“query.Fuzziness = [0或1或2]”来调整搜索的模糊程度

    连词查询示例:

    cjQuery := bleve.NewConjunctionQuery()
    // Keyword search
    searchQuery := bleve.NewMatchQuery(searchTerm)
    searchQuery.setField("name")
    cjQuery.addQuery(searchQuery)
    
    // Price range search
    minPrice := 100
    maxPrice := 200
    priceQuery := bleve.NewNumericRangeQuery(&minPrice, &maxPrice)
    priceQuery.setField("price")
    cjQuery.AddQuery(priceQuery)
    
    searchRequest := bleve.NewSearchRequestOptions(cjQuery, maxPerPage, offset, false)

    您会注意到语法非常相似,基本上可以交替使用“Conjunction”和“Disjunction”查询。

    这在 SQL 中看起来类似于以下内容:

    SELECT docs FROM docs 
    WHERE title LIKE '%x%' 
    AND (price >= ? and price < ?)

    总之,当您希望所有子查询至少匹配一个文档时,请使用“连接查询”;当您希望匹配至少一个子查询但不一定匹配所有子查询时,请使用“分离查询”。

    分片

    如果遇到速度问题,Bleve 还可以将您的数据分布在多个索引分片中,然后在一个请求中查询这些分片,例如:

    searchShardHandler := bleve.NewIndexAlias()
    searchShardHandler.Add(indexes...)
    searchShardHandler.Search(searchRequest)

    分片可能变得非常复杂,但正如您上面看到的,Bleve 消除了很多麻烦,因为它会自动“合并”所有索引并在它们之间进行搜索,然后在一个结果集中返回结果,就像您搜索单个索引一样。

    我一直使用分片来搜索 100 多个分片。整个搜索过程平均仅需 100-200 毫秒即可完成。

    您可以按如下方式创建分片:

    var indexes []bleve.Index
    var i := 1
    for {
        if i >= 5 {
            return
        }
        indexShardName := fmt.Sprintf("/some/path/index_%d.bleve", i)
        index, err = bleve.NewUsing(indexShardName, mappings, "scorch", "scorch", nil)
        if err == nil {
            indexes = append(indexes, index)
        }
    }

    只需确保为每个文档创建唯一的 ID,或者采用某种可预测的方式添加和更新文档,而不会弄乱索引。

    一个简单的方法是将包含分片名称的前缀存储在源数据库中,或者您从中获取文档的任何地方。这样,每次您尝试插入或更新时,您都会查找“前缀”,它将告诉您在哪个分片上调用“.index”。

    说到更新,只需调用“index.index(idstr, struct)”即可更新现有文档。

    结论

    仅使用上述基本搜索技术并将其置于 GIN 或标准 Go HTTP 服务器之后,您就可以构建相当强大的搜索 API 并满足数百万个请求,而无需推出复杂的基础设施。

    不过需要注意的是,Bleve 不支持复制,因为您可以将其包装在 API 中。只需有一个 cron 作业从您的源读取并使用 goroutines 将更新“发送”到您的所有 Bleve 服务器即可。

    或者,您可以锁定写入磁盘几秒钟,然后将数据“rsync”到从属索引,但我不建议这样做,因为您可能每次都需要重新启动 go 二进制文件。