QBit 데이터 타입
QBit 데이터 타입
더 빠른 근사 검색을 위해 벡터 저장을 재구성하는 데이터 타입이에요. 각 벡터의 요소를 함께 저장하는 대신 모든 벡터에서 같은 이진 자릿수 위치를 그룹으로 묶어요. 전체 정밀도로 저장하면서 검색 시점에 세밀한 양자화 수준을 고를 수 있어요.
출처: 문서
본문
QBit 데이터 타입은 더 빠른 근사 검색을 위해 벡터 저장을 재구성해요. 각 벡터의 요소를 함께 저장하는 대신, 모든 벡터에서 같은 이진 자릿수 위치를 그룹으로 묶어요. 이것은 전체 정밀도로 벡터를 저장하면서, 검색 시점에 세밀한 양자화 수준을 고를 수 있게 해줘요. 더 적은 비트를 읽으면 I/O가 줄고 계산이 빨라지고, 더 많은 비트를 읽으면 정확도가 높아져요. 양자화에서 오는 데이터 전송·계산 감소의 속도 이점을 얻으면서도 필요한 때 원래 데이터가 모두 유지돼요.
QBit 타입 컬럼을 선언하려면 다음 문법을 사용해요.
column_name QBit(element_type, dimension[, stride])
element_type– 각 벡터 요소의 타입. 허용 타입은Int8,BFloat16,Float32,Float64예요.dimension– 각 벡터의 요소 수.stride– 선택 사항. 한 그룹의 스트림에 함께 저장되는 차원 수. 생략하면dimension(단일 그룹)으로 기본 설정돼요. 제공하면dimension은stride의 배수여야 하고,stride가dimension보다 작으면stride는 8의 배수여야 해요.dimension차원은dimension / stride개의 연속 그룹으로 나뉘고, 각 그룹의 비트 평면이 별도의 스트림에 저장돼요. 이것은 첫D차원(D는stride의 배수)에 대한 검색이 그 차원들을 덮는 그룹의 스트림만 읽게 해주는데, Matryoshka 임베딩에 유용해요.
QBit 만들기 (Creating QBit)
QBit 타입을 테이블 컬럼 정의에 사용하기:
CREATE TABLE test (id UInt32, vec QBit(Float32, 8)) ENGINE = Memory;
INSERT INTO test VALUES (1, [1, 2, 3, 4, 5, 6, 7, 8]), (2, [9, 10, 11, 12, 13, 14, 15, 16]);
SELECT vec FROM test ORDER BY id;
┌─vec──────────────────────┐
│ [1,2,3,4,5,6,7,8] │
│ [9,10,11,12,13,14,15,16] │
└──────────────────────────┘
배열을 QBit로 변환 (Converting arrays to QBit)
배열은 길이가 QBit의 차원과 일치할 때 QBit로 변환돼요. 배열의 요소 타입은 QBit 요소 타입과 일치할 필요가 없어요. 어떤 숫자 요소 타입이든 자동으로 변환돼요. 이것은 기존 임베딩 컬럼을 바로 QBit 컬럼으로 옮길 수 있게 해줘요.
CREATE TABLE embeddings (id UInt32, embedding Array(Float32)) ENGINE = Memory;
INSERT INTO embeddings VALUES (1, [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]), (2, [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1]);
CREATE TABLE vectors (id UInt32, vec QBit(Float32, 8)) ENGINE = Memory;
INSERT INTO vectors SELECT id, embedding FROM embeddings;
SELECT * FROM vectors ORDER BY id;
┌─id─┬─vec───────────────────────────────┐
│ 1 │ [0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8] │
│ 2 │ [0.8,0.7,0.6,0.5,0.4,0.3,0.2,0.1] │
└────┴───────────────────────────────────┘
변환은 CAST(embedding AS QBit(Float32, 8))처럼 CAST로 명시적으로도 동작해요.
QBit를 배열로 변환 (Converting QBit to arrays)
반대 변환은 비트 전치 표현에서 원래 벡터를 재구성하므로, QBit를 Array로 캐스팅하면 저장된 값을 반환해요. 이것은 배열을 QBit로 변환의 역이에요.
SELECT [1, 2, 3, 4]::QBit(Float32, 4)::Array(Float32) AS vec;
┌─vec───────┐
│ [1,2,3,4] │
└───────────┘
재구성된 배열은 QBit의 요소 타입을 사용하며, 그 요소들은 요청된 배열 요소 타입으로 변환돼요. 그래서 요소 타입도 바꾸는 캐스트(예: QBit(Float32, N)을 Array(Float64)로)도 동작해요. Array -> QBit -> Array 왕복은 Int8, Float32, Float64에 대해 무손실이에요. BFloat16의 경우 BFloat16로의 직접 변환과 일치해요 — 잃는 정밀도는 BFloat16 자체의 정밀도뿐이에요. dimension이 8의 배수가 아니면 내부 표현에 있는 뒤쪽 채움 요소가 버려지므로, 결과는 항상 정확히 dimension개의 요소를 가져요.
QBit 타입 사이의 변환 (Converting between QBit types)
QBit는 dimension(벡터 요소 수)이 같게 유지되는 한 다른 QBit로 캐스팅할 수 있어요. element_type과 stride는 둘 다 바뀔 수 있어요. dimension이 다른 QBit로 캐스팅하면 예외가 발생해요. 그것은 벡터 자체를 바꾸게 되기 때문이에요. element_type을 바꾸면 벡터를 재구성하고 각 요소를 새 타입으로 변환해요. 해당 Array 변환과 정확히 같아요. 확대(예: QBit(Float32, N)을 QBit(Float64, N)으로)는 정확하고, 축소는 축소형 Array 캐스트가 그렇듯 정밀도를 잃어요.
SELECT [1, 2, 3, 4]::QBit(Float32, 4)::QBit(Float64, 4) AS vec;
┌─vec───────┐
│ [1,2,3,4] │
└───────────┘
stride만 바꾸면(같은 element_type 유지) 값은 건드리지 않고 저장된 비트 평면을 다시 그룹화하므로 항상 무손실이에요.
SELECT range(16)::Array(Float32)::QBit(Float32, 16)::QBit(Float32, 16, 8)::Array(Float32)
= range(16)::Array(Float32) AS is_lossless;
┌─is_lossless─┐
│ 1 │
└─────────────┘
QBit 서브컬럼 (QBit subcolumns)
QBit는 저장된 벡터의 개별 비트 평면에 접근할 수 있게 해주는 서브컬럼 접근 패턴을 구현해요. 각 비트 위치는 .N 문법(N은 비트 위치)으로 접근할 수 있어요.
CREATE TABLE test (id UInt32, vec QBit(Float32, 8)) ENGINE = Memory;
INSERT INTO test VALUES (1, [0, 0, 0, 0, 0, 0, 0, 0]);
INSERT INTO test VALUES (1, [-0, -0, -0, -0, -0, -0, -0, -0]);
SELECT bin(vec.1) FROM test;
┌─bin(tupleElement(vec, 1))─┐
│ 00000000 │
│ 11111111 │
└───────────────────────────┘
접근 가능한 서브컬럼 수는 요소 타입에 따라 달라져요(그리고 stride가 있을 때는 stride 그룹 수에 따라):
Int8: stride 그룹당 8개 서브컬럼 (1-8)BFloat16: stride 그룹당 16개 서브컬럼 (1-16)Float32: stride 그룹당 32개 서브컬럼 (1-32)Float64: stride 그룹당 64개 서브컬럼 (1-64)
서브컬럼은 그룹 우선(group-major) 순서를 따라요. 일반적으로 vec.N은 stride 그룹 (N-1) / element_size의 비트 평면 (N-1) % element_size를 읽어요. 예를 들어 QBit(BFloat16, 4096, 1024)에서는 4096 차원이 4개의 1024 그룹으로 나뉘므로 64개의 서브컬럼이 있어요. vec.1 … vec.16은 첫 번째 stride 그룹(차원 1–1024)의 비트 평면이고, vec.17 … vec.32는 두 번째 그룹(차원 1025–2048)에 속하며, 이런 식으로 이어져요.
Strides
기본적으로 QBit는 각 비트 평면을 모든 dimension 차원에 걸친 단일 스트림으로 저장하므로, 검색은 항상 전체 벡터에 걸친 비트 평면 전체를 읽어요. 옵션 stride 매개변수는 dimension 차원을 dimension / stride개의 연속 그룹으로 분할하고 각 그룹의 비트 평면을 별도 스트림에 저장해요. 이것은 첫 D 차원(D는 stride의 배수)에 대해서만 검색할 때 그 차원들을 덮는 그룹의 스트림만 읽게 해줘요. 선두 차원이 사용 가능한 저차원 임베딩을 형성하는 Matryoshka 임베딩에 유용해요.
CREATE TABLE test (id UInt32, vec QBit(BFloat16, 4096, 1024)) ENGINE = MergeTree ORDER BY id;
여기서 4096 차원은 4개의 1024 그룹으로 나뉘어요. 서브컬럼은 그룹 우선 순서를 따라요. BFloat16(16개 비트 평면)에서는 vec.1 … vec.16이 첫 번째 stride 그룹(차원 1–1024)의 16개 비트 평면이고, vec.17 … vec.32는 두 번째 그룹(차원 1025–2048)에 속하며, 이런 식으로 이어져요. 일반적으로 vec.N은 stride 그룹 (N-1) / element_size의 비트 평면 (N-1) % element_size를 읽어요. 축소 차원 검색을 실행하려면 전치 거리 함수(아래 참고)의 네 번째 인자로 읽을 차원 수를 전달해요. 참조 벡터는 적어도 그만큼의 요소를 가져야 하고(추가 뒤쪽 요소는 무시돼요), 값은 stride의 배수여야 해요.
요소 접근과 슬라이싱 (Element access and slicing)
arrayElement(그리고 vec[n] 연산자)는 요소 타입의 전체 정밀도로 재구성된 n번째 벡터 요소를 반환해요. 요소를 포함하는 stride 그룹의 비트 평면만 읽어요. arraySlice는 선택된 차원에 대한 QBit(차원 부분집합으로의 투영)를 반환해요. 오프셋과 길이는 상수여야 해요. QBit의 차원이 그 타입의 일부이기 때문이에요. stride 그룹 경계에 정렬된 슬라이스는 stride를 유지하고 복사 없이 저장된 비트 평면 스트림을 재사용해요.
CREATE TABLE test (id UInt32, vec QBit(Float32, 8)) ENGINE = Memory;
INSERT INTO test VALUES (1, [1, 2, 3, 4, 5, 6, 7, 8]);
SELECT vec[3], arraySlice(vec, 2, 3) FROM test;
┌─arrayElement(vec, 3)─┬─arraySlice(vec, 2, 3)─┐
│ 3 │ [2,3,4] │
└──────────────────────┴───────────────────────┘
벡터 검색 함수 (Vector search functions)
이것들은 QBit 데이터 타입을 사용하는 벡터 유사도 검색용 거리 함수들이에요.
strided QBit의 경우 이 함수들은 옵션 네 번째 인자 used_dims — 읽을 선두 차원 수 — 를 받아들이며, 그 차원들을 덮는 stride 그룹만 읽어요. 참조 벡터는 적어도 used_dims개의 요소를 가져야 하고(추가 뒤쪽 요소는 무시되므로, 축소 차원 검색에 전체 크기 쿼리 벡터를 슬라이싱 없이 재사용할 수 있어요), used_dims는 stride의 배수여야 해요.