Back to blog
Performance

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.

Abhilash B9 min read
#elasticsearch#vector search#bbq#diskbbq#quantization

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 badgeStated default for float vectors
Stack 9.0int8_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):

ComponentFormulaBytes
BBQ codes (bbq_hnsw)N x (ceil(d / 64) x 8 + 14) = 10M x 1421,420,000,000
HNSW graphN x 4 x m = 10M x 4 x 16640,000,000
bbq_hnsw RAM working setcodes + graph2,060,000,000
DiskBBQ centroidsceil(N / 384) x (d + 16) = 26,042 x 1,04027,083,680
DiskBBQ clusters (disk)N x 2 x (ceil(d / 8) + 16) = 10M x 2882,880,000,000
Raw float vectors (disk, both)N x d x 440,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

SituationPickWhy
Codes + graph fit in page cache, recall target above 95%bbq_hnswDocs: DiskBBQ "can degrade" at 99%+ recall
Basic or Platinum license, self-managedbbq_hnswbbq_disk rejects writes without Enterprise
Tens of millions of vectors, RAM is the budget limitbbq_diskOnly centroids must be resident
Small nodes, many tenants, cold indicesbbq_diskElastic's benchmark shows smooth latency under memory pressure
Fewer than 384 dimstest bothBBQ "may see less accuracy" below 384 dims, per the BBQ page
Serverlessdefault is bbq_diskStateless 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.

Advisor, Query Quotient

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.

Keep reading

Building something similar?

Tell us what you are working on. In a free 30-minute call we will give you an honest view of the approach, the effort and how to measure success.