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”的原因:
设置索引?
在 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 个参数:
以上内容现在将填充我们搜索结果中的“方面”。
接下来,我们只需将我们的方面添加到搜索请求中即可。它包含“方面名称”和实际方面。“方面名称”是在我们的搜索结果中找到此结果集的“关键”。
高级查询和过滤
虽然“QueryStringQuery”解析器可以为您带来相当多的益处;但有时您需要更复杂的查询,例如“必须匹配”,您希望将搜索词与多个字段进行匹配,只要至少有一个字段匹配就返回结果。
您可以使用“Disjunction”和“Conjunction”查询类型来实现这一点。
分离查询示例:
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 二进制文件。