메인 콘텐츠로 건너뛰기
이 가이드에서 다룰 내용은 다음과 같습니다:
  • 벡터 검색을 간략히 소개합니다
  • 근사 최근접 이웃(ANN)과 Hierarchical Navigable Small World(HNSW)에 대해 알아봅니다
  • Quantised Bit(QBit)에 대해 알아봅니다
  • QBit를 사용해 DBPedia 데이터셋으로 벡터 검색을 수행합니다

벡터 검색 입문

수학과 물리학에서 벡터는 크기와 방향을 모두 가진 객체로 엄밀하게 정의됩니다. 벡터는 흔히 공간 속의 선분이나 화살표 형태로 나타나며, 속도, 힘, 가속도와 같은 물리량을 표현하는 데 사용할 수 있습니다. 컴퓨터 과학에서 벡터는 유한한 수열입니다. 즉, 숫자 값을 저장하는 데 사용되는 데이터 구조입니다. 머신 러닝에서 벡터는 컴퓨터 과학에서 말하는 것과 같은 데이터 구조이지만, 그 안에 저장된 수치 값에는 특별한 의미가 있습니다. 텍스트 블록이나 이미지를 그것이 나타내는 핵심 개념만 남도록 추려내는 과정을 인코딩이라고 합니다. 그 결과물은 이러한 핵심 개념을 수치 형태로 표현한 기계의 표현입니다. 이것이 임베딩이며, 벡터에 저장됩니다. 다르게 말하면, 이러한 맥락적 의미가 벡터에 담기면 이를 임베딩이라고 할 수 있습니다. 이제 벡터 검색은 곳곳에서 사용됩니다. 음악 추천을 구동하고, 외부 지식을 가져와 답변을 개선하는 대규모 언어 모델용 검색 증강 생성(RAG)에 활용되며, 심지어 웹 검색도 어느 정도는 벡터 검색을 기반으로 합니다. 전용 벡터 저장소의 장점에도 불구하고, 사용자는 완전히 특화된 벡터 저장소보다 필요할 때 바로 사용할 수 있는 벡터 기능을 갖춘 일반 데이터베이스를 더 선호하는 경우가 많습니다. ClickHouse는 브루트포스 벡터 검색근사 최근접 이웃(ANN) 검색 메서드를 모두 지원하며, 여기에는 현재 고속 벡터 검색의 표준인 HNSW도 포함됩니다.

임베딩 이해하기

벡터 검색이 어떻게 작동하는지 이해하기 위해 간단한 예시를 살펴보겠습니다. 단어의 임베딩(벡터 표현)을 예로 들어 보겠습니다: 몇 가지 예시 임베딩으로 아래 테이블(table)을 생성하십시오:
주어진 임베딩에 가장 유사한 단어를 검색할 수 있습니다:
쿼리 임베딩은 “apple”에 가장 가깝습니다(거리가 가장 짧음). 두 임베딩을 나란히 비교해 보면 그 이유를 알 수 있습니다:

근사 최근접 이웃 (ANN)

대규모 데이터셋에서는 브루트포스 검색이 너무 느립니다. 이럴 때 근사 최근접 이웃 기법이 사용됩니다.

양자화

양자화는 더 작은 수치 타입으로 다운캐스팅하는 것을 의미합니다. 숫자가 작아질수록 데이터 크기도 줄어들고, 데이터가 작을수록 거리 계산도 더 빨라집니다. ClickHouse의 벡터화된 쿼리 실행 엔진은 연산마다 프로세서 레지스터에 더 많은 값을 담을 수 있으므로 처리량이 직접 증가합니다. 다음 두 가지 옵션이 있습니다.
  1. 양자화된 복사본을 원본 컬럼과 함께 유지합니다 - 저장 공간은 두 배로 늘어나지만, 언제든 전체 정밀도로 되돌릴 수 있으므로 안전합니다
  2. 원래 값을 완전히 대체합니다 (삽입 시 다운캐스팅) - 공간과 I/O를 절약할 수 있지만, 되돌릴 수 없는 선택입니다

계층형 탐색 가능 소세계(HNSW)

HNSW는 여러 계층의 노드(벡터)로 구성됩니다. 각 노드는 하나 이상의 계층에 무작위로 할당되며, 상위 계층에 포함될 확률은 지수적으로 감소합니다. 검색을 수행할 때는 최상위 계층의 노드에서 시작해 가장 가까운 이웃 쪽으로 greedy 방식으로 이동합니다. 더 가까운 노드를 더 이상 찾을 수 없으면, 다음의 더 조밀한 계층으로 내려갑니다. 이러한 계층형 설계 덕분에 HNSW는 노드 수에 대해 로그 시간 검색 복잡도를 달성합니다.
HNSW의 한계주요 병목은 메모리입니다. ClickHouse는 분할을 지원하지 않는 인메모리 데이터 구조인 HNSW의 usearch 구현을 사용합니다. 그 결과, 데이터셋이 커질수록 그에 비례해 더 많은 RAM이 필요합니다.

