Qdrant에서 GPU 가속 HNSW 인덱싱
Qdrant에서 GPU 가속 HNSW 인덱싱 (tutorials-operations-gpu-accelerated-hnsw-indexing)
| 시간: 45분 | 수준: 중급 | 출력: GitHub |
|---|
Qdrant v1.13부터 Qdrant는 셀프 호스팅 인스턴스에서 GPU 가속 HNSW(Hierarchical Navigable Small World) 인덱싱을 지원해요. Qdrant Cloud는 2026년 4월 Qdrant Cloud에 추가된 새 기능의 일부로, 이를 관리형 옵션으로 더 최근에 추가했어요.
GPU 가속은 HNSW 인덱스 빌드 속도를 높여줘요. 이는 벡터 검색으로 구축하는 모든 팀이 언젠가는 마주치는 문제, 즉 다른 임베딩 모델로 전환할 때 컬렉션을 다시 인덱싱하는 비용을 해결해줘요.
수백만 또는 수천만 개의 포인트 규모에서는 CPU 기반 재인덱싱이 느리고 비쌀 수 있어요. 인덱싱과 다른 최적화가 검색과 같은 리소스를 두고 경쟁하기 때문에, 검색 지연 시간을 높이고 전체 트래픽을 느리게 만들죠. GPU는 대규모 병렬화에 뛰어나기 때문에 여기서 도움이 돼요. GPU는 수많은 작은 작업을 한 번에 실행하는 반면, CPU는 순차 작업에 최적화되어 있어요.
HNSW 인덱스를 구축하는 일은 대부분 그래프에서의 노드·에지 배치 같은 많은 작은 연산으로 이뤄져요. 그래서 I/O 바운드 작업(보통 파일에 순차 접근이 필요한 작업)보다 GPU 가속의 이점이 훨씬 커요.

