一文了解 GoFound
GoFound 去发现,去探索全文检索的世界,一个小巧精悍的全文检索引擎,支持持久化和单机亿级数据毫秒级查找。
基础概念
全文检索
全文检索一般有两种方法。一是从头到尾扫描作为检索对象的文档,以此来搜索要检索的字符串,如 Unix 中的grep
命令,但是文档数量越多,文档越大,检索的时间会越长,甚至超出预期上限。另一种是利用索引进行全文搜索,事先为文档建立索引,尽管建立索引可能需要不少时间,但是优点是即使文档的数量增加,检索的速度也不会大幅下降,GoFound 也是采取这种方式。
倒排索引
倒排索引与词典索引类似,用一本书的倒排索引作为示例,key 存储了单词,value 存储了一个组成页数的数组,当你需要查找I search keywords in Google.
这句话时,你可以很直观的看到它的单词在哪一页。
倒排索引中,每个单词都有一个引用指向属于它的一个倒排列表,倒排列表中存储了众多的倒排项,倒排项即文档 ID。取出多个单词的倒排列表后,可以根据情况进行交集处理与评分,获取到更符合预想的搜索结果。
正排索引
正排索引存储了文档 id 和索引词组的映射,便于在修改索引时判断索引 text 变更,以及计算相关度。
文档
文档是作为检索对象的数据,在全文搜索中,通常将构建索引的单位称为文档 (Document),将文档的唯一标识信息称为编号 (文档 ID)。可以说,倒排索引就是将单词和单词所在文档的文档编号对应起来的表格。
分词与词典
由于中文不像英文那样有空格隔开,中文分词手段主要依靠了字典和统计学。
GoFound 使用的中文分词组件是结巴分词,支持精确模式、全模式、搜索引擎模式和 paddle 模式四种分词模式。分词时采取的是搜索引擎模式CutForSearch
,在精准模式的基础上试图将长词分为若干个短词,提高召回率。CutForSearch()
会返回一个 channel,用于防止分词阻塞以及接收分词后的短语。
GoFound 通过 jieba 的接口载入自定义词典,在调用的 jieba-go sdk 中,jieba 通过将词典的每一个词以及该词的词频、词性封装为Token
,将其存入 map,设定词频为其所给词频,然后将该词继续分割为前缀词,将前缀词加载入词典中,设定前缀词词频为 0.0
1 | func (d *Dictionary) addToken(token dictionary.Token) { |
数据结构与定义
索引实体-IndexDoc
1 | // IndexDoc 索引实体 |
文档对象-StorageIndexDoc
1 | // StorageIndexDoc 文档对象 |
响应文档结果-ResponseDoc
1 | type ResponseDoc struct { |
删除文档索引模型-RemoveIndexModel
1 | type RemoveIndexModel struct { |
搜索请求-SearchRequest
1 | // SearchRequest 搜索请求 |
搜索响应-SearchResult
1 | // SearchResult 搜索响应 |
存储结构-LeveldbStorage
1 | type LeveldbStorage struct { |
分页限制-Pagination
1 | type Pagination struct { |
容器-Container
真的难以形容这个命名,实际上也只是将引擎封装了一波
1 | type Container struct { |
引擎-Engine
引擎其实就是一个 GoFound 数据库结构的封装,包含了正排索引、倒排索引以及文档数据的存储结构,还有分词器和添加索引工作 channel,分片数配置等。
1 | type Engine struct { |
执行流程
初始化
- 解析配置文件信息
- 包含设置默认值
- 初始化分词器和容器
- 分词器:调用 jieba 分词的 api
LoadDictionary
加载路径下的词典data/dictionary.txt
- 容器:包括设置加载配置中的参数,如数据目录,分片数,超时时间和分片缓冲数,以及分词器。并创建数据目录,初始化引擎,加载数据库,如果有多个数据库(数据目录下有多个目录),则会创建多个引擎,数量相等于数据库数量。当该数据库不存在时,会创建数据库。
- 初始化引擎:主要用于管理索引、文档存储相关内容,会初始化一些参数如分片数
Shard
、分片缓冲数BufferNum
。还会初始化索引引擎。 - 初始化索引引擎:为每个分片都创建一个个 channel 用作添加索引缓冲,缓冲 channel 的缓冲区大小为 BufferNum,并设定一个工作 g 专门用于添加文档索引。为每个分片新建索引文档、正排索引和倒排索引的存储结构。设定一个 g 用于自动 GC(10s 一次)。
- 分词器:调用 jieba 分词的 api
- 初始化业务逻辑
- 初始化基础管理,数据库管理,索引管理和分词管理,注册相应的回调函数。可以由统一的全局变量 srv 来调度
- 注册路由
- 选择是否开启 gzip 压缩,设置 basic 认证
- 分组注册路由以及中间件
- 启动服务
- 开启一个 goroutine 启动服务
- 优雅关机
- 接收到 os 的 signal 时会等待五秒,取消所有的请求再关闭
添加、更新索引
在这里要提到文档的 Id,文档 Id 是用户自己设置的,而不是 GoFound 给予的。接口的处理方法是异步添加索引,在接收到索引实体 (IndexDoc) 后,自增文档数量并把文档的索引实体传入文档中,返回 nil。
1 | func (e *Engine) IndexDocument(doc *model.IndexDoc) error { |
在初始化时已经为每个分片创建了一个 g 用来添加索引工作,处理对应缓冲 channel 的文档。工作 g 执行方法是在 for 中等待 worker channel 传来的 doc,然后调用添加文档方法AddDocument()
,此处才真正开始处理添加文档和索引。
首先是调用分词器对索引的原始文本进行分词,然后调用optimizeIndex()
检测是否需要更新倒排索引,获取新插入的以及需要更新倒排索引的词组。调用getDifference(id)
获取索引词组新旧的差别,这里传入了文档 Id,通过正排索引查找出旧索引的词组,比较俩词组的区别,返回需要新增的,需要移除的词组。如果新词组与旧词组不一致,先移除需要移除词组的倒排索引,将需要插入的词组返回,由AddDocument()
进行处理。对需要新增的词组调用addInvertedIndex()
添加倒排索引,最后调用addPositiveIndex()
更新覆盖正排索引以及文档存储。至此添加与更新索引结束
删除索引/文档
删除文档的处理较为简单,但因为没有加锁处理可能会导致不寻常的意外发生,这些我们在后文细谈。通过接收到的文档 id,查找相应的正排索引,如果没找到则直接返回。删除获取到的索引词组对应倒排索引的文档 id 映射,删除文档存储,并减少 engine 的 documentCount。
查询
查询调用的是 engine 的方法MultiSearch()
多线程查询。先对查询文本进行分词,设定排序模式,对每一个词都开启一个 g 调用processKeySearch()
进行搜索。获取到该词所在分片的倒排索引映射的一组文档 id,将文档 id 数组加入 fastSort 的 temps 中,等待进一步处理。处理完每个词后,还需要进行交集得分和去重,在fastSort.Process()
中进。上一步已经将所有词组相关的文档 id 存入了 fastSort 的 temps 中,先将 temps 进行排序,遍历 temps,根据 id 的数量来增加 id 对应的分数,将分数统计加入到 fastSort.data 中,它是个 SliceItem 数组,记录了文档 id 以及对应的分数。统计完成后,对 fastSort.data 进行降序排序,然后开始处理分页和进行自定义分数统计。
分页统计也很简单,计算取得数据的区间,直接获取fastSort.data[start:end]
即可。
对于每个 SliceItem,启动一个 g 去获取该 SliceItem 的文档 id 对应的文档数据,进行文档存储的 gob 解析,使用request.Highlight
高亮原始索引文本里的本次的索引词组。调用第三方 govaluate 包进行表达式解析、执行 (Evaluate),并更改评分,如果此时排序模式是倒序的,则需要将顺序对调,再返回即可。
性能实测
笔者测试虚拟机配置为 6 核 6G Linux 发行版为 Ubuntu22.04
1 | $ uname -a |
添加索引
添加索引速度很慢,同时占用 cpu 高,可以看到 index 处是枚举每个文档进行插入的,而且 channel 也有缓冲数量,限制了添加索引的速度,默认 shard 为 10,buffnum 为 1000 时差不多 200 个索引/10s。在 AddDocument() 中,对索引进行操作都有全局锁锁住,特别是 optimizeIndex(),在计算新旧索引词组的时候也一并锁住了,影响了 g 的并发性能。个人认为此处只锁住 id 就行了,对于更新正排索引而言,还需要锁住词组即可,这样可以保证多个 g 并行添加索引,又保证了数据的安全性。
1 | // BatchAddIndex 批次添加索引 |
添加索引时会占用大量 cpu(3 核)与部分内存(2G)
正常启动并添加 5w 数据后内存恒定占 2G
查询性能
虽然代码比较简单,但是查询性能还是挺给力的,笔者 mock 了 5w 数据写入,搜索长度 45 的句子,在 3w 数据中还是达到毫秒级的
查询过程中会占用少量 cpu
问题与优化
搜索结果不稳定
笔者同一查询参数去查询时 total 的数量不稳定
修改分片可能会导致数据正常无法访问到
没有做相应的分片数据转移和负载均衡功能
查询过程中没有有意地控制 g 的数量
每一个词一个 g,数据量较大时会 oom