搜索引擎模块设计与实现——索引模块 | 青训营笔记

1,920 阅读11分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第6篇笔记

基本架构

Untitled.png

索引层(Index)

基本概念

一个索引可以看成是一个数据库,存储了指定字段的所有文档,并提供给引擎层增删改查的接口

索引构建

采用分段的方式构建

  • 首先设定一个阈值,比如10000篇文档,在这10000篇文档的范围内,按照第一种方式构建索引,生成一个字典文件和一个倒排文件,这一组文件叫做一个段(segment)
  • 每10000篇文档生成一个段(segment) ,直到所有文档构建完成,从而生成了多个段,并且在搜索引擎启动以后,增量数据也按这个方法进行构建,所以段会越来越多
  • 每一个段就是索引的一部分,他有倒排索引的全部东西(词典,倒排表),可以进行一次正常的检索操作,每次检索的时候依次搜索各个段,然后把结果合并起来就是最终结果了
  • 如果段的数量过多,对多个段的词典和倒排文件进行多路合并操作,由于词典是有序的,所以可以按照term的顺序进行归并操作,每次归并的时候把倒排全拉出来,然后生成一个新的词典和新的倒排文件,当合并完了以后把老的都删掉。

核心组件

索引类

type Index struct {
    Name              string            `json:"name"`
    PathName          string            `json:"pathName"`
    Fields            map[string]uint64 `json:"fields"`
    PrimaryKey        string            `json:"primaryKey"`
    StartDocId        uint64            `json:"startDocId"`
    MaxDocId          uint64            `json:"maxDocId"`
    DelDocNum         int               `json:"delDocNum"`
    NextSegmentSuffix uint64            `json:"nextSegmentSuffix"`
    SegmentNames      []string          `json:"segmentNames"`
​
    segments      []*segment.Segment
    memorySegment *segment.Segment
    primary       *tree.BTreeDB
    bitmap        *utils.Bitmap
​
    pkMap map[int64]string // 内存中的主键信息
​
    segmentMutex *sync.Mutex
    Logger       *utils.Log4FE `json:"-"`
}

文件组成

  • {indexName}.meta 这里是索引的元信息,包括索引中字段的名称,类型,也包括索引内文档的起始和终止编号
  • {IndexName}.bitmap 标记索引中文档状态——删除或未删除
  • {indexName}.pk 记录索引中主键与生成的docID的映射

增加文档

新增文档.png

删除文档

删除文档.png

段层(Segment)

段结构

type Segment struct {
	StartDocId  uint64            `json:"startDocId"` // 段内docId的最小值
	MaxDocId    uint64            `json:"maxDocId"` // 段内docId的最大值
	SegmentName string            `json:"segmentName"` // 段的名称,序列化时文件名的一部分
	FieldInfos  map[string]uint64 `json:"fields"` // 记录段内字段的类型信息
	Logger      *utils.Log4FE     `json:"-"`
	fields   map[string]*Field // 段内字段的
	isMemory bool              // 标识段是否在内存中
	btdb     *tree.BTreeDB     // 段的数据库,用于存储各字段的正排索引
}

基本概念

一个索引由多个段组成,段分为内存段和磁盘段,磁盘段是由内存段序列化而来,只有内存段可以新增文档,一旦内存段进行了序列化变成了磁盘段,就不再进行修改,即只读,每次新增数据时,我们总是新建一个段来到数据进行存储,这样可以解决多线程读写冲突的问题,而且分段存储,也可以很方便的进行多线程搜索。

段合并

因为段太多会导致要查询的段太多,故会每隔一段时间对段进行合并

如果要删除文档,可以用 bitmap 记录被删除的文档ID,然后在段合并的时候判断该文档是否被删除 段的合并.png

段文件组成

段需要存储,段内文档的倒排索引,文档数据,正排索引

