Skip to content

Split View: 벡터 인덱스의 기본값이 HNSW에서 디스크로 바뀐 이유 — Elasticsearch bbq_disk의 트레이드오프

|

벡터 인덱스의 기본값이 HNSW에서 디스크로 바뀐 이유 — Elasticsearch bbq_disk의 트레이드오프

들어가며 — 기본값이 바뀌었다는 사실부터

벡터 검색 이야기는 대개 임베딩 모델이나 RAG 파이프라인에서 시작합니다. 이 글은 그 아래층, 인덱스 자체에 관한 것입니다. 그리고 시작점은 논쟁이 아니라 그냥 사실 하나입니다 — Elasticsearch의 벡터 필드 기본 인덱스 타입이 바뀌었습니다.

dense_vector 공식 레퍼런스의 기본값 표는 이렇게 흘러갑니다.

  • 9.0 — float 벡터는 전부 int8_hnsw.
  • 9.1 이상 — 384차원 이상은 bbq_hnsw, 384차원 미만은 int8_hnsw.
  • 9.4 이상 및 Serverless — 문서의 문장 그대로 옮기면, float 또는 bfloat16 벡터를 색인할 때 "현재 라이선스에서 사용 가능한 경우 기본 인덱스 타입은 bbq_disk"입니다.

버전 날짜를 붙이면 이 이동이 얼마나 빠른지 보입니다. 9.1은 2025-07-23, 9.2는 2025-10-21, 9.4.0은 2026-05-05에 나왔습니다(endoflife.dateelastic/elasticsearch 릴리스 태그 기준. 이 글을 쓰는 2026년 7월 현재 최신 릴리스는 9.4.3입니다). 즉 1년이 채 안 되는 사이에 기본값이 스칼라 양자화 그래프 → 이진 양자화 그래프 → 그래프가 아예 아닌 디스크 인덱스로 두 번 옮겨갔습니다.

마지막 단계가 중요합니다. bbq_disk는 HNSW가 아닙니다. IVF(역파일 인덱스) 계열이고, 검색 구조가 RAM이 아니라 디스크에 삽니다. 20년 가까이 "근사 최근접 이웃 검색 = 그래프 인덱스"였던 기본 상식을, 적어도 기본값의 자리에서는 뒤집은 겁니다. 왜 그랬는지, 그리고 그 대가가 무엇인지가 이 글의 내용입니다.

HNSW의 진짜 비용은 계산이 아니라 RAM이다

HNSW의 매력은 분명합니다. 데이터에 대해 로그 스케일로 확장되는 빠르고 계산 효율적인 알고리즘입니다. DiskBBQ 소개 글(Benjamin Trent, John Wagster, Ignacio Vera Sequeiros, 2025-10-23)의 첫 문단도 "We absolutely love HNSW"로 시작합니다.

그런데 같은 글이 바로 대가를 지적합니다. HNSW가 제대로 작동하려면 모든 벡터가 RAM에 있어야 합니다. 양자화로 이 비용을 계속 낮춰 왔지만, 벡터가 메모리 밖으로 밀려나는 순간 성능이 급락하는 문제는 남습니다. 게다가 덜 알려진 두 번째 비용이 있습니다 — 색인을 하려면 기존 HNSW 그래프를 탐색해야 하므로, RAM 비용이 검색 때만이 아니라 색인 때도 그대로 발생합니다.

여기서 핵심 단어는 "급락"입니다. 성능이 완만하게 나빠지는 게 아니라 절벽에서 떨어집니다. Elastic이 이걸 숫자로 보여준 게 저메모리 벤치마크(John Wagster, 2025-10-23)입니다.

측정 조건 — 벤더 자체 측정입니다. 100만 벡터, 기본 병합 정책으로 약 12개 세그먼트, 두 방식 모두 BBQ 1비트 양자화 적용, 양쪽 설정을 recall 약 0.89로 맞춤. Elasticsearch에 내장된 KnnIndexTester 유틸리티를 Docker 컨테이너에 격리하고 swap을 끈 뒤 컨테이너 총 메모리를 제한하는 방식입니다. 표기는 컨테이너 RAM / JVM 힙입니다.

