스칼라 양자화

수백만 개의 벡터를 다루다 보면 메모리가 급격히 걱정되기 시작해요. 이 글에서는 Qdrant 1.1.0부터 지원하는 **스칼라 양자화(Scalar Quantization)**가 어떻게 float32int8로 바꿔 메모리를 75% 줄이면서도, 오히려 검색 성능까지 좋아질 수 있는지 설명할게요. 벤치마크 숫자와 공식은 그대로 기억해 두시면 좋아요.

출처: 공식문서

고차원 벡터 임베딩은 수백만 개의 벡터로 이루어진 대용량 데이터셋을 다룰 때 특히 메모리를 많이 차지할 수 있어요. 규모를 키우기 시작하면 메모리 사용량(footprint)이 정말로 걱정거리가 돼요. 한 숫자를 저장하는 데 쓰는 데이터 타입을 고르는 것만으로도 수십억 개의 숫자에 영향을 미치고, 메모리 요구량을 크게 좌우할 수 있어요. 타입의 정밀도가 높을수록 숫자를 더 정확하게 표현할 수 있죠. 벡터가 정확할수록 거리 계산도 더 정밀해지고요. 하지만 점점 더 많은 메모리를 주문해야 할 시점이 오면 이런 장점들이 더 이상 이득이 되지 않아요.

Qdrant는 임베딩의 숫자를 저장하는 기본 타입으로 float32를 선택했어요. 따라서 한 숫자는 4바이트의 메모리를 차지하고, 512차원 벡터는 2kB를 차지하죠. 이건 벡터를 저장하는 데만 쓰이는 메모리예요. 여기에 HNSW 그래프의 오버헤드도 있으므로, 대략적으로는 다음 공식으로 메모리 크기를 추정합니다.

memory_size = 1.5 * number_of_vectors * vector_dimension * 4 bytes

Qdrant가 데이터의 일부를 디스크에 저장하는 여러 옵션을 제공하지만, 1.1.0 버전부터는 임베딩을 압축해서 메모리를 최적화할 수도 있어요. 바로 스칼라 양자화(Scalar Quantization) 매커니즘을 구현한 거예요! 이 기법은 메모리뿐 아니라 성능에도 긍정적인 영향을 준다고 밝혀졌습니다.

스칼라 양자화

스칼라 양자화는 부동소수점 값을 정수로 변환하는 데이터 압축 기법이에요. Qdrant의 경우 float32int8로 변환되므로, 한 숫자에 필요한 메모리가 75% 줄어듭니다. 그런데 이건 단순한 반올림이 아니에요! 이 과정은 변환을 부분적으로 되돌릴 수 있게 만들어서, 약간의 정밀도 손실만으로 정수를 다시 부동소수점으로 되돌릴 수도 있어요.

이론적 배경

float32 벡터 컬렉션이 있고, 단일 값을 f32라고 표기한다고 가정해 볼게요. 실제로 신경망 임베딩은 부동소수점 숫자가 표현할 수 있는 전체 범위를 덮지 않고, 아주 작은 부분 범위만 덮어요. 우리는 다른 모든 벡터를 알고 있으므로, 모든 숫자의 몇 가지 통계를 세울 수 있어요. 예를 들어 값의 분포는 전형적으로 정규분포를 띱니다.

우리 예시는 값의 99%가 [-2.0, 5.0] 범위에서 온다는 것을 보여줘요. 그리고 int8로의 변환은 분명 일부 정밀도를 잃으므로, 우리는 99%의 가장 가능성 높은 값들의 범위 안에서 표현 정확도를 유지하고, 이상치(outlier)의 정밀도는 무시하는 쪽을 선호해요. 범위 너비는 다르게 고를 수도 있는데, 실제로 [0, 1] 범위의 어떤 값이든 가능해요. 0은 빈 범위를, 1은 모든 값을 유지하는 걸 의미하죠. 이건 이 절차의 하이퍼파라미터로 quantile이라고 불러요. 0.950.99 값이 전형적으로 합리적인 선택이지만, 일반적으로 quantile ∈ [0, 1]이에요.

정수로의 변환

int8로의 변환에 대해 이야기해 볼게요. 정수도 표현 가능한 값의 유한한 집합을 가져요. 한 바이트 안에서는 [-128, 127] 또는 [0, 255]에서 최대 256개의 서로 다른 값을 표현할 수 있어요.

f32로 표현할 수 있는 숫자에 몇 가지 경계를 두었고, i8에도 자연스러운 경계가 있으므로, 두 범위 사이의 값을 변환하는 과정은 꽤 자연스러워요.

f32=α×i8+offset

i8=f32−offsetα

α와 offset 파라미터는 주어진 벡터 집합에 대해 계산해야 하지만, f32i8 각각의 표현 범위의 최솟값과 최댓값을 대입하면 쉽게 구할 수 있어요.

부호 없는 int8의 경우 다음과 같이 진행됩니다.