一个段在文件保存在一个命名为indexname_{segementNumber}的文件夹内,文件夹内包含几个文件

  • seg.meta 这里是段的元信息,包括段中字段的名称,类型,也包括段的文档的起始和终止编号
  • seg.bt 记录段中所有正排信息
  • {fieldName}_invert.fst 这里是段中各字段的倒排文件
  • {fieldName}_index.idx这是段中字段的实际数据即profile
  • {fieldName}_profile.pfl存储字段数据的文件,如果字段类型是字符类型就会将数据存储在.dtl中,pfl中只存储数据在.dtl文件中的偏移量。
  • {fieldName}_detail.dtl主是存储字符类型字段数据的文件
  • {fieldName}_profileindex.pfi存储字段的正排索引

上面的indexname是这个索引的名称,相当于数据库中的表名,segmentNumber是段编号,这个编号是系统生成的。

添加文档

文档如下

{"name":"张三","age":18,"introduce":"我喜欢跑步"}

{"name":"李四","age":28,"introduce":"我喜欢唱歌"}

对于每个文档给它分配一个doc_id,假设第一个文档的doc_id为0,第二个的为1,分配后如下

字段:name

map{“张三”:0}{“李四”:1}

stringArray[”张三”,”李四”]

字段:age

integeArray[18,28]

字段:introduce

map{“我”:0,1}{“喜欢”:0,1}{“跑步”:0}{“唱歌”:1}

stringArray[”我喜欢跑步”,”我喜欢唱歌”]

将数据持久化到磁盘中

我们设置一个阈值,当文档量到达阈值时,将文档数据持久化到磁盘中

进行持久化的过程中

  • 如果是倒排索引,我们将fst和倒排链分别存储,首先将倒排链持久化,前8个字节记录倒排链的长度,后面写入倒排链,记录每个倒排链在文件中的偏移量,之后持久化fst,key为term,value为倒排链的偏移量。
  • 如果是IntegerArray,我们遍历整个数组,然后把数据写入到pfl文件中,每个数据占用8个字节。
  • 如果是StringArray,我们遍历整个数据,首先把value追加写入到dtl文件中,然后把文件偏移量写入到pfl文件中

字段层 (Field)

基本概念

字段表示的是一个文档有哪些属性,比如标题,内容,作者,日期等

字段有不同的类型,这些类型决定在新增文档的时候如何处理

比如对于字符串类型,我们会给它建立倒排索引,对于日期,数值类型我们会对它建立正排索引以便于范围过滤

倒排索引(Invert)

基本概念

把文件ID对应到关键词的映射转换为关键词到文件ID的映射,每个关键词都对应着一系列的文件,这些文件中都出现这个关键词

实现思路

构建倒排索引目的是我们希望可以通过它快速找到包含某些内容的文档。所以构建倒排索引首先我们需要将文档中需要构建倒排的字段内容进行分词。比方说,“今天中午吃什么”,分词后变成今天、中午、吃、什么。map的key就是分词后的结果,叫做term。value保存的就是包含这个分词的文档id集合,可以叫做倒排链。大概的结构如下:

term(关键词)docs_id(文档编号)posting_list
Go1
语言1,2,3
实现1,3
搜索引擎1,4
PHP2
世界2,4
最好2,4
汇编3
公司4

我们可以使用map构建倒排查询的时间复杂度为O(1)这是非常理想的时间复杂度,但map占用的空间非常大,在文档量大时,内存可能无法存放下map。所以我们选择了使用FST作为存储倒排索引的结构,FST可以充分利用term中的前缀和后缀,节省了一部分空间,查询的时间复杂度为O(n)——n为需要的term长度,同时将倒排链存入文件中,FST存储term对应的倒排链在文件中的偏移量(offset),这样可以进一步缩小了倒排的大小。关于倒排链的读写,我们先对倒排链文件,用mmap将文件映射到内存中,加快文件的读写。

FST构建过程

  1. 插入key:carrry, value:1

Untitled.png