RAM / 힙DiskBBQ 지연HNSW BBQ 지연
101m / 10m15.83ms실행 불가
150m / 100m12.13ms289.7ms
250m / 150m7.46ms26.81ms
350m / 250m3.65ms7.7ms
450m / 350m2.38ms3.06ms
550m / 450m2.41ms3.14ms

550m 구간에서는 둘이 사실상 같습니다(2.41 대 3.14). 메모리가 넉넉하면 이 논쟁 자체가 성립하지 않는다는 뜻입니다. 그런데 아래로 내려갈수록 갈라집니다. 250m에서 3.6배, 150m에서는 24배 차이가 납니다. Elastic의 표현으로는 DiskBBQ는 완만하게 저하되는데(degrades gracefully), HNSW BBQ는 메모리가 극도로 제한되면 그냥 무너집니다(falls apart).

색인 쪽은 더 극적입니다. 같은 글의 색인 시간 표에서 HNSW BBQ는 1g / 850m 이하 구간에서 아예 완료되지 못했습니다(550m, 750m, 1g 세 구간 모두 측정치 없음). DiskBBQ는 같은 1g / 850m에서 82,397ms에 색인을 마쳤습니다. 이게 앞서 말한 "색인할 때도 그래프를 탐색해야 한다"는 성질의 실물입니다.

바닥은 어디인가도 재밌습니다. 저자는 힙을 10MB까지 내려서 측정했는데, 그 아래로는 log4j 초기화가 실패해서 더 못 내렸다고 합니다. 컨테이너 전체로는 101MB에서 동작했고, 그 아래로는 OS가 SIGKILL(exit 137)을 보냅니다. 참고로 같은 openjdk:24-jdk-slim-bookworm 컨테이너에서 HelloWorld 한 줄을 돌리는 데만 50MB가 필요합니다. 그러니까 DiskBBQ는 100만 벡터를 실질적으로 60MB 남짓한 여유 공간에서 15.83ms에 서빙한 셈입니다.

이 벤치마크가 말하지 않는 것. 정직하게 짚자면, 이 글은 어떤 데이터셋을 썼는지, 몇 차원인지를 본문에 명시하지 않습니다. 100만 벡터 한 규모, 한 머신에서의 측정이고, recall도 0.89 한 지점에 고정돼 있습니다. 방향성(HNSW는 메모리 절벽이 있고 IVF는 없다)은 알고리즘의 구조에서 나오는 것이라 신뢰할 만하지만, 이 표의 절대 수치를 당신의 코퍼스에 옮겨 적으면 안 됩니다.

BBQ는 RaBitQ에서 무엇을 가져오고 무엇을 버렸나

bbq_disk의 BBQ부터 정리해야 합니다. 여기가 이 글에서 가장 덜 알려진 부분입니다.

BBQ(Better Binary Quantization)는 Elasticsearch 8.16과 Lucene에 도입됐습니다(Benjamin Trent, 2024-11-11). 그리고 그 글은 출처를 명확히 밝힙니다 — 싱가포르 난양공대(NTU) 연구진이 제안한 RaBitQ라는 기법에서 얻은 통찰로부터 개발했다고요.

RaBitQ 논문(Jianyang Gao, Cheng Long, SIGMOD 2024)의 핵심 주장은 이렇습니다. D차원 벡터를 D비트 문자열로 양자화하면서, 선명한 이론적 오차 한계를 보장한다는 것. 논문의 문제의식은 PQ(곱 양자화) 계열이 경험적으로는 잘 되지만 이론적 오차 한계가 없어서 실제 데이터셋에서 실패할 수 있다는 점이었습니다. 즉 RaBitQ의 세일즈 포인트는 압축률이 아니라 보장입니다.

여기서 중요한 대목. Elastic의 BBQ 글은 원 저자들의 제안과 다른 점을 스스로 나열합니다. 그중 두 번째 항목을 그대로 옮기면 — 코드북을 무작위 회전(random rotation)하지 않기 때문에, 추정자가 알고리즘의 여러 번 호출에 걸쳐 불편(unbiased)이라는 성질을 갖지 못한다고 적혀 있습니다.

