Published on

Milvus IVF_PQ 튜닝으로 검색속도 10배 올리기

Authors

서버 비용이 빠르게 불어나는 벡터 검색 시스템에서 가장 흔한 병목은 “정확도를 조금만 양보하면 훨씬 빨라질 수 있는데, 기본값으로 돌리고 있다”는 점입니다. Milvus에서 그 대표가 IVF_PQ 입니다.

이 글에서는 IVF_PQ 가 왜 빨라지는지, 어떤 파라미터가 속도와 정확도를 교환하는지, 그리고 실제로 검색속도를 10배 가까이 끌어올릴 때 어떤 순서로 튜닝해야 하는지 정리합니다.

IVF_PQ를 먼저 이해해야 튜닝이 됩니다

IVF_PQ 는 두 단계를 결합합니다.

  • IVF (Inverted File): 전체 벡터를 nlist 개의 클러스터(버킷)로 나누고, 검색 시에는 그중 일부 버킷만 탐색합니다.
  • PQ (Product Quantization): 각 벡터를 압축 코드로 변환해 메모리 사용량과 거리 계산 비용을 크게 줄입니다.

즉, 가속의 핵심은 아래 두 가지입니다.

  1. 탐색 후보를 줄인다: nprobe 개 버킷만 본다.
  2. 거리 계산을 싸게 만든다: 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는 벡터 차원 dimm 개의 서브벡터로 쪼개 양자화합니다.

  • m 증가: 표현력이 좋아져 recall이 좋아질 수 있지만, 코드/테이블 비용이 늘 수 있습니다.
  • m 감소: 더 강한 압축, 더 빠를 수 있으나 정확도 손실 가능

일반적으로 dim % m == 0 이 되게 잡습니다.

4) PQ의 nbits: 코드북 비트 수

  • nbits=8 이 흔한 기본값입니다.
  • nbits 를 줄이면 메모리와 계산이 줄지만 정확도가 떨어질 수 있습니다.

추천 튜닝 순서: “IVF 먼저, PQ는 그 다음”

현장에서 가장 안전하게 성과를 내는 순서는 보통 다음과 같습니다.

  1. IVF를 먼저 안정화: nlistnprobe 로 큰 폭의 latency를 줄이고, recall이 유지되는 지점을 찾습니다.
  2. PQ를 조정: 메모리/캐시 효율을 올려 추가 가속을 얻습니다.
  3. 필터링/스칼라 조건이 있다면: 필터 선택도와 세그먼트 구조까지 함께 봅니다.

실전: 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 호출이면 체감이 덜할 수 있습니다. 검색 단계 최적화와 함께 전체 파이프라인을 같이 봐야 합니다.

튜닝을 자동화하는 방법: 그리드 탐색의 최소 형태

아래는 오프라인에서 nprobenlist 조합을 훑으며 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 튜닝 사례도 함께 비교해보는 것을 추천합니다.