本文是基于基于之前的两篇博客 Vespa 索引结构实现、Vespa 索引检索实现 重新梳理的、自上而下的、更易懂的 Vespa Matching 的介绍,用于本人分享。
Vespa 是由 Yahoo 开发的高性能大数据检索引擎,专为大规模低延迟的场景设计,支持文本、结构化数据、向量的检索。
Blog Documentation DeepWiki GitHub Slack
Admin/config cluster
cluster controller 集群状态管理
configserver 基于内置 zookeeper
slobrok 服务(组件 / route)发现
monitoring metrics
Stateless Java container cluster
Searching
Indexing
Content cluster
参照上图,对于一次搜索请求,在最简单的情况下
本文以下内容重点关注的内容在:
Vespa
补课:一般来说,倒排索引都包含两部分:词典(dictionary)和拉链(posting list)。
举个例子,假设引擎中现在存储多篇文档,如下。
[
{"0": "foo bar"},
{"1": "bar zoo"},
{"2": "foo zoo"}
]
会构建如下所示的倒排索引:
上一节中已经提到,输入的 YQL 会在 QRS 上转换为 query tree,后在 searchnode 上转换为执行计划。如下图所示(此处简要看下,下边会详细展开):
更详细地,在写入文档时,每个 searchnode 会将其全局 id(global id)映射为从 0 开始连续的 id、称为 local id。
基于 Blueprint 会构建若干 SearchIterator 树用于每个线程使用,于是可以将 local id 空间按线程数分为若干段,每颗 SearchIterator 树在这段 local id 空间上从低位至高位匹配,简化代码如下:
uint32_t MatchThread::inner_match_loop(Context &context, MatchTools &tools, DocidRange &docid_range) {
SearchIterator *iterator = &tools.search();
search->initRange(docid_range.begin, docid_range.end); // reset the state & find the first docId
while (docId < docid_range.end) {
if (do_rank) {
search->unpack(docId); // unpack features needed by ranking
context.rankHit(docId); // first-phase ranking using another ranking Blueprint tree
} else {
context.addHit(docId);
}
docId = search->seekFirst(docId + 1); // linearly find the next docId
}
return docId;
}
seekFirst
的实现可以分为如下几种情况:
为简化后续代码,我们先引入 vespa 的一项优化:strict。
上述 seekFirst
提到检索就是线性地在 docId 空间或倒排表上前进。显然可以只让其中一部分 SearchIterator 线性地前进(即所谓 strict),另一部分只探测这些 docId 是否匹配,以简化后的 SearchIteratorBitVector
代码为例:
// strict
void BitVectorIteratorStrictT::doSeek(uint32_t docId) {
docId = getNextBit(docId);
this->setDocId(docId);
}
// non-strict
void BitVectorIteratorT::doSeek(uint32_t docId) {
if (isSet(docId)) {
this->setDocId(docId);
}
}
getNextBit
向后探测 bit,直至找到下一个为 1 的位置;在构建 SearchIterator 树前,会计算 strict 或 non-strict 的预期开销,选择开销更低的实现构造,实现见searchlib/src/vespa/searchlib/queryeval/flow.h
。
自顶向下地,我们在 Blueprint 层面上从中间节点开始讲起。
在引入 strict 概念后,可以看到 non-strict 时,中间 Blueprint 的代码比较简单,即遍历子树判断 docId 是否匹配。
// non-strict
void OrLikeSearch::doSeek(uint32_t docId) {
for (uint32_t i = 0; i < children.size(); ++i) {
if (children[i]->seek(docId)) {
setDocId(docId);
return;
}
}
}
void AndSearchNoStrict::doSeek(uint32_t docId) {
for (uint32_t i = 0; i < children.size(); ++i) {
if (!children[i]->seek(docId)) {
return;
}
}
setDocId(docId);
}
void AndNotSearch::doSeek(uint32_t docId) {
if (!children[0]->seek(docId)) {
return; // not match in positive subtree
}
for (uint32_t i = 1; i < children.size(); ++i) {
if (children[i]->seek(docId)) {
return; // match in negative subtree
}
}
setDocId(docId);
}
而对于 strict 情况,暴力循环性能很差,vespa 引入如下几种优化:
将所有子 SearchIterator 按照其当前 docId 排序(数据结构大部分情况为最小堆),于是在推进儿子节点时,可以减少调用 seek
次数:
weakAnd 算子是更高效率的 OR 中间节点实现,如果被检索字段的相关性计算方式是简单的权重相加,那么可以使用该方式减少下层检索次数。
当一个 Or SearchIterator 包含多个子节点时、尤其当这些子节点的并集覆盖了大量文档时,如上图所示的优化并不能从数量级上减少检索次数。
WAND 算法会借助索引中写入的每个拉链可以贡献的分数最大值(如 bm25 等),亦即每个子节点可以贡献的最大分数,通过堆的方式,维护一个 threshold 值,只有当某个文档存在于的多个拉链的分数最大值之和大于 threshold 时,才会实际进行检索与分数计算。
通过以上的算法,在理想状态下可以减少 90% 以上的计算次数,在实际线上也有非常显著的收益。
具体原理见论文:Efficient Query Evaluation using a Two-Level Retrieval Process (PDF)
ElasticSearch 也有类似实现:https://www.elastic.co/blog/faster-retrieval-of-top-hits-in-elasticsearch-with-block-max-wand
在构建完整的 SearchIterator 树后,最后一步的优化是寻找子节点均为 bitvector 实现(取决于索引结构)的中间节点,于是可以利用 SIMD 加速 and / or / multi 的运算。
最后,我们来到具体的最底层的 term 检索:
select * from sources * where name contains "foo"
该 YQL 语句要检索 name
字段包含 foo
的文档,根据 name
字段配置的索引方式,需要分类讨论。
Vespa 提供了以下几种存储/索引方式:
Attribute:内存中列式存储,不分词,结构化数据
attribute: fast-search
Index:分词,有倒排,内存+磁盘混合索引
documentstore:类 LSM 树的行存
来到这一节,需要再次明确,Vespa 在单机上都是映射为 local id 后,再进行存储,Blueprint 于是可以在连续的 local id 空间上匹配。以 local id 为数组序号,Vespa 可以在一个数组中,密集地放下某个字段所有文档的值(最简单的情况下)。当然随着文档的过期删除,Vespa 会在后台自动地压缩这片空间。
Attribute 在内存中的列式存储便于:
对于单值、定长(比如数字)、无倒排,Vespa 的存储结构只需简单的 RCU vector 即可:
单机 10M 文档,只需要 10M x 4 Byte = 40 MiB。
对于单值、不定长、无倒排,变长的字符串需要存储在额外的 EnumStore 中:
多值、不定长、无倒排,每个 docId 先指向在 ArrayStore 中存储的数组,再指向 EnumStore 中的变长值。
于是,当 SearchIterator 在该字段上检索 seek 至某个 docId 后,取出其值进行匹配即可:
简单的代码如下:
// single value
int32_t find(DocId docId, int32_t elemId) const {
T v = _enum_store.get_value(_enum_indices[docId].load_acquire());
return this->match(v) ? 0 : -1;
}
// multi value
int32_t find(DocId doc, int32_t elemId) const {
auto indices(_mv_mapping_read_view.get(doc));
for (uint32_t i(elemId); i < indices.size(); i++) {
T v = _enum_store.get_value(multivalue::get_value_ref(indices[i]).load_acquire());
if (this->match(v)) {
return i;
}
}
return -1;
}
当有倒排时,在构建 Blueprint 时,既可通过词典(dictionary)定位至拉链(posting list),SearchIterator 在拉链上线性地推进。
void AttributePostingListIteratorT::doSeek(uint32_t docId) {
// _iterator is on the posting list
_iterator.linearSeek(docId);
if (_iterator.valid()) {
setDocId(_iterator.getKey());
} else {
setAtEnd();
}
}
Index 是 Vespa 中另一主要的索引方式。与 attribute 不同,使用 index 索引方式的字段,会经历 tokenizing、normalizing、stemming,即会把一整段文字进行分词归一化等操作,这样的索引方式显然是为像网页文本搜索服务的;
index 只会构建倒排索引,不会额外存储全量的数据。且倒排索引不会全部放在内存中,会有相当一部分存在于磁盘上。当文档写入时,会先更新内存中的 memory index,memory index 会定期写入磁盘(flush),然后和已有的磁盘上的 disk index 合并(fusion)。当检索该字段时,需要同时查询 memory index 和 disk index 后合并结果;
Disk index 是不可更改的,当要删除的文档在 disk index 上时,如果不借助额外的数据结构,检索时会搜到该文档。Vespa 提供的方案是,在原始构建的 Blueprint 上层,AndNot 一个 WhiteListBlueprint,WhiteListBlueprint 会读取 document store 中的 doc id bitvector,筛选已经被删除的文档。
于是,一个简单的 term query,构建的 Blueprint 会变成:
下边具体展开每项阐述。
这里与 attribute x fast-search 几乎一致,不同的是:
注意到事实上,尽管这些文件都在磁盘上,在内存足够的情况下,在我们内部的版本中,往往会 mmap 分配一块匿名内存,然后将这些文件全部映射至内存中。官方的版本提供了 populate 这一选项,也就是在 mmap 文件至内存时,尽量减少 page fault 的发生。
DocumentMetaStore 中以 BitVector 的形式存储正在使用的 local id(即下图中的 lid)空间,WhiteListBlueprint 会由此构建 BitVectorIterator
,筛选掉不存在的 doc id。
管理 index 时会存储如下图所示的 SourceSelector
,其复用了 attribute 的数据结构。于是在 seek 每个 doc id 时,可以找到对应的 index。
// non-strict
void SourceBlenderSearch::doSeek(uint32_t docId) {
Source sourceId = this->sourceSelector->getSource(docId);
this->matchedChild = this->sources[sourceId];
if (this->matchedChild->seek(docId)) {
setDocId(docId);
}
}
最通常状态下,倒排表以如上图中 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:
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 的基础上,用索引赋予了在这一系列数据(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 需要:
<services version="1.0">
<container id="default">
<search/>
<document-api/>
</container>
<content id="default">
<documents>
<document type="shop_ads" selection="shop_ads.mtime + 86400 > now()"/>
</documents>
<min-redundancy>2</min-redundancy>
<distribution partitions="1|*"/>
<group name="group0" distribution-key="0">
<node hostalias="node0" distribution-key="0"/>
<node hostalias="node1" distribution-key="1"/>
<node hostalias="node2" distribution-key="2"/>
</group>
<group name="group1" distribution-key="1">
<node hostalias="node3" distribution-key="3"/>
<node hostalias="node4" distribution-key="4"/>
<node hostalias="node5" distribution-key="5"/>
</group>
<engine>
<proton>
<tuning>
<searchnode>
<requestthreads>
<search>3</search>
</requestthreads>
<feeding>
<concurrency>0.4</concurrency>
</feeding>
<index>
<io>populate</io>
</index>
</searchnode>
</tuning>
</proton>
</engine>
</content>
</services>
attribute: fast-search
构建索引;indexing: index
分词、构建倒排索引;schema shop_ads {
document {
field shop_id type string {
indexing: attribute | summary
}
field item_id type string {
indexing: attribute | summary
}
field mtime type string {
indexing: attribute | summary
}
field price type int {
indexing: attribute | summary
attribute: fast-search
}
field title type string {
indexing: attribute | index | summary
}
field keywords type array<string> {
indexing: attribute | summary
attribute: fast-search
}
struct ItemInfo {
field price type int {}
field weight type int {}
field visible type bool {}
}
field broad_item_info type map<string, ItemInfo> {
indexing: summary
summary: matched-elements-only
struct-field key {
indexing: attribute
attribute: fast-search
}
struct-field value.visible {
indexing: attribute
attribute: fast-search
}
}
field ctr_vec_v1 type tensor<float>(x[64]) {
indexing: attribute | index | summary
attribute {
distance-metric: dotproduct
}
index {
hnsw {
max-links-per-node: 64
neighbors-to-explore-at-insert: 512
}
}
}
}
rank-profile ctr_v1 {
first-phase {
expression: "closeness(field, ctr_vec_v1)"
}
}
rank-profile v1 inherits ctr_v1 {
second-phase {
expression: "firstPhase + bm25(title)"
rerank-count: 2000
}
}
document-summary simple {
summary shop_id {}
summary item_id {}
summary mtime {}
summary title {}
omit-summary-features: true
}
document-summary v1 inherits simple {
summary keywords {}
summary broad_item_info {}
}
}
YQL 语法类似于 SQL;
{
"yql": '''select * from shop_ads
where title contains phone
and keywords matches iPhone
and broad_item_info.key matches iPhone
'''
"ranking.profile": "v1",
"summary": "simple"
}
或者在之前的工作中,改造为了易于从 ES 迁移的模式:
{
"query": {
"bool": {
"filter": [ // 不参与分数计算
{
"term": {
"field": "title",
"value": "phone"
}
}
],
"should": [
{
"term": {
"field": "title",
"value": "iPhone"
}
},
{
"term": {
"field": "broad_item_info.key",
"value": "iPhone"
}
}
]
}
},
"ranking": {
"profile": "v1"
},
"presentation": {
"summary": "simple"
}
}