이게 무슨 뜻이냐면, RaBitQ가 이론적 보장을 얻어내는 기제가 바로 그 무작위 회전인데, BBQ는 HNSW와의 단순한 통합과 빠른 색인을 위해 그걸 뺐다는 겁니다. 나열된 차이는 다섯 가지입니다.

  1. 중심점(centroid)을 하나만 사용 — HNSW와 단순하게 통합하고 색인을 빠르게 하려고.
  2. 코드북을 무작위 회전하지 않음 → 추정자의 불편성을 잃음.
  3. 재점수(rescoring)가 추정된 양자화 오차에 의존하지 않음.
  4. 재점수를 그래프 색인 탐색 중에 하지 않고, 초기 추정 벡터 계산 이후로 미룸.
  5. 내적(dot product)을 완전히 구현·지원. 원 저자들은 유클리드 거리에만 집중했고, 내적은 언급만 됐을 뿐 구현·측정되지 않았음. 벡터 크기가 중요한 max-inner-product도 지원.

그러니까 BBQ는 RaBitQ가 아닙니다. 논문의 이론적 보장을 엔지니어링 편의와 맞바꾼 파생물입니다. 이 사실 자체는 잘못이 아닙니다 — Elastic이 이걸 블로그에 스스로 적어 뒀다는 점은 오히려 후한 점수를 줄 만합니다. 다만 "RaBitQ 기반이라 이론적 오차 한계가 있다"는 식으로 BBQ를 소개하는 2차 자료를 만나면, 그건 틀린 설명입니다.

그리고 재밌는 후일담이 있습니다. 9.4에서 precondition이라는 파라미터가 추가됐는데, 레퍼런스 문서의 설명은 이렇습니다 — true로 두면 색인된 벡터를 무작위 직교 사영(random orthogonal projection)으로 변환하며, 벡터 성분이 정규분포를 따르지 않을 때 정확도 개선에 도움이 될 수 있다. 기본값은 false이고, 필드 생성 후에는 바꿀 수 없습니다. 2024년에 뺐던 무작위 회전이 2026년에 옵트인 스위치로 돌아온 셈입니다.

32배가 32배가 아닌 이유

압축률도 한 번 정직하게 짚고 갑시다. BBQ 레퍼런스 문서는 BBQ가 float32 차원을 비트로 바꿔 각 벡터를 32배 압축하되, 벡터당 14바이트의 보정 데이터가 추가로 붙는다고 적습니다.

산수를 해 보면 1024차원 벡터에서 1024비트는 128바이트, 여기에 보정 14바이트를 더하면 142바이트입니다. 원본은 4096바이트니까 실제 비율은 약 29배입니다. 실제로 Jina v5 임베딩 측정 글(Jeffrey Rengifo, 2026-07-10)의 표가 정확히 이 숫자를 씁니다 — bbq는 차원당 약 0.14바이트, 1024차원에서 142바이트, 약 29배.

더 중요한 건 디스크는 줄지 않는다는 점입니다. 같은 Jina 글은 두 인덱스의 디스크 사용량이 거의 동일했다고 보고합니다. 양자화 벡터를 얹어도 원본 벡터는 재점수용으로 그대로 남기 때문입니다. dense_vector 문서도 양자화·원본을 함께 저장하는 오버헤드로 디스크가 int8은 약 25%, int4는 약 12.5%, bbq는 약 3.1% 늘어난다고 적습니다. 32배는 RAM 이야기지 스토리지 이야기가 아닙니다.

Jina 글의 헤드라인 숫자도 조건을 붙여 읽어야 합니다. recall@10이 float32 기준선 대비 0.994로 유지됐다는데(벤더 자체 측정, 5개 언어 다국어 뉴스 코퍼스, jina-embeddings-v5-text-small, 1024차원), 글 자신이 그 이유를 밝힙니다 — Jina v5가 양자화 인식 학습(quantization-aware training)을 거친 모델이기 때문입니다. 즉 1비트 양자화에 맞춰 훈련된 모델의 숫자이지, 아무 임베딩 모델에나 이식되는 결과가 아닙니다. 그리고 메모리가 12.71MB에서 0.44MB로 줄었다는 수치의 절대 규모를 보십시오. 12MB는 데모 크기입니다.

DiskBBQ가 실제로 하는 일

