作者:来自 Elastic Jim Ferenczi

在相同硬件上,Elasticsearch 和 Qdrant 在 56 QPS 时处于相同范围。io_uring 磁盘评分器和内存方面的声明最终证明是影响最小的两个因素。
通过这个用于 Search AI 的 自定进度动手学习,亲自尝试向量搜索。你现在可以开始 免费云试用,或者立即在你的 本地机器 上尝试 Elastic。
像一个好的 基准测试 一样,向量搜索厂商最近一直在进行比较,而最近 Elasticsearch 和 Qdrant 一直在相互比较。今年早些时候,我们 发布了一个基准测试,比较 Elasticsearch 的 bbq_disk 和 Qdrant 在磁盘重新评分工作负载下的表现,其中完整精度向量存储在磁盘上,并被重新读取以对顶部候选结果进行重新评分。Qdrant 进行了回应,提供了他们一方更好的数据,并给出了一组解释原因:基于 io_uring 的异步磁盘评分器,以及关于 Elasticsearch 需要更多内存的说明。
我们不想通过另一轮数字进行反击。复现一个基准测试是简单的部分;理解为什么它会产生这些数字才是有价值的部分,而这正是两篇原始文章都跳过的部分。因此,我们在自己的集群上搭建了 Qdrant 完全相同的设置,加载了相同的 2100 万个向量,复现了他们的结果,然后追踪每个数字背后的原因。
以下是我们的发现。结果归结为几个设置选择:你构建多少个分片,数据是否已经在内存中预热,如何返回结果 id,以及你运行在哪种硬件上。控制这些因素后,结果会双向变化:匹配 Qdrant 的设置时,两个引擎在 查询 速度上处于同一水平,而凭借快速的默认摄取,Elasticsearch 在搜索速度和索引速度上都更快。两篇文章中最大的倍数差异 ------ 我们的 7 倍和他们回应中的差异 ------ 都是这些选择造成的结果,而不是对任何一个引擎的最终判断。Qdrant 最依赖的两个原因,即 io_uring 磁盘评分器和 Elasticsearch 的内存使用情况,最终证明是影响最小的两个因素:运行过程中从未读取磁盘,而内存差距只是标签上的差异,并不是实际字节数上的差异。
我们也用相同标准审视我们自己最初发布的文章。双方都同时改变了多个因素,并报告了结果,而不是原因。这篇文章剩下部分遵循一个规则:每个结论都必须有一个数字和一个机制作为依据,否则不会发布。
向量搜索基准测试设置:硬件、配置和查询集合
以下是双方实际运行的设置。
| Elastic(原始文章)Qdrant(回应)本文(公平对比) | |||
|---|---|---|---|
| 节点 | 3 × n4-standard-8(7 vCPU / 26 GB),GCP | 3 × m6g.large(2 vCPU / 8 GB),AWS | 3 × m6g.large(2 vCPU / 8 GB) |
| Elasticsearch | bbq_disk 2-bit,副本:1 | (引用原始配置) | bbq_disk 2-bit,vectordb_document,bfloat16,副本:0 |
| Qdrant | 2-bit,异步评分器关闭,RF=2 | TurboQuant 4-bit,异步评分器开启,RF=1 | TurboQuant 4-bit,异步评分器开启,RF=1 |
| 每个分片的副本数量 | 2 = 2(匹配) | 1 | 1 = 1(匹配) |
| 查询集合 | 固定 1 万条,recall@100 | 固定 1 万条,recall@100 | 固定 1 万条,recall@100 |
有三件事值得明确,因为这三件事都曾被用作讨论焦点。
-
副本数量在两轮测试中都进行了匹配:一个设置
replicas: 1的 Elasticsearch 索引会保留每个分片的两个副本,这与 Qdrant 的 RF=2 完全一致。在这一轮测试中,双方都运行单个副本。它从未在任何方向上成为影响结果的因素。 -
我们有意改变了自己一侧的一些设置,并且会承认这些变化:
vectordb_document索引模式,将存储的完整精度向量从 float32 改为 bfloat16(每个向量从 3,072 字节减少到 1,536 字节),以及针对向量数据调优的合并策略。这些选择之后会产生影响,因此我们提前说明,而不是隐藏它们。 -
内存比较必须保持同类对比。Qdrant 的文章将 Elasticsearch 的 JVM 堆分配与 Qdrant 的总内存使用进行比较,但这属于不同的统计类别,因为堆大小并不等于总内存消耗。在这些 8 GB 节点上,我们为 JVM 堆分配 2 GB,占节点内存的 25%,这是有意为之:它遵循 Elastic 的向量搜索优化配置,该配置将剩余 75% 留给操作系统页缓存,因为向量实际上存储在那里,而不是采用通用的 50% 堆上限。公平的比较应该是常驻索引加上运行时内存以及活跃页缓存,而在 8 GB 节点上,两个引擎都处于相同的内存范围内。
不过,看看中间这一列。在我们的原始运行和他们的回应之间,Qdrant 几乎同时改变了所有内容:新的硬件、从 2-bit 改为 4-bit 的 量化、开启异步评分器,以及不同的副本因子。然后他们将胜利归因于其中某一个变化。从多个同时变化中得出有意义的结论很困难,而确定究竟哪个变化真正推动了结果,这正是接下来几个章节的全部任务。
向量搜索基准测试中的磁盘重新评分成本是多少?
在这个基准测试中,答案几乎是零,因为磁盘从未被读取。两个简单的估算预算可以说明原因,后面文章中的实际测量也会证实这一点。
被测试的设计围绕一个理念构建:在 RAM 中保留每个向量的小型量化副本,将完整精度的原始向量保存在磁盘上,并读取少量原始向量,对顶部候选结果进行重新评分以提高准确性。
两个预算决定性能:重新评分读取所需的磁盘 I/O,以及必须常驻内存的数据所需的 RAM。这两个预算都可以通过简单估算计算出来,因此在进行任何测量之前,让我们先计算它们。
向量搜索重新评分的磁盘 I/O 预算
重新评分会读取每个查询的前 100 个候选结果。一个 768 维 float32 向量大小为 768 × 4 = 3,072 字节。由于这些读取不是按页面对齐的,因此每个向量的实际成本更接近一次 8 KB 页面读取。每个查询大约需要 800 KB 和 100 次随机读取。将其扩展:
| 目标 QPS(冷缓存)重新评分向量数/秒每个节点的读取次数/秒(IOPS)每个节点 MB/s | |||
|---|---|---|---|
| 10 | 1,000 | 333 | 3 |
| 100 | 10,000 | 3,333 | 27 |
| 1,000 | 100,000 | 33,333 | 273 |
| 10,000 | 1,000,000 | 333,333 | 2,731 |
两个结论立即显现出来:第一,冷重新评分是一个 IOPS 问题,而不是带宽问题。即使达到 10,000 QPS,每个节点也只需要大约 2.7 GB/s,但需要每个节点三分之一百万 IOPS。第二,这个基准测试的总工作集非常小:100 个候选结果 × 10,000 个查询 × 8 KB = 集群范围内 8 GB,或者每个节点 2.7 GB。这可以放入一个 8 GB 节点的空闲页缓存中,并且还有剩余空间。基准测试第一次遍历这 10,000 个查询时,它访问的原始向量会被加载到页缓存中,此后的每次读取都是内存复制。
同时也给它设定一个上限。在 gp3 基准配置(每个节点 3,000 IOPS)下,如果读取确实来自磁盘,仅依靠 IOPS,该工作负载的上限大约为 90 QPS;配置更高 IOPS 的 gp3 或本地 NVMe 可以分别将这个数字提升到大约 480 或 3,000。记住这个数字。它是在一个真正访问磁盘的基准测试中才会产生影响的上限。
基于磁盘的向量索引的 RAM 预算
必须常驻内存的是量化副本和搜索结构。其他所有内容都由页缓存处理。在 4-bit 量化以及 m=16 的 HNSW 图情况下:
| 组件每个向量字节数21M 向量每个节点 | ||
|---|---|---|
| 4-bit 量化向量(always_ram) | 384 | 2.69 GB |
| HNSW 图,m=16(2m 链接 × 4 B) | 136 | 0.95 GB |
| 常驻总量 | 520 | 3.64 GB |
| 原始 float32 向量(磁盘上) | 3,072 | 21.5 GB(磁盘上) |
我们测量了运行节点上的常驻内存占用,结果为 3.4 到 3.9 GB,与 3.64 GB 的估算值非常接近。需要记住的重点是,在 2100 万个向量规模下,整个可搜索索引(量化向量加图结构)大约为每个节点 3.6 GB,并且可以放入 RAM 中,同时还剩余几 GB 空间。这个基准测试中的任何因素都不会迫使基于磁盘的设计真正使用磁盘。我们会在最后再次讨论这一点,因为这才是真正的关键。
io_uring 会让向量搜索更快吗?
Qdrant 的回应将两个他们认为我们遗漏的因素归功于结果:一个两阶段预取和重新评分的 检索 模式,以及一个基于 io_uring 的异步磁盘评分器。它们实际上是同一个重新评分步骤的两个方面,而相同的证据可以同时回答这两个问题。我们先看异步评分器,因为这是 Qdrant 重点强调的部分:使用 io_uring 在重新评分期间并行化磁盘读取。这是一个很好的功能,而在这个基准测试中,它只有一个任务:在重新评分期间并行化磁盘读取。但它一次都没有执行。我们通过三种独立方式验证了这一点。
第一,数学计算。从 RAM 预算来看,量化索引和图结构都固定在 RAM 中。从 I/O 预算来看,重新评分工作集每个节点为 2.7 GB,并且在第一次遍历后会存在于页缓存中。因此,在需要加速的热路径上没有磁盘读取。
第二,他们自己的测试方法保证了这一点。测试框架中的每个测试点都会运行完整的 10,000 个查询召回过程,然后进入一个计时的 吞吐量 窗口,再次循环执行相同的 10,000 个查询。计时器开始时,这些查询将访问的所有向量都已经驻留在内存中。该测量从设计上就是热缓存状态。
第三,也是我们坚持实际执行测量而不是进行争论的部分,我们进行了测试。这里我们非常谨慎,因为很容易意外地测试 io_uring 关闭的状态而没有察觉。我们在 Docker 中运行 Qdrant 时发现,io_uring 根本没有初始化:默认的 seccomp 配置文件阻止了 io_uring 系统调用,因此它静默回退到了同步读取(failed to initialize io_uring instance: Operation not permitted)。我们修复了这个问题,使用 seccomp unconfined 运行容器,确认日志中没有任何 io_uring 错误,然后重新运行测试。这意味着我们实际上拥有两种状态:
-
异步评分器实际关闭(同步回退):在 ef=50 时为 31.6 QPS。
-
异步评分器开启(确认 io_uring 正常工作):在 ef=50 时为 35.8 QPS。
提升了 13%,但即使这个提升也处于运行间噪声范围内,因为这个测量的上限由 CPU 决定,而不是磁盘决定。简单来说,开启整个结果被归因于的那个功能,带来的变化幅度和再次运行一次基准测试差不多。为了验证这一点,在实际吞吐量窗口期间,我们使用 iostat 观察了三个节点上的块设备:读取吞吐量保持在 0 MB/s 和 0 IOPS,而 CPU 保持在 60-70%,并且随着增加并发量升至 100%。瓶颈是 CPU 执行 量化距离计算 和图遍历。不是磁盘,因为磁盘处于休眠状态。
两阶段预取和重新评分模式 ------ Qdrant 首先列出的那个因素------通过相同的测试结果可以得到答案。这里所有 Qdrant 数据都来自 Qdrant 自己的复现实验脚本,该脚本始终使用他们的两阶段查询方式,因此两阶段模式在整个基准测试中一直开启,包括 io_uring 对比测试,而磁盘仍然保持 0 IOPS。它也不是我们遗漏的内容:预取是对量化向量执行的近似搜索,该过程运行在 RAM 中;重新评分读取原始向量,而这些向量在这里位于页缓存中。这就是磁盘重新评分工作负载,而 bbq_disk 执行的是相同的模式。两阶段中唯一不涉及磁盘的部分,即重新评分的候选数量,是 CPU 而不是 I/O 问题;它已经包含在这些数字中,并且两个引擎都可以使用,因此它也不是隐藏因素。
还有一个较小但值得注意的细节。Qdrant 的文章没有说明他们使用的磁盘类型。对于一个前提是磁盘访问的基准测试来说,这通常很重要,但在这里并不重要,原因与异步评分器相同:你无法成为一个从未读取过的设备的瓶颈。磁盘没有被说明,而实际上它也不是关键因素。
以下是我的观察,作为一个参与过大量此类讨论的工程师:io_uring 被当作通用解决方案的频率远高于它真正成为瓶颈的情况。当工作集超过 RAM 时,它非常优秀,而上面的冷 I/O 预算正好展示了这种情况,此时并行化读取可以提升吞吐量。但这个基准测试从未进入这种状态。异步磁盘读取是对真实问题的真实答案,而这个基准测试从未提出这个问题。在两次运行之间发生的所有变化中,io_uring 获得了最多关注,但对数字的影响却最小。
为什么更快的摄取会导致更慢的向量搜索查询?
这正是复现数据变得有趣的地方,也是差距真正的原因所在。
我们第一次加载产生了 128 个分段,吞吐量大约只有预期的一半。Qdrant 发布结果中的来源数据,就在他们的 结果 JSON 中,记录了 67 个分段。这一个差异几乎解释了所有结果。当我们将集合合并到与他们相同的分段数量后,所有三个指标都同时向他们的数据靠近:
| 分段数 | Recall@100 | QPS | 平均延迟 |
|---|---|---|---|
| 128(我们第一次,并行加载) | 0.9745 | 35.8 | 112 ms |
| 66(合并以匹配他们) | 0.9531 | 53.3 | 75 ms |
| 67(Qdrant 发布数据) | 0.9596 | 67.2 | 59.5 ms |
观察变化趋势。随着分段减少,Recall 下降,因为更少的分段意味着每个查询检查的总候选数量减少。这说明分段数量,而不是调优,正在提高我们的 Recall。而吞吐量提升,是因为 HNSW 查询成本对分段数量非常敏感:每个查询都会扩散到每个分段的图结构中,因此分段数量翻倍接近于每次查询工作量翻倍,再加上在两个核心上执行所有这些工作的竞争开销。我们 53 QPS 和他们 67 QPS 之间剩余的小差距,是我们较短运行时间导致的缓存预热完整性问题,而不是引擎本身的问题。
这里有一点值得谨慎说明,因为它是架构层面的因素,而且它既对我们有利,也对我们不利。我们使用的是相同的分段设计;这不是 Qdrant 特有的问题。问题在于如何达到较少的分段数量。你通过单线程摄取实现这一点,让数据集中到更少、更大的分段中。更少的分段意味着更快的查询,但代价是更慢的摄取。这是一个真实的权衡,而且双方都存在同样的问题。
两个设计真正不同的地方在于,这种权衡带来的影响有多大。构建 HNSW 图成本很高,而且它的查询成本会随着分段数量增加而明显变差。在这些 2-vCPU 节点上,使用 ef_construct=256 构建图花费了大约 1.6 小时,并且 CPU 一直保持 100% 占用。bbq_disk 背后的 IVF 布局构建成本低,并且对分段数量不那么敏感,因此我们可以使用多个线程进行摄取,同时保持较低的查询 延迟。我们不需要二选一。
这不是基准测试技巧。这就是 IVF 和 HNSW 之间的权衡,真实地呈现出来。
为什么文档检索,而不是向量搜索,解释了剩余的延迟
现在用同样的视角来看我们自己的结果,因为同样的计算方式也适用于我们自己的数据。
在搜索轻量级的一端,每次查询时间中很大一部分实际上并不是向量搜索,而是获取阶段组装响应所花费的时间。vectordb_document 会返回完整的源文档,这正是你在实际需要返回 文档 的检索和 混合搜索 场景中想要的结果。纯向量基准测试只需要顶部 N 个 id,而目前 _id 与 _source、文本和向量存储在同一个 stored-fields 列中。因此,获取一个 id 会让每个命中结果都通过解压缩器读取整个压缩块。当我们将 id 从该列中隔离出来时,Elasticsearch 直接进入 Qdrant 的低分段吞吐量范围。这个差距来自文档检索,而不是向量搜索。
解决方案是结构性的,而不是一个调优开关:为 _id 提供独立的 doc-values 字段,这样返回一个 id 时永远不会访问 _source。这正是我们正在构建的 vectordb_columnar 模式会自动完成的事情。我们在这里指出这一点,是因为好的基准测试意味着像说明其他成本一样清楚地说明自己的成本,而这个成本正是隐藏在向量搜索得分中的文档检索成本。
Elasticsearch 与 Qdrant 向量搜索基准测试:相同机器上的数据
在相同的三个 m6g.large 节点上运行 Elasticsearch,使用 vectordb_document、bfloat16、热缓存状态,同时展示当前运行方式以及隔离 id 后的结果:
| Visit %Recall@100QPS(默认)QPS(id 隔离) | |||
|---|---|---|---|
| 1 | 0.894 | 44 | 89 |
| 2 | 0.939 | 39 | 73 |
| 3 | 0.956 | 35 | 56 |
| 5 | 0.970 | 31 | 40 |
id 隔离这一列通过完全移除 stored-fields 获取过程进行测量,这是接近 doc-values _id 上限的代理指标(doc-values 读取成本很低,但并不是完全免费)。在大约 0.96 recall 时,id 隔离后的 Elasticsearch 达到约 56 QPS,与 Qdrant 在相同硬件上的 53 到 67 QPS 处于同一范围。同一个量级。两篇文章中的差距来自设置和文档检索,而不是向量引擎本身:分段数量、id 获取、硬件。他们的回应我们可以在这里完整解释;而我们自己第一篇文章中的更大 7 倍差距目前还不能完全解释,下面我们会讨论这一点。当你控制这些因素后,两个设计良好的系统在相同机器上执行相同工作时会接近,这是符合预期的。
所以,这里是明确的结论:性能取决于设置。匹配 Qdrant 的慢速单线程摄取方式,也就是产生他们 67 个分段配置的方式,那么两个引擎在搜索本身上处于同一水平:id 隔离后的 Elasticsearch 在相同 recall 下约为 56 QPS,而他们是 53 到 67 QPS。但这个低分段数量是通过慢速摄取换来的。
让两个引擎都进行快速摄取,这是自然默认方式,那么 Qdrant 会回到 128 个分段和约 35 QPS,而 Elasticsearch 随着分段数量增加性能下降较少,因为 bbq_disk 背后的 IVF 布局相比 HNSW 对分段数量不那么敏感。因此,在默认快速摄取情况下,Elasticsearch 的搜索速度也更快,并且它一开始就更快地达到可查询状态的索引。
我们目前唯一落后的地方仍然是返回 id,而这属于文档检索,而不是搜索,并且这正是 vectordb_columnar 要解决的问题。
我们有意没有运行 4 vCPU / 16 GB 配置。它会同时提升两个引擎的性能,并展示相同趋势,因此只会增加成本,而不会增加洞察。
我们原始的 Elasticsearch 与 Qdrant 7 倍基准测试数据,解释如下
本文的规则是,每个结论都必须有一个数字和一个机制作为依据。这个规则同样应用于我们原始基准测试。
那篇文章测量出 Elasticsearch 具有 7 倍吞吐量优势,而这个数字对于那个配置来说是真实的。测试设置已经完整披露,包括 Qdrant 在 NVMe 上表现更好这一点。但文章为这个数字附带了一个机制解释:Qdrant 在重新评分期间受到原始向量随机磁盘读取的限制,而网络连接存储让这个问题更加严重。本文中的计算削弱了这个解释。第一轮测试中的那些节点拥有 26 GB RAM,比这里的 8 GB 节点有更多空间,而我们已经观察到这里的磁盘保持 0 IOPS,因此如果磁盘在这里处于空闲状态,那么它在那里几乎肯定也是空闲状态。
那么,究竟是什么让第一轮测试中的 Qdrant 在 0.97 recall 时只有 4.5 QPS?我们现在还不能声称知道答案,但原始文章自己的配置指向了一个方向:它在 Qdrant 上运行的是 2-bit 量化,并且 oversampling 固定为 1。2-bit 编码比较粗糙,而当 oversampling 固定为 1 时,恢复 recall 的唯一手段就是提高 ef。仅依靠 ef 达到 0.97 recall 意味着需要非常大的 ef,而非常大的 ef 会让每次查询本身变得昂贵,甚至在磁盘参与之前就是如此。这仍然只是一个假设,但原始文章提出的机制 ------ 重新评分期间的随机磁盘读取 ------ 无法通过我们刚刚应用于 Qdrant 的相同计算方式得到验证。
基准测试,而不是基准营销
数据可以复现;解释无法复现。一个将 "磁盘读取" 作为核心卖点的基准测试,实际运行时磁盘处于休眠状态,而我们观察到整个窗口期间它保持 0 IOPS。我们真正找到的差异是分段数量(一个摄取速度与查询速度之间的权衡,其中 IVF 布局让 bbq_disk 可以快速摄取并快速查询),以及我们这一侧的 stored-field 检索成本(这是文档检索,而不是向量搜索,并且 vectordb_columnar 可以移除它)。这些都不是 io_uring,也不是"Java 很重"。
原始文章中更重要、但在 io_uring 讨论中被忽略的观点是:将向量放在磁盘上从来不是为了重新评分,而是为了内存。关键在于保持可搜索索引、量化向量以及 IVF 或 HNSW 结构足够紧凑,并且能够驻留在磁盘中,从而相比完全将所有内容固定在内存中的设计,每 GB RAM 可以服务更多向量。Qdrant 的设置通过 always_ram 将 4-bit 向量固定在 RAM 中,并将 HNSW 图保存在 RAM 中,对于 2100 万个向量,每个节点大约需要 3.6 GB。bbq_disk 则将量化后的 IVF 保存在磁盘上。在 8 GB 节点上运行 2100 万个向量时,无论哪种方式,所有内容都可以放入 RAM,这正是为什么这个基准测试无法区分两种设计。它测量的是一个有趣变量已经被保持不变的情况。
真正有趣的问题是,当可搜索索引无法继续放入内存时会发生什么。扩大 语料库 ,直到量化向量和结构超过页缓存,两种设计就会产生差异:一种继续从磁盘提供服务,另一种需要每个节点更多 RAM。这正是 bbq_disk 被设计解决的问题,也是两篇文章都没有测量的场景。磁盘压力下的重新评分场景,即明显多于缓存容量的不同查询数量,也值得测量,但这是第二个问题,而不是第一个问题。
我们尝试达到这个场景,而第一次尝试仍然可以放入缓存,因为这些节点上的 2100 万个向量不会溢出。因此我们还没有完成。我们正在构建一个规模足够大的基准测试,使可搜索索引无法继续放入 RAM,并且我们会发布这些数据,同时按照相同方式附带并验证背后的原因。
这里希望读者关注的不是 Qdrant,而是一个原则:当一个基准测试给出一个漂亮的倍数差异时,在相信它背后的故事之前,要追踪每个数字背后的真正原因。很多时候,它只是热缓存或者分段数量。
Qdrant 团队构建了一个优秀的引擎,我们并不是在质疑这一点。公平的结论不是谁更快:匹配他们的设置时,两者处于同一水平,而在快速默认路径下,Elasticsearch 在 索引 和搜索方面都更快。数据本身没有问题。真正值得检查的是附加在这些数据上的解释,而检查应该从我们自己开始。
而真正能够压力测试基于磁盘索引的基准测试 ------ 即工作集无法放入 RAM 的场景 ------ 仍然需要被编写。我们会带来这些数据。
原文:Vector search benchmark: Elasticsearch matches Qdrant - Elasticsearch Labs