每个字母形成一条边,首个字母还会包含val值

  1. 插入key:depend,value: 10

Untitled1.png

  1. 插入key:deep,value:20 Untitled2.png

deep和前面的depend有公共的前缀,所以前面的路径相同,deep在插入过程中,不断减去路径上的value,在需要新的路径时,将剩余的value写入,这样查询时,只要把路径上的value相加就是结果的value值。

  1. 插入end

Untitled3.png 5. 插入marry

Untitled4.png

合并倒排文件

我们使用fst对倒排进行存储,key为term,value为倒排链在文件中的偏移量,合并时我们可以遍历fst,遍历结果是一个term的有序数组,所以流程有点类似于合并k个有序链表,所以采用小顶堆来进行合并,对于不同fst中相同的key我们需要集中处理,将key对应的倒排链进行合并,保存进文件,再将偏移量写入新的fst中。

倒排持久化

由于fst在value只有存储数字类型,所以倒排索引的倒排链无法直接存储在fst中,所以我先将倒排链存入另一个文件,记录倒排链在文件中的开始位置,即偏移量(offset),并在开始位置的前8个字节写入倒排链的长度。

文件{fieldName}_invert.idx保存着倒排链,fst的key为term,value为倒排链在文件中的偏移量

比如我们要插入如下数据

term(关键词)docs_id(文档编号)posting_list
Go1,3
语言1,2,3

我先将倒排链写入文件,大概结构如下

倒排持久化.png

之后将term和term对应倒排链在idx文件中的偏移量写入fst中

正排索引(ProfileIndex)

基本概念

正排索引是为了进行范围查找服务的,当我们通过倒排索引查找到一些文档后,我们想过滤出在一定日期内的文档,这时我们就会去搜索正排索引,找到符合范围的文档,然后对倒排索引查找的文档和正排索引查找的文档求交集,得到最终结果

实现思路

使用 boldDB 实现,因为 boltDB 是 B+树 实现的KV数据库,可以很好的支持范围查找,因为B+树的叶子节点本来就是有序的。查询时先找到范围内的第一个数,然后往后遍历直到到达边界条件

比如要搜索价格是 2000-4000 的商品,就会从B+树的根节点开始向下搜索第一个大于等于2000的结点,然后从该结点开始向后一个一个遍历,直到遍历完所有结点或者当前结点的数值大于 4000

使用举例

  • 首先,通过标题的倒排索引,检索出所有的带手机这个关键词的商品的结果集,他们的docId是【1,2,3】
  • 在查询倒排同时,我们可以查询价格的正排索引,找出符合条件的文档,返回docId列表【2,3,4】
  • 遍历完成以后,对两个结果取交集,得到最终的结果集【2,3】

合并多颗B+树

因为段需要合并,所以段中的正排索引也需要合并

我们使用b+树对正排进行存储,B+树天然带排序,那么合并正排的时候实际上就是合并多个B+树,我们只要使用多路归并排序的方式就能合并多个B+树了。每个B+树的Key就是待归并的元素,一边扫描B+树一边构建一个新的B+树,然后把正排文件就合并完了

文档仓(Profile)

基本概念

每个字段都有一个profile,记录字段的数据,文档仓中记录了所有字段的内容,用 Mmap 方式进行读取

具体实现

在profile在内存中时,使用数组保存数据,序列化时再将数组中数据写入磁盘,如果是int或者float类型的数据,字节大小固定只需要保存进.pfl文件就行。因为我们记录了段中文档的startId,并且docID是连续的,所以读取时,只需要将要查询的docid-startId再乘以数据的占用位数,就可以得到数据在文件中的偏移量,从而读取到数据。如果是string类型,因为长度不固定,则pfl文件中保存的是的偏移量,数据实际保存在dtl文件中,通过读取pfl可以得到数据偏移量,找到实际数据在dtl中的位置,其中前8个字节记录的是文本的长度,这样就可以通过文本长度和偏移量等到文本内容了。