那块从未唤醒的磁盘:究竟是什么决定了我们的 Qdrant 向量搜索基准测试重新对决的结果
作者来自 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-87 vCPU / 26 GBGCP3 × m6g.large2 vCPU / 8 GBAWS3 × m6g.large2 vCPU / 8 GBElasticsearchbbq_disk 2-bit副本1引用原始配置bbq_disk 2-bitvectordb_documentbfloat16副本0Qdrant2-bit异步评分器关闭RF2TurboQuant 4-bit异步评分器开启RF1TurboQuant 4-bit异步评分器开启RF1每个分片的副本数量2 2匹配11 1匹配查询集合固定 1 万条recall100固定 1 万条recall100固定 1 万条recall100有三件事值得明确因为这三件事都曾被用作讨论焦点。副本数量在两轮测试中都进行了匹配一个设置replicas: 1的 Elasticsearch 索引会保留每个分片的两个副本这与 Qdrant 的 RF2 完全一致。在这一轮测试中双方都运行单个副本。它从未在任何方向上成为影响结果的因素。我们有意改变了自己一侧的一些设置并且会承认这些变化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/s101,000333310010,0003,333271,000100,00033,33327310,0001,000,000333,3332,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 量化以及 m16 的 HNSW 图情况下组件每个向量字节数21M 向量每个节点4-bit 量化向量always_ram3842.69 GBHNSW 图m162m 链接 × 4 B1360.95 GB常驻总量5203.64 GB原始 float32 向量磁盘上3,07221.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 错误然后重新运行测试。这意味着我们实际上拥有两种状态异步评分器实际关闭同步回退在 ef50 时为 31.6 QPS。异步评分器开启确认 io_uring 正常工作在 ef50 时为 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 个分段。这一个差异几乎解释了所有结果。当我们将集合合并到与他们相同的分段数量后所有三个指标都同时向他们的数据靠近分段数Recall100QPS平均延迟128我们第一次并行加载0.974535.8112 ms66合并以匹配他们0.953153.375 ms67Qdrant 发布数据0.959667.259.5 ms观察变化趋势。随着分段减少Recall 下降因为更少的分段意味着每个查询检查的总候选数量减少。这说明分段数量而不是调优正在提高我们的 Recall。而吞吐量提升是因为 HNSW 查询成本对分段数量非常敏感每个查询都会扩散到每个分段的图结构中因此分段数量翻倍接近于每次查询工作量翻倍再加上在两个核心上执行所有这些工作的竞争开销。我们 53 QPS 和他们 67 QPS 之间剩余的小差距是我们较短运行时间导致的缓存预热完整性问题而不是引擎本身的问题。这里有一点值得谨慎说明因为它是架构层面的因素而且它既对我们有利也对我们不利。我们使用的是相同的分段设计这不是 Qdrant 特有的问题。问题在于如何达到较少的分段数量。你通过单线程摄取实现这一点让数据集中到更少、更大的分段中。更少的分段意味着更快的查询但代价是更慢的摄取。这是一个真实的权衡而且双方都存在同样的问题。两个设计真正不同的地方在于这种权衡带来的影响有多大。构建 HNSW 图成本很高而且它的查询成本会随着分段数量增加而明显变差。在这些 2-vCPU 节点上使用ef_construct256构建图花费了大约 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 %Recall100QPS默认QPSid 隔离10.894448920.939397330.956355650.9703140id 隔离这一列通过完全移除 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