{−2=α×0+offset5=α×255+offset

부호 있는 int8의 경우에는 표현 범위 경계만 바꾸면 돼요.

{−2=α×(−128)+offset5=α×127+offset

어떤 벡터 값 집합이든 α와 offset을 간단히 계산할 수 있고, 타입 사이를 변환할 수 있도록 이 값들은 컬렉션과 함께 저장되어야 해요.

거리 계산

컬렉션의 벡터를 float32 대신 int8로 저장하는 건 단지 메모리를 압축하기 위한 게 아니에요. 좌표는 벡터 사이의 거리를 계산할 때도 사용되니까요. 내적(dot product)과 코사인 거리는 모두 두 벡터의 대응 좌표를 곱해야 하므로, 이건 float32에서 꽤 자주 수행하는 연산이에요. int8로 변환하면 다음과 같이 보일 거예요.

f32×f32′==(α×i8+offset)×(α×i8′+offset)==α2×i8×i8′+offset×α×i8′+offset×α×i8+offset2⏟pre-compute

첫 번째 항 α2×i8×i8′는 두 벡터에 모두 의존하므로 거리를 측정할 때 계산해야 해요. 하지만 두 번째와 세 번째 항(offset×α×i8′offset×α×i8 각각)은 단일 벡터에만 의존하므로 미리 계산해 각 벡터에 대해 보관해 둘 수 있어요. 마지막 항 offset2는 어떤 값에도 의존하지 않으므로 한 번만 계산해 재사용할 수도 있어요.

거리를 측정하기 위해 모든 항을 계산해야 한다면, 성능이 변환 전보다 오히려 나빠질 수도 있어요. 하지만 대부분의 항을 미리 계산할 수 있다는 덕분에 상황은 더 단순해집니다. 그리고 스칼라 양자화는 메모리 사용량뿐 아니라 성능에도 긍정적인 영향을 준다는 게 밝혀졌어요. 늘 그렇듯 이 주장을 뒷받침하기 위해 벤치마크를 수행했습니다!

벤치마크

공개하는 다른 벤치마크에서 쓰는 것과 동일한 접근 방식을 그대로 사용했어요. 양자화되지 않은 벡터와 양자화된 벡터를 비교하기 위해 Arxiv-titles-384-angular-no-filtersGist-960 데이터셋을 모두 골랐습니다. 결과는 다음과 같은 표로 요약됩니다.

Arxiv-titles-384-angular-no-filters

ef = 128 ef = 256 ef = 512
Upload and indexing time Mean search precision Mean search time
--- --- --- ---
Non-quantized vectors 649 s 0.989 0.0094
Scalar Quantization 496 s 0.986 0.0037
Difference -23.57% -0.3% -60.64%

검색 정밀도의 약간의 감소는 지연 시간(latency)에서 상당한 개선으로 이어져요. 최고의 정밀도를 노리는 게 아니라면, 검색 품질의 차이는 느끼지 못할 거예요.

Gist-960

ef = 128 ef = 256 ef = 512
Upload and indexing time Mean search precision Mean search time
--- --- --- ---
Non-quantized vectors 452 0.802 0.077
Scalar Quantization 312 0.802 0.043
Difference -30.79% 0% -44,16%

모든 경우에서 검색 정밀도의 감소는 무시할 만한 수준이지만, 검색 시 지연 시간은 최소 28.57%, 최대 60,64%까지 줄어듭니다. 대략적인 기준으로, 벡터의 차원이 높을수록 정밀도 손실은 더 작아요.

오버샘플링과 리스코어링 (Oversampling and rescoring)

Qdrant 아키텍처의 독특한 특징은 양자화된 벡터와 원본 벡터에 대한 검색을 단일 질의로 결합할 수 있다는 점이에요. 이를 통해 속도, 정확도, RAM 사용량의 최상의 조합을 얻을 수 있습니다.

Qdrant는 원본 벡터를 저장하므로, 양자화된 공간에서 이웃 검색을 수행한 뒤 원본 벡터로 top-k 결과를 리스코어(rescore) 할 수 있어요. 물론 이는 성능에 어느 정도 영향을 주는데, 그 영향이 얼마나 큰지 측정하기 위해 서로 다른 검색 시나리오로 비교를 수행했습니다. 네트워크 마운트 디스크가 아주 느린 머신을 사용해서, 허용되는 RAM 양을 다르게 한 다음 시나리오들을 테스트했어요.

Setup RPS Precision
4.5GB memory 600 0.99
4.5GB memory + SQ + rescore 1000 0.989

그리고 더 엄격한 메모리 제한이 있는 다른 그룹도 있었어요.

Setup RPS Precision
2GB memory 2 0.99
2GB memory + SQ + rescore 30 0.989
2GB memory + SQ + no rescore 1200 0.974

이 실험들에서 처리량은 주로 디스크 읽기 횟수에 의해 결정되었고, 양자화는 더 많은 벡터를 RAM에 담을 수 있게 해줘 디스크 읽기를 효율적으로 줄여줬어요. Qdrant의 온디스크 저장과 그 성능 측정 방법에 대해 더 알고 싶다면 백만 벡터를 서빙하는 데 필요한 최소 RAM 글을 참고하세요.

리스코어링을 끈 스칼라 양자화 매커니즘은 저사양 머신의 한계를 더욱 넓혀줍니다. 검색 정밀도의 작은 감소를 수용할 수 있다면, 많은 요청을 처리하는 데 값비싼 구성이 필요하지 않다는 뜻이에요.

모범 사례 참고하기

스칼라 양자화에 관한 Qdrant 문서는 최대 4배 낮은 메모리 사용량과 최대 2배 성능 향상을 달성하기 위한 다양한 시나리오와 전략을 설명하는 훌륭한 자료예요.

더 알아보기 (Learn more)