用Golang写一个搜索引擎(0x07)---正排索引

https://segmentfault.com/a/

最近各种技术盛会太多,朋友圈各种刷屏,有厂商发的各种广告,有讲师发的各种自拍,各种参会的朋友们各种自拍,好不热闹,不知道你的朋友圈是不是也是这样啊,去年还没这么多技术会议,今年感觉爆发了,呵呵,真是一个互联网技术的好时代,而且还有各种撕B可看,真想八一八,怕得罪人,我们这种码农还是专注技术专注写代码吧。

你有什么想了解的也可以给我留言哈,欢迎交流,我的工作之前主要做的是搜索的,也做推荐和广告,这部分的东西可能写得多点,对了,嵌入式领域也行(跨得有点大,这个嵌入式不是iOS和Android,是真的嵌入式),没什么高端背景,也不是BAT这种大厂的,就是一小公司写代码的,所以有很多东西还是不懂,你要是和我交流了发现我答不上来很正常啊,人艰不拆啊。。

本篇也比较长,但是干货不多,建议上厕所的时候看,或者在地铁一边听歌一边看。

前面几篇,基本上把倒排索引的数据结构给讲完了,并且简单的说了一下排序,然后说了一下倒排索引的构建。这一篇主要写一下正排索引以及倒排和正排怎么配合起来形成一个完整的字段索引。

正排索引

正排索引,也叫前向索引,和倒排索引(也叫反向索引)是相对的,正排索引相对倒排来说简单多了,第二篇文章的时候有下面两个表格(表1和表2)

这个是表1

文档编号 文档内容


1 这是一个Go语言实现的搜索引擎 2 PHP是世界上最好的语言 3 Linux是C语言和汇编语言实现的 4 谷歌是一个世界上最好的搜索引擎公司

这个是表2

关键词 文档编号


Go 1 语言 1,2,3 实现 1,3 搜索引擎 1,4 PHP 2 世界 2,4 最好 2,4 汇编 3 公司 4

我们之前一直在说作为倒排索引的表2,对于表1,我们认为是数据的详情(detail)信息,最后用来做数据内容展示的,如果是放在一个只支持全文搜索的搜索引擎中的话,那确实表1只是用来做最后的数据展示,但是如果我们的搜索引擎还想要一些复杂的功能,那么表1就是一个正排索引,如果我们的搜索引擎同时支持倒排索引和正排索引,我们可以简单的认为这是一个数据库系统(当然,和真正的数据库还差得远啊)。

首先,我们看什么情况下要使用正排索引

很明显,如果倒排索引满足不了搜索要求的时候,就需要引入正排索引,比如一个电商的搜索引擎,那么正排索引就是必须的了,假如我们有以下几个商品需要上架:

商品编号 商品标题 发布时间 价格 品牌


10001 锤子手机T9 2026-06-06 5000 锤子 10002 小米手机10 2020-02-02 1999 小米 10003 华为手机P20 2022-12-12 3999 华为

搜索的时候我们可能需要搜索价格在一个区间的手机,那么仅仅用全文倒排索引就比较难完成任务了,而且我们在使用电商的搜索引擎的时候,经常会在搜索结果的上方看到一些汇总的信息【比如品牌,型号,价格汇总】,这一部分的东西也是通过正排索引来实现的,像下面这个图

所以说,如果我们的搜索需求不仅仅是进行关键词的匹配,还需要进行一些过滤操作(比如价格区间的过滤),汇总操作(比如结果集中每种品牌数量的统计),那么就必须引入正排索引了。

第二,我们看看如何实现一个正排索引

实现正排索引有两种方式:

