Published on

Milvus IVF_PQ 튜닝으로 recall 올리고 지연 줄이기

Authors

서버사이드 벡터 검색에서 IVF_PQ는 “메모리 절약 + 빠른 근사 검색”을 동시에 노릴 수 있는 대표 조합입니다. 하지만 기본값으로는 recall이 기대보다 낮거나, nprobe를 올리면 지연이 급증하는 식으로 트레이드오프가 쉽게 무너집니다. 이 글은 Milvus에서 IVF_PQ를 실전 기준으로 튜닝하는 순서를 정리하고, 어떤 파라미터를 어떤 근거로 만져야 recall을 올리면서도 지연을 관리할 수 있는지에 집중합니다.

아래 내용은 Milvus 2.x 계열을 전제로 하며, 컬렉션 스키마와 데이터 분포(벡터 차원, 코사인/내적/유클리드, 데이터 수, 업데이트 빈도)에 따라 최적점은 달라집니다.

IVF_PQ를 “빠른데 그럴듯하게” 만드는 원리

IVF_PQ는 크게 두 단계로 생각하면 쉽습니다.

  • IVF(Inverted File): 전체 벡터를 nlist개의 클러스터(centroid)로 나눠서, 검색 시 전체를 훑지 않고 일부 클러스터만 봅니다.
  • PQ(Product Quantization): 각 벡터를 여러 서브벡터로 쪼개고(m), 각 서브벡터를 코드북으로 양자화해서(보통 nbits 비트) 저장합니다. 이렇게 하면 원본 float 벡터 대신 짧은 코드만 저장하므로 메모리와 I/O가 줄어듭니다.

검색 시에는

  1. 질의 벡터가 어떤 클러스터에 가까운지 찾고
  2. 그 클러스터들 안에서 PQ 코드 기반으로 근사 거리 계산을 해서 TopK를 뽑습니다.

여기서 성능을 좌우하는 핵심은 다음 매핑으로 정리됩니다.

  • recall을 올리는 레버: nprobe(검색할 클러스터 수), nlist(클러스터 세분화), m/nbits(양자화 정밀도), 그리고 “후처리 rerank”
  • 지연을 줄이는 레버: nprobe를 낮추기, nlist를 적절히 키워 후보 수를 줄이기, PQ 코드 길이 최적화, 캐시/세그먼트/병렬도, 그리고 “2-stage 검색”

핵심은 nprobe를 무작정 올려서 recall을 맞추는 게 아니라, nlist와 PQ 설정, rerank를 조합해 “적은 후보를 더 똑똑하게” 보는 구조로 만드는 것입니다.

튜닝의 목표를 수치로 고정하기

튜닝을 시작하기 전에 아래 3가지를 먼저 고정해야 합니다.

  1. 품질 목표: recall@K 또는 hit-rate@K
  2. 지연 목표: p95/p99, QPS, 동시성
  3. 비용 목표: 메모리(인덱스 상주), 디스크, CPU

예를 들어 “recall@10 0.95 이상, p95 30ms 이하, QPS 200” 같은 형태로 목표를 못 박아야 파라미터 스윕이 의미가 있습니다.

또한 평가 데이터셋은 반드시 “실제 쿼리 분포”를 반영해야 합니다. 랜덤 쿼리로 측정하면 nprobe를 낮춰도 좋아 보이는데, 실서비스 쿼리(특정 도메인에 쏠림, 중복 질문, 근접 군집)가 들어오면 recall이 급락하는 경우가 흔합니다.

인덱스 설계: nlist를 먼저 잡고, nprobe는 나중에

IVF의 품질은 nlist에서 크게 갈립니다. 직관적으로는

  • nlist가 너무 작으면: 한 클러스터에 너무 많은 벡터가 들어가서 후보가 많아지고, nprobe를 올려도 “비슷한 것”을 찾기 어렵습니다.
  • nlist가 너무 크면: 학습/빌드 비용이 커지고, 클러스터가 과도하게 쪼개져 오히려 쿼리당 오버헤드가 늘거나 데이터가 희소해질 수 있습니다.

실무에서 자주 쓰는 출발점은 대략 nlist를 데이터 개수 N에 대해 sqrt(N) 근처에서 시작하거나, N / 1000 수준에서 시작하는 방식입니다. 예를 들어 1,000만 벡터면 nlist를 4096, 8192, 16384 같은 그리드로 두고 A/B를 돌립니다.

그 다음에 nprobe를 조절합니다. nprobe는 검색할 클러스터 수이므로, 올리면 recall은 올라가고 지연도 거의 선형에 가깝게 증가합니다. 따라서 nprobe는 “최소한으로” 쓰는 게 원칙입니다.

권장 튜닝 순서

  1. nlist 후보 3개 정도를 정한다
  2. nlist마다 nprobe를 작은 값부터 올리며 recall-지연 곡선을 만든다
  3. 가장 좋은 곡선(같은 지연에서 recall이 높은)을 고른다

이 순서를 지키면 “nprobe만 올려서 때우는” 상황을 줄일 수 있습니다.