이 튜토리얼에서는 Qdrant Cloud에서 HNSW 인덱싱을 설정하고, 인덱싱 속도와 쿼리 지연 시간에 미치는 영향을 측정하고, CPU 인덱스 빌드와 비용을 비교하고, 어떤 트레이드오프가 따르는지 살펴볼 거예요.
GPU 지원 클러스터 설정하기
Qdrant Cloud에서 GPU 지원 클러스터는 전용 UI를 통해서도, 튜토리얼에서처럼 Qdrant Cloud 클러스터를 관리하는 명령줄 도구인 qcloud CLI를 통해서도 설정할 수 있어요.
CLI 설치
qcloud는 GitHub Releases에서 설치하거나 go를 사용해 설치할 수 있어요:
go install github.com/qdrant/qcloud-cli/cmd/qcloud@latest
GitHub Releases에서 대신 설치하려면:
curl -L https://github.com/qdrant/qcloud-cli/releases/download/v0.25.0/qcloud-linux-amd64.tar.gz | tar -xz
sudo mv qcloud /usr/local/bin/qcloud
설치를 확인해봐요:
qcloud version
인증 및 컨텍스트 설정
모든 후속 작업의 기반이 되는 Qdrant Cloud 계정에 연결된 컨텍스트를 만들어요.
인증에는 Management API 키와 계정 ID가 필요해요. 이를 환경 변수로 내보내세요:
export QDRANT_MANAGEMENT_KEY="..."
export QDRANT_ACCOUNT_ID="..."
그런 다음 컨텍스트를 만들어요:
qcloud context set my-cloud \
--api-key QDRANT_MANAGEMENT_KEY \
--account-id QDRANT_ACCOUNT_ID
클러스터 만들기
qcloud cluster create로 GPU 지원 클러스터를 만들어요.
클러스터를 만들기 전에, 할당하려는 리소스와 배포하려는 리전에 따른 예상 요금을 확인해보세요.
GPU에 필요한 최소 리소스로 클러스터를 만드는 예시 명령은 다음과 같아요:
qcloud cluster create \
--disk 64GiB \
--cloud-provider aws \
--cloud-region us-east-1 \
--cpu 4000m \
--gpu 1 \
--ram 16GiB \
--nodes 1 \
--disk-performance cost-optimised \
--name "gpu-experiment"
컬렉션 만들기 및 데이터 업로드
의존성 설치
새 클러스터와 상호작용하기 위한 qdrant-client, 데이터셋을 다운로드하고 처리하기 위한 huggingface-hub와 polars를 설치해요.
pip install -q qdrant-client huggingface-hub polars
클라이언트 초기화
위에서 만든 클러스터의 자격 증명을 사용해 비동기 Qdrant 클라이언트를 인스턴스화해요.
import os
from qdrant_client import AsyncQdrantClient, models
def create_qdrant_client(url: str, api_key: str) -> AsyncQdrantClient:
return AsyncQdrantClient(
url=url,
api_key=api_key,
timeout=60,
prefer_grpc=True
)
gpu_client = create_qdrant_client(
os.getenv("QDRANT_URL"),
os.getenv("QDRANT_API_KEY")
)
데이터셋 준비
사전 임베딩된 100,000개의 Wikipedia 구절이 담긴 ashraq/cohere-wiki-embedding-100k 데이터셋을 다운로드해요.
from huggingface_hub import snapshot_download
import polars as pl
data_path = snapshot_download(
repo_id="ashraq/cohere-wiki-embedding-100k",
repo_type="dataset",
allow_patterns=["data/train-*-of-*.parquet"],
)
data = pl.read_parquet(
source=f"{data_path}/data/train-*-of-*.parquet",
columns=["emb"]
)
컬렉션 만들기
단일 dense 벡터 필드를 가진 컬렉션을 만들고, 업로드가 끝날 때까지 인덱싱을 비활성화해요. indexing_threshold를 업로드할 데이터의 총 크기(KB)보다 높게 설정하면 돼요. 이렇게 하면 포인트가 스트리밍으로 들어오는 동안 Qdrant가 HNSW 그래프를 (재)빌드하지 않으므로 초기 업로드가 빨라져요.
hnsw_config.m과 hnsw_config.ef_construct는 실험 간에 다양하게 바꿔볼 수 있도록 여기서는 변수로 남겨둘게요.
HNSW_M = 32
HNSW_EF_CONSTRUCT = 128
DIMENSIONS = len(data["emb"][0])
# 1 full-precision 256-dim vector is ~1KB
# so the size of the dataset in KB is (DIMENSIONS / 256) * DATASET_SIZE.
SIZE_KB = (DIMENSIONS // 256) * data.height
async def create_collection(client: AsyncQdrantClient, collection_name: str) -> None:
await client.create_collection(
collection_name=collection_name,
optimizers_config=models.OptimizersConfigDiff(
# add a few KB to make sure the threshold isn't surpassed
indexing_threshold=SIZE_KB + 1000,
),
vectors_config={
"dense": models.VectorParams(
size=DIMENSIONS,
distance=models.Distance.COSINE,
hnsw_config=models.HnswConfigDiff(
m=HNSW_M,
ef_construct=HNSW_EF_CONSTRUCT,
),
)
},
)
await create_collection(gpu_client, "gpu-hnsw-experiment")
데이터 업로드
임베딩을 배치로 업로드하고, 각 포인트에 임의의 UUID를 부여해요.
import uuid
BATCH_SIZE = 1000
def upload_points(client: AsyncQdrantClient, collection_name: str) -> None:
client.upload_points(
collection_name=collection_name,
points=(
models.PointStruct(
id=str(uuid.uuid4()),
vector={"dense": row["emb"]},
) for row in data.iter_rows(named=True)
),
batch_size=BATCH_SIZE,
)
upload_points(gpu_client, "gpu-hnsw-experiment")
쿼리 세트 준비
HNSW 인덱스가 구축되는 동안 쿼리 지연 시간을 측정하려면, 업로드한 임베딩의 임의 표본을 쿼리 벡터로 따로 떼어두세요.
NUM_QUERIES = 200
queries = data.sample(NUM_QUERIES)["emb"].to_list()
인덱싱 및 쿼리 지연 시간 모니터링
인덱싱 활성화
이제 업로드가 끝났으니 indexing_threshold를 기본값으로 낮춰서 Qdrant가 HNSW 그래프를 구축하도록 해요.
async def enable_indexing(client: AsyncQdrantClient, collection_name: str) -> None:
await client.update_collection(
collection_name=collection_name,
optimizers_config=models.OptimizersConfigDiff(
indexing_threshold=10_000,
),
)
await enable_indexing(gpu_client, "gpu-hnsw-experiment")
인덱싱하는 동안 쿼리 실행
두 코루틴을 동시에 실행해요:
- 하나는 실행 중이거나 대기 중인 모든 최적화가 끝날 때까지 0.2초마다
GET /collections/{collection}/optimizations를 폴링하면서 각 스냅샷을 기록해요. - 다른 하나는 가능한 한 빨리 컬렉션을 반복적으로 질의하며 모든 요청의 지연 시간을 기록해요.
폴링 코루틴이 인덱싱이 끝났음을 관찰하는 순간 두 코루틴 모두 멈춰요. 이렇게 하면 나중에 쿼리 지연 시간과 HNSW 빌드 상태를 연관지을 수 있어요.
먼저 공유 임포트와 각 최적화 스냅샷에 타임스탬프를 찍는 데 쓰는 모델부터 시작해요:
import asyncio
import time
from collections.abc import AsyncGenerator
from pydantic import BaseModel
MAX_POLLING_ITERATIONS = 14_400 # 14_400 its x 0.5 s/it = 7200s (2hr)
QUERY_LIMIT = 10
class OptimizationProgress(BaseModel):
response: models.OptimizationsResponse
timestamp: float
최적화 폴링
poll_for_optimizations는 반복마다 컬렉션의 실행 중/대기 중 최적화와 세그먼트 수의 타임스탬프 스냅샷을 생성하는 async generator예요.
Qdrant의 유휴 상태는 최적화 실행 사이에 잠깐 깜빡일 수 있으므로, 루프는 완료를 신호하기 전에 연속 5번의 유휴 스냅샷(0.2초 폴링 간격에서 약 1초)을 기다려요. max_iterations 상한은 폴링을 2시간으로 제한하며, 그 후에는 무한 루프 대신 TimeoutError를 발생시켜요.
async def poll_for_optimizations(
client: AsyncQdrantClient,
collection_name: str,
signal: asyncio.Event,
max_iterations: int = MAX_POLLING_ITERATIONS
) -> AsyncGenerator[OptimizationProgress]:
iterations = 0
idle_its = 0
while iterations < max_iterations:
optimizations, coll_info = await asyncio.gather(client.get_optimizations(
collection_name=collection_name, _with="completed,queued,idle_segments"
), client.get_collection(collection_name=collection_name))
yield OptimizationProgress(response=optimizations, timestamp=time.time())
if len(optimizations.running) == 0 and len(optimizations.queued or []) == 0 and optimizations.summary.idle_segments == coll_info.segments_count:
# been idle for ~1s
if idle_its == 5:
signal.set()
break
idle_its += 1
iterations += 1
await asyncio.sleep(0.2)
if iterations == max_iterations:
signal.set()
raise TimeoutError("Operation timed out after 2 hours")
async def consume_optimizations(
client: AsyncQdrantClient,
collection_name: str,
signal: asyncio.Event,
max_iterations: int = MAX_POLLING_ITERATIONS
) -> list[OptimizationProgress]:
optimizations = []
async for o in poll_for_optimizations(client, collection_name, signal, max_iterations):
optimizations.append(o)
return optimizations
컬렉션 쿼리
query는 위의 폴링 루프와 독립적으로 자체 코루틴에서 실행돼요. 앞서 만든 queries 표본을 반복하며, 클라이언트와 서버가 허용하는 한 빠르게 query_points 요청을 연달아 보내고 각 요청의 지연 시간을 실행 시작 이후 경과 시간과 함께 기록해요. signal 이벤트는 poll_for_optimizations와 공유되므로, 그 코루틴이 인덱싱 완료를 표시하면 이 루프는 같은 이벤트를 확인하고 실험 종료 시점을 넘겨 실행하지 않고 중간에 멈춰요.
async def query(
client: AsyncQdrantClient,
collection_name: str,
signal: asyncio.Event,
queries: list[list[float]],
limit: int = QUERY_LIMIT
) -> list[tuple[float, float]]:
latencies = []
start = time.time()
while True:
for d in queries:
if signal.is_set():
break
timestamp = time.time()
await client.query_points(
collection_name=collection_name, query=d, limit=limit, using="dense",
)
finished = time.time() - timestamp
latencies.append((timestamp - start, finished))
if signal.is_set():
break
return latencies
두 코루틴 실행하기
같은 event를 공유하는 consume_optimizations와 query를 동시 작업으로 시작하고, 둘 다 끝날 때까지 기다린 뒤 결과를 디스크에 저장해요:
import json
OPTIMIZATIONS_FILE = "optimizations.jsonl"
LATENCIES_FILE = "latencies.jsonl"
event = asyncio.Event()
optimizations_task = asyncio.create_task(consume_optimizations(gpu_client, "gpu-hnsw-experiment", event))
query_task = asyncio.create_task(query(gpu_client, "gpu-hnsw-experiment", event, queries))
optimizations_result, latencies_result = await asyncio.gather(optimizations_task, query_task)
with open(OPTIMIZATIONS_FILE, "w") as f:
f.writelines([r.model_dump_json() + "\n" for r in optimizations_result])
with open(LATENCIES_FILE, "w") as f:
f.writelines(
[
json.dumps({"timestamp": r[0], "latency": r[1]}) + "\n"
for r in latencies_result
]
)
이 모니터링 결과, 위에서 논의한 이유로 GPU 지원 클러스터가 더 빠른 최적화 시간을 보여줄 것으로 기대해요.
쿼리 시간은 두 클러스터 모두 비슷할 거예요. 다만 CPU 전용 클러스터는 쿼리와 최적화가 같은 CPU 사이클을 두고 경쟁해서 읽기·쓰기 사이의 리소스 경합이 늘어나므로 지연 시간 스파이크가 더 많이 나타날 수 있어요.

결과 분석하기
HNSW 인덱싱 시간
optimizations.jsonl을 파싱해 최적화의 시작·종료 시각을 얻고, 그 사이의 총 시간을 계산해요.
def hnsw_indexing_time(optimizations_file: str) -> dict:
with open(optimizations_file) as f:
optimizations = [OptimizationProgress.model_validate_json(line.strip()) for line in f]
full_time = optimizations[-1].timestamp - optimizations[0].timestamp
return len(optimizations), full_time
num_recoded, full_time = hnsw_indexing_time(OPTIMIZATIONS_FILE)
print(f"Recoded {num_recoded} optimization reports.\nOptimization duration: {full_time:.2f}")
Recoded 27 optimization reports.
Optimization duration: 6.10
쿼리 지연 시간 통계
latencies.jsonl을 파싱해 인덱싱이 실행되는 동안 발행된 모든 쿼리에 대한 처리량(qps)과 min, p50, p95, p99, max, mean 지연 시간을 계산해요.
from statistics import mean, quantiles
from pydantic import BaseModel
class LatencyModel(BaseModel):
latency: float
timestamp: float
def get_latency_stats(latency_file: str) -> dict:
latencies: list[LatencyModel] = []
with open(latency_file) as f:
for line in f:
latencies.append(LatencyModel.model_validate_json(line.strip()))
all_time = latencies[-1].timestamp - latencies[0].timestamp
throughput = len(latencies) / all_time # qps
times = [l.latency for l in latencies]
quant_t = quantiles(times, n=100)
return {
"throughput": throughput,
"min": min(times),
"max": max(times),
"mean": mean(times),
"p50": quant_t[49],
"p95": quant_t[94],
"p99": quant_t[98],
}
print(json.dumps(get_latency_stats(LATENCIES_FILE), indent=2))
{
"throughput": 21.371003142800664,
"min": 0.03345012664794922,
"max": 0.07314538955688477,
"mean": 0.047059608228278885,
"p50": 0.04842805862426758,
"p95": 0.05851303339004517,
"p99": 0.06967230081558227
}
CPU와 비교하기
위 클러스터와 동일한 사양에서 GPU만 뺀 CPU 전용 클러스터를 만들어서 GPU 클러스터와 비교해요.
qcloud cluster create \
--disk 64GiB \
--cloud-provider aws \
--cloud-region us-east-1 \
--cpu 4000m \
--ram 16GiB \
--nodes 1 \
--disk-performance cost-optimised \
--name "cpu-experiment"
CPU 클러스터를 GPU 지원 클러스터와 같은 단계로 실행해요:
cpu_client = create_qdrant_client(
os.getenv("QDRANT_URL"),
os.getenv("QDRANT_API_KEY")
)
# create collection -> upload points -> re-enable indexing
await create_collection(cpu_client, "cpu-hnsw-experiment")
upload_points(cpu_client, "cpu-hnsw-experiment")
await enable_indexing(cpu_client, "cpu-hnsw-experiment")
# collect optimizations and latency statistics
cpu_event = asyncio.Event()
cpu_optimizations_task = asyncio.create_task(consume_optimizations(cpu_client, "cpu-hnsw-experiment", cpu_event))
cpu_query_task = asyncio.create_task(query(cpu_client, "cpu-hnsw-experiment", cpu_event, queries))
optimizations_result, latencies_result = await asyncio.gather(cpu_optimizations_task, cpu_query_task)
# save statistics
CPU_OPTIMIZATIONS_FILE = "cpu-optimizations.jsonl"
CPU_LATENCIES_FILE = "cpu-latencies.jsonl"
with open(CPU_OPTIMIZATIONS_FILE, "w") as f:
f.writelines([r.model_dump_json() + "\n" for r in optimizations_result])
with open(CPU_LATENCIES_FILE, "w") as f:
f.writelines(
[
json.dumps({"timestamp": r[0], "latency": r[1]}) + "\n"
for r in latencies_result
]
)
# compute HNSW indexing time
num_recoded, full_time = hnsw_indexing_time(CPU_OPTIMIZATIONS_FILE)
print(f"Recoded {num_recoded} optimization reports.\nOptimization duration: {full_time:.2f}s")
# compute latencies statistics
print(json.dumps(get_latency_stats(CPU_LATENCIES_FILE), indent=2))
Recoded 277 optimization reports.
Optimization duration: 66.27s
{
"throughput": 20.840132458186957,
"min": 0.037625789642333984,
"max": 0.3050253391265869,
"mean": 0.04800858673459796,
"p50": 0.04686164855957031,
"p95": 0.05590367317199707,
"p99": 0.06498237609863282
}
GPU vs. CPU 비교
두 클러스터는 스펙이 동일했고(16GB RAM, 4 vCPU, 64GB 디스크) 같은 m과 ef_construct로 같은 100,000개 벡터를 인덱싱했어요. 따라서 두 실행 사이의 유일한 변수는 GPU의 유무였어요.
인덱싱 시간: GPU 클러스터는 HNSW 인덱싱을 약 6.1초에 끝낸 반면, CPU 클러스터는 약 66.3초가 걸렸어요. 대략 10배의 속도 향상이에요. 이는 폴링 데이터와 일치해요. GPU에서의 인덱싱은 27개의 최적화 스냅샷(0.2초 폴링 간격) 안에 끝났지만, CPU 실행은 같은 유휴 상태에 도달하는 데 277개의 스냅샷이 필요했어요.

인덱싱 중 쿼리 지연 시간: 처리량은 두 클러스터 모두 거의 같았어요(GPU 약 21 qps vs CPU 약 21 qps). 전형적인(p50) 지연 시간과 p95 지연 시간도 마찬가지로 비슷했어요.
차이는 꼬리에서 드러나요: CPU 실행의 최대 지연 시간은 약 0.31초로 치솟았는데, 이는 GPU 실행의 약 0.07초 최대값보다 4배 이상 큰 값이에요. 또한 CPU의 p99 지연 시간(약 0.065초)이 그 꼬리에 훨씬 가깝게 위치했어요.
다시 말해 CPU는 인덱스 구축과 쿼리 서빙 사이에서 사이클을 나눠 써야 했고, 그래서 가끔 요청이 멈추기도 했어요. 반면 GPU는 인덱스 구축을 오프로드해서 쿼리 서빙을 대부분 방해받지 않게 했죠.

이 워크로드에서 GPU 가속 인덱싱은 CPU 전용 빌드가 보여준 지연 시간 스파이크를 유발하지 않으면서 재인덱싱 시간 창을 한 자릿수로 줄였어요. 인덱싱 시간과 CPU/쿼리 리소스 경합 모두 포인트 수에 따라 커지므로, 이 이점은 데이터셋 크기가 커질수록 더 커질 것으로 예상돼요.
인덱싱 비용
GPU 클러스터는 동일한 스펙의 CPU 전용 클러스터보다 시간당 비용이 더 드는 경향이 있어요. 그게 가치가 있는지는 시간당 요금만이 아니라 그 GPU로 실제로 인덱싱을 얼마나 많이 하느냐에 따라 달려요. 인덱싱 시간이 충분히 빨라지면 더 높은 시간당 요금을 상쇄할 수 있지만, GPU가 실제로 인덱싱하는 동안에만 그렇죠.
다만 GPU 클러스터는 주로 두 가지 시나리오에서 그 비용을 메운다는 점을 고려하는 게 중요해요:
- 컬렉션이 계속 커져서 증분 재인덱싱이 필요해 정기적으로 재인덱싱하는 경우
- 임베딩 모델을 자주 전환해서 재인덱싱이 일회성이 아니라 반복 비용이 되는 경우
둘 다 해당하지 않으면 유휴 GPU 클러스터는 CPU 전용 클러스터보다 더 나쁜 조건이 될 수 있어요. GPU가 가속할 인덱싱 작업이 없으므로, 상쇄할 속도 향상 없이 더 높은 시간당 요금을 지불하게 될 수 있기 때문이에요.

더 알아보기 (Learn more)
출처: Qdrant 공식문서