一种还是基于倒排索引,之前的倒排索引不是通过B+树构建的么,B+树天然的带排序功能,所以是可以进行范围查找的,比如上面那个表格,我们要搜索的关键词为手机,价格区间在1500–4000之间

  • 我们把价格字段和商品标题字段分别建立一个倒排。

  • 首先,通过标题的倒排索引,检索出所有的带手机这个关键词的商品的结果集,他们是【1,2,3】

  • 然后进行价格区间的检索,因为B+树最下面的叶子节点是通过指针连在一起的,我们只需要通过指针遍历叶子节点,就可以遍历出价格区间中所有价格的倒排链,然后把这些链求并集,得到的结果集是【2,3】,就是满足这个价格区间的所以商品了。

  • 最后再和关键词查出来的商品求交集,就是最后的结果了。

这是第一种实现方式,汇总操作大家可以自己想想怎么做,也能做,就是麻烦点。这种实现方式有下面几个特点

  • 没有单独的正排文件,和倒排文件合在一起了,同时也不占用额外的空间。

  • 但是它限制了倒排索引的实现方式只能是B+树这种带排序的字典,如果倒排文件使用哈希表来实现的话,就不能这么干了。

  • 检索的时候如果是区间搜索的话,需要进行多次求并集操作,效率上需要进行优化。

  • 由于只有倒排文件,那么最后用来做数据展示的时候还需要一个辅助的Detail文件或者和数据库绑定在一起才能进行最终的结果展示。

除了上面那个,还有一种实现方式,就是通过一个数组来实现,数组的下表就是文档编号(docid,不是商品编号,商品编号是主键),由于在搜索引擎中,docid是自增的,而且不会进行删除,所以也是唯一的,正好可以和一个一维数组的下标对上,所以可以用一个数组来存储正排索引,就像下面这个表格,分别表示价格和品牌建立的正排索引,其实就是把表1的数据拆开来进行存储了而已。(为了节省空间,我把两个写在一起了)

DOCID 价格 DOCID 品牌


0 5000 0 锤子 1 1999 1 小米 2 3999 2 华为

这么存的话,检索的时候怎么做呢?如果还是上面那个检索条件要搜索的关键词为手机,价格区间在1500–4000之间

  • 只把标题建立倒排,价格字段建立一个一维数组的正排

  • 首先,通过标题的倒排索引,检索出所有的带手机这个关键词的商品的结果集,他们的DOCID是【1,2,3】

  • 遍历结果集,每遍历一个docid,直接通过那个一维数组和对应的正排文件进行比对,看是否满足条件,满足的留下,不满足的丢弃。

  • 遍历完成以后,得到最终的结果集【2,3】

如果是汇总操作的话,和上述类似,在第二步遍历结果集的时候顺便就可以进行统计了,遍历完了也就统计完了。

条条大路通罗马,通过两种不同的数据结构,最后得到了一样的结果,第二方式有以下几个特点

  • 要为需要进行范围查找的字段单独建立正排索引,不能和倒排的数据结构合并。

  • 通过倒排获取到结果集以后需要对结果集进行一次遍历,然后得到一个新的结果集作为最后的结果,如果结果集特别巨大,那么也需要时间进行遍历。

  • 因为是一维数组来实现的正排,如果文档数非常多的话,内存中是装不下这么多正排文件的,需要在磁盘上来实现这个一维数组。

  • 如果我们将每一个字段都建立一个正排索引的话,那就不需要单独的detail文件或者和数据库对接了,直接正排文件合起来就是一个完整的文档信息,少了外部依赖。

上面就是正排索引的两种实现方式,使用哪一种要看具体的业务需求,比如像百度这种全文搜索引擎,主要的需求其实就是查找关键字,很少用到过滤,汇总操作,那么不用单独来实现正排索引,用第一种方式就行了,而如果是电商类型的搜索引擎的话,有大量的过滤啊,汇总操作,那么通过第二种方式来实现正排索引还是比较必要的。

我的代码里面就是用的第二种方式,并且实现的时候是用mmap的方式在磁盘上实现的,如果内存够大,可以全载入到内存提高检索速度。

索引设计管理

正排索引和倒排索引终于都说完了,这要是搜索引擎最关键的数据结构了,其他所有的东西都是在这个基础上发展起来的,我们已经有了正排和倒排索引的结构,那么如果来构建一个索引系统的,我是这么来做的。

