上一文大概讲述了 Vespa 与 ElasticSearch 的区别,本文具体展开讲述 Vespa 的索引实现,主要聚焦于数据结构。下篇Vespa 索引检索实现中会详细讲述如何使用这些数据结构进行检索。
一般来说,倒排索引都包含两部分:词典(dictionary)和倒排表(posting list)。
其中词典以 term 为 key,value 为指向其对应倒排表的 id,在 Vespa 中称为 EnumStoreDictionary。
倒排表中也存储了一系列 key-value,其中 key 为 docId,value 为空、或权重或特征(如 bm25 需要的词频等信息),在 Vespa 中倒排表由 PostingStore 管理。
举个例子,假设引擎中现在存储多篇文档,如下。
[
{"0": "foo bar"},
{"1": "bar zoo"},
{"2": "foo zoo"}
]
会构建如下所示的倒排索引:
我们从最下层的倒排表开始介绍。
PostingStore,倒排表的管理类,是 Vespa 列存的一部分,每一列也即每个需要构建倒排索引的字段都持有一份 PostingStore。
PostingStore 是对 DataStore 这一 Vespa 通用容器的封装,向 DataStore 中存储某个实例会得到一个 uint32_t 类型的 id,同样能够通过该 id 获取到存储的实例,具体实现暂按下。
通过 DataStore 提供的接口,PostingStore 持有了这一列所有倒排表,每个倒排表根据大小和配置的不同,使用不同的数据结构存储:
最通常状态下,倒排表以如上图中 B-Tree 的形式组织:
从文档写入的角度来讲,增删的复杂度都相对较低;
从查询的角度来讲,B-Tree 是可遍历的,且对内存访问较友好;
值得注意的,插入或删除一系列文档时,若完全重新构建的开销更低,则不会选择直接插入;公式: $$ buildCost = treeSize * 2 + additionSize $$ $$ modifyCost = (log_2(treeSize + additionSize) + 1) * (additionSize + removeSize) $$
当倒排表长度小于 8 时,B-Tree 会被替代为简单的数组,可以在几乎不影响查询性能的情况下,减少写入的开销。
当长度大于 max(128, 文档数 >> 6)
时,会在 B-Tree 外构建 BitVector:
以上是基础倒排表存储结构。在以上描述中,忽略了 B-Tree 中 value 位置的数据。
这一部分代码逻辑基本体现在 EnumStoreDictionary。
Dictionary 存储了从 term 到倒排表 id 的映射。其支持 BTree 或 hash 两种索引结构,也可以同时存在,具体选择哪种,需在配置中显示定义。
默认情况下使用 BTree 结构,BTree 在支持前缀匹配、范围查询的情况下,提供了较合理的查询性能。
当 dictionary 所指的字段唯一性较高时,推荐使用 hash 索引,比如该字段是文档的主键时。
上一节是特化的用于倒排索引的数据结构,这一节介绍其它在 Vespa 中用到的。
DataStore 是 Vespa 中的通用容器,向其中存储一段数据会得到一个唯一 id,之后可以通过这个 id 获取到之前存储的数据。
其接受的 id 类型为 EntryRef
,本质上是个 32 位的 id,在这个类型的定义中,默认情况下将前 10 位视为 buffer id,后 22 位为一个 buffer 内的 offset。DataStore 于是持有 2^10 即 1024 个 buffer,每个 buffer 可持有不同类型的数据。
当插入数据时
当通过 entry id 获取数据时,即是线性地,将 entry id 分为 buffer id 和 offset 即可定位至相应的数据。
ArrayStore 是基于 DataStore 上封装的支持单一类型、不同数组长度的数据结构。
EnumStore 在使用了 DataStore 的基础上,可以使用 EnumStoreDictionary 赋予了在这一系列数据(string, int 等 primary type)上进行快速查找的能力。
数据被直接地存储在 DataStore 中,而 EnumStoreDictionary 以这些存储的值为 key,value 中存储了其在 DataStore 中的 id 和对应的倒排表 id。
EnumStore 只能支持单值字段的存储与检索,为了支持多值字段,如 array<int>
、array<string>
、weightedset<string>
,MultiValueMapping 基于 RCUVector 和 ArrayStore 构建了 local docId 到多值字段值的映射。
如上图所示,RCU Vector 以 docId 为序号,存储指向 ArrayStore 的 EntryRef。通过 EntryRef,能够获取到 ArrayStore 中存储的定长或变长的数组。
注意 ArrayStore 存储的数组,每个元素的长度是固定的,因此如果要存储多值 string 类型,要将字符串存储至额外的 EnumStore,这里只存储指向 EnumStore 的 EntryRef。
在了解以上的数据结构实现后,我们介绍 Vespa 中最常用的索引结构之一,attribute。
最基本的情况下,attribute 选项要做到的是把这一列数据全量地放在内存中,便于:
我们由简到繁介绍不同情况下 attribute 的实现,主要的变量是两个:
以上这张表中列出了不同类型会使用到的数据结构,具体实现请往下看。
首先说明,在向 Vespa 写入文档时,需要指定文档 id,这个 id 通常被称作 global id。实际上的内存存储中,global id 会转换为 local id。global id 可以是满足 pattern 的任意字符串,而 local id 则是从 0 开始递增的,这样,以 local id 为数组序号,可以在一个数组中密集地放下一个字段所有文档的值。随着较小 local id 文档的删除,local id 空间的空槽比例会增加,vespa 会在后台自动地压缩 id 空间。稍后我们会详细讲述这个数据是如何存储的。
单值、数字(也就是定长)、不构建倒排,只需要简单的 RCU Vector 即可。
基于 SingleValue-Numeric,变长的字符串需要存储在额外的 EnumStore 中。
与 SingleValue-Numeric 不同,这里使用了 MultiValueMapping 直接地存储数组。
FastSearch 即是要倒排,这里的话,即便值类型是定长的,依然将其存储在 EnumStore 中,而不是直接存储在 document vector 中,取而代之的是指向 EnumStore 的 EntryRef。
在文档写入时,需要分别构建 DataStore,PostingStore 和基于这两者之上的 EnumStoreDictionary。
单值、变长、fast-search 的实现与定长时的实现是一致的。
Index 是 Vespa 中另一主要的索引方式。与 attribute 不同,使用 index 索引方式的字段,会经历 tokenizing、normalizing、stemming,即会把一整段文字进行分词归一化等操作,这样的索引方式显然是为像网页文本搜索服务的。
index 只会构建倒排索引,不会额外存储全量的数据。且倒排索引不会全部放在内存中,会有相当一部分存在于磁盘上。当文档写入时,会先更新内存中的 memory index,memory index 会定期写入磁盘(flush),然后和已有的磁盘上的 disk index 合并(fusion)。当检索该字段时,需要同时查询 memory index 和 disk index 后合并结果。
我们先从内存中的那部分开始。
Memory index 的数据结构非常类似于 attribute 中的 MultiValue-String-FastSearch,不同的是:
注意到事实上,尽管这些文件都在磁盘上,在内存足够的情况下,在我们内部的版本中,往往会 mmap 分配一块匿名内存,然后将这些文件全部映射至内存中。官方的版本提供了 populate 这一选项,也就是在 mmap 文件至内存时,尽量减少 page fault 的发生。