고차원 벡터를 바이너리 양자화로 최적화하기

고차원 벡터를 바이너리 양자화로 최적화하기 (Optimizing High-Dimensional Vectors with Binary Quantization)

벡터 검색이 커질수록 늘 따라오는 고민이 있어요. 메모리는 한정돼 있는데, 벡터는 수백만 개고, 게다가 빨라야 하고요. Qdrant는 그런 고민을 덜기 위해 여러 압축 기법을 제공하는데, 이 글에서 다룰 **바이너리 양자화(Binary Quantization, BQ)**는 특히 벡터 길이가 길고 포인트 수가 많은 컬렉션에서 강력한 선택지예요. 결과가 꽤 극적이죠. BQ를 쓰면 메모리 사용량이 줄고 검색 속도는 최대 40배까지 빨라질 수 있어요. 물론 다른 양자화 기법과 마찬가지로 정확도(recall)가 조금 떨어지는 대가가 있어요. 다행히 Qdrant 구현은 인덱스를 만들 때가 아니라 검색하는 시점에서 속도와 정확도의 트레이드오프를 조절할 수 있게 해 줘요.

이 글에서 다룰 내용은 이렇게 세 가지예요:

  1. 바이너리 양자화의 중요성
  2. Python 클라이언트로 하는 기본 구현
  3. 벤치마크 분석과 사용 권장 사항

출처: 공식문서

바이너리 양자화가 뭔가요?

바이너리 양자화(BQ)는 부동소수점 숫자로 된 벡터 임베딩을 바이너리(boolean) 값 벡터로 변환하는 기법이에요. 이 기능은 이전에 다룬 스칼라 양자화의 연장선이에요. 스칼라 양자화에서는 float32uint8로 변환한 뒤, 특정 SIMD CPU 명령어를 활용해 빠른 벡터 비교를 수행했죠.

이 이진화 함수는 값의 범위를 바이너리 값으로 바꾸는 함수예요. 0보다 큰 모든 숫자는 1로, 0 이하인 숫자는 0으로 표시해요.

벡터 임베딩을 바이너리 값으로 줄였을 때의 이점은, boolean 연산이 매우 빠르고 CPU 명령어도 훨씬 적게 필요하다는 점이에요. 32비트 임베딩을 1비트 임베딩으로 줄이는 대가로 최대 40배의 검색 속도 향상을 얻을 수 있어요.

이렇게 높은 압축률에서도 벡터 검색이 여전히 잘 동작하는 이유 중 하나는, 이런 큰 벡터들이 사실 검색 목적에는 과하게 파라미터가 많기(over-parameterized) 때문이에요. 이런 벡터는 랭킹, 클러스터링 같은 용도에 쓰도록 설계돼서, 벡터에 더 많은 정보가 인코딩돼 있을 필요가 있거든요.

예를 들어 1536차원의 OpenAI 임베딩은 검색과 랭킹에서 384차원의 오픈소스 대안보다 오히려 더 나빠요. 구체적으로 말하면 같은 Embedding Retrieval Benchmark에서 49.25점을 받는데, 오픈소스 bge-small은 51.82점이에요. 이 2.57점 차이는 꽤 빨리 쌓여요.

Qdrant의 양자화 구현은 랭킹 시점에는 크고 완전한 벡터를, 검색·조회 시점에는 바이너리 벡터를 모두 활용하는 좋은 균형을 이룹니다. 그리고 사용 사례에 따라 이 균형을 조절할 수도 있어요.

더 빠른 검색과 조회

프로덕트 양자화(Product Quantization)와 달리 바이너리 양자화는 각 프로브마다 검색 공간을 줄이는 데 의존하지 않아요. 대신 바이너리 인덱스를 구축해서 검색 속도를 크게 올리는 방식이에요.

HNSW는 근사 최근접 이웃(Approximate Nearest Neighbor) 검색이에요. 즉 인덱스에서 더 유사한 후보를 확인할수록 정확도는 수익 체감이 오는 지점까지 향상돼요. 바이너리 양자화의 맥락에서는 이 걸 **오버샘플링 비율(oversampling rate)**이라고 불러요.

예를 들어 oversampling=2.0이고 limit=100이라면, 먼저 양자화된 인덱스로 200개의 벡터를 선택해요. 그 200개 벡터에 대해서는 전체 32비트 벡터를 HNSW 인덱스와 함께 사용해서 훨씬 정확한 100개 결과 집합을 만들어요. 전체 HNSW 검색을 하는 대신, 예비 검색을 오버샘플링한 뒤 그 훨씬 작은 벡터 집합에 대해서만 전체 검색을 하는 방식이에요.

저장 효율 개선

아래 다이어그램은 32비트 저장 공간을 1비트 정보로 줄이는 이진화 함수를 보여줘요.

텍스트 임베딩은 1024개가 넘는 32비트 부동소수점 요소를 가질 수 있어요. 예를 들어 OpenAI 임베딩이 1536개 요소 벡터라는 걸 기억해 보세요. 이는 벡터 하나를 저장하는 것만으로 각 벡터가 6kB라는 뜻이에요.

