Ken
벡터 검색의 세 축 — 정확도 · 속도 · 공간 — 은 보통 적재 시점에 결정해야 한다. 한 번 양자화하면 되돌리기 어렵고, HNSW 같은 인덱스는 RAM에 통째로 올라가야 한다. ClickHouse 26.2에서 GA된 QBit 데이터 타입은 이 결정을 쿼리 시점으로 미룬다. 원본 정밀도는 그대로 두고, 검색할 때마다 "몇 비트만 읽을지"를 골라 I/O와 계산량을 동적으로 조절한다.이 글은 QBit의 내부 구조(bit-sliced storage)를 해부하고, ClickHouse Cloud 26.2.1에서 직접 300K 임베딩으로 측정한 저장 크기 · 지연시간 · I/O · recall 데이터를 근거로 사용 시점을 제언한다.
1. 벡터 검색의 현재 지형 — 왜 QBit가 나왔나
LLM RAG, 추천, 음악·이미지 유사도 검색까지 모두 벡터 검색이다. ClickHouse는 수년 전부터 brute-force(전수 스캔) 벡터 검색을 지원해 왔고, 최근에는 HNSW ANN 인덱스도 들어왔다. 각 방식의 트레이드오프를 정리하면:
핵심은 두 가지다. 첫째, HNSW는 빠르지만 인덱스가 메모리에 다 들어가야 한다. ClickHouse가 쓰는 usearch 구현은 in-memory + split 불가다. 데이터가 늘면 RAM도 같이 늘려야 한다. 둘째, 전통적 quantization은 적재 시점에 정밀도가 박힌다. 너무 공격적이면 정확도가 망가지고, 보수적이면 속도 이득이 줄어든다. 양쪽 다 사전 결정을 강요한다.
QBit는 발상을 뒤집는다. 원본 float을 그대로 저장하되, 저장 방식을 비트 평면(bit plane) 단위로 transpose해서, 쿼리할 때 "상위 몇 비트만 읽을지"를 골라 정밀도-속도를 조절한다. 사전 결정이 없다.
2. QBit 내부 구조 해부
2.1 비트 평면(bit plane) 저장
Float32 값 8개로 된 벡터 [v0, v1, ..., v7]을 생각해 보자. 일반적인 Array(Float32)라면 각 값의 32비트를 연속으로 저장한다 — v0의 32비트, 그다음 v1의 32비트, … 이런 식.
QBit는 다르다. 모든 값의 1번 비트(부호)를 먼저 모으고, 2번 비트를 모으고, … 이렇게 비트 위치별로 줄을 다시 세운다.
각 비트 평면은 FixedString(N) 컬럼 한 개로 저장되고, 32개(또는 16, 64개)의 FixedString이 하나의 Tuple로 묶여 QBit의 실제 내부 타입이 된다. 정리하면:
선언 | 내부 표현 | 비트 평면 수 | 평면당 FixedString 크기 |
QBit(BFloat16, dim) | Tuple of 16 × FixedString(⌈dim/8⌉) | 16 | ⌈dim/8⌉ bytes |
QBit(Float32, dim) | Tuple of 32 × FixedString(⌈dim/8⌉) | 32 | ⌈dim/8⌉ bytes |
QBit(Float64, dim) | Tuple of 64 × FixedString(⌈dim/8⌉) | 64 | ⌈dim/8⌉ bytes |
패딩 규칙. FixedString은 바이트 단위 연산이므로, 벡터 길이 dim이 8의 배수가 아니면 0으로 패딩해 다음 8의 배수로 맞춘다. 예: dim=5이면 8로 패딩 → 평면당 1바이트. dim=1536이면 192바이트 그대로(8의 배수).
2.2 쿼리 시점 I/O — "필요한 비트 평면만 읽는다"
데이터가 비트 평면별로 분리돼 있으므로, ClickHouse는 상위 N개 평면만 읽고 나머지 평면은 통째로 스킵할 수 있다(컬럼 단위 I/O이므로 disk read가 실제로 감소).
상위 비트를 읽지 않으면 결과가 무의미해진다는 점이 중요하다 — float의 sign + 상위 exponent bit이 사라지면 방향 자체가 망가진다. 반대로 하위 mantissa 비트는 마이크로한 정밀도만 추가할 뿐, 코사인/L2 거리의 순위에는 거의 영향을 주지 않는다. 양자화의 본질을 비트 단위로 풀어낸 셈이다.
이 최적화를 책임지는 세팅이 따로 있다.
SELECT name, value, description FROM system.settings WHERE name LIKE '%qbit%';세 가지 사실이 확인된다.
allow_experimental_qbit_type은 26.2에서 obsolete. GA됐기 때문에 별도 활성화 불필요(25.10/26.1 시절 사용하던set allow_experimental_qbit_type = 1은 이제 안 써도 됨).optimize_qbit_distance_function_reads(기본 ON) 가 실제 I/O 절감의 핵심. 이게 꺼지면 distance 함수가 모든 비트 평면을 읽게 된다.- distance 함수 두 개 제공:
L2DistanceTransposed,cosineDistanceTransposed.
2.3 비트 평면 접근 — .N 서브컬럼
QBit는 비트 평면 단위 서브컬럼 접근을 지원한다. vec.1은 1번 평면(sign bit), vec.32는 32번 평면(가장 하위 mantissa bit).
원소 타입 | 서브컬럼 인덱스 |
BFloat16 | 1–16 |
Float32 | 1–32 |
Float64 | 1–64 |
다음 실습에서 이걸 활용해 transpose를 직접 눈으로 확인한다.
3. 실습 1 — 내부 layout을 눈으로 확인하기
ClickHouse Cloud 26.2.1.390 환경. 5행짜리 미니 테이블로 비트 평면이 실제로 어떻게 채워지는지 본다.
이제 1번 평면(sign), 2번 평면(exponent MSB), 9번 평면, 32번 평면(mantissa LSB)을 비트 단위로 본다.
SELECT id, label,
bin(vec.1) AS plane_1_sign,
bin(vec.2) AS plane_2_exp_msb,
bin(vec.9) AS plane_9_mantissa_msb,
bin(vec.32) AS plane_32_lsb
FROM qbit_intro ORDER BY id;id | label | plane_1 (sign) | plane_2 (exp MSB) | plane_9 (mantissa MSB) | plane_32 (LSB) |
1 | all_zeros | 00000000 | 00000000 | 00000000 | 00000000 |
2 | all_neg_zeros | 11111111 | 00000000 | 00000000 | 00000000 |
3 | pos_ones | 00000000 | 00000000 | 11111111 | 00000000 |
4 | neg_ones | 11111111 | 00000000 | 11111111 | 00000000 |
5 | ramp [1..8] | 00000000 | 11111110 | 01111001 | 00000000 |
읽어내는 패턴이 인상적이다.
all_neg_zeros의 1번 평면이11111111— 8개 원소 모두 음수의 부호 비트를 들고 있음을 한 바이트가 정확히 표현. 만약 일반 Array였다면 부호 비트 8개가 32바이트에 흩어져 있었을 것이다.pos_ones의 9번 평면이11111111— Float32에서1.0 = 0 01111111 0…0인데, 9번 비트가 exponent의 마지막 비트(=1)에 해당. 8개 원소가 모두 같은 값이라 평면이 균질하게 1.ramp의 2번 평면11111110— 값 1.0의 exponent MSB(0)와 값 2.0~8.0의 exponent MSB(1)가 갈리는 지점이 그대로 한 바이트에 새겨짐.- 마지막 32번 평면(mantissa LSB)이 모두 0 — 작은 정수값에서는 mantissa 하위 비트가 정보를 거의 안 담는다는 사실이 그대로 보인다. 상위 비트만 읽어도 검색이 잘 되는 이유가 여기서 시작된다.
요약: QBit는 정말로 비트를 transposing해서 저장한다. 이건 단순한 메타데이터 트릭이 아니라 디스크 레이아웃 자체가 바뀐 것이다.
4. 실습 2 — 300K 임베딩으로 진짜 벤치마크
meetup_observability.log_embeddings 에 OTel 로그 임베딩 약 2M개(Float32, 128차원)가 있다. 동일 샘플 300K를 두 테이블에 적재하고 비교한다.
노트:Array(Float32)를QBit(Float32, 128)컬럼에 그대로INSERT … SELECT할 수 있다. 길이가 차원과 같으면 ClickHouse가 자동 변환한다(CAST(arr AS QBit(Float32, 128))도 동등).
4.1 저장 크기 비교
SELECT table, sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS on_disk,
formatReadableSize(sum(data_compressed_bytes)) AS compressed,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed
FROM system.parts
WHERE database='default' AND table IN ('qbit_logs','array_logs') AND active
GROUP BY table;Table | Rows | On disk | Compressed | Uncompressed |
array_logs | 300,000 | 60.07 MiB | 60.07 MiB | 185.57 MiB |
qbit_logs | 300,000 | 48.62 MiB | 48.62 MiB | 183.26 MiB |
압축 후 19% 더 작다. 같은 float 값을 저장하는데도 디스크가 줄어드는 이유는 단순하다 — 비트 평면별로 데이터가 동질화되기 때문이다. 예컨대 sign 평면은 양수/음수가 한쪽으로 쏠리면 거의 단일 값에 가깝고, mantissa 하위 평면들은 거의 무작위지만 그 자체로 압축이 잘 안 되는 노이즈라 차이가 작다. 결국 상위 평면의 압축 이득이 하위 평면의 손실보다 크다.
미압축 용량은 거의 같다(둘 다 128 × 4 = 512 bytes/row). 압축률이 다른 이유는 layout이 LZ4에 더 친화적이기 때문이다.
4.2 정밀도별 지연시간 · I/O · Recall
쿼리 벡터 1개를 골라 baseline (L2Distance on Array(Float32)) vs QBit를 정밀도 32/16/8/4로 비교했다. system.query_log에서 실측치를 뽑으면:
Query | Engine + 함수 | 정밀도 | Duration | Read bytes | I/O 비율 | 속도 비율 |
array_baseline | Array(Float32) + L2Distance | (full 32) | 217 ms | 181.2 MB | 100% | 1.00× |
qbit_p32 | QBit(Float32,128) + L2DistanceTransposed | 32 | 263 ms | 178.5 MB | 99% | 0.83× |
qbit_p16 | 〃 | 16 | 152 ms | 101.7 MB | 56% | 1.43× |
qbit_p8 | 〃 | 8 | 113 ms | 63.3 MB | 35% | 1.92× |
qbit_p4 | 〃 | 4 | 97 ms | 44.1 MB | 24% | 2.24× |
이제 결과의 품질. baseline의 top-20 log_id 집합과 각 QBit 정밀도의 top-20 집합을 교차하여 distinct log_id 단위 recall을 측정했다(중복 임베딩을 가진 행은 distinct로 묶어 12개 보유).
정밀도 | 일치한 distinct log_id | Recall@20 |
p32 | 12 / 12 | 100% |
p16 | 12 / 12 | 100% |
p8 | 8 / 12 | 67% |
p4 | 0 / 12 | 0% |
세 가지가 즉시 보인다.
- p16이 sweet spot. 100% recall을 유지하면서 baseline 대비 1.43× 빠르고 I/O는 56%. 공짜 점심에 가까움.
- p32는 baseline보다 약간 느리다. I/O는 거의 같은데(99%) 시간이 21% 늘었다. SIMD untranspose 오버헤드가 그대로 노출된 케이스다 — 정밀도를 줄이지 않으면 QBit는 손해다.
- p4는 이 데이터에서 완전히 깨졌다. 128-dim Float32에서 mantissa를 통째로 버리고 exponent 4bit만 남기면 거리의 방향까지 흔들린다.
4.3 그래프로 보면
수치적으로 정리하면 두 곡선이 동시에 흥미롭다:
- 속도 곡선 : 정밀도가 절반이 되면 시간도 대략 절반에 가까워진다(I/O 지배 워크로드).
- Recall 곡선: p16까지는 평평하다가 p8에서 급락한다 — 비선형 cliff.
비트 정보량의 비대칭성이 그대로 드러난다. 상위 비트는 정보 밀도가 매우 높고, 하위 비트는 노이즈에 가깝다. 어디서 cliff가 오는지가 워크로드(데이터 분포 + 차원)에 의존한다.
4.4 차원에 따른 cliff 위치
ClickHouse 팀이 공개한 DBpedia 벤치마크(1536-dim Float32 × 1M개)에서는 precision=5(sign 1bit + exponent 4bit, mantissa 0bit)에서도 의미 있는 검색 결과를 냈다. 같은 Float32인데 우리 케이스(128-dim)에서는 p4가 깨졌다.
이유는 단순하다 — 고차원일수록 하위 비트는 더 redundant하다. 1536개 차원에 정보가 분산돼 있으면 일부 차원의 정밀도가 떨어져도 합산된 거리는 안정적이다. 128차원에서는 각 차원이 더 무겁다.
경험 법칙: dimension이 클수록 더 공격적인 정밀도 컷이 가능. 256-dim 이하면 p16 안전, 1024+ dim이면 p8까지 시도해 볼 만하다.
5. 언제 QBit을 써야 하나 — 의사결정 가이드
요점은 단순하다.
- HNSW가 RAM에 들어가면 HNSW가 이긴다. QBit는 여전히 O(n) brute-force이므로.
- 데이터가 RAM을 넘기는 순간 QBit가 강해진다. 디스크에서 비트 평면을 부분 스캔하면 RAM 제약 없이 동작.
- QBit p16은 거의 항상 안전한 첫 선택. 100% recall을 유지하면서 baseline보다 빠르고 작다.
- HNSW의 quantization 옵션과 결합 가능. HNSW 빌드 시 QBit 컬럼을 입력으로 줘서 더 작은 인덱스를 만들 수도 있다 — 이 경우 인덱스 build 시점에 quantization 결정이 박힌다는 점은 동일.
6. 운영 고려사항
6.1 버전 · 세팅
- 26.1 이상: QBit 정식 지원 (26.1 beta, 26.2 GA).
allow_experimental_qbit_type: 26.2부터 obsolete (그대로 둬도 무해).optimize_qbit_distance_function_reads: 기본 ON. 끄지 말 것 — 끄면 모든 비트 평면을 읽어 QBit의 핵심 이점이 사라진다.
6.2 데이터 타입 선택
원본 타입 | QBit 평면 수 | 권장 정밀도 (128~512 dim) | 권장 정밀도 (1024+ dim) |
BFloat16 | 16 | 12–16 | 8–12 |
Float32 | 32 | 16–24 | 8–16 |
Float64 | 64 | 16–32 | 8–16 |
주의: Float64는 Float32 두 배가 아니다. exponent가 11bit, mantissa 52bit로 layout 자체가 다르다. 하위 비트를 무시하는 트릭이 그대로 안 통하는 부분이 있다 — 굳이 Float64가 필요한 워크로드가 아니라면 Float32가 안전한 기본값.
6.3 Array → QBit 마이그레이션
기존 Array(Float32) 컬럼을 가진 테이블에 QBit를 추가하는 패턴:
-- 1. 컬럼 추가
ALTER TABLE my_embeddings ADD COLUMN vec_qbit QBit(Float32, 128);
-- 2. 백필 (배치로 끊어 실행 권장)
ALTER TABLE my_embeddings UPDATE vec_qbit = embedding WHERE 1;
-- 3. 쿼리에서 점진적으로 전환
SELECT id, L2DistanceTransposed(vec_qbit, :query_vec, 16) AS dist
FROM my_embeddings ORDER BY dist LIMIT 50;원본 컬럼을 유지하면 정밀도가 부족할 때 polledning fallback이 가능하다. 저장 공간은 약 2배가 되지만, QBit 자체가 ~19% 더 작으니 실효 오버헤드는 1.8배 수준.
6.4 함수 시그니처 — 기억해 둘 것
L2DistanceTransposed(qbit_col, reference_vec, precision_bits)
cosineDistanceTransposed(qbit_col, reference_vec, precision_bits)reference_vec는 일반 Array(Float32|Float64|...). 내부적으로 reference 벡터를 적절한 타입(예: BFloat16)으로 downcast해서 비교한다 — 이 downcast가 row-by-row가 아니라 한 번만 일어나기 때문에 효율적이다.
7. 정리
QBit가 흥미로운 이유는 결정의 시점을 옮긴다는 점이다. 데이터 양자화는 보통 "지금 적재하면서 정확도를 영구히 잃을 것이냐, 두 배 공간을 쓸 것이냐"의 양자택일이었다. QBit는 둘 다 안 하고, 대신 정밀도를 쿼리 파라미터로 만든다. 같은 데이터셋 위에 정확도 우선 쿼리, 속도 우선 쿼리, 점진적 탐색 쿼리를 자유롭게 섞을 수 있다.
오늘 본 300K × 128-dim 워크로드에서 p16은 baseline 대비 1.43× 빠르고 디스크는 19% 더 작으며 recall 100%였다. 데이터셋이 커지고 HNSW 인덱스가 RAM에 안 들어가는 순간, QBit의 진가가 더 드러난다 — 디스크 기반 O(n) 스캔에서 부분 비트 평면 I/O는 본질적으로 줄일 수 있는 유일한 비용이기 때문이다.
참고 자료
- QBit Data Type — 공식 문서
- L2DistanceTransposed · cosineDistanceTransposed
- We built a vector search engine that lets you choose precision at query time — Raufs Dunamalijevs
- ClickHouse Release 26.2 — QBit GA
- ClickHouse Release 25.10 — QBit 최초 도입
- DBpedia vector search dataset (1M, 1536-dim)
- 본 글의 모든 실측치는 ClickHouse Cloud 26.2.1.390 (AWS ap-northeast-2) 환경에서 측정