접근 방식 비교

QBit 자세히 살펴보기

양자화 비트(QBit)

QBit는 부동소수점 수가 비트로 표현되는 방식을 활용해 BFloat16, Float32, Float64 값을 저장할 수 있는 새로운 데이터 구조입니다. 각 숫자를 통째로 저장하는 대신, QBit는 값을 비트 평면으로 나눕니다. 즉, 각 값의 첫 번째 비트들, 두 번째 비트들, 세 번째 비트들처럼 분리합니다. 이 접근 방식은 기존 양자화의 주요 한계를 해결합니다. 중복 데이터를 저장할 필요도 없고, 값이 무의미해질 위험도 없습니다. 또한 QBit는 메모리 내 인덱스를 유지하지 않고 저장된 데이터에 직접 작동하므로 HNSW의 RAM 병목도 피할 수 있습니다.
장점무엇보다도, 사전에 결정해야 할 사항이 없습니다. 정밀도와 성능은 쿼리 시점에 동적으로 조정할 수 있으므로, 사용자는 정확도와 속도 간의 균형을 큰 부담 없이 살펴볼 수 있습니다.
한계QBit는 벡터 검색 속도를 높여 주지만, 계산 복잡도는 여전히 O(n)입니다. 다시 말해, 데이터셋이 충분히 작아 HNSW 인덱스가 RAM에 무리 없이 올라간다면, 여전히 그 방식이 가장 빠른 선택입니다.

데이터 타입

QBit 컬럼을 생성하는 방법은 다음과 같습니다:
데이터가 QBit 컬럼에 삽입되면 모든 첫 번째 비트끼리, 모든 두 번째 비트끼리 정렬되도록 전치됩니다. 이를 그룹이라고 합니다. 각 그룹은 별도의 FixedString(N) 컬럼에 저장됩니다. 즉, 길이가 N바이트로 고정된 문자열이 메모리에 구분자 없이 연속해서 저장됩니다. 이후 이 모든 그룹이 하나의 Tuple로 묶이며, 이것이 QBit의 기본 구조를 이룹니다. 예시: 8×Float64 요소로 이루어진 벡터에서 시작하면 각 그룹에는 8비트가 들어갑니다. Float64는 64비트이므로 최종적으로 64개의 그룹(비트마다 하나씩)이 생깁니다. 따라서 QBit(Float64, 8)의 내부 레이아웃은 64×FixedString(1) 컬럼으로 구성된 Tuple과 같습니다.
원래 벡터 길이가 8로 나누어떨어지지 않으면 8에 맞도록 보이지 않는 요소를 추가해 패딩합니다. 이렇게 하면 전체 바이트 단위로만 엄격하게 동작하는 FixedString과의 호환성이 보장됩니다.

거리 계산

QBit을 사용해 쿼리하려면 precision 매개변수와 함께 L2DistanceTransposed 함수를 사용하십시오:
세 번째 매개변수(16)는 정밀도 수준을 비트 단위로 지정합니다.

I/O 최적화

거리를 계산하려면 먼저 필요한 데이터를 디스크에서 읽은 다음 전치를 되돌려야 합니다(그룹화된 비트 표현을 다시 전체 벡터로 변환). QBit는 값을 정밀도 수준별로 비트 전치된 형태로 저장하므로, ClickHouse는 원하는 정밀도까지 숫자를 복원하는 데 필요한 상위 비트 평면만 읽을 수 있습니다. 위 쿼리에서는 정밀도 수준 16을 사용합니다. Float64는 64비트이므로 처음 16개의 비트 평면만 읽어 데이터의 75%를 스키핑합니다. 읽은 후에는 로드된 비트 평면에서 각 숫자의 상위 부분만 복원하고, 읽지 않은 비트는 0으로 둡니다.

계산 최적화

Float32나 BFloat16처럼 더 작은 타입으로 캐스팅하면 사용되지 않는 이 부분을 없앨 수 있지 않을까 생각할 수 있습니다. 실제로는 가능하지만, 모든 행에 명시적 CAST를 적용하면 비용이 큽니다. 대신 참조 벡터에만 다운캐스팅을 적용하고, QBit 데이터는 더 좁은 값을 담고 있는 것처럼 취급할 수 있습니다(즉, 일부 컬럼의 존재를 “무시”하는 방식입니다). 이는 해당 레이아웃이 이러한 타입의 잘린 버전에 해당하는 경우가 많기 때문입니다.