이제 디스크 쪽입니다. DiskBBQ는 IVF 인덱스의 진화형이고, 동작은 이렇습니다.

  • 계층적 K-means로 벡터를 작은 클러스터로 분할합니다. 문서 기준 cluster_size 기본값은 클러스터당 384개 벡터이고, 64에서 65536 사이로 조정할 수 있습니다. 작을수록 정확도가 오르고 성능이 떨어집니다.
  • 질의가 오면 대표 중심점부터 찾고, 계층의 다층 구조를 활용해 최대 2개 층의 중심점만 탐색해 탐색 공간을 제한합니다.
  • 선택된 클러스터 안의 벡터들을 벌크 스코어링합니다. 벡터가 BBQ로 잘게 압축돼 있으니 한 번에 여러 블록을 메모리로 올려 빠르게 점수를 매길 수 있고, 이 연산 대부분이 오프힙에서 일어납니다.
  • 벡터를 여러 중심점에 중복 배정합니다. Google의 SOAR(Spilling with Orthogonality-Amplified Residuals)의 변형을 써서, 벡터가 두 클러스터 경계에 놓일 때 특히 도움이 됩니다. 벡터가 워낙 강하게 압축돼 있어서 디스크 오버헤드는 미미하고, 대신 탐색해야 할 중심점 수가 줄어듭니다.

질의 시점 손잡이는 visit_percentage입니다. 샤드당 방문할 벡터 비율을 정하고, 낮추면 메모리와 지연이 줄고 높이면 recall이 오릅니다. 매핑 쪽 default_visit_percentage 기본값은 문서 기준 100만 벡터당 샤드당 약 1% 수준입니다.

양자화는 비대칭입니다. 기본적으로 색인 벡터는 1비트, 질의 벡터는 4비트로 양자화합니다. 질의 쪽에 비트를 더 쓰는 건 저장 비용이 안 늘면서 검색 품질이 크게 오르기 때문입니다. 9.4부터 bbq_disk에 한해 색인 벡터의 bits를 1(기본), 2, 4, 7로 바꿀 수 있고, 오버샘플링 계수가 자동으로 따라옵니다 — 1비트면 3.0배, 2비트면 1.5배, 4비트와 7비트면 오버샘플링 없음. 이건 재색인 없이 언제든 바꿀 수 있습니다.

메모리가 충분한 경우의 비교도 같은 소개 글에 있습니다. 벤더 자체 측정, 100만 벡터가 전부 메모리에 들어가는 조건입니다.

방식색인 시간지연recall
HNSW BBQ1,054,319ms약 3.4ms92%
DiskBBQ94,075ms약 4.0ms91%

읽는 법은 이렇습니다. HNSW가 가장 유리한 조건(전부 RAM에 있음)에서도 지연 차이는 3.4 대 4.0으로 크지 않은데, 색인은 11배 넘게 차이 납니다. 그래프를 짓는 비용이 그만큼 비쌉니다. Elastic도 "모든 상황에서 100% 따라잡은 건 아니"라고 적어 뒀습니다.

정직한 한계 — 95%의 벽, 그리고 라이선스

여기가 이 글에서 가장 중요한 절입니다.

첫째, recall 천장이 있습니다. BBQ 레퍼런스 문서의 문장을 그대로 옮기면, DiskBBQ는 보통 약 95%까지의 recall 수준에서 잘 동작합니다. 예외적으로 높은 recall(99% 이상)이 필요한 경우에는 많은 클러스터를 방문해야 하고, 이는 성능에 부정적 영향을 줄 수 있습니다. 그런 경우엔 메모리 상황에 따라 bbq_hnsw 같은 HNSW 계열이 더 낫습니다.

소개 블로그도 같은 말을 더 직설적으로 합니다. 아주 높은 recall이 필요하고, 오프힙 메모리가 넉넉하거나 그 비용을 낼 의향이 있고, 색인 갱신이 드물다면 — 양자화를 곁들인 HNSW가 여전히 최선일 가능성이 높다. 반대로 95% 이하의 recall로 충분하고, 비용에 민감하고, 그래도 빠른 검색을 원한다면 DiskBBQ가 답일 수 있다.

이건 알고리즘 구조에서 나오는 한계라 튜닝으로 없앨 수 있는 종류가 아닙니다. IVF는 클러스터 단위로 가지치기를 하고, 마지막 몇 %의 recall을 얻으려면 결국 가지치기를 포기하고 점점 더 많은 클러스터를 열어야 합니다. 그 지점에서 IVF의 장점이 사라집니다.