벡터를 저장하는 것에 더해, 더 빠른 검색과 조회를 위한 인덱스도 유지해야 해요. Qdrant가 전체 메모리 사용량을 추정하는 공식은 다음과 같아요:

memory_size = 1.5 * number_of_vectors * vector_dimension * 4 bytes

100K개의 OpenAI 임베딩(ada-002) 벡터를 저장하려면 900MB의 RAM과 디스크 공간이 필요해요. 컬렉션을 여러 개 만들거나 데이터베이스에 아이템을 더 추가하면 이 사용량은 아주 빠르게 늘어나기 시작해요.

바이너리 양자화를 쓰면 동일한 100K OpenAI 벡터가 RAM을 단 128MB만 차지해요. 이 결과는 Scalar Quantization 메모리 추정에서 다룬 것과 유사한 방법으로 벤치마크한 값이에요.

이런 RAM 절감은 바이너리 변환 과정에서 일어나는 압축 덕분이에요. HNSW와 양자화된 벡터는 빠른 접근을 위해 RAM에 상주하고, 원본 벡터는 디스크에만 올려둘 수 있어요. 검색 시 양자화된 HNSW가 오버샘플링된 후보를 제공하고, 그 후보들을 디스크에 저장된 원본 벡터로 재평가해서 최종 결과를 다듬어요. 이 모든 과정은 여러분이 별도로 개입하지 않아도 내부적으로 처리돼요.

BQ를 쓰면 안 되는 경우는?

이 기법은 임베딩의 과잉 파라미터화를 이용하는 방식이라, 1024차원 미만의 작은 임베딩에서는 결과가 좋지 않을 수 있어요. 요소 수가 적으면 바이너리 벡터에 좋은 결과를 내기 충분한 정보가 담기지 않기 때문이에요. boolean 연산이 빨라지고 RAM 사용량도 줄어들겠지만, 정확도 저하가 너무 클 수 있어요.

샘플 구현

바이너리 양자화에 대해 알아봤으니 이제 기본 구현을 직접 해 볼게요. 이 예제에서는 OpenAI와 Cohere를 Qdrant와 함께 사용할 거예요.

바이너리 양자화를 활성화한 컬렉션 만들기

인덱싱 시점에 컬렉션을 만들 때 해야 할 일은 다음과 같아요.

  1. 모든 "full" 벡터를 디스크에 저장해요.
  2. 그런 다음 바이너리 임베딩을 RAM에 놓아요.

기본적으로 full 벡터와 BQ 둘 다 RAM에 저장돼요. 우리는 full 벡터를 디스크로 옮겨서 메모리를 아끼고 더 많은 벡터를 RAM에 저장할 수 있게 해요. 이렇게 하면서 always_ram=True를 설정해 명시적으로 바이너리 벡터를 메모리에 두는 거예요.

from qdrant_client import QdrantClient

#collect to our Qdrant Server
client = QdrantClient(
    url="http://localhost:6333",
    prefer_grpc=True,
)

#Create the collection to hold our embeddings
# on_disk=True and the quantization_config are the areas to focus on
collection_name = "binary-quantization"
if not client.collection_exists(collection_name):
    client.create_collection(
        collection_name=f"{collection_name}",
        vectors_config=models.VectorParams(
            size=1536,
            distance=models.Distance.DOT,
            on_disk=True,
        ),
        optimizers_config=models.OptimizersConfigDiff(
            default_segment_number=5,
        ),
        hnsw_config=models.HnswConfigDiff(
        m=0,
        ),
        quantization_config=models.BinaryQuantization(
            binary=models.BinaryQuantizationConfig(always_ram=True),
        ),
    )

HnswConfig에서는 무슨 일이 일어나나요?

m을 0으로 설정하는 건 HNSW 그래프 구성을 비활성화한다는 뜻이에요. 이렇게 하면 벡터와 페이로드를 더 빠르게 업로드할 수 있어요. 모든 데이터가 로드되면 아래에서 다시 켤 거예요.

다음으로 벡터를 업로드하고 그래프 구성을 활성화하기

batch_size = 10000
client.upload_collection(
    collection_name=collection_name,
    ids=range(len(dataset)),
    vectors=dataset["openai"],
    payload=[
        {"text": x} for x in dataset["text"]
    ],
    parallel=10, # based on the machine
)

HNSW 그래프 구성을 다시 활성화해요.

client.update_collection(
    collection_name=f"{collection_name}",
    hnsw_config=models.HnswConfigDiff(
        m=16,
    ,
)

검색 파라미터 구성하기

검색 파라미터를 설정할 때는 oversamplingrescore를 사용하겠다고 지정해요. 예시 스니펫은 다음과 같아요.

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7, ...],
    search_params=models.SearchParams(
        quantization=models.QuantizationSearchParams(
            ignore=False,
            rescore=True,
            oversampling=2.0,
        )
    )
)