BFloat16 최적화

BFloat16은 Float32를 절반으로 줄여 잘라낸 형식입니다. 부호 비트와 8비트 지수는 그대로 유지하고, 23비트 가수의 상위 7비트만 남깁니다. 따라서 QBit 컬럼에서 처음 16개의 비트 평면을 읽으면 사실상 BFloat16 값의 레이아웃이 그대로 재현됩니다. 이런 경우 참조 벡터를 BFloat16으로 안전하게 변환할 수 있으며, 실제로도 그렇게 처리합니다.

Float64의 복잡성

하지만 Float64는 사정이 다릅니다. 11비트 지수와 52비트 가수를 사용하므로, 단순히 비트 수가 2배인 Float32가 아닙니다. 구조와 지수 바이어스도 완전히 다릅니다. Float64를 Float32 같은 더 작은 포맷으로 다운캐스팅하려면 실제 IEEE-754 변환이 필요하며, 이 과정에서 각 값은 표현 가능한 가장 가까운 Float32 값으로 반올림됩니다. 이 반올림 단계는 계산 비용이 큽니다.
QBit의 성능 요소를 자세히 살펴보고 싶다면 “Let’s vectorize”를 참고하십시오.

DBpedia를 활용한 예시

Float32 임베딩으로 표현된 Wikipedia 기사 100만 개가 포함된 DBpedia 데이터셋을 사용해, 실제 사례에서 QBit이 어떻게 동작하는지 살펴보겠습니다.

설정

먼저 테이블을 생성합니다
명령줄에서 데이터를 삽입하세요:
데이터를 삽입하는 데는 다소 시간이 걸릴 수 있습니다. 커피 한 잔 하며 잠시 쉬어가세요!
또는 아래와 같이 개별 SQL 문을 실행해 25개의 Parquet 파일을 각각 로드할 수 있습니다:
dbpedia 테이블에 100만 개의 행이 있는지 확인하십시오:
다음으로 QBit 컬럼을 추가합니다:

검색 쿼리

Moon, Apollo 11, Space Shuttle, Astronaut, Rocket 등 모든 우주 관련 검색어와 가장 밀접한 개념을 찾아보겠습니다:
이 쿼리는 5개 개념 각각에 대해 의미적으로 가장 유사한 상위 1000개 항목을 찾습니다. 원본은 제외하고, 해당 결과들 중 3개 이상에 포함된 항목을 반환하며, 일치하는 개념 수와 각 개념까지의 최소 거리를 기준으로 순위를 매깁니다. 단 5비트만 사용하는 경우(부호 1비트 + 지수 4비트, 가수 0):
성능: 결과로 10개 행이 반환되었습니다. 경과 시간: 0.271초. 846만 행, 4.54 GB를 처리했습니다(초당 3,119만 행, 16.75 GB). 최대 메모리 사용량: 739.82 MiB.
성능: 결과 10행. 소요 시간: 1.157초. 처리: 1,000만 행, 32.76 GB (864만 행/초, 28.32 GB/초). 최대 메모리 사용량: 6.05 GiB.

핵심 인사이트

결과는 단지 좋은 수준이 아니었습니다. 놀라울 정도로 좋았습니다. 가수 전체와 지수의 절반을 제거한 부동소수점에서도 여전히 의미 있는 정보가 남아 있다는 점은 쉽게 예상하기 어렵습니다. QBit의 핵심 인사이트는 중요하지 않은 비트를 무시해도 벡터 검색이 여전히 작동한다는 점입니다. 뛰어난 시맨틱 검색 품질을 유지하면서 메모리 사용량이 6.05 GB에서 740 MB로 감소했습니다!

결론

QBit은 부동소수점 값을 비트 평면으로 저장하는 컬럼 유형입니다. 이를 사용하면 벡터 검색 시 읽을 비트 수를 선택해, 데이터를 변경하지 않고도 재현율과 성능을 조정할 수 있습니다. 각 벡터 검색 메서드에는 재현율, 정확도, 성능 사이의 균형을 결정하는 고유한 매개변수가 있습니다. 보통은 이러한 값을 미리 정해야 합니다. 이를 잘못 정하면 많은 시간과 리소스가 낭비되고, 나중에 방향을 바꾸는 일도 매우 부담스러워집니다. QBit을 사용하면 이런 결정을 초기에 내릴 필요가 없습니다. 쿼리 시점에 정밀도와 속도 사이의 균형을 직접 조정하면서, 상황에 맞는 최적의 지점을 찾아갈 수 있습니다.
Raufs Dunamalijevs의 블로그 게시물을 바탕으로 작성되었으며, 2025년 10월 28일 게시
마지막 수정일 2026년 7월 2일