首先,我们需要定一个规矩,所谓规矩就是我们的这个搜索引擎哪些操作我支持,哪些操作我不支持,比如,我为了简单,我就支持全文检索,其他都不支持,那么只需要好好的实现一个倒排索引结构,那数据结构部分就设计的差不多了。而我在做这个搜索引擎的时候,想实现的是下面这些个功能。

  • 支持关键词的倒排,也支持完全匹配类型的倒排。

  • 支持过滤操作,但是只支持整数类型(如果是浮点数根据保留的小数位数转成整数)和日期类型的过滤,对于字符串只提供检索操作,不提供过滤操作。

  • 对于过滤操作,支持大于,小于,等于,不等于,区间的过滤。

  • 支持字段的汇总。

  • 不要外接数据库系统进行数据详情的展示。

既然是这么来实现,那对于每个字段,他可能的类型就是

字段类型 行为 备注举例


完整匹配的字符串 建立倒排,正排(正排只展示,不进行过滤操作) 主键,型号 关键词字符串 建立倒排,正排(正排只展示,不进行过滤操作) 标题,描述 数字 只建立正排 价格,库存 日期 只建立正排 上架 仅展示 只建立正排(正排只展示,不进行过滤操作) 商品详情描述

这样,我们实现的时候,首先实现一个倒排索引(src/FalconIndex/segment/invert.go),然后实现一个正排索引(src/FalconIndex/segment/profile.go),然后实现一个字段类(src/FalconIndex/segment/field.go)用来管理倒排和正排,那么搜索引擎最最基本的数据结构就OK了,对外来说倒排和正排是隐藏的,只有Field类对外暴露,对检索操作来说主要提供几个接口方法:

  • addDocument 添加文档(建立正排或者倒排)

  • query 通过倒排检索文档

  • filter 通过正排过滤文档

  • getValue 通过正排文件获取这个字段的值

文章中我尽量少列或者不列代码,主要是对搜索引擎的原理有了解,原理了解了可以自己来实现代码,实在不会可以自己去参考参考我的代码,毕竟编程这东西只要知道了原理和算法,怎么实现并不是麻烦事。

写在后面的话

之前我一直做C++开发的,写的搜索代码也是C++的,现在用Golang,也没啥特别的难度,当然因为我对Golang的特性并不是很熟悉,所以基本没有用Golang的高级功能,写出来的代码当然不够Golang范,但这也不影响我的实现。

OK,字段部分介绍完了,搜索引擎的核心数据结构也介绍完了,后面接下来会继续往上走,先到段层,然后到索引层,然后会说一下检索逻辑实现,合并逻辑之类的,索引之上会继续说一下搜索引擎的引擎部分,后面还会遇到一些数据结构,比如bitmap,哦,还会单独写一到两篇来介绍分词,至于排序和索引结构优化也会单独拿出来说。

另外,我的代码基本完成了,包括分布式的部分,会在最近提交到github上去,所以后面也会有几篇来说搜索引擎的分布式实现,还是本着原生的原则,没用第三方库,所以分布式部分没有PAXOS这种高端的理论,也没有ZooKeeper这种高端玩意,到时候大家看吧。

目前我的代码初步测试,8G,24核的机器中,1000万条数据*(微博数据,每条不超过140个字,我不是微博的人哈,不存在数据泄密,数据是某号称亚二爬的博士爬来的,我只是下下来用而已)*,单个term的平均检索时间在5ms,用AB进行单个URL测试,QPS大概在7000,如果是随机关键词测试,QPS大约在2000,基本达到我之前自己定的目标了,而且还有优化空间。下次测测ElasticSearch,目前感觉比它报出来的数据要快,但是环境不一样,下次部一个比较一下,而且功能上还完全达不到ElasticSearch的水平,不过它那一套要实现出来也是没什么问题的,需要的是坚持,我会把这个项目维护下去,不过最近实在是太忙了,苦逼啊。。。

最后,继续发个二维码,你懂的,关注一下呗:)