PQ 설계: m과 nbits는 메모리와 recall의 교환비

PQ는 저장을 줄이는 대신 정밀도를 깎는 방식이라, mnbits가 곧 recall과 직결됩니다.

  • m: 벡터를 몇 조각으로 나눌지. 일반적으로 m이 커지면 정밀도가 좋아져 recall이 오르지만, 코드 길이와 연산량이 늘 수 있습니다.
  • nbits: 각 서브벡터를 표현하는 비트 수. 보통 8이 많이 쓰이고, 4는 더 압축되지만 recall 손실이 커질 수 있습니다.

또 하나의 제약이 있습니다. 벡터 차원 dimm으로 나눠떨어지는 편이 좋습니다. 예를 들어 dim이 768이면 m을 96, 64, 48 같은 값으로 잡아 dim / m이 정수가 되게 하는 식입니다.

실전 가이드(출발점)

  • 텍스트 임베딩(예: 768차원)에서 비용이 빡빡하면: m=96, nbits=8 또는 m=64, nbits=8
  • recall을 더 원하면: m을 올리거나, 가능하면 “후처리 rerank”를 추가
  • 메모리가 최우선이면: nbits=4도 고려하되, 반드시 recall 측정 후 결정

Milvus에서 IVF_PQ 인덱스 생성 예시

아래는 Python SDK 기준 예시입니다. 환경에 따라 import와 연결 코드가 다를 수 있으니, 핵심은 index_params 구성 방식만 참고하면 됩니다.

from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection

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": "IP",
    "params": {
        "nlist": 8192,
        "m": 96,
        "nbits": 8
    }
}

col.create_index(field_name="vec", index_params=index_params)
col.load()

검색 시에는 nprobesearch_params로 줍니다.

query = [[0.0] * dim]  # 예시

search_params = {"metric_type": "IP", "params": {"nprobe": 16}}

res = col.search(
    data=query,
    anns_field="vec",
    param=search_params,
    limit=10,
    output_fields=[]
)

여기서 중요한 포인트는 nprobe를 “상황에 따라 동적으로” 바꿀 수 있다는 점입니다. 즉, 인덱스는 고정(nlist, m, nbits)하고, 요청별 SLA나 쿼리 난이도에 따라 nprobe를 조절하는 전략이 가능합니다.

recall을 올리면서 지연을 덜 늘리는 2-stage 전략

IVF_PQ의 본질적 한계는 PQ 근사로 인한 오차입니다. 이를 해결하는 가장 실용적인 방법이 “2-stage”입니다.

  1. 1차: IVF_PQ로 넓게 후보를 뽑는다(TopK보다 더 크게, 예: limit=200)
  2. 2차: 후보에 대해 원본 float 벡터로 정확 거리 재계산(rerank)해서 최종 TopK를 만든다

Milvus에서 환경에 따라 원본 벡터를 함께 저장하고, 애플리케이션에서 rerank를 수행할 수 있습니다. 아래는 개념 코드입니다.

import numpy as np

K = 10
CAND = 200

search_params = {"metric_type": "IP", "params": {"nprobe": 8}}
res = col.search(
    data=query,
    anns_field="vec",
    param=search_params,
    limit=CAND,
    output_fields=["vec"],
)

hits = res[0]
q = np.array(query[0], dtype=np.float32)

scored = []
for h in hits:
    v = np.array(h.entity.get("vec"), dtype=np.float32)
    score = float(np.dot(q, v))
    scored.append((score, h.id))

scored.sort(reverse=True)
final = scored[:K]

이 방식의 장점은

  • nprobe를 크게 올리지 않아도 recall을 끌어올릴 수 있고
  • 지연은 “후보 수 CAND”에 더 좌우되므로, CAND를 적절히 제한하면 안정적입니다.

실무 팁은 다음과 같습니다.

  • 1차에서 nprobe를 8~32 범위로 고정하고
  • CAND를 50, 100, 200처럼 스윕해서 recall과 지연을 같이 측정
  • 최종적으로 “p95 지연을 깨지 않는 최대 CAND”를 선택

병목을 만드는 흔한 실수 6가지

1) nprobe만 올려서 recall을 맞추는 방식

nprobe=64 같은 값으로 억지로 recall을 맞추면, 트래픽이 올라갈 때 p99가 먼저 터집니다. 먼저 nlist와 PQ, rerank를 조합해 “적은 후보로 높은 recall” 구조를 만들고, 마지막 미세조정으로 nprobe를 쓰는 게 안전합니다.

2) nlist가 데이터 규모에 비해 너무 작음

데이터가 수백만 단위인데 nlist=1024 같은 값이면 후보 풀이 커져서 지연이 잘 안 내려갑니다. 반대로 너무 크게 잡으면 빌드/메모리/오버헤드가 커지니, 3개 정도 후보로 실측 곡선을 뽑아 고르세요.

3) PQ 압축을 과하게 해서 rerank 없이 운영

