Skip to content

필사 모드: Why the Default Vector Index Moved from HNSW to Disk — the Tradeoffs of Elasticsearch bbq_disk

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — Start With the Fact That the Default Changed

Vector search stories usually start with the embedding model or the RAG pipeline. This post is about the layer underneath that — the index itself. And the starting point isn't a debate, just a fact: Elasticsearch's default index type for vector fields changed.

The defaults table in the official dense_vector reference reads like this.

  • 9.0 — all float vectors get int8_hnsw.
  • 9.1 and laterbbq_hnsw for 384 dimensions and above, int8_hnsw below 384.
  • 9.4 and later, and Serverless — carrying the docs' own wording over, when indexing float or bfloat16 vectors, "the default index type is bbq_disk if available on your current license."

Attach version dates and you can see how fast this shift moved. 9.1 shipped 2025-07-23, 9.2 shipped 2025-10-21, and 9.4.0 shipped 2026-05-05 (per endoflife.date and the elastic/elasticsearch release tags; as of this writing in July 2026, the latest release is 9.4.3). In under a year, the default moved twice — from a scalar-quantized graph, to a binary-quantized graph, to a disk index that isn't a graph at all.

That last step matters. bbq_disk isn't HNSW. It's in the IVF (inverted file index) family, and the search structure lives on disk rather than in RAM. It overturns — at least at the default's seat — nearly two decades of conventional wisdom that "approximate nearest-neighbor search = graph index." Why that happened, and what it costs, is what this post is about.

HNSW's Real Cost Isn't Compute — It's RAM

HNSW's appeal is obvious. It's a fast, compute-efficient algorithm that scales logarithmically with data size. Even the DiskBBQ introduction post (Benjamin Trent, John Wagster, Ignacio Vera Sequeiros, 2025-10-23) opens its first paragraph with "We absolutely love HNSW."

But the same post immediately points out the cost. For HNSW to work properly, every vector has to be in RAM. Quantization has kept lowering that cost, but the problem remains: the moment vectors get pushed out of memory, performance falls off a cliff. There's also a lesser-known second cost — indexing requires traversing the existing HNSW graph, so the RAM cost hits just as hard at index time as it does at query time.

The key word here is "falls off." Performance doesn't degrade gradually — it drops off a cliff. Elastic put numbers to this in its low-memory benchmark (John Wagster, 2025-10-23).

Measurement conditions — this is a vendor's own measurement. 1 million vectors, roughly 12 segments under the default merge policy, BBQ 1-bit quantization applied to both approaches, both configurations tuned to a recall of about 0.89. The method isolates Elasticsearch's built-in KnnIndexTester utility inside a Docker container, disables swap, and then caps total container memory. Figures are labeled container RAM / JVM heap.

RAM / HeapDiskBBQ LatencyHNSW BBQ Latency
101m / 10m15.83mscould not run
150m / 100m12.13ms289.7ms
250m / 150m7.46ms26.81ms
350m / 250m3.65ms7.7ms
450m / 350m2.38ms3.06ms
550m / 450m2.41ms3.14ms

At the 550m tier, the two are effectively tied (2.41 vs. 3.14). That means when memory is plentiful, this whole debate doesn't even apply. But going down, they diverge — 3.6x at 250m, and 24x at 150m. In Elastic's phrasing, DiskBBQ degrades gracefully, while HNSW BBQ simply falls apart once memory gets extremely constrained.

The indexing side is more dramatic still. In the same post's indexing-time table, HNSW BBQ couldn't complete at all at 1g/850m and below (no measurements for the 550m, 750m, or 1g tiers). DiskBBQ, at that same 1g/850m tier, finished indexing in 82,397ms. This is the concrete face of the "indexing has to traverse the graph too" property mentioned earlier.