둘째, 기본값이 라이선스에 묶여 있습니다. dense_vector 문서는 bbq_diskEnterprise 구독을 필요로 한다고 명시합니다. 그리고 9.4의 기본값 문장에는 "현재 라이선스에서 사용 가능한 경우"라는 조건이 붙어 있습니다.

실무적으로 이 문장의 무게를 짚고 갑시다. 같은 매핑 JSON이 라이선스 등급에 따라 다른 인덱스 타입을 만듭니다. index_options를 생략한 채 9.4 클러스터 두 대에 같은 정의를 올리면, Enterprise 쪽은 디스크 IVF를, 그 아래 등급은 bbq_hnsw를 만듭니다. 메모리 프로파일도, recall 특성도, 색인 속도도 다른 인덱스가 나오는데 매핑은 똑같이 생겼습니다. 개발 환경과 운영 환경의 라이선스가 다르면 성능 재현이 안 되는 이유가 여기 숨을 수 있습니다. 기본값에 의존하지 말고 index_options.type을 명시하는 게 안전합니다.

셋째, 자잘하지만 실재하는 제약들. bbq_diskelement_typefloat 또는 bfloat16인 경우만 지원합니다. bytebit는 해당 없음이고, 이 타입들은 모든 버전에서 양자화 없는 hnsw가 기본입니다. precondition은 필드 생성 후 변경 불가입니다. 그리고 384차원 미만 데이터셋은 정확도가 덜 나오고 보정 계수 오버헤드 비율이 커질 수 있다는 게 BBQ 문서의 경고입니다(다만 e5-small 같은 낮은 차원에서도 잘 동작한 프로덕션 사례를 봤다고 덧붙입니다).

참고로 문서상 auto_calibrate라는 기능이 9.5 GA로 예고돼 있습니다. 병합 시점에 세그먼트마다 최대 17,000개 벡터를 샘플링해 k=10에서 90% recall 목표를 만족하는 가장 싼 설정(양자화 인코딩 5종, 재순위 깊이 배수 6종, 프리컨디셔닝 적용/미적용)을 자동으로 고르는 방식입니다. 다만 이 글을 쓰는 시점의 최신 릴리스는 9.4.3이므로, 이건 아직 문서에만 있는 예고편으로 읽어야 합니다.

벤치마크 전쟁 — 그리고 잠들어 있던 디스크

이제 2026년 여름의 사건입니다. 이 이야기가 재밌는 건 결론보다 방법론 때문입니다.

발단은 Elastic이 2026-06-24에 낸 글(Sachin Frayne)의 주장이었습니다 — 네트워크 연결 스토리지(NAS)에서 DiskBBQ가 Qdrant보다 최대 7배 높은 처리량을 낸다. recall 0.93~0.97 구간에서 격차가 일관되고 recall이 오를수록 벌어진다는 주장이었고, 원인으로 재점수 중 원본 벡터의 랜덤 디스크 읽기를 지목했습니다. 이 메커니즘을 기억해 두십시오. 나중에 철회되는 게 바로 이 부분입니다.

Qdrant는 자기 쪽 숫자와 설명으로 반박했습니다. io_uring 기반 비동기 디스크 스코어러가 있고, Elasticsearch는 메모리를 더 먹는다는 지적이었습니다.

그리고 2026-07-13, Elastic의 Jim Ferenczi가 반박의 반박을 냈는데, 이게 보통의 벤더 반격문이 아닙니다. 글의 규칙을 이렇게 선언합니다 — 모든 주장에는 숫자와 메커니즘이 붙어야 하고, 아니면 싣지 않는다. 그리고 그 잣대를 자기 원 글에도 들이댑니다.

Qdrant의 설정을 그대로 자기 클러스터에 세우고(2100만 벡터, 3 × m6g.large = 2 vCPU / 8GB, AWS, 고정 1만 질의, recall@100), 결과를 재현한 뒤 모든 숫자를 원인까지 추적했습니다. 결론부터 말하면 이렇습니다.

