上一文,Vespa 索引结构实现,讲述了 Vespa 构建各种索引、倒排索引使用的数据结构,本文将介绍检索是如何基于这些数据结构进行的。
我们首先从宏观,自顶向下地介绍一次检索需要经过那些步骤。
众所周知,客户端发起的一次请求,在 QRS 上会分为两阶段进行,大部分任务在第一阶段完成:匹配、记分、排序等等,最终会返回给 QRS 这一轻量级的服务以 doc ids 及其它信息,QRS 上会对多个分片上的 doc ids 排序归并,到第二阶段时,向 content 节点请求这些 doc ids 的正排信息,以减少不必要的 IO。
我们重点关注第一阶段中的匹配过程。
下图中是一个简化的从 YQL 到 QueryTree、Blueprint、SearchIterator 的实例。
更详细地来说,Blueprint 树会构建若干个 SearchIterator 树用于每个 match thread 使用,匹配时会将本机的 local doc id 空间,按 thread 数量分为若干段,每根 SearchIterator 树在在这段 local doc id 空间上从低位至高位匹配,同时 unpack 出相关性分数计算需要的数据,ranking 部分会据此同时计算出每个文档的分数。
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);
while (docId < docid_range.end) {
if (do_rank) {
search->unpack(docId);
context.rankHit(docId);
} else {
context.addHit(docId);
}
docId = search->seekFirst(docId + 1);
}
return docId;
}
这里我们注意到 SearchIterator 的三个方法
initRange
:初始化或重置一些内部类及子 iterator 的状态;seekFirst
:线性地找到下一个匹配条件的 docId;unpack
:将从倒排表中读到的相关性分数需要的数据存储在临时变量中,用于另一根分数计算的 Blueprint 使用。关于其中的 seekFirst
,我们简单展开介绍。实现部分,大体可以分为如下几种:
然后是很重要的 strict
这一概念,事实上,没有必要让所有的 SearchIterator 都线性地在 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);
}
}
在构建 SearchIterator 树之前,会计算 strict 或 non-strict 的预期开销,选择开销更低的实现构造,实现见 searchlib/src/vespa/searchlib/queryeval/flow.h
。
接下来我们再自底向上介绍,从最简单的 term 检索开始:
select * from sources * where name contains "foo"
这一 YQL 查询语句要检索 name 这一字段包含 foo 的文档,根据 name
字段的索引方式,分类讨论:
对于没有倒排的单值或多值字段,当 SearchIterator seek 至某 docId 时,从 EnumStore 取出其值进行暴力的匹配。
// 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 检索至倒排表,SearchIterator 在倒排表上线性地推进。
void AttributePostingListIteratorT::doSeek(uint32_t docId) {
// _iterator 为基于倒排表的 iterator
_iterator.linearSeek(docId);
if (_iterator.valid()) {
setDocId(_iterator.getKey());
} else {
setAtEnd();
}
}
来到 index 索引,情况变得复杂了起来:
于是,一个简单的 term query,构建的 Blueprint 会变成:
下边具体展开每项阐述。
这里与 attribute x fast-search 几乎一致,如下图。
Disk index 的结构是多层跳表,查询即是在每层上定位区间,通过 offset 定位至下一层的区间内继续匹配。
DocumentMetaStore 中以 BitVector 的形式存储正在使用的 local id(即下图中的 lid)空间,WhiteListBlueprint 会由此构建 BitVectorIterator
,筛选掉不存在的 doc id。
管理 index 时会存储如下图所示的 SourceSelector
,其复用了 attribute 的数据结构。于是在 seek 每个 doc id 时,可以找到对应的 index。
void SourceBlenderSearch::doSeek(uint32_t docId) {
Source sourceId = this->sourceSelector->getSource(docId);
this->matchedChild = this->sources[sourceId];
if (this->matchedChild->seek(docId)) {
setDocId(docId);
}
}
以上是 non-strict 的版本,对于 strict 版本,需要快速地找到下一个 docId 所在的 index,实现较为复杂不展开介绍。
在这一节,我们暂时对这些常见的 IntermediateBlueprint 只做初级的介绍。诸如 Or、And、AndNot 的基本实现是简单的:
// 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);
}
对于 prefix / range / fuzzy / regex 搜索来说,并不是每个 Blueprint 对应一个倒排表,Vespa 将这些逻辑全部封装在 PostingListSearchContext
中。在构建 SearchContext
检索到多个倒排表时,会将这些倒排表合并,合并的结果是数组或 bitvector。
显然上边的中间节点的实现的效率是很低的,尤其当子节点的数量过于膨胀时,这一节介绍 Vespa 中用到的常见的优化手段。
如果被检索字段的相关性计算方式是简单的权重累加,可以使用此优化方案减少 or 和 and 下层检索的次数。weakAnd 在最大堆中维护一系列 score,以其中的最小分为阈值,剪枝掉一部分子 iterator 在不必要的 doc id 上进行的 seek。
具体实现较复杂, 建议在读过 Efficient Query Evaluation using a Two-Level Retrieval Process (PDF) 后查看具体代码 searchlib/src/vespa/searchlib/queryeval/wand/wand_parts.h
。
如前边所说,简单的中间节点 seek 的方式,就是遍历所有的子 iterator 逐个推进。Vespa 中广泛使用的一种优化是将它们按 docId 排序(数据结构往往是最小堆),在推进 docId 时可以减少调用子 iterator 的次数。
在构建完整的 SearchIterator 树后,最后一步的优化是寻找子节点均是 bitvector 实现的中间节点,于是可以批量地将这些 bitvector and/or,还可以利用 SIMD 加速这一过程。