Where the floor sits is interesting too. The author pushed the heap down to 10MB, and below that log4j initialization failed, so that's as low as it went. At the container level, it ran at 101MB; below that, the OS sends SIGKILL (exit 137). For reference, running a single line of HelloWorld in the same openjdk:24-jdk-slim-bookworm container needs 50MB by itself. So DiskBBQ effectively served 1 million vectors at 15.83ms with something like 60MB of actual headroom to spare.

What this benchmark doesn't say. To be honest about it, the post doesn't state in its body what dataset was used or how many dimensions. It's a single measurement at a single scale (1 million vectors) on a single machine, with recall pinned to one point, 0.89. The directional claim — HNSW has a memory cliff and IVF doesn't — is trustworthy because it follows from the algorithms' structure, but you shouldn't carry this table's absolute numbers over to your own corpus.

What BBQ Kept From RaBitQ, and What It Dropped

Let's start by sorting out the BBQ in bbq_disk. This is the least-known part of the story.

BBQ (Better Binary Quantization) was introduced in Elasticsearch 8.16 and Lucene (Benjamin Trent, 2024-11-11). That post is explicit about where it came from — it was developed from insights gained from a technique called RaBitQ, proposed by researchers at Nanyang Technological University (NTU) in Singapore.

The RaBitQ paper (Jianyang Gao, Cheng Long, SIGMOD 2024) makes a core claim: quantizing a D-dimensional vector into a D-bit string while guaranteeing a sharp theoretical error bound. The paper's motivating problem was that PQ (product quantization) methods work well empirically but have no theoretical error bound, so they can fail on real datasets. In other words, RaBitQ's selling point isn't the compression ratio — it's the guarantee.

Here's the important part. Elastic's BBQ post itself lists where it diverges from the original authors' proposal. Carrying over the second item verbatim — because it does not randomly rotate the codebook, the estimator does not have the property of being unbiased across multiple invocations of the algorithm.

What this means is that the very mechanism through which RaBitQ obtains its theoretical guarantee is that random rotation — and BBQ removed it for simpler integration with HNSW and faster indexing. Five differences are listed.

  1. Uses a single centroid — for simpler integration with HNSW and faster indexing.
  2. Does not randomly rotate the codebook → loses the estimator's unbiasedness.
  3. Rescoring doesn't depend on the estimated quantization error.
  4. Rescoring is deferred to after the initial estimated-vector computation, rather than happening during graph-index traversal.
  5. Fully implements and supports dot product. The original authors focused only on Euclidean distance; dot product was merely mentioned, not implemented or measured. Also supports max-inner-product, where vector magnitude matters.

So BBQ is not RaBitQ. It's a derivative that traded the paper's theoretical guarantee for engineering convenience. That fact by itself isn't a fault — that Elastic wrote this down in its own blog post is, if anything, worth crediting. But if you run into secondary material introducing BBQ as "RaBitQ-based, so it has a theoretical error bound," that's a wrong description.

And there's an amusing coda. 9.4 added a parameter called precondition, which the reference docs describe like this — setting it to true transforms indexed vectors with a random orthogonal projection, which can help improve accuracy when vector components don't follow a normal distribution. The default is false, and it can't be changed after the field is created. The random rotation dropped in 2024 has, in effect, come back in 2026 as an opt-in switch.

Why 32x Isn't Actually 32x

Let's also be honest about the compression ratio. The BBQ reference docs state that BBQ turns each float32 dimension into a bit, compressing each vector 32x — but with 14 extra bytes of correction data appended per vector.

Do the math: for a 1024-dimensional vector, 1024 bits is 128 bytes; add the 14-byte correction and you get 142 bytes. The original is 4096 bytes, so the actual ratio is about 29x. In fact, the table in the Jina v5 embeddings measurement post (Jeffrey Rengifo, 2026-07-10) uses exactly this figure — bbq at about 0.14 bytes per dimension, 142 bytes at 1024 dimensions, roughly 29x.

