Published on

Milvus IVF_PQ 튜닝으로 Pinecone급 검색속도

Authors

서버리스 벡터 DB를 쓰다 보면 결국 두 가지 요구가 동시에 옵니다. "지연시간은 더 줄이고", "비용은 더 낮추고". Pinecone은 운영 난이도를 낮추고 빠른 검색을 제공하지만, 워크로드가 커질수록 비용 구조가 부담이 될 수 있습니다. 반대로 Milvus는 직접 운영/튜닝 여지가 크고 비용 효율이 좋지만, 기본값 그대로 쓰면 지연시간이 기대만큼 나오지 않는 경우가 많습니다.

이 글은 Milvus의 IVF_PQ 인덱스를 중심으로 검색 지연시간을 Pinecone급으로 끌어내리는 튜닝 방법을 정리합니다. 핵심은 단순합니다.

  • IVF로 후보를 빠르게 줄이고
  • PQ로 메모리 풋프린트를 줄이며
  • nprobe, nlist, m, nbits를 워크로드에 맞춰 균형 잡는 것

아래 내용을 따라가면 “왜 느린지”를 감으로 추측하는 대신, 측정 가능한 루틴으로 튜닝을 반복할 수 있습니다.

IVF_PQ가 빠른 이유(그리고 느려지는 이유)

IVF: 검색 범위를 줄이는 1차 필터

IVF는 전체 벡터를 여러 개의 클러스터(리스트)로 나누고, 쿼리 벡터와 가까운 클러스터만 탐색합니다.

  • nlist: 클러스터 개수
  • nprobe: 쿼리 시 탐색할 클러스터 개수

일반적으로:

  • nlist를 키우면 클러스터가 더 촘촘해져 후보가 줄어들 수 있지만, 학습/빌드 비용이 커지고 잘못 잡으면 오히려 recall이 떨어질 수 있습니다.
  • nprobe를 키우면 recall이 올라가지만, 지연시간이 증가합니다.

PQ: 메모리를 줄이는 2차 압축

PQ(Product Quantization)는 벡터를 여러 서브벡터로 쪼개고, 각 서브벡터를 코드북 인덱스로 압축합니다.

  • m: 서브벡터 개수(분할 수)
  • nbits: 각 서브벡터 코드북 크기(보통 8을 많이 사용)

일반적으로:

  • m이 커질수록 표현력이 좋아져 recall이 좋아질 수 있지만, 쿼리 계산량이 늘 수 있습니다.
  • nbits가 커질수록 코드북이 커져 정확도가 좋아질 수 있지만, 메모리/빌드 비용이 증가합니다.

튜닝 전 체크리스트: “인덱스”보다 먼저 봐야 할 것

IVF_PQ를 아무리 잘 만져도 아래가 안 맞으면 지연시간이 흔들립니다.

  1. 차원(dimension): 768, 1024, 1536 등. 차원에 따라 m 선택이 제약됩니다.
  2. 메트릭(metric): COSINE, IP, L2 중 무엇인지. 임베딩 생성 방식에 맞춰야 합니다.
  3. 정규화 여부: 코사인 유사도면 보통 벡터 정규화가 필요합니다.
  4. 필터 조건: 스칼라 필터가 있으면 후보군이 줄어드는 대신 플랜이 바뀌어 지연시간이 요동칠 수 있습니다.
  5. 세그먼트/컴팩션 상태: 세그먼트가 쪼개져 있으면 쿼리 팬아웃이 늘어납니다.

운영 중 지연시간이 갑자기 튀면, 인덱스 파라미터보다 먼저 “운영 상태”를 의심해야 합니다. 쿠버네티스에서 돌린다면 장애 징후를 빠르게 확인하는 루틴도 함께 가져가세요. 예: Kubernetes CrashLoopBackOff 원인별 10분 진단

Milvus에서 IVF_PQ 인덱스 생성 예시

아래는 Python SDK 기준의 예시입니다. 본문에 등장하는 모든 특수 기호는 MDX 빌드 에러를 피하기 위해 인라인 코드로 처리합니다.

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

connections.connect(alias="default", host="localhost", port="19530")

dim = 768
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=dim),
]

schema = CollectionSchema(fields, description="ivf_pq_tuning_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()

여기서 중요한 제약이 하나 있습니다.

  • m은 보통 dim과 나누어떨어지는 값을 권장합니다.
  • 예를 들어 dim=768이면 m=48은 서브벡터 길이가 16이라 구조가 깔끔합니다.

검색 파라미터: 지연시간을 좌우하는 nprobe

인덱스는 “구조”이고, 실제 쿼리에서 지연시간을 가장 많이 흔드는 건 search_paramsnprobe입니다.

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

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

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

실전에서는 nprobe를 다음처럼 다루는 게 좋습니다.

  • 기본 시작점: nprobe=8 또는 16
  • recall이 부족하면 32, 64로 올려보되
  • 지연시간 SLO가 깨지면 nlistm을 재검토

Pinecone급 체감을 만드는 튜닝 전략 5가지

1) nlist는 “데이터 규모”에 맞춰 잡는다

경험적으로 nlist는 데이터 개수 N에 대해 대략 sqrt(N) 전후가 출발점으로 자주 쓰입니다. 하지만 Milvus에서는 세그먼트 구조, 샤딩, CPU 코어 수에 따라 최적점이 달라집니다.

추천 루틴:

  • N이 수백만 이상이면 nlist2048, 4096, 8192로 스윕
  • nlist에 대해 nprobe8, 16, 32, 64로 스윕
  • 지표는 p50, p95, recall@k를 함께 본다

2) m은 “정확도와 계산량”의 스위치

