- Published on
Milvus IVF_PQ 튜닝으로 검색속도 10배 올리기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 비용이 빠르게 불어나는 벡터 검색 시스템에서 가장 흔한 병목은 “정확도를 조금만 양보하면 훨씬 빨라질 수 있는데, 기본값으로 돌리고 있다”는 점입니다. Milvus에서 그 대표가 IVF_PQ 입니다.
이 글에서는 IVF_PQ 가 왜 빨라지는지, 어떤 파라미터가 속도와 정확도를 교환하는지, 그리고 실제로 검색속도를 10배 가까이 끌어올릴 때 어떤 순서로 튜닝해야 하는지 정리합니다.
IVF_PQ를 먼저 이해해야 튜닝이 됩니다
IVF_PQ 는 두 단계를 결합합니다.
IVF(Inverted File): 전체 벡터를nlist개의 클러스터(버킷)로 나누고, 검색 시에는 그중 일부 버킷만 탐색합니다.PQ(Product Quantization): 각 벡터를 압축 코드로 변환해 메모리 사용량과 거리 계산 비용을 크게 줄입니다.
즉, 가속의 핵심은 아래 두 가지입니다.
- 탐색 후보를 줄인다:
nprobe개 버킷만 본다. - 거리 계산을 싸게 만든다: PQ 코드로 근사 거리 계산을 한다.
이 구조를 알면 튜닝 방향도 명확해집니다.
- 더 빠르게:
nprobe를 줄이거나 PQ를 더 강하게 압축 - 더 정확하게:
nprobe를 늘리거나 PQ를 덜 압축, 또는nlist를 조정
“10배”를 만들기 위한 측정 기준부터 잡기
튜닝은 감으로 하면 망합니다. 최소한 아래 3가지는 수치로 고정하세요.
- P95 또는 P99 latency (ms)
- Recall@K (정답 포함률, 또는 오프라인 GT 대비 재현율)
- QPS 또는 동시성에서의 안정성
오프라인 평가가 어렵다면, 최소한 A/B로 nprobe 를 바꿨을 때 “상위 K 결과의 overlap” 같은 근사 지표라도 확보하는 게 좋습니다.
IVF_PQ 핵심 파라미터: nlist, nprobe, m, nbits
1) nlist: 클러스터 개수
nlist가 커질수록 각 버킷의 데이터가 줄어들어 탐색 비용이 감소할 수 있습니다.- 하지만 너무 크면 학습 및 관리 오버헤드가 늘고, 분포가 나쁘면 빈 버킷이 늘어 효율이 떨어집니다.
실무에서 자주 쓰는 출발점은 아래 중 하나입니다.
nlist ≈ 4 * sqrt(N)- 또는
nlist ≈ N / 1000(버킷당 평균 1000개를 목표)
여기서 N 은 총 벡터 수입니다.
2) nprobe: 검색 시 탐색할 버킷 수
nprobe 가 IVF의 “속도 다이얼”입니다.
nprobe증가: recall 상승, latency 상승nprobe감소: recall 하락, latency 하락
10배 가속은 보통 nprobe 를 공격적으로 낮추는 것에서 시작합니다. 단, nlist 가 충분히 크지 않으면 nprobe 를 낮춰도 recall이 급락할 수 있습니다.
3) PQ의 m: 서브벡터 개수
PQ는 벡터 차원 dim 을 m 개의 서브벡터로 쪼개 양자화합니다.
m증가: 표현력이 좋아져 recall이 좋아질 수 있지만, 코드/테이블 비용이 늘 수 있습니다.m감소: 더 강한 압축, 더 빠를 수 있으나 정확도 손실 가능
일반적으로 dim % m == 0 이 되게 잡습니다.
4) PQ의 nbits: 코드북 비트 수
nbits=8이 흔한 기본값입니다.nbits를 줄이면 메모리와 계산이 줄지만 정확도가 떨어질 수 있습니다.
추천 튜닝 순서: “IVF 먼저, PQ는 그 다음”
현장에서 가장 안전하게 성과를 내는 순서는 보통 다음과 같습니다.
- IVF를 먼저 안정화:
nlist와nprobe로 큰 폭의 latency를 줄이고, recall이 유지되는 지점을 찾습니다. - PQ를 조정: 메모리/캐시 효율을 올려 추가 가속을 얻습니다.
- 필터링/스칼라 조건이 있다면: 필터 선택도와 세그먼트 구조까지 함께 봅니다.
실전: Milvus에서 IVF_PQ 인덱스 생성 예시
아래는 Python SDK 기준 예시입니다. 컬렉션 스키마와 필드는 환경에 맞게 바꾸세요.
from pymilvus import (
connections, Collection, CollectionSchema, FieldSchema, DataType
)
connections.connect(alias="default", host="127.0.0.1", port="19530")
dim = 768
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=dim),
]
schema = CollectionSchema(fields, description="ivf_pq_demo")
col = Collection(name="demo_ivf_pq", schema=schema)
index_params = {
"index_type": "IVF_PQ",
"metric_type": "COSINE",
"params": {
"nlist": 4096,
"m": 48,
"nbits": 8
}
}
col.create_index(field_name="vec", index_params=index_params)
col.load()
검색 시에는 nprobe 를 runtime 파라미터로 조절합니다.
search_params = {
"metric_type": "COSINE",
"params": {
"nprobe": 16
}
}
results = col.search(
data=[query_vec],
anns_field="vec",
param=search_params,
limit=10,
output_fields=[]
)
10배 가속이 나오는 대표 시나리오
다음은 실제로 “확연한” 개선이 나오기 쉬운 패턴입니다.
시나리오 A: nprobe 가 과도하게 큰 경우
- 초기 설정:
nlist=2048,nprobe=128 - 튜닝 후:
nlist=4096,nprobe=16
이때 latency는 크게 줄고, recall은 데이터 분포가 좋으면 생각보다 덜 떨어집니다. 특히 nlist 를 늘리면 버킷당 후보가 줄어 nprobe 를 낮춰도 정답이 포함될 확률이 유지되는 경우가 많습니다.
시나리오 B: PQ가 너무 약해서 메모리 병목이 나는 경우
- 벡터 원본이 너무 커서 캐시 미스가 많고, 세그먼트 로딩이 무겁거나
- CPU에서 거리 계산이 비싼데 후보 수가 많아 연산이 몰리는 경우
이때 m 을 늘려 표현력을 유지하면서도 PQ 코드 기반 계산으로 전환하면 체감 성능이 크게 개선됩니다.
튜닝 체크리스트: 병목을 “검색 파라미터”로만 착각하지 않기
IVF_PQ 를 잘 잡아도 아래에서 무너질 수 있습니다.
1) 메모리 부족과 OOM
인덱스/세그먼트 로딩이 흔들리면 P99가 폭발합니다. 특히 컨테이너 환경에서는 cgroup 제한 때문에 “남아 보이는 메모리”가 있어도 OOMKilled가 날 수 있습니다. 이 경우는 Milvus 파라미터보다 먼저 시스템 레벨 원인 추적이 필요합니다.
2) 필터 조건이 있는 검색
스칼라 필터가 강하면, IVF 후보를 뽑아도 필터에서 많이 탈락해 “추가 탐색”이 필요해질 수 있습니다. 이때는 nprobe 만 낮추면 오히려 결과 품질이 급락하거나 tail latency가 불안정해질 수 있습니다.
3) RAG 파이프라인에서의 재랭킹/출처검증
벡터 검색을 10배 줄여도 전체 RAG 지연의 병목이 재랭킹이나 LLM 호출이면 체감이 덜할 수 있습니다. 검색 단계 최적화와 함께 전체 파이프라인을 같이 봐야 합니다.
튜닝을 자동화하는 방법: 그리드 탐색의 최소 형태
아래는 오프라인에서 nprobe 와 nlist 조합을 훑으며 latency와 recall을 기록하는 간단한 형태입니다. 실제로는 동일 쿼리셋, 동일 워밍업, 동일 동시성 조건을 맞추는 게 중요합니다.
import time
import numpy as np
def benchmark(col, queries, gt_ids, nprobe, topk=10):
search_params = {"metric_type": "COSINE", "params": {"nprobe": nprobe}}
# warmup
_ = col.search([queries[0]], "vec", search_params, limit=topk)
t0 = time.perf_counter()
hits = 0
for q, gt in zip(queries, gt_ids):
res = col.search([q], "vec", search_params, limit=topk)[0]
got = [r.id for r in res]
if gt in got:
hits += 1
t1 = time.perf_counter()
latency_ms = (t1 - t0) * 1000 / len(queries)
recall = hits / len(queries)
return latency_ms, recall
for nprobe in [4, 8, 16, 32, 64]:
lat, rec = benchmark(col, queries, gt_ids, nprobe=nprobe, topk=10)
print(f"nprobe={nprobe} latency_ms={lat:.2f} recall@10={rec:.4f}")
이 결과로 “목표 recall을 만족하는 최소 nprobe”를 찾고, 그 다음 nlist 를 바꿔 같은 작업을 반복하면 됩니다.
자주 하는 실수
1) nlist 를 너무 작게 두고 nprobe 만 만지는 경우
버킷이 거칠면 nprobe 를 조금만 줄여도 정답이 다른 버킷으로 빠져 recall이 급락합니다. nlist 를 먼저 키워 “버킷의 해상도”를 올린 뒤 nprobe 를 줄이는 게 더 안정적입니다.
2) PQ 파라미터를 먼저 건드리는 경우
PQ는 압축 손실이 개입되어 디버깅이 어려워집니다. IVF만으로도 충분히 가속이 나오는 경우가 많으니, IVF로 큰 폭의 개선을 만든 뒤 PQ로 추가 최적화하는 편이 시행착오가 적습니다.
3) 벡터 차원과 m 의 궁합을 무시
dim % m == 0 을 맞추지 못하면 설정 자체가 제한되거나 내부적으로 비효율이 생길 수 있습니다. 예를 들어 dim=768 에서는 m=48, m=64, m=96 같은 값이 다루기 쉽습니다.
마무리: 가장 효과적인 조합은 “데이터 분포”가 결정합니다
IVF_PQ 튜닝은 정답이 한 가지가 아닙니다. 하지만 실무적으로는 아래 결론이 유효합니다.
- 속도 10배의 1차 레버는 대개
nprobe입니다. nprobe를 안전하게 낮추려면nlist를 함께 설계해야 합니다.- PQ의
m,nbits는 메모리와 캐시 효율까지 고려해 2차로 최적화합니다. - 시스템이 불안정하다면 먼저 OOM, 스로틀링, 디스크 병목 등 인프라 레벨을 의심하세요.
같은 RAG 검색 최적화 관점에서 다른 인덱스 접근이 궁금하다면, 그래프 기반 탐색인 HNSW 튜닝 사례도 함께 비교해보는 것을 추천합니다.