More important is that disk usage doesn't shrink. The same Jina post reports that disk usage was nearly identical between the two indices. Even with quantized vectors added on top, the original vectors stay in place for rescoring. The dense_vector docs likewise state that storing quantized and original vectors together increases disk usage — by about 25% for int8, about 12.5% for int4, and about 3.1% for bbq. 32x is a RAM story, not a storage story.

The headline number in the Jina post also needs a qualifier attached. It claims recall@10 held at 0.994 relative to the float32 baseline (vendor-measured, a 5-language multilingual news corpus, jina-embeddings-v5-text-small, 1024 dimensions), and the post itself explains why — Jina v5 is a model that went through quantization-aware training. In other words, this figure belongs to a model trained specifically for 1-bit quantization; it doesn't transfer to just any embedding model. And look at the absolute scale of the "memory went from 12.71MB to 0.44MB" figure — 12MB is a demo-sized number.

What DiskBBQ Actually Does

Now for the disk side. DiskBBQ is an evolution of the IVF index, and it works like this.

  • Splits vectors into small clusters via hierarchical K-means. Per the docs, the default cluster_size is 384 vectors per cluster, adjustable from 64 to 65536. Smaller raises accuracy and lowers performance.
  • When a query comes in, it finds the representative centroids first, and uses the hierarchy's multi-level structure to limit the search space by visiting centroids at no more than 2 levels.
  • Bulk-scores the vectors inside the selected clusters. Because the vectors are tightly compressed with BBQ, several blocks can be loaded into memory at once for fast scoring, and most of this computation happens off-heap.
  • Redundantly assigns vectors to multiple centroids. It uses a variant of Google's SOAR (Spilling with Orthogonality-Amplified Residuals), which especially helps when a vector sits on the boundary between two clusters. Because vectors are so heavily compressed, the disk overhead is minimal, while the number of centroids that need to be searched goes down.

The query-time knob is visit_percentage. It sets what fraction of vectors to visit per shard — lower it and memory and latency drop, raise it and recall goes up. Per the docs, the mapping-side default_visit_percentage default is roughly 1% per shard per 1 million vectors.

Quantization is asymmetric. By default, indexed vectors are quantized to 1 bit and query vectors to 4 bits. Spending more bits on the query side raises search quality substantially without increasing storage cost. As of 9.4, bbq_disk alone lets you change the indexed vectors' bits to 1 (default), 2, 4, or 7, with the oversampling factor following automatically — 3.0x at 1 bit, 1.5x at 2 bits, no oversampling at 4 or 7 bits. This can be changed at any time without reindexing.

The same introduction post also has a comparison for the case where memory is plentiful. Vendor-measured, with all 1 million vectors fitting in memory.

ApproachIndexing TimeLatencyRecall
HNSW BBQ1,054,319ms~3.4ms92%
DiskBBQ94,075ms~4.0ms91%

Here's how to read it. Even under HNSW's most favorable conditions (everything in RAM), the latency gap — 3.4 vs. 4.0 — isn't large, while indexing differs by more than 11x. Building the graph is that expensive. Elastic itself notes it hasn't "100% caught up in every scenario."

The Honest Limits — the 95% Wall, and the License

This is the most important section of this post.

First, there's a recall ceiling. Carrying the BBQ reference docs' wording over directly, DiskBBQ typically performs well up to about 95% recall. For exceptionally high recall (99%+), it has to visit many clusters, which can hurt performance negatively. In those cases, an HNSW-family option like bbq_hnsw is better, depending on your memory situation.

The introduction blog says the same thing even more bluntly. If you need very high recall, have plenty of off-heap memory or are willing to pay for it, and index updates are rare — HNSW with quantization is still likely your best option. Conversely, if 95%-or-lower recall is sufficient, you're cost-sensitive, and you still want fast search, DiskBBQ can be the answer.

This is a limit that comes from the algorithm's structure, not something tuning can remove. IVF prunes at the cluster level, and to squeeze out the last few percent of recall, you eventually have to give up on pruning and open more and more clusters. That's the point where IVF's advantage disappears.

