使用golang和bluge打造自己的全文搜索引擎
全文搜索引擎有许多,其中最出名的是elasticsearch, 无论是性能还是体验都是最顶尖的,但是对小应用来不友好,因为小应用的硬件资源比较少,所以能够通过库/模块的方式内置在应用中会是比较好的选择,就像sqlite3一样。
鉴于此,本文选择bluge,一个go语言的全文搜索库
bluge还有一个历史版本bleve, 如果搜索bluge相关问题找不到的话,可以搜索bleve, 不过两者的接口还是会有些不同的,需要手动适配。
其他全文搜索数据库:
- typesense
- melisearch
- redissearch
本文总会粘贴完整的代码,是为了大家复制方便,也为了我自己以后复制代码方便^_^
一些必要的前置知识
假设有以下文本, 每行是一篇文章
文章1 hello world
文章2 hello china
文章3 hello hunan
如果我们要搜素那篇文章包含hello
, 我们可以遍历每篇文章然后依次搜索,很显然这是一个比较低效的做法,如果有10w篇文章,每次搜索都遍历10w遍,那肯定是无法接受的,所以有人提出了倒排索引。
倒排索引
还是上面的文本, 我们可以建立一个map, 用来存储上面的文本, 效果如下
index := map[string][]string{
"hello": []string{"文章1", "文章2", "文章3"},
"world": []string{"文章1"},
"china": []string{"文章2"},
"hunan": []string{"文章3"},
}
基于上面的数据结构我们再次搜索hello
可以很快的发现"文章1", "文章2", "文章3"
三篇文章都包含该关键词, 即使是10w篇文章,我们也能很快的找到对应的文章。
这里有一个小问题, hello world可以通过空格的方式来将其分成一个个单词, 那么中文呢? 你好世界
肯定不应该变成"你", "好", "世", "界"
. 这个问题的答案叫做分词,后续文章在详细说明。
小结
上面只是简单的介绍一下倒排索引的基本原理, 具体的实现肯定要复杂一些。
简陋的hello world
全文搜索引擎总结起来就两个功能, 创建索引, 搜索,首先来两个简单的示例预览一下效果,也引入一些问题并在后续文章中解决。
创建索引
package main
import (
"log"
"github.com/blugelabs/bluge"
)
func main() {
lines := []string{
"hello world",
"hello china",
"hello hunan",
}
indexPath := "helloworld"
writer, err := bluge.OpenWriter(bluge.DefaultConfig(indexPath))
panicErr("创建索引文件失败,", err)
defer func() {
err := writer.Close()
if err != nil {
log.Fatal("关闭writer失败")
}
}()
batch := bluge.NewBatch()
for _, line := range lines {
doc := bluge.NewDocument(line)
doc.AddField(bluge.NewTextField("content", line))
batch.Update(doc.ID(), doc)
}
err = writer.Batch(batch)
panicErr("批量插入失败, ", err)
}
func panicErr(s string, err error) {
if err != nil {
log.Fatal(s, err)
}
}
通过运行上面的代码,如果没有报错的话,会在工作目录新建一个helloworld
的目录, 里面会存在若干的文件,那是bluge的索引文件。
搜索
package main
import (
"context"
"fmt"
"log"
"github.com/blugelabs/bluge"
)
func main() {
var err error
indexPath := "helloworld"
writer, err := bluge.OpenWriter(bluge.DefaultConfig(indexPath))
panicErr("创建索引文件失败,", err)
defer func() {
err := writer.Close()
if err != nil {
log.Fatal("关闭writer失败")
}
}()
reader, err := writer.Reader()
panicErr("获取reader对象失败,", err)
query := bluge.NewMatchQuery("hello").SetField("content")
request := bluge.NewTopNSearch(10, query)
ctx := context.TODO()
dmi, err := reader.Search(ctx, request)
panicErr("搜索失败,", err)
dm, err := dmi.Next()
panicErr("迭代数据失败", err)
for dm != nil && err == nil {
dm.VisitStoredFields(func(field string, value []byte) bool {
fmt.Printf("%s => %s\n", field, value)
return true
})
dm, err = dmi.Next()
}
panicErr("迭代数据过程中失败", err)
}
func panicErr(s string, err error) {
if err != nil {
log.Fatal(s, err)
}
}
输出结果如下:
_id => hello world
_id => hello china
_id => hello hunan
如果你眼尖的话会发现,只有_id
字段! content字段呢? 按照预期应该是有两个字段, _id
, content
。但是现实狠狠的打脸了,之所以这样,是因为我们只索引了这个字段而没有存储这个字段。
在前文我们提到,倒排索引可以提升搜索速度, 做法是将文本分词, 然后建立一个映射的数据结构(倒排索引)用于检索数据, 如果文本被分词了,我们还会存储分词前的文本么? bluge的默认选择是不存储, 所以想要将字段存储在索引里面,需要显示的说明。
只需在建立索引的时候将doc.AddField(bluge.NewTextField("content", line))
改成doc.AddField(bluge.NewTextField("content", line).StoreValue())
即可。
重新建立索引后在搜索,结果如下。
_id => hello world
content => hello world
_id => hello china
content => hello china
_id => hello hunan
content => hello hunan
小结
bluge默认只保存_id
字段, 其他字段要想在搜索结果中可以获取,需要显式的设置。
中文分词
bluge是外国人写的, 支持英文很正常,但是我们要存储的肯定是中文,所以需要自己分词。
首先看看bluge自带的分词方法。
package main
import (
"fmt"
"github.com/blugelabs/bluge/analysis/analyzer"
)
func main() {
lines := []string{
"hello world",
"你好世界",
}
a := analyzer.NewStandardAnalyzer()
for _, line := range lines {
fmt.Println("line: ", line)
tokens := a.Tokenizer.Tokenize([]byte(line))
for _, token := range tokens {
fmt.Println(string(token.Term))
}
fmt.Println("============")
}
}
输出如下:
line: hello world
hello
world
============
line: 你好世界
你
好
世
界
============
直接使用自带的分词不是不可以用,只是不够精确,假设我们搜索你好
, 搜索的内容会首先被分词,变成"你" "好"
两个字段, 那么文章里面包含了两者的内容会被搜索出来,比如好的, 鸡你太美
, 这样的内容也会被判断为符合条件, 因为这句话里面分别有你, 好两个词,但是按照我们的预期,应该是搜索包含你好
这样的一个词语而非两个字段。
为了解决这个问题,我们需要使用额外的分词模块.比较出名的有gojieba, 但是windows下编译C源码不是太方便,这里就使用一个纯Go写的分词模块(github.com/huichen/sego).
使用如下:
package main
import (
"fmt"
"github.com/huichen/sego"
)
func main() {
// 载入词典
var segmenter sego.Segmenter
// dictionary.txt可以去https://github.com/huichen/sego/blob/master/data/dictionary.txt下载到本地
segmenter.LoadDictionary("dictionary.txt")
// 分词
text := []byte("你好世界")
segments := segmenter.Segment(text)
// 处理分词结果
fmt.Println(sego.SegmentsToSlice(segments, false))
}
输出如下:
2023/02/21 18:04:39 载入sego词典 dictionary.txt
2023/02/21 18:04:40 sego词典载入完毕
[你好 世界]
小结
为了符合中文习惯,我们需要支持中文分词。
一个实际例子
使用中文分词的代码
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"github.com/blugelabs/bluge"
"github.com/blugelabs/bluge/analysis"
"github.com/huichen/sego"
)
const dataPath = "news"
var chineseTokenizer *SegoAnalyzer
var news = []string{"二十届二中全会2月26日至28日召开",
"拜登波兰演讲:提了10次普京的名字",
"网友呼吁接回旅美大熊猫丫丫",
"出政策抓项目 夺取“开门红”",
"“芳香型”贪官:花84万在单位装香氛",
"长沙凌晨1点马路人流量惊人",
"美国工厂“两班倒”疯狂生产炮弹",
"座位被占女子爬火车行李架睡觉",
"拜登宣布美国将主办明年北约峰会",
"60岁分拣工不属于劳动者?网友炸锅",
"周杰伦演唱会",
"美国再发生重大火车脱轨事故",
"章子怡担任中戏表演系考官",
"女儿考研437分喜极而泣 母亲哽咽",
"智障男子因身揣逃犯身份证坐牢9年",
"女子拒接反诈电话被骗122万",
"女子卖气球城管要没收 当场全放飞",
"普京:整个地球都点缀着美军基地",
"“试管婴儿放错胚胎”事件初步和解",
"普京:在战场上战胜俄罗斯不可能",
"女子试戴金戒指断两半遭索赔",
"女子连刷10个差评商家找上门怒怼",
"林志颖问谁还记得《放羊的星星》",
"武契奇:坚持不对俄实施制裁",
"俞敏洪说想给董宇辉在北京买房子",
"发3000元收回2800元 慈善主播被行拘",
"网传苏州有游艇女仆party?警方回应",
"音悦台将回归",
"北大方正集团财务公司进入破产程序",
"全球第5位艾滋病治愈者出现",
"泽连斯基站在拜登身后悄悄抹泪",
"拜拜, 登登",
}
func index(writer *bluge.Writer, lines []string) error {
batch := bluge.NewBatch()
for i, line := range lines {
doc := bluge.NewDocument(fmt.Sprintf("%d", i))
// 不指定Analyzer的话会使用默认的Analyzer
doc.AddField(bluge.NewTextField("content", line).StoreValue().WithAnalyzer(chineseTokenizer))
batch.Update(doc.ID(), doc)
}
return writer.Batch(batch)
}
func newWriter(config bluge.Config) (*bluge.Writer, error) {
return bluge.OpenWriter(config)
}
func main() {
var err error
var writer *bluge.Writer
config := bluge.DefaultConfig(dataPath)
var segmenter sego.Segmenter
segmenter.LoadDictionary("dictionary.txt")
chineseTokenizer = &SegoAnalyzer{core: segmenter}
// 之所以不直接替代DefaultSearchAnalyzer是因为默认的Analyzer还有一个filters
config.DefaultSearchAnalyzer.Tokenizer = chineseTokenizer
// 每次创建query并设置搜索的字段很没意思
config.DefaultSearchField = "content"
if !fileExist(dataPath) {
writer, err = newWriter(config)
panicErr("创建索引文件失败,", err)
err = index(writer, news)
panicErr("创建索引失败,", err)
// 刷新数据到磁盘
err = writer.Close()
panicErr("保存writer失败", err)
writer = nil
}
if writer == nil {
writer, err = newWriter(config)
panicErr("创建索引文件失败,", err)
}
defer func() {
err = writer.Close()
panicErr("保存writer失败", err)
}()
reader, err := writer.Reader()
panicErr("获取reader对象失败", err)
query := bluge.NewMatchQuery("拜登")
request := bluge.NewTopNSearch(10, query)
// request := bluge.NewAllMatches(query)
dmi, err := reader.Search(context.TODO(), request)
panicErr("搜索请求失败, ", err)
dm, err := dmi.Next()
for dm != nil && err == nil {
dm.VisitStoredFields(func(field string, value []byte) bool {
if field == "_id" {
return true
}
fmt.Printf("%s => %s\n", field, value)
return true
})
dm, err = dmi.Next()
}
panicErr("迭代数据过程中失败", err)
}
func panicErr(msg string, err error) {
if err != nil {
log.Fatal(msg, err)
}
}
func fileExist(path string) bool {
_, err := os.Stat(path)
return !errors.Is(err, os.ErrNotExist)
}
type SegoAnalyzer struct {
core sego.Segmenter
}
func (s *SegoAnalyzer) Tokenize(input []byte) analysis.TokenStream {
tokens := analysis.TokenStream{}
words := s.core.Segment(input)
for _, word := range words {
tokens = append(tokens, &analysis.Token{
Start: word.Start(),
End: word.End(),
Term: []byte(word.Token().Text()),
KeyWord: true,
PositionIncr: 1,
})
}
return tokens
}
func (s *SegoAnalyzer) Analyze(input []byte) analysis.TokenStream {
tokens := analysis.TokenStream{}
words := s.core.Segment(input)
for _, word := range words {
tokens = append(tokens, &analysis.Token{
Start: word.Start(),
End: word.End(),
Term: []byte(word.Token().Text()),
KeyWord: true,
PositionIncr: 1,
})
}
return tokens
}
输出结果如下:
2023/02/22 11:11:58 载入sego词典 dictionary.txt
2023/02/22 11:11:59 sego词典载入完毕
content => 拜登宣布美国将主办明年北约峰会 1.586332
content => 泽连斯基站在拜登身后悄悄抹泪 1.586332
content => 拜登波兰演讲:提了10次普京的名字 1.381888
不使用中文分词的代码:
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"github.com/blugelabs/bluge"
)
const dataPath = "news2"
var news = []string{"二十届二中全会2月26日至28日召开",
"拜登波兰演讲:提了10次普京的名字",
"网友呼吁接回旅美大熊猫丫丫",
"出政策抓项目 夺取“开门红”",
"“芳香型”贪官:花84万在单位装香氛",
"长沙凌晨1点马路人流量惊人",
"美国工厂“两班倒”疯狂生产炮弹",
"座位被占女子爬火车行李架睡觉",
"拜登宣布美国将主办明年北约峰会",
"60岁分拣工不属于劳动者?网友炸锅",
"周杰伦演唱会",
"美国再发生重大火车脱轨事故",
"章子怡担任中戏表演系考官",
"女儿考研437分喜极而泣 母亲哽咽",
"智障男子因身揣逃犯身份证坐牢9年",
"女子拒接反诈电话被骗122万",
"女子卖气球城管要没收 当场全放飞",
"普京:整个地球都点缀着美军基地",
"“试管婴儿放错胚胎”事件初步和解",
"普京:在战场上战胜俄罗斯不可能",
"女子试戴金戒指断两半遭索赔",
"女子连刷10个差评商家找上门怒怼",
"林志颖问谁还记得《放羊的星星》",
"武契奇:坚持不对俄实施制裁",
"俞敏洪说想给董宇辉在北京买房子",
"发3000元收回2800元 慈善主播被行拘",
"网传苏州有游艇女仆party?警方回应",
"音悦台将回归",
"北大方正集团财务公司进入破产程序",
"全球第5位艾滋病治愈者出现",
"泽连斯基站在拜登身后悄悄抹泪",
"拜拜, 登登",
}
func init() {
if fileExist(dataPath) {
return
}
}
func index(writer *bluge.Writer, lines []string) error {
batch := bluge.NewBatch()
for i, line := range lines {
doc := bluge.NewDocument(fmt.Sprintf("%d", i))
doc.AddField(bluge.NewTextField("content", line).StoreValue())
batch.Update(doc.ID(), doc)
}
return writer.Batch(batch)
}
func newWriter(config bluge.Config) (*bluge.Writer, error) {
return bluge.OpenWriter(config)
}
func main() {
var err error
var writer *bluge.Writer
config := bluge.DefaultConfig(dataPath)
// 每次创建query并设置搜索的字段很没意思
config.DefaultSearchField = "content"
if !fileExist(dataPath) {
writer, err = newWriter(config)
panicErr("创建索引文件失败,", err)
err = index(writer, news)
panicErr("创建索引失败,", err)
// 刷新数据到磁盘
err = writer.Close()
panicErr("保存writer失败", err)
writer = nil
}
if writer == nil {
writer, err = newWriter(config)
panicErr("创建索引文件失败,", err)
}
reader, err := writer.Reader()
panicErr("获取reader对象失败", err)
query := bluge.NewMatchQuery("拜登")
request := bluge.NewTopNSearch(10, query)
dmi, err := reader.Search(context.TODO(), request)
panicErr("搜索请求失败, ", err)
dm, err := dmi.Next()
for dm != nil && err == nil {
dm.VisitStoredFields(func(field string, value []byte) bool {
if field == "_id" {
return true
}
fmt.Printf("%s => %s %f\n", field, value, dm.Score)
return true
})
dm, err = dmi.Next()
}
panicErr("迭代数据过程中失败", err)
}
func panicErr(msg string, err error) {
if err != nil {
log.Fatal(msg, err)
}
}
func fileExist(path string) bool {
_, err := os.Stat(path)
return !errors.Is(err, os.ErrNotExist)
}
输出结果如下:
content => 拜拜, 登登 5.235475
content => 泽连斯基站在拜登身后悄悄抹泪 2.977241
content => 拜登波兰演讲:提了10次普京的名字 2.889334
content => 拜登宣布美国将主办明年北约峰会 2.889334
可以发现,不使用中文分词会搜索到不符合预期的结果,而且结果的打分还比预期的搜索结果高,这是因为拜登出现了两次 , 即使是分开的。
后记
不过bluge的资料实在少的可怜,本文也只是简单的介绍了全文搜索功能, 即只使用了文本字段(TextField),除了文本字段,还有数字,geo,时间类型的数据可以索引, 除此之外还有丰富的聚合操作,总得来说还是一应俱全的,但是发展需要时间。
如果对bluge的使用有问题,可以看看zinc的源代码, 抄zinc的代码是个不错的选择。
最后在说一个小技巧: 如果是嵌套的数据结构怎么处理? 将其扁平化。具体可抄zinc。