nbits=4로 강하게 압축했는데 rerank가 없으면, 특정 쿼리에서 “그럴듯한데 미묘하게 틀린” 결과가 늘어납니다. 검색 품질을 요구하는 서비스라면 2-stage는 사실상 필수 옵션에 가깝습니다.

4) 필터(스칼라 조건)와 벡터 검색의 결합을 고려하지 않음

태그/테넌트/권한 같은 필터가 있으면, 실제 후보군이 줄어들어 IVF의 장점이 희석되거나 반대로 특정 파티션에만 데이터가 몰려 recall이 흔들릴 수 있습니다. 필터가 강하면 nlist가 커도 효과가 제한될 수 있으니, 필터 패턴별로 벤치마크를 분리하세요.

5) load, 캐시, 세그먼트 상태를 무시한 벤치마크

인덱스가 메모리에 올라온 상태인지(load), 워밍업이 되었는지, 세그먼트가 과도하게 쪼개져 있지 않은지에 따라 지연이 크게 달라집니다. 벤치마크는 반드시 동일 조건에서 반복 측정해야 합니다.

6) 동시성에서만 발생하는 tail latency를 놓침

단일 스레드로는 p95가 좋아도, 실제 트래픽에서는 CPU 경합과 GC, 네트워크 큐잉으로 p99가 튑니다. 지연 목표가 있으면 “동시성 포함 부하 테스트”가 필수입니다. 브라우저 성능에서 Long Task가 INP를 터뜨리듯, 벡터 검색도 tail이 SLA를 망칩니다. 관련해서 지연 꼬리 원인을 추적하는 관점은 Chrome INP 폭증? Long Task 추적·분해 실전 글의 접근법이 꽤 유사합니다.

실전 튜닝 레시피: 추천 그리드와 의사결정

여기서는 “데이터 N, 차원 dim, 목표 recall@10, p95”가 주어졌다고 가정하고, 가장 빠르게 수렴하는 레시피를 제시합니다.

1) nlist 그리드부터

  • N이 100만 이하: nlist를 1024, 2048, 4096
  • N이 100만~1000만: nlist를 4096, 8192, 16384
  • N이 1000만 이상: 8192 이상부터 시작하되, 빌드/메모리 비용을 함께 체크

nlist마다 nprobe를 4, 8, 16, 32로 스윕합니다.

2) PQ는 “안전한 기본”에서 시작

  • nbits=8 고정으로 시작
  • mdim을 나눌 수 있는 값 중 64 또는 96부터

그 다음 메모리가 부족하면 m을 낮추거나 nbits=4를 검토하되, rerank를 반드시 함께 고려합니다.

3) 2-stage를 기본 옵션으로 넣고, CAND로 조절

  • 1차: nprobe=8 또는 16
  • 2차: CAND 50, 100, 200 스윕

대부분의 서비스에서 “nprobe를 2배로 올리는 것”보다 “CAND를 조금 늘리고 rerank”가 recall 대비 지연 증가가 더 예측 가능합니다.

운영 관점: 변경을 안전하게 배포하는 방법

인덱스 튜닝은 사실상 “검색 품질과 성능의 동시 변경”이라 롤백 플랜이 중요합니다.

  • 새 인덱스 파라미터로 컬렉션을 복제하거나, 별도 컬렉션에 재색인 후 트래픽을 점진적으로 전환
  • A/B에서 recall@K와 p95/p99를 함께 수집
  • 문제가 생기면 즉시 이전 컬렉션으로 스위치

이 과정에서 Git으로 벤치마크 코드와 설정을 자주 리베이스하다 충돌이 반복되면 생산성이 급격히 떨어집니다. 반복 충돌을 자동 재사용하는 rerere는 실무에서 꽤 도움이 됩니다. 자세한 설정은 Git rebase 충돌을 자동 재사용 - rerere 설정법 을 참고하세요.

체크리스트: recall과 지연을 동시에 잡는 최소 조건

  • nlist를 최소 3개 후보로 두고 실측 곡선으로 선택했는가
  • nprobe는 목표 recall을 만족하는 최소값인가
  • mnbits를 메모리 제약 안에서 최대한 안전하게 잡았는가
  • rerank(2-stage)를 적용했는가, 적용했다면 CAND는 p95를 깨지 않는가
  • 필터/테넌트/권한 조건을 포함한 “실제 쿼리 분포”로 평가했는가
  • 동시성 부하에서 p99가 튀지 않는가

마무리

IVF_PQ 튜닝의 요령은 단순합니다. nprobe로만 해결하려고 하지 말고, nlist로 후보 풀을 잘 설계한 뒤 PQ 정밀도(m, nbits)와 2-stage rerank로 품질을 복원하는 구조를 만드세요. 그러면 recall은 올라가고, 지연은 “예측 가능한 비용”으로 관리할 수 있습니다.

다음 단계로는 (1) HNSW 같은 그래프 인덱스와의 비교, (2) 하이브리드 검색에서 필터 선택도 최적화, (3) 쿼리 난이도에 따른 동적 nprobe 정책까지 확장하면 운영 효율이 크게 좋아집니다.