Second, the default is tied to a license. The dense_vector docs explicitly state that bbq_disk requires an Enterprise subscription. And 9.4's default-value sentence carries the qualifier "if available on your current license."

Let's weigh the practical weight of this sentence. The same mapping JSON produces a different index type depending on license tier. Deploy the same definition, with index_options omitted, to two 9.4 clusters, and the Enterprise one gets disk-based IVF while the lower tier gets bbq_hnsw. Different memory profile, different recall characteristics, different indexing speed — while the mapping looks identical. This can be a hidden reason performance doesn't reproduce when your dev and production environments run different license tiers. It's safer not to rely on the default and to explicitly set index_options.type.

Third, small but real constraints. bbq_disk only supports element_type values of float or bfloat16. byte and bit don't apply, and those types default to unquantized hnsw on every version. precondition can't be changed after the field is created. And the BBQ docs warn that datasets under 384 dimensions may see reduced accuracy and a larger overhead ratio from the correction factor (though they add that they've seen production cases that worked well even at lower dimensions like e5-small).

For reference, the docs preview a feature called auto_calibrate, slated for 9.5 GA. At merge time it would sample up to 17,000 vectors per segment and automatically pick the cheapest configuration (5 quantization encodings, 6 rescore-depth multipliers, preconditioning on/off) that meets a 90% recall target at k=10. But since the latest release as of this writing is 9.4.3, this should be read as a docs-only preview for now.

The Benchmark War — and the Disk That Was Asleep

Now for the summer 2026 incident. What makes this story interesting is the methodology, not the conclusion.

It began with a claim in a post Elastic put out on 2026-06-24 (Sachin Frayne) — that on network-attached storage (NAS), DiskBBQ delivered up to 7x higher throughput than Qdrant. The claim was that the gap held consistently across a recall range of 0.93-0.97 and widened as recall rose, and it pointed to random disk reads of original vectors during rescoring as the cause. Keep that mechanism in mind — it's exactly the part that gets retracted later.

Qdrant pushed back with its own numbers and explanation — pointing to its io_uring-based asynchronous disk scorer, and arguing that Elasticsearch consumes more memory.

Then, on 2026-07-13, Elastic's Jim Ferenczi published a rebuttal to the rebuttal, and it isn't the usual vendor counterattack piece. It declares a rule for the post — every claim must come attached to a number and a mechanism, or it doesn't get published. And it holds its own original post to that same standard.

It stood up Qdrant's own configuration on its own cluster (21 million vectors, 3 × m6g.large = 2 vCPU / 8GB, AWS, a fixed set of 10,000 queries, recall@100), reproduced the results, and traced every number back to its cause. Here's the bottom line, stated up front.

1. io_uring wasn't doing anything. There's a funny finding first. Running Qdrant in Docker, io_uring was never being initialized at all — the default seccomp profile blocks the io_uring syscall, so it was silently falling back to synchronous reads (log: failed to initialize io_uring instance: Operation not permitted). Here are the results after loosening seccomp, turning it on properly, and re-measuring.

  • Async scorer effectively off (synchronous fallback): 31.6 QPS at ef=50
  • Async scorer on (io_uring confirmed working): 35.8 QPS at ef=50

That's a 13% difference, and in the post's own words, even that sits within run-to-run noise. In other words, turning on the feature credited with the whole result moved things about as much as simply running the benchmark one more time would.

2. The reason is that disk was never actually read. This is the highlight of the post. Watching the block devices of all three nodes with iostat during the throughput measurement window, read throughput stayed at 0 MB/s, 0 IOPS, while CPU sat at 60-70% and climbed to 100% as concurrency was raised. The bottleneck was the CPU doing quantized-distance computation and graph traversal.

Why wasn't disk read? Back-of-envelope math gives the answer. Rescoring reads the top 100 candidates per query; a 768-dimensional float32 vector is 3,072 bytes, but without page alignment the actual read is closer to an 8KB page. So the total working set is 100 × 10,000 queries × 8KB = 8GB across the whole cluster, or 2.7GB per node — comfortably fits inside an 8GB node's page cache. On top of that, the measurement harness runs a full recall pass over the 10,000 queries first, then measures throughput with that same set of 10,000 queries. As the post puts it, the measurement is warm by construction.

3. What actually moved the numbers was segment count. Elastic's first reproduction ended up with 128 segments and roughly half the expected throughput. Qdrant's published results JSON listed 67 segments. Merging segments to match caused all three metrics to move together.

Segmentsrecall@100QPSAvg Latency
128 (parallel bulk-load, Elastic's first attempt)0.974535.8112ms
66 (merged to match Qdrant)0.953153.375ms
67 (Qdrant's published figure)0.959667.259.5ms

How to read this table matters. Fewer segments mean recall drops — the total pool of candidates scanned per query shrinks. At the same time, throughput rises — since a query fans out across every segment's graph, doubling the segment count roughly doubles the work per query. In other words, Elastic's first attempt got a higher recall not because of good tuning, but because it had more segments.

But the way to get fewer segments is loading slowly, single-threaded. That means buying fast queries with slow ingestion. And this, the post points out, is the actual point where IVF and HNSW diverge. HNSW graph construction is expensive (roughly 1.6 hours at ef_construct=256 on a 2-vCPU node, pinned at 100% CPU) and its query cost is sensitive to segment count, while the IVF layout behind bbq_disk is cheap to build and much less sensitive to segment count. In the post's words, it's "not a benchmark trick, but an honestly stated IVF-vs-HNSW tradeoff."

4. And it counts its own cost too. A significant chunk of Elasticsearch's latency turned out to be the fetch phase assembling the response, not vector search. Because _id currently lives in the same stored-field column as _source, the document body, and the vector, fetching a single id runs the entire compressed block through the decompressor. Measuring with the id split out from that column brought Elasticsearch straight up into Qdrant's low-segment throughput band. In the post's own words — that gap is document retrieval, not vector search.

Visit %recall@100QPS (default)QPS (id separated)
10.8944489
20.9393973
30.9563556
50.9703140

At around 0.96 recall, Elasticsearch with the id separated sits at about 56 QPS — landing in the same band as Qdrant's 53-67 on the same hardware.

5. Finally, it retracts its own original post's 7x. More precisely, it retracts the mechanism, not the number. The 7x figure was real for that setup, but the explanation the original post attached to it — that random disk reads of original vectors during rescoring were Qdrant's bottleneck, worsened by NAS — doesn't survive the same arithmetic. The logic: Round 1's nodes had 26GB of RAM, more headroom than these 8GB nodes, so if disk sat idle at 8GB, it was almost certainly idle at 26GB too. So why did Round 1's Qdrant stay at 4.5 QPS at 0.97 recall? The post admits it doesn't know and offers only a hypothesis (with 2-bit quantization and oversampling pinned to 1, ef was the only knob left to raise recall, and a very large ef makes queries expensive in its own right).

What to Take From This Episode

The most important sentence sits in the conclusion. Put 21 million vectors on an 8GB node and, no matter the design, everything fits in RAM — which means this benchmark cannot distinguish between the two designs. It measured with the interesting variable held constant.

In other words, it was a benchmark that measured a disk-based index at a scale where disk isn't needed. Qdrant's configuration pins 4-bit vectors to always_ram and keeps the HNSW graph in RAM, which at 21 million vectors comes to about 3.6GB per node (the math: 4-bit quantization at 384 bytes/vector = 2.69GB, an m=16 HNSW graph at 136 bytes/vector = 0.95GB, totaling 520 bytes/vector = 3.64GB; measured resident memory of 3.4-3.9GB landed almost exactly on the estimate). 3.6GB leaves headroom on an 8GB node. The territory bbq_disk was designed for is the moment the quantized vectors and the search structure exceed the page cache — and neither post measured that. Elastic acknowledges this and states it's building a benchmark at a scale where the index doesn't fit in RAM.

And this post, too, is still the vendor's own measurement. Its methodology is unusually honest, but its conclusion is still "on the fast default path, Elasticsearch is faster at both indexing and search." It states that the Qdrant reproduction used Qdrant's own reproduction script, but tuning of a competitor's engine is always going to be shallower than tuning your own. It states it deliberately didn't run the 4 vCPU / 16GB tier, reasoning that it would "just lift both engines together and show the same trend, adding cost without insight" — a reasonable call, but also, as a fact, an unverified claim.

Even so, the lesson this post leaves behind holds regardless of vendor affiliation. Carrying over the post's own request — when a benchmark hands you a clean multiplier, chase every number back to its cause before you believe the story. Half the time it's a warm cache or a segment count.

So, When to Use It, and When Not To

When bbq_disk earns its keep

  • The combined size of quantized vectors and the search structure exceeds the node's RAM/page cache. This is the only condition that genuinely matters. If it doesn't exceed that, this whole debate isn't about you.
  • A recall of 95% or lower satisfies your product requirements.
  • Indexing and updates are frequent. HNSW's graph-build cost and segment sensitivity are the pain points.
  • You're cost-sensitive. RAM is the most expensive resource in vector search.
  • Embedding dimensionality is 384 or above.

When to stay with the HNSW family

  • You need 99%+ recall. The docs directly recommend a different approach.
  • Latency has to be extremely tight.
  • The index fits comfortably in RAM, and you already have that RAM or are willing to buy it.
  • The index is updated rarely. You only pay the graph-build cost once.
  • You don't have an Enterprise subscription. In that case there's no choice to make.
  • You need the byte or bit element type. bbq_disk doesn't support them.

One more thing. If filtered vector search is your primary workload, you need to look at a different axis. Elasticsearch 9.1 introduced the ACORN-1 algorithm, which integrates filters directly into HNSW graph traversal (based on an academic paper published in 2024), and Elastic typically reports a 5x speedup — but that's an HNSW-side story. How the IVF family behaves on workloads with low filter selectivity isn't something these posts cover, and you'll have to measure it yourself.

Closing

To sum up: in May 2026's 9.4, Elasticsearch changed the default index for float vectors to disk-based IVF. Behind that decision sits one judgment — the resource that's genuinely expensive in vector search isn't CPU, it's RAM, and HNSW doesn't degrade gracefully when RAM runs short, it falls off a cliff.

The cost is clear. There's a ceiling around 95% recall, and because it comes from IVF's structure, tuning can't remove it. And that default sits behind an Enterprise license, so the same mapping produces a different index depending on your license.

BBQ itself also needs an honest look. It grew out of insights from the RaBitQ paper, but started life without the random rotation that produces the theoretical error bound that was the paper's selling point (and partially brought it back as an opt-in in 9.4). The 32x compression is a RAM story, and disk usage actually increases by 3.1%.

Finally, the most practical thing left behind by the summer 2026 benchmark exchange is this: in a benchmark headlining a disk-based index, disk was asleep at 0 IOPS, asynchronous I/O moved things by 13%, and the real culprits were segment count and _id fetch cost. And, as Elastic itself admitted, nobody has written a benchmark yet that measures the point where the two designs actually diverge.

So the conclusion always lands back in the same place. Start by measuring whether your index fits in RAM. If it does, most of this post isn't your problem. The moment it stops fitting — that's when it's time to reach for a disk index, and even then, measure it against your own corpus and your own recall target, not someone else's benchmark.

References

현재 단락 (1/122)

Vector search stories usually start with the embedding model or the RAG pipeline. This post is about...

작성 글자: 0원문 글자: 24,810작성 단락: 0/122