1. io_uring은 아무 일도 하지 않았습니다. 먼저 웃긴 발견이 있습니다. Docker에서 Qdrant를 돌리니 io_uring이 아예 초기화되지 않고 있었습니다 — 기본 seccomp 프로파일이 io_uring 시스템 콜을 막아서, 조용히 동기 읽기로 폴백하고 있었던 겁니다(로그: failed to initialize io_uring instance: Operation not permitted). seccomp를 풀고 제대로 켠 뒤 다시 측정한 결과가 이렇습니다.

  • 비동기 스코어러 사실상 꺼짐(동기 폴백): ef=50에서 31.6 QPS
  • 비동기 스코어러 켜짐(io_uring 동작 확인): ef=50에서 35.8 QPS

13% 차이이고, 글의 표현으로는 그마저도 실행 간 노이즈 범위 안입니다. 결과 전체의 공으로 지목된 기능을 켜 봤더니, 벤치마크를 한 번 더 돌린 것만큼 움직였다는 얘기입니다.

2. 이유는 디스크를 읽은 적이 없기 때문입니다. 이게 이 글의 백미입니다. 처리량 측정 구간 동안 세 노드의 블록 디바이스를 iostat으로 지켜본 결과, 읽기 처리량은 0 MB/s, 0 IOPS로 유지됐고 CPU는 60~70%에서 동시성을 올리자 100%까지 갔습니다. 병목은 양자화 거리 계산과 그래프 순회를 하는 CPU였습니다.

왜 디스크를 안 읽었나. 봉투 뒷면 계산이 답을 줍니다. 재점수는 질의당 상위 100개 후보를 읽고, 768차원 float32 벡터는 3,072바이트인데 페이지 정렬이 안 돼 있어 실제로는 8KB 페이지 읽기에 가깝습니다. 그러면 총 작업 집합은 100개 × 1만 질의 × 8KB = 클러스터 전체 8GB, 노드당 2.7GB입니다. 8GB 노드의 페이지 캐시에 여유롭게 들어갑니다. 게다가 측정 하네스는 1만 질의로 recall 패스를 한 번 다 돌린 다음 같은 1만 질의로 처리량을 잽니다. 글의 표현대로 측정은 구조적으로 이미 따뜻합니다(warm by construction).

3. 숫자를 실제로 움직인 건 세그먼트 개수였습니다. Elastic의 첫 재현은 128개 세그먼트가 나왔고 기대의 절반쯤 되는 처리량을 보였습니다. Qdrant가 공개한 결과 JSON에는 67개 세그먼트가 적혀 있었습니다. 세그먼트를 병합해 맞추자 세 지표가 함께 움직였습니다.

세그먼트recall@100QPS평균 지연
128 (병렬 적재, Elastic 첫 시도)0.974535.8112ms
66 (Qdrant에 맞춰 병합)0.953153.375ms
67 (Qdrant 공개 수치)0.959667.259.5ms

이 표를 읽는 법이 중요합니다. 세그먼트가 줄면 recall이 떨어집니다. 질의당 훑는 후보 총량이 줄기 때문입니다. 동시에 처리량은 오릅니다. 질의가 모든 세그먼트의 그래프로 팬아웃하니 세그먼트가 두 배면 질의당 작업도 거의 두 배가 되니까요. 즉 Elastic의 첫 시도가 recall이 높게 나온 건 튜닝을 잘해서가 아니라 세그먼트가 많아서였습니다.

그런데 세그먼트를 적게 만드는 방법은 단일 스레드로 느리게 적재하는 것입니다. 빠른 질의를 느린 적재로 사는 셈입니다. 그리고 이게 IVF와 HNSW가 진짜로 갈리는 지점이라고 글은 짚습니다. HNSW 그래프 구축은 비싸고(2 vCPU 노드에서 ef_construct=256으로 약 1.6시간, CPU 100% 고정) 질의 비용이 세그먼트 수에 민감한 반면, bbq_disk 뒤의 IVF 레이아웃은 구축이 싸고 세그먼트 수에 훨씬 덜 민감합니다. 글의 표현으로는 "벤치마크 트릭이 아니라, 정직하게 진술한 IVF 대 HNSW 트레이드오프"입니다.

