Inhzus

Official

Vespa Matching - 自顶向下


本文是基于基于之前的两篇博客 Vespa 索引结构实现Vespa 索引检索实现 重新梳理的、自上而下的、更易懂的 Vespa Matching 的介绍,用于本人分享。

Vespa 是由 Yahoo 开发的高性能大数据检索引擎,专为大规模低延迟的场景设计,支持文本、结构化数据、向量的检索。

Blog Documentation DeepWiki GitHub Slack

Overview

Components

Vespa Overview

Searching Process

vespa.drawio

参照上图,对于一次搜索请求,在最简单的情况下

  1. 请求发送至无状态的 QRS (Query Rewrite Service) 集群后:
    1. 由 YQL(类似于 SQL)或其它形式的请求体,构造 query tree;
    2. 使用负载均衡策略,发送 query 请求至某个 group(或称一行、一个副本,含全量文档)全部 node 的 searchnode; 一个 node 通常等价于 k8s 的一个 pod,包含两个组件 distributor 和 searchnode; distributor 不会参与搜索流程,它只与 document 相关请求有关;
  2. 每个 searchnode 收到 query 请求后:
    1. 从 query tree 构建执行计划;
    2. 多线程地一边匹配一边进行第一阶段的打分;
    3. 取第一阶段匹配/打分的 top k 结果,进行第二阶段打分;
    4. 排序;
    5. 返回 docIds, sortData 等;
  3. QRS 收到所有 response 后:
    1. 归并;
    2. global-phase 打分;
    3. 排序;
    4. 发送 fetch 请求至原 searchnode,填充字段、分数及其它调试信息;
  4. End

vespa-searching.drawio

本文以下内容重点关注的内容在:

Vespa

Matching

Inverted Index

补课:一般来说,倒排索引都包含两部分:词典(dictionary)和拉链(posting list)。

举个例子,假设引擎中现在存储多篇文档,如下。

[
  {"0": "foo bar"},
  {"1": "bar zoo"},
  {"2": "foo zoo"}
]

会构建如下所示的倒排索引:

Inverted Index

Execution Plan

上一节中已经提到,输入的 YQL 会在 QRS 上转换为 query tree,后在 searchnode 上转换为执行计划。如下图所示(此处简要看下,下边会详细展开):

match-macro-view.jpg
  1. YQL => QueryTree:将用户输入转化为便于引擎处理的语法树,通常会进行优化,减少树的深度;
  2. QueryTree => Blueprint:即所谓的逻辑执行计划,若检索的字段有索引,此时已经定位到其拉链(posting list),于是可以确定每个叶子/中间节点 blueprint 的预估文档数量,进行进一步地剪枝、执行顺序优化(含向量检索的 pre/post filter);
  3. Blueprint => SearchIterator:即物理执行计划,定义了在内存/磁盘上的流程。

Match Loop

更详细地,在写入文档时,每个 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 的实现可以分为如下几种情况:

Optimization: strict

为简化后续代码,我们先引入 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);
  }
}

在构建 SearchIterator 树前,会计算 strict 或 non-strict 的预期开销,选择开销更低的实现构造,实现见searchlib/src/vespa/searchlib/queryeval/flow.h

Intermediate Blueprints

自顶向下地,我们在 Blueprint 层面上从中间节点开始讲起。

And / Or / AndNot

在引入 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 引入如下几种优化:

Optimization: heap

将所有子 SearchIterator 按照其当前 docId 排序(数据结构大部分情况为最小堆),于是在推进儿子节点时,可以减少调用 seek 次数:

weightedset-term-search.jpg

Optimization: weakAnd

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

Optimization: SIMD

在构建完整的 SearchIterator 树后,最后一步的优化是寻找子节点均为 bitvector 实现(取决于索引结构)的中间节点,于是可以利用 SIMD 加速 and / or / multi 的运算。

multibitvector.jpg

Term

最后,我们来到具体的最底层的 term 检索:

select * from sources * where name contains "foo"

该 YQL 语句要检索 name 字段包含 foo 的文档,根据 name 字段配置的索引方式,需要分类讨论。

Vespa 提供了以下几种存储/索引方式:

来到这一节,需要再次明确,Vespa 在单机上都是映射为 local id 后,再进行存储,Blueprint 于是可以在连续的 local id 空间上匹配。以 local id 为数组序号,Vespa 可以在一个数组中,密集地放下某个字段所有文档的值(最简单的情况下)。当然随着文档的过期删除,Vespa 会在后台自动地压缩这片空间。

Attribute

Attribute 在内存中的列式存储便于:

Plain

对于单值、定长(比如数字)、无倒排,Vespa 的存储结构只需简单的 RCU vector 即可:

单机 10M 文档,只需要 10M x 4 Byte = 40 MiB。

singlevalue-numeric-attribute.drawio.jpg

对于单值、不定长、无倒排,变长的字符串需要存储在额外的 EnumStore 中:

singlevalue-string-attribute.drawio.jpg

多值、不定长、无倒排,每个 docId 先指向在 ArrayStore 中存储的数组,再指向 EnumStore 中的变长值。

multivalue-string-attribute.drawio.jpg

于是,当 SearchIterator 在该字段上检索 seek 至某个 docId 后,取出其值进行匹配即可:

match-attribute

简单的代码如下:

// 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;
}

FastSearch

当有倒排时,在构建 Blueprint 时,既可通过词典(dictionary)定位至拉链(posting list),SearchIterator 在拉链上线性地推进。

