bbq_hnsw vs bbq_disk (DiskBBQ) in Elasticsearch: which vector index to choose
We tested both BBQ index types on Elasticsearch 9.5.5. On self-managed clusters the default is still bbq_hnsw, bbq_disk needs an Enterprise license to index, and switching between them means a reindex.
Choose bbq_hnsw when the quantized vectors and the HNSW graph fit in RAM and you need recall above about 95%. Choose Elasticsearch DiskBBQ (index_options.type: bbq_disk) when the corpus is large or memory is tight and about 95% recall is enough, which is the line Elastic's own BBQ documentation draws. On self-managed Elasticsearch 9.5.5, the current release as of October 2026, a new dense_vector field with 384 or more dimensions still defaults to bbq_hnsw, and bbq_disk needs an Enterprise (or trial) license before it will accept a single document.
The docs disagree with themselves on both of those points, so we ran them on a fresh 9.5.5 container and read the source. Every request and response below comes from that run unless it is labelled otherwise.
The two contradictions, settled
The dense_vector reference has three default rules, each with a version badge:
| Docs badge | Stated default for float vectors |
|---|---|
| Stack 9.0 | int8_hnsw |
| Stack 9.1+ | bbq_hnsw at 384+ dims, int8_hnsw below 384 |
| Serverless, and Stack "since 9.4" | bbq_disk "when available under the current license" |
The BBQ page repeats the third rule and marks bbq_disk as GA since 9.2 in one place and 9.4 in another. Both pages say bbq_disk "requires an Enterprise subscription". The subscriptions page lists "DiskBBQ" as a feature, but its tier marks don't survive text extraction, so we tested the license behaviour directly.
What 9.5.5 actually does
We started docker.elastic.co/elasticsearch/elasticsearch:9.5.5 (Lucene 10.5.1, single node, security off) on its default Basic license and created a field with no index_options:
curl -XPUT localhost:9200/default-384 -H 'Content-Type: application/json' -d '
{"mappings":{"properties":{"emb":{"type":"dense_vector","dims":384}}}}'
curl localhost:9200/default-384/_mapping
{"default-384":{"mappings":{"properties":{"emb":{
"type":"dense_vector","dims":384,"index":true,"similarity":"cosine",
"index_options":{"type":"bbq_hnsw","m":16,"ef_construction":100,
"rescore_vector":{"oversample":3.0}}}}}}}
A 128-dimension field got int8_hnsw with m: 16 and ef_construction: 100. Then we ran POST _license/start_trial?acknowledge=true, restarted the node, and created new indices. The default stayed bbq_hnsw, for explicit mappings and for dynamically mapped float arrays alike.
The reason is in DiskBBQPlugin.java at the v9.5.5 tag. The mapper only picks bbq_disk as a default if this check passes:
return indexType != DenseVectorFieldMapper.VectorIndexType.BBQ_DISK
|| indexVersionCreated.onOrAfter(IndexVersions.DISK_BBQ_LICENSE_ENFORCEMENT) == false
|| (statelessNode && DISK_BBQ_FEATURE.check(getLicenseState()));
statelessNode is true only on Serverless. So the "default is bbq_disk" rule applies to Elastic Cloud Serverless. On self-managed 9.4 and 9.5 (the same check is in the v9.4.0 tag and on main today) the default is bbq_hnsw at 384+ dims, whatever your license. The "Stack since 9.4" badge on that line is wrong for self-managed clusters.
The license rule, observed
On Basic, the mapping with "type": "bbq_disk" was accepted. Indexing was not:
{"index":{"error":{"type":"security_exception",
"reason":"current license is non-compliant for [bbq_disk]",
"license.expired.feature":"bbq_disk"}}}
After start_trial, the same bulk request succeeded. Then we went back with POST _license/start_basic?acknowledge=true to see what a lapsed license does to an existing bbq_disk index. kNN searches still returned hits. Single-document writes returned HTTP 403 with the same security_exception, and _forcemerge failed with background merge hit exception caused by the license check. A lapsed license leaves you with a read-only vector index that can't merge.
The enforcement is tied to the index creation version. DISK_BBQ_LICENSE_ENFORCEMENT first appears in the v9.3.0 tag, so indices created with bbq_disk on 9.2.x pass the check without Enterprise. Anything created on 9.3.0 or later needs Enterprise or a trial. Our answer to "is it GA on self-managed": yes, it is GA, and it is Enterprise-only for new indices.
How bbq_hnsw and bbq_disk work
Both use Better Binary Quantization (BBQ): each float vector becomes a 1-bit code plus 14 bytes of corrective terms, roughly 32x smaller than float32. The difference is the structure used to find candidates.
bbq_hnsw builds an HNSW graph over the quantized codes. A query enters at the top layer and greedily walks toward closer neighbours, layer by layer. Every hop is a random memory access, which is why the graph has to sit in the page cache.
flowchart TD
Q([Query vector]) --> E[Entry point, top layer]
E --> L1[Greedy walk on sparse upper layers]
L1 --> L0["Base layer: explore num_candidates neighbours<br/>using 1-bit codes"]
L0 --> R[Rescore k x oversample candidates<br/>against raw float vectors]
R --> K([Top k])bbq_disk is an IVF-style index. Elasticsearch clusters the vectors with a k-means variant (cluster_size, default 384 vectors per cluster), keeps the centroids small, and stores each cluster's quantized vectors contiguously. A query scores the centroids, then reads only the closest clusters until it has visited visit_percentage of the shard's vectors. The reads are sequential blocks, which page in from disk far better than graph hops.
flowchart TD
Q([Query vector]) --> C[Score all centroids<br/>small, kept in RAM]
C --> P[Pick nearest clusters]
P --> S[Scan their 1-bit codes sequentially<br/>until visit_percentage is reached]
S --> R[Rescore top k x oversample<br/>against raw float vectors]
R --> K([Top k])Elastic's low-memory benchmark (October 2025, 9.2.0-SNAPSHOT, 1M DBpedia E5-small vectors, k=10, both configs at about 0.89 recall, force-merged) shows what that difference means when RAM runs out. Elastic reports these search latencies by container memory limit:
xychart-beta
title "Elastic-reported search latency, 1M vectors"
x-axis ["250m", "350m", "450m", "550m"]
y-axis "Latency ms" 0 --> 30
bar [7.46, 3.65, 2.38, 2.41]
bar [26.81, 7.7, 3.06, 3.14]Blue is DiskBBQ, grey is HNSW BBQ. At 150m Elastic reports 12.13 ms for DiskBBQ and 289.7 ms for HNSW BBQ (left off the chart so the other bars stay readable). At 101m only DiskBBQ produced a result. With enough memory the two converge. The benchmark doesn't publish recall per memory setting, so read it as a latency-under-pressure test.
Memory: the sizing math
Elastic's approximate kNN tuning page gives the formulas. For an illustrative 10M-vector, 1024-dimension index (one copy):
| Component | Formula | Bytes |
|---|---|---|
BBQ codes (bbq_hnsw) | N x (ceil(d / 64) x 8 + 14) = 10M x 142 | 1,420,000,000 |
| HNSW graph | N x 4 x m = 10M x 4 x 16 | 640,000,000 |
bbq_hnsw RAM working set | codes + graph | 2,060,000,000 |
| DiskBBQ centroids | ceil(N / 384) x (d + 16) = 26,042 x 1,040 | 27,083,680 |
| DiskBBQ clusters (disk) | N x 2 x (ceil(d / 8) + 16) = 10M x 288 | 2,880,000,000 |
| Raw float vectors (disk, both) | N x d x 4 | 40,960,000,000 |
So bbq_hnsw wants about 2.06 GB of page cache per copy before it behaves. DiskBBQ needs the 27 MB of centroids resident and pages the clusters in as queries touch them. The cluster figure uses the docs' x2 upper bound, so on disk DiskBBQ can be larger than the HNSW codes. You trade disk for RAM. The vector sizing calculator runs the same formulas for your own N, dims and replica count.
Tuning knobs for each
bbq_hnsw: num_candidates and oversample
Recall on bbq_hnsw is set by how much of the graph you explore (num_candidates) and how many quantized hits you rescore against the raw floats (rescore_vector.oversample, 3.0 by default, as the mapping above shows). Both can go in the query:
POST docs-hnsw/_search
{
"knn": {
"field": "emb",
"query_vector": [0.12, -0.03, "...384 floats"],
"k": 10,
"num_candidates": 100,
"rescore_vector": { "oversample": 5.0 }
},
"_source": false
}
oversample must be greater than 1.0 and less than 10.0, or exactly 0 to turn rescoring off. Build-time m (16) and ef_construction (100) raise graph quality at the cost of indexing time and RAM.
bbq_disk: visit_percentage
DiskBBQ has one main query-time knob. visit_percentage is the share of vectors each shard visits, and it must be between 0 and 100:
POST docs-disk/_search
{
"knn": {
"field": "emb",
"query_vector": [0.12, -0.03, "...384 floats"],
"k": 10,
"visit_percentage": 10.0
},
"_source": false
}
A value of 150 returns [visit_percentage] must be between 0 and 100. The mapping-level fallback is default_visit_percentage. Our mapping showed 0.0 when unset, and the docs describe the automatic default as "~1% per shard for every 1 million vectors". In 9.4+ you can also set bits (1, 2, 4 or 7; 1 by default) to trade disk size for less rescoring, and in 9.5 an opt-in auto_calibrate, which the docs say targets 90% recall at k=10 and is planned as the default for indices created in 9.6.
One trap: visit_percentage sent to a bbq_hnsw field is accepted without an error and does nothing. Our results with and without it were identical. If you switch types and forget to change the query, nothing will warn you.
Decision table
| Situation | Pick | Why |
|---|---|---|
| Codes + graph fit in page cache, recall target above 95% | bbq_hnsw | Docs: DiskBBQ "can degrade" at 99%+ recall |
| Basic or Platinum license, self-managed | bbq_hnsw | bbq_disk rejects writes without Enterprise |
| Tens of millions of vectors, RAM is the budget limit | bbq_disk | Only centroids must be resident |
| Small nodes, many tenants, cold indices | bbq_disk | Elastic's benchmark shows smooth latency under memory pressure |
| Fewer than 384 dims | test both | BBQ "may see less accuracy" below 384 dims, per the BBQ page |
| Serverless | default is bbq_disk | Stateless nodes with the license pass the check |
Our recommendation: stay on bbq_hnsw until the sizing table says your graph won't fit, then move to DiskBBQ and treat visit_percentage as the recall dial. At 10M vectors and 1024 dims the table above puts that working set near 2 GB per copy, which most production nodes can hold, and HNSW is the better-understood failure mode. Above that, paying for RAM to hold a graph you only partly use stops making sense. If your recall target is in the high 90s on a big corpus, budget the RAM and keep HNSW.
Reindex path between the two
index_options can't change in place between these types. Our attempt on 9.5.5:
{"type":"illegal_argument_exception","reason":"Mapper for [emb] conflicts with existing mapper:\n\tCannot update parameter [index_options] from [...BBQHnswIndexOptions...] to [{type=bbq_disk, cluster_size=384, ...}]"}
The reverse direction fails the same way. Use a new index, _reindex, and an alias swap:
sequenceDiagram
participant App
participant Alias as alias docs
participant Old as docs-hnsw
participant New as docs-disk
App->>Alias: search
Alias->>Old: routed
Note over New: create with bbq_disk mapping
Old->>New: _reindex
App->>Alias: _aliases remove old, add new
Alias->>New: routed# 1. New index with the target type
curl -XPUT localhost:9200/docs-disk -H 'Content-Type: application/json' -d '
{"mappings":{"properties":{"emb":{"type":"dense_vector","dims":384,
"similarity":"cosine",
"index_options":{"type":"bbq_disk","default_visit_percentage":5.0}}}}}'
# 2. Copy the data (vectors are re-quantized and re-clustered on write)
curl -XPOST 'localhost:9200/_reindex?wait_for_completion=true' \
-H 'Content-Type: application/json' -d '
{"source":{"index":"docs-hnsw"},"dest":{"index":"docs-disk"}}'
# 3. Atomic alias swap
curl -XPOST localhost:9200/_aliases -H 'Content-Type: application/json' -d '
{"actions":[{"remove":{"index":"docs-hnsw","alias":"docs"}},
{"add":{"index":"docs-disk","alias":"docs"}}]}'
On our 2,000-vector test index the reindex returned {"total":2000,"created":2000,"failures":[]}. Start the trial or Enterprise license before step 2, or every bulk item fails with the security_exception above. Before step 3, update the queries: swap num_candidates for visit_percentage and run your recall test against an exact script_score baseline before you move the alias.
If you're weighing this against OpenSearch's options, our Elasticsearch vs OpenSearch comparison covers licensing, and the sibling post on moving OpenSearch indices off nmslib to Faiss covers the same reindex-and-swap pattern there. For a different take on cutting vector memory, see Qdrant's TurboQuant quantization. Fusing these kNN results with BM25 is its own problem, covered in hybrid search scoring explained.
Where we can help
We size and migrate Elasticsearch vector indices for production, with a recall test before any index type change. Our Elasticsearch performance work for AlphaSense made their document search 90% faster across more than 100M documents. If you're deciding between bbq_hnsw and DiskBBQ, or a license change has left you with a read-only vector index, see our Elasticsearch consulting service.
Abhilash B is an advisor at Query Quotient and an information retrieval and AI expert who holds patents in the field. Abhilash brings that retrieval depth to the search, RAG and agent systems Query Quotient builds for clients.