m을 키우면 PQ가 더 세밀해져 recall이 올라갈 여지가 있지만, 쿼리 시 룩업/누적 계산이 늘어납니다.

  • dim=768 기준 후보: m=32, 48, 64
  • m=48은 균형이 좋은 편
  • m=64는 recall을 더 뽑고 싶을 때 고려

3) nbits=8에서 시작하고, 올릴 땐 근거를 만든다

대부분의 운영 케이스에서 nbits=8이 비용 대비 효율이 좋습니다.

  • nbits=8은 코드북 크기 256
  • nbits=101024로 커지며 빌드/메모리 비용이 증가

nbits를 올릴 때는 “recall이 실제 품질 지표를 개선하는지”를 반드시 확인하세요. 검색 결과 품질은 offline recall만으로 끝나지 않습니다.

4) topk를 과도하게 키우지 않는다

Pinecone급 지연시간을 만들려다 topk=200 같은 설정을 넣으면, 인덱스가 아무리 좋아도 결과 정렬/후처리에서 시간이 늘어납니다.

  • 사용자 경험에 필요한 topk를 먼저 정의
  • reranking이 있다면 1차 검색은 topk=20 또는 50로 제한

5) 필터가 있다면 “필터 친화적 스키마”로 바꾼다

Milvus에서 스칼라 필터가 들어가면 실행 계획이 바뀌고, 후보군이 적어져도 오히려 느려지는 패턴이 나올 수 있습니다.

  • 필터 카디널리티가 낮은 필드는 인덱싱 전략을 별도로 고려
  • 가능하면 “필터로 먼저 줄이고 벡터 검색”이 되도록 데이터 파티셔닝을 설계

운영 자동화 관점에서는 배포/인덱스 재빌드 파이프라인이 꼬이지 않게 구성하는 것도 중요합니다. 캐시/아티팩트가 꼬이면 성능 실험 결과가 재현되지 않습니다. 관련해서는 GitHub Actions 캐시 미스? 키·경로 함정 9가지도 함께 참고할 만합니다.

성능 측정: “지연시간”과 “recall”을 같이 잡는 방법

튜닝은 결국 트레이드오프입니다. 아래처럼 간단한 벤치 스크립트를 만들어 nprobe 스윕을 자동화하세요.

import time
import numpy as np

def benchmark(col, queries, nprobe, topk=10):
    search_params = {"metric_type": "COSINE", "params": {"nprobe": nprobe}}
    t0 = time.perf_counter()
    _ = col.search(
        data=queries,
        anns_field="vec",
        param=search_params,
        limit=topk,
        output_fields=["doc_id"],
    )
    t1 = time.perf_counter()
    return (t1 - t0) * 1000.0

# 예시 쿼리 100개
queries = np.random.randn(100, 768).astype("float32").tolist()

for nprobe in [4, 8, 16, 32, 64]:
    ms = benchmark(col, queries, nprobe, topk=10)
    print(f"nprobe={nprobe} latency_ms={ms:.2f}")

여기에 recall 측정을 붙이려면, 같은 데이터에 대해 “정답에 가까운 brute force 결과”를 일부 샘플로 뽑아 비교하는 방식이 가장 단순합니다.

  • 전체 데이터에 brute force는 비싸므로
  • 쿼리 샘플 100개, 후보 데이터 샘플 50k 같은 식으로 축소
  • IVF_PQ 결과의 topk가 brute force topk와 얼마나 겹치는지 측정

이렇게 하면 “지연시간만 빠른데 품질은 망가진” 튜닝을 피할 수 있습니다.

실전 추천 프리셋(출발점)

데이터 규모와 차원에 따라 달라지지만, 많이 쓰는 출발점은 아래 조합입니다.

dim=768, 수백만 벡터

  • 인덱스: IVF_PQ
  • nlist=4096
  • m=48
  • nbits=8
  • 쿼리: nprobe=16부터 시작, 품질 부족 시 32

dim=1536, 수백만 벡터

  • m=96 또는 64를 먼저 검토(서브벡터 길이 균형)
  • nlist=4096 또는 8192
  • nprobe=16부터 시작

중요한 건 “프리셋을 믿는 것”이 아니라, 프리셋을 측정 루틴의 시작점으로 쓰는 것입니다.

운영에서 자주 터지는 함정 6가지

  1. 인덱스 생성 후 load를 안 해서 첫 쿼리가 느림
  2. 컴팩션 미진행으로 세그먼트가 과도하게 쪼개져 팬아웃 증가
  3. 동시성 증가 시 CPU 바운드로 p95 급증
  4. 필터 조합이 바뀌며 플랜이 바뀜: 어떤 쿼리는 빠르고 어떤 쿼리는 느림
  5. 임베딩 분포 드리프트: 학습 시점과 실제 쿼리 분포가 달라져 recall 하락
  6. 실험 재현 불가: 데이터 스냅샷, 파라미터, 코드 버전이 안 맞음

특히 6번은 성능 튜닝에서 치명적입니다. “오늘 빠른데 내일 느린” 상태는 대부분 재현성 부재에서 시작합니다.

결론: Pinecone급 체감은 “파라미터”가 아니라 “루틴”에서 나온다

Milvus IVF_PQ로 Pinecone급 검색속도를 노릴 때 핵심은 다음 3줄로 정리됩니다.

  • nlist로 후보군 구조를 잡고
  • mnbits로 메모리/정확도 균형을 잡고
  • nprobe 스윕으로 지연시간과 recall의 최적점을 찾는다

즉, 한 번의 마법 같은 설정이 아니라 측정 가능한 튜닝 루틴이 성능을 만듭니다. 위 코드와 스윕 전략으로 p50, p95, recall@k를 같이 보면서 조정하면, 비용 효율을 유지한 채 체감 성능을 크게 끌어올릴 수 있습니다.