singlevalue-string-fastsearch-attribute.drawio.jpg
void AttributePostingListIteratorT::doSeek(uint32_t docId) {
    // _iterator is on the posting list
    _iterator.linearSeek(docId);
    if (_iterator.valid()) {
        setDocId(_iterator.getKey());
    } else {
        setAtEnd();
    }
}

Index

  1. Index 是 Vespa 中另一主要的索引方式。与 attribute 不同,使用 index 索引方式的字段,会经历 tokenizing、normalizing、stemming,即会把一整段文字进行分词归一化等操作,这样的索引方式显然是为像网页文本搜索服务的;

  2. index 只会构建倒排索引,不会额外存储全量的数据。且倒排索引不会全部放在内存中,会有相当一部分存在于磁盘上。当文档写入时,会先更新内存中的 memory index,memory index 会定期写入磁盘(flush),然后和已有的磁盘上的 disk index 合并(fusion)。当检索该字段时,需要同时查询 memory index 和 disk index 后合并结果;

  3. Disk index 是不可更改的,当要删除的文档在 disk index 上时,如果不借助额外的数据结构,检索时会搜到该文档。Vespa 提供的方案是,在原始构建的 Blueprint 上层,AndNot 一个 WhiteListBlueprint,WhiteListBlueprint 会读取 document store 中的 doc id bitvector,筛选已经被删除的文档。

于是,一个简单的 term query,构建的 Blueprint 会变成:

match-index-blueprint.jpg

下边具体展开每项阐述。

Memory Index

这里与 attribute x fast-search 几乎一致,不同的是:

  1. 不需要另外存储全量数据,于是没有 DocumentVector 和 MultiValueMapping;
  2. 经过 tokenizing 的文本,会带有如词频、位置等信息,用于之后的 bm25、nativeRank 计算,故会有额外的 FeatureStore 数据结构存储这些特征信息,在 PostingStore 中成对存储了 docId 和指向 FeatureStore 的 EntryRef。
match-memory-index.jpg

Disk Index

注意到事实上,尽管这些文件都在磁盘上,在内存足够的情况下,在我们内部的版本中,往往会 mmap 分配一块匿名内存,然后将这些文件全部映射至内存中。官方的版本提供了 populate 这一选项,也就是在 mmap 文件至内存时,尽量减少 page fault 的发生。

disk-index.drawio.jpg

WhiteListBlueprint

DocumentMetaStore 中以 BitVector 的形式存储正在使用的 local id(即下图中的 lid)空间,WhiteListBlueprint 会由此构建 BitVectorIterator ,筛选掉不存在的 doc id。

whitelist-blueprint.jpg

SourceBlenderBlueprint

管理 index 时会存储如下图所示的 SourceSelector,其复用了 attribute 的数据结构。于是在 seek 每个 doc id 时,可以找到对应的 index。

source-blender.jpg
// 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);
  }
}

Data Structure

Posting List

最通常状态下,倒排表以如上图中 B-Tree 的形式组织:

buildCost = treeSize * 2 + additionSize  \\
modifyCost = (log_2(treeSize + additionSize) + 1) * (additionSize + removeSize)

当拉链长度小于 8 时,B-Tree 会被替代为简单的数组,可以在几乎不影响查询性能的情况下,减少写入的开销。

当长度大于 max(128, 文档数 >> 6) 时,会在 B-Tree 外构建 BitVector:

DataStore

DataStore 是 Vespa 中的通用容器,向其中存储一段数据会得到一个唯一 id,之后可以通过这个 id 获取到之前存储的数据。

其接受的 id 类型为 EntryRef,本质上是个 32 位的 id,在这个类型的定义中,默认情况下将前 10 位视为 buffer id,后 22 位为一个 buffer 内的 offset。DataStore 于是持有 2^10 即 1024 个 buffer,每个 buffer 可持有不同类型的数据。

当插入数据时

  1. 检查 free list 中是否有之前已释放的槽位;
  2. 检查当前插入的数据类型所指向的 active buffer 是否有空闲空间,否则切换到新的空闲的 buffer;
  3. 找到空闲的 buffer 后,定位至空闲的 offset,插入数据至此处;
  4. 此时 buffer id + offset 即构成了返回的 entry id。

当通过 entry id 获取数据时,即是线性地,将 entry id 分为 buffer id 和 offset 即可定位至相应的数据。

data-store.drawio.jpg

ArrayStore

ArrayStore 是基于 DataStore 上封装的支持单一类型、不同数组长度的数据结构。

EnumStore

EnumStore 在 DataStore 的基础上,用索引赋予了在这一系列数据(string, int 等 primary type)上进行快速查找的能力。

数据被直接地存储在 DataStore 中,而 EnumStoreDictionary 以这些存储的值为 key,value 中存储了其在 DataStore 中的 id 和对应的倒排表 id。

enum-store.drawio

MultiValueMapping

EnumStore 只能支持单值字段的存储与检索,为了支持多值字段,如 array<int>array<string>weightedset<string>,MultiValueMapping 基于 RCUVector 和 ArrayStore 构建了 local docId 到多值字段值的映射。

multi-value-mapping.drawio.jpg

如上图所示,RCU Vector 以 docId 为序号,存储指向 ArrayStore 的 EntryRef。通过 EntryRef,能够获取到 ArrayStore 中存储的定长或变长的数组。

注意 ArrayStore 存储的数组,每个元素的长度是固定的,因此如果要存储多值 string 类型,要将字符串存储至额外的 EnumStore,这里只存储指向 EnumStore 的 EntryRef。

Sample App

简单了解,从用户视角来看,配置 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>
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"
  }
}