4. 그리고 자기 비용도 셉니다. Elastic 쪽 지연의 상당 부분이 벡터 검색이 아니라 응답을 조립하는 페치 단계였습니다. 현재 _id_source·본문·벡터와 같은 저장 필드 컬럼에 살기 때문에, id 하나를 가져오려고 그 압축 블록 전체를 디컴프레서에 통과시킵니다. id를 그 컬럼에서 분리해 측정하니 Qdrant의 저세그먼트 처리량 대역으로 바로 올라섰습니다. 글의 문장 그대로 — 그 격차는 문서 검색이지 벡터 검색이 아닙니다.

방문 %recall@100QPS (기본)QPS (id 분리)
10.8944489
20.9393973
30.9563556
50.9703140

recall 약 0.96에서 id를 분리한 Elasticsearch는 약 56 QPS로, 같은 하드웨어에서 Qdrant의 53~67과 같은 대역에 앉습니다.

5. 마지막으로 자기 원 글의 7배를 철회합니다. 정확히는 숫자가 아니라 메커니즘을 철회합니다. 7배라는 수치는 그 설정에서 진짜지만, 원 글이 그 숫자에 붙인 설명(재점수 중 원본 벡터의 랜덤 디스크 읽기가 Qdrant의 병목이었고 NAS가 이를 악화시켰다)은 같은 산수를 통과하지 못한다는 겁니다. 1라운드 노드는 RAM이 26GB로 이번 8GB 노드보다 여유가 많았으니, 8GB에서 디스크가 놀았다면 26GB에서도 거의 확실히 놀았을 거라는 논리입니다. 그럼 1라운드 Qdrant가 왜 0.97 recall에서 4.5 QPS에 머물렀는지는? 글은 모른다고 인정하고 가설만 답니다(2비트 양자화에 오버샘플링을 1로 고정했으니 recall을 올릴 손잡이가 ef밖에 없었고, 아주 큰 ef는 그 자체로 질의를 비싸게 만든다).

이 사건에서 가져갈 것

가장 중요한 문장은 결론부에 있습니다. 2100만 벡터를 8GB 노드에 올리면 어느 설계든 전부 RAM에 들어가고, 그래서 이 벤치마크는 두 설계를 구분할 수 없습니다. 흥미로운 변수를 상수로 고정해 놓고 측정한 겁니다.

즉 디스크 기반 인덱스를 디스크가 필요 없는 규모에서 측정한 벤치마크였습니다. Qdrant의 설정은 4비트 벡터를 always_ram으로 고정하고 HNSW 그래프를 RAM에 두는데, 2100만 벡터에서 노드당 약 3.6GB입니다(계산: 4비트 양자화 384바이트/벡터 = 2.69GB, m=16 HNSW 그래프 136바이트/벡터 = 0.95GB, 합 520바이트/벡터 = 3.64GB. 실측 상주 메모리는 3.4~3.9GB로 추정치에 거의 정확히 앉았습니다). 8GB 노드에 3.6GB면 남습니다. bbq_disk가 설계된 영역은 양자화 벡터와 검색 구조가 페이지 캐시를 넘어서는 순간인데, 두 글 중 누구도 거기를 측정하지 않았습니다. Elastic은 이걸 인정하고, 인덱스가 RAM에 안 들어가는 규모의 벤치마크를 만들고 있다고 적습니다.

그리고 이 글도 여전히 벤더 자체 측정입니다. 방법론은 이례적으로 정직하지만, 결론은 여전히 "빠른 기본 경로에서는 Elasticsearch가 색인도 검색도 더 빠르다"입니다. Qdrant 쪽 재현은 Qdrant의 재현 스크립트를 썼다고 밝히고 있지만, 상대 엔진의 튜닝은 언제나 자기 엔진의 튜닝보다 얕기 마련입니다. 4 vCPU / 16GB 등급은 "두 엔진을 함께 들어올리고 동일한 추세를 보여줄 뿐이라 통찰 없이 비용만 는다"는 이유로 의도적으로 돌리지 않았다고 밝히는데, 합리적인 판단이지만 검증되지 않은 주장인 것도 사실입니다.

그럼에도 이 글이 남긴 교훈은 벤더 소속과 무관하게 유효합니다. 글 자신의 요청을 옮기면 — 벤치마크가 깔끔한 배수를 건네줄 때, 그 이야기를 믿기 전에 모든 숫자를 원인까지 쫓아가라. 절반은 따뜻한 캐시이거나 세그먼트 개수다.