Qdrant가 오버샘플링된 벡터 집합을 가져오면, 예를 들어 OpenAI의 1536차원 같은 full 벡터를 디스크에서 올려요. Qdrant는 쿼리 벡터와의 최근접 이웃을 계산하고, 정확하게 재정렬된(rescored) 순서를 반환해요. 이 방식은 훨씬 정확한 결과를 만들어요. 우리는 rescore=True로 설정해서 이 기능을 켰어요.

이 두 파라미터가 속도와 정확도의 균형을 맞추는 방법이에요. 오버샘플 크기가 클수록 디스크에서 읽어야 할 항목이 많아지고, 상대적으로 느린 full 벡터 인덱스로 검색해야 할 요소도 많아져요. 그 대신 결과는 더 정확해지죠.

정확도 요구가 낮다면 작은 오버샘플링에 리스코어 없이 시도해 볼 수도 있어요. 아니면 데이터셋과 정확도·속도 요구를 조합해서 바이너리 인덱스만 검색하고 리스코어를 아예 하지 않는, 즉 검색 쿼리에서 이 두 파라미터를 빼 버리는 방법도 있어요.

벤치마크 결과

DBPedia OpenAI 1M 벡터 데이터셋으로 limit과 oversampling 사이의 관계에 대한 초기 결과를 얻었어요. 모든 실험은 100K 벡터가 인덱싱된 Qdrant 인스턴스에서, 100개의 무작위 쿼리로 실행했어요.

쿼리 시간과 정확도에 영향을 주는 3가지 파라미터(limit, rescore, oversampling)를 바꿔 가며 실험했어요. 이것은 이 새 기능의 초기 탐색으로, 여러분의 데이터셋으로 이 실험을 재현해 보시길 적극 권합니다.

참고: 벡터 데이터베이스의 새 혁신이라서 피드백과 결과를 듣고 싶어요. 더 깊은 논의는 Discord 서버에서 함께해요!

오버샘플링 (Oversampling):

아래 그림은 recall과 후보 수 사이의 관계를 보여줘요.

"정답(correct)" 결과, 즉 recall은 잠재적 "후보(candidates)" 수(limit x oversampling)가 증가함에 따라 증가하는 걸 볼 수 있어요. limit 변화의 영향을 강조하기 위해, 서로 다른 limit 값을 서로 다른 곡선으로 나눠 놓았어요. 예를 들어 limit 50에서 recall이 가장 낮은 지점은 100개의 후보에서 약 94개의 정답이에요. 이는 oversampling 2.0을 사용했다는 뜻이기도 해요.

오버샘플링이 증가하면 전반적으로 결과가 개선되지만, 모든 경우에 그런 건 아니에요.

리스코어 (Rescore):

예상대로 리스코어는 쿼리 결과를 반환하는 데 걸리는 시간을 늘려요. 오버샘플링 실험을 반복하되, 이번에는 rescore가 결과 정확도에 어떤 영향을 주는지 살펴봤어요.

Limit:

Top 1부터 Top 50까지의 limit으로 실험했고, 100K 벡터 인덱스에서 rescore=True로 limit 50에서 100% recall에 도달할 수 있었어요.

권장 사항

양자화는 다른 파라미터들에 대해 트레이드오프를 만들 수 있는 옵션을 줍니다:

  • 차원 수 / 임베딩 크기
  • 처리량(Throughput)과 지연시간(Latency) 요구사항
  • Recall 요구사항

OpenAI나 Cohere 임베딩을 사용한다면 다음 oversampling 설정을 권장해요.

Method Dimensionality Test Dataset Recall Oversampling
OpenAI text-embedding-3-large 3072 DBpedia 1M 0.9966 3x
OpenAI text-embedding-3-small 1536 DBpedia 100K 0.9847 3x
OpenAI text-embedding-3-large 1536 DBpedia 1M 0.9826 3x
OpenAI text-embedding-ada-002 1536 DbPedia 1M 0.98 4x
Gemini 768 No Open Data 0.9563 3x
Mistral Embed 768 No Open Data 0.9445 3x

바이너리 양자화가 여러분의 데이터셋과 쿼리에 적합하다고 판단되면 다음을 권장해요:

  • 'always_ram=True'로 바이너리 양자화 사용
  • 벡터를 디스크에 저장
  • 'Oversampling=2.0' (또는 그 이상)
  • 'Rescore=True'

다음은 뭐예요?

높은 recall을 기대하면서 대량의 데이터를 다뤄야 한다면 바이너리 양자화가 훌륭한 선택이에요. 이 기능은 Qdrant 컨테이너 이미지를 로컬에서 띄우거나, 클라우드 호스팅 서비스의 무료 계정으로 만들어서 시작해 볼 수 있어요.

이 글의 예제 데이터셋과 구성으로 시작해 보세요. 문서에서는 대규모 데이터셋을 Qdrant에 추가하는 방법더 많은 양자화 기법에 대해 다루고 있어요.

피드백이 있다면 Twitter나 LinkedIn으로 결과를 알려주세요. BQ에 대해 관심 있는 분들과 이야기하고 싶다면 활발한 Discord 서버에 참여해 보세요!

더 알아보기 (Learn more)