그래서 언제 쓰고, 언제 쓰지 말아야 하나

bbq_disk가 값을 하는 경우

  • 양자화 벡터와 검색 구조를 합친 크기가 노드의 RAM·페이지 캐시를 넘어서는 규모다. 이게 유일하게 진짜 중요한 조건입니다. 안 넘으면 이 논쟁은 당신 것이 아닙니다.
  • recall 95% 이하로 제품 요구사항이 충족된다.
  • 색인·갱신이 잦다. HNSW의 그래프 구축 비용과 세그먼트 민감도가 아픈 지점입니다.
  • 비용에 민감하다. RAM은 벡터 검색에서 가장 비싼 자원입니다.
  • 임베딩 차원이 384 이상이다.

HNSW 계열에 남아야 하는 경우

  • recall 99% 이상이 필요하다. 문서가 직접 다른 방식을 권합니다.
  • 지연이 극단적으로 빡빡하다.
  • 인덱스가 RAM에 편하게 들어가고, 그 RAM을 이미 갖고 있거나 살 의향이 있다.
  • 색인이 거의 갱신되지 않는다. 그래프 구축 비용을 한 번만 내면 됩니다.
  • Enterprise 구독이 없다. 이 경우 선택지 자체가 없습니다.
  • bytebit element type이 필요하다. bbq_disk가 지원하지 않습니다.

한 가지 더. 필터링된 벡터 검색이 주 워크로드라면 다른 축을 봐야 합니다. Elasticsearch 9.1은 필터를 HNSW 그래프 순회에 직접 통합하는 ACORN-1 알고리즘을 도입했고(2024년 발표된 학술 논문 기반), Elastic은 전형적으로 5배 속도 향상을 보고합니다 — 이건 HNSW 쪽 이야기입니다. 필터 선택도가 낮은 워크로드에서 IVF 계열이 어떻게 거동하는지는 이 글들이 다루지 않는 주제이고, 직접 재야 합니다.

마치며

정리하면 이렇습니다. Elasticsearch는 2026년 5월 9.4에서 float 벡터의 기본 인덱스를 디스크 기반 IVF로 바꿨습니다. 이 결정 뒤에는 하나의 판단이 있습니다 — 벡터 검색에서 진짜 비싼 자원은 CPU가 아니라 RAM이고, HNSW는 RAM이 모자랄 때 완만하게 나빠지는 게 아니라 절벽에서 떨어진다.

대가는 명확합니다. recall 95% 언저리의 천장이 있고, 그건 IVF의 구조에서 나오므로 튜닝으로 없앨 수 없습니다. 그리고 그 기본값은 Enterprise 라이선스 뒤에 있어서, 같은 매핑이 라이선스에 따라 다른 인덱스를 만듭니다.

BBQ 자체도 정직하게 볼 필요가 있습니다. RaBitQ 논문의 통찰에서 나왔지만, 논문의 세일즈 포인트였던 이론적 오차 한계를 얻어내는 무작위 회전을 빼고 시작했습니다(그리고 9.4에서 옵트인으로 일부 되돌렸습니다). 32배 압축은 RAM 이야기이고 디스크는 오히려 3.1% 늘어납니다.

마지막으로, 2026년 여름의 벤치마크 공방이 남긴 게 가장 실용적입니다. 디스크 기반 인덱스를 자랑하는 벤치마크에서 디스크는 0 IOPS로 잠들어 있었고, 비동기 I/O는 13% 움직였으며, 진짜 범인은 세그먼트 개수와 _id 페치였습니다. 그리고 Elastic 스스로 인정했듯, 두 설계가 갈리는 지점을 측정한 벤치마크는 아직 아무도 쓰지 않았습니다.

그러니 결론은 늘 같은 자리로 돌아옵니다. 당신의 인덱스가 RAM에 들어가는지부터 재십시오. 들어간다면 이 글의 대부분은 당신 문제가 아닙니다. 안 들어가기 시작할 때 — 그때가 디스크 인덱스를 꺼낼 시점이고, 그때도 남의 벤치마크가 아니라 당신의 코퍼스와 당신의 recall 목표로 재야 합니다.

참고 자료

Why the Default Vector Index Moved from HNSW to Disk — the Tradeoffs of Elasticsearch bbq_disk

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