Published on

Milvus IVF_FLAT·HNSW 튜닝으로 지연 50% 줄이기

Authors

서빙 환경에서 벡터 검색 지연이 튀기 시작하면 보통은 CPU 사용률, 네트워크, 디스크, 캐시 같은 인프라부터 의심합니다. 하지만 Milvus에서는 인덱스 타입과 파라미터 조합만 바꿔도 p95 지연이 눈에 띄게 내려가는 경우가 흔합니다. 특히 IVF_FLATHNSW 는 둘 다 “근사 최근접 탐색(ANN)” 계열이지만, 지연을 결정하는 레버가 완전히 다릅니다.

이 글은 다음 목표를 전제로 합니다.

  • 동일한 리콜 목표(예: recall@10 0.95 내외)를 유지하면서 p95 지연을 50% 수준으로 낮추기
  • IVF_FLATHNSW 중 무엇을 선택해야 하는지 판단 기준 만들기
  • 튜닝을 실험이 아니라 “측정 가능한 루프”로 만들기

운영에서 검색 호출이 gRPC 기반이라면, 지연이 곧 데드라인 초과로 이어질 수 있습니다. 지연 튜닝과 함께 데드라인 설계도 점검해두는 편이 안전합니다. 관련해서는 Go gRPC 데드라인 초과 해결 - context·LB·Keepalive 도 같이 참고하면 좋습니다.

1) IVF_FLAT vs HNSW, 먼저 선택부터 맞추기

두 인덱스의 성격을 “지연을 만드는 요인” 관점에서 비교하면 다음처럼 정리됩니다.

IVF_FLAT의 핵심

  • 데이터는 클러스터(버킷)로 나뉘고, 검색 시 일부 클러스터만 스캔
  • 지연은 주로 nprobenlist 에 의해 결정
  • 리콜을 올리려면 더 많은 클러스터를 보거나( nprobe 증가), 더 촘촘히 나누거나( nlist 증가)
  • 메모리 사용량은 상대적으로 예측 가능(Flat 스캔 기반)

HNSW의 핵심

  • 그래프 기반 탐색, 탐색 폭과 그래프 품질이 지연과 리콜을 좌우
  • 지연은 주로 ef (검색) 와 M , efConstruction (빌드) 조합에 의해 결정
  • 리콜을 올리려면 ef 를 올리는 방향이 일반적(대신 지연 증가)
  • 그래프 오버헤드로 메모리 사용량이 커질 수 있음

대략적인 선택 가이드

  • 데이터가 크고(수백만 이상), 필터링 조건이 자주 붙고, 리빌드 비용을 관리하고 싶으면 IVF_FLAT
  • 비교적 빠른 탐색 지연과 높은 리콜을 동시에 노리되, 메모리를 더 써도 괜찮다면 HNSW

여기서 중요한 전제가 하나 있습니다. “필터링이 있는 검색” 이라면 인덱스만 잘 튜닝해도 기대한 만큼 지연이 안 내려갈 수 있습니다. 필터가 인덱스 후보를 지나치게 줄이거나, 반대로 후보를 너무 넓게 남기면 ANN의 장점이 흐려집니다. 이때는 검색 파이프라인에서 필터 적용 위치와 방식도 같이 봐야 합니다.

2) 지연 50%를 만드는 튜닝 루프: 측정부터 고정하기

튜닝은 파라미터를 바꾸는 작업이 아니라 “측정 루프를 고정” 하는 작업입니다.

고정해야 할 것

  • 쿼리 셋: 실제 트래픽에서 샘플링한 대표 쿼리 500개 이상 권장
  • 메트릭: p50, p95, p99, recall@k, QPS, CPU, RSS
  • 동시성: 단일 스레드가 아니라 실제 동시성(예: 8, 16, 32)에서 측정
  • 워밍업: 캐시 워밍업 후 측정(초기 1분 제외 등)

리콜 기준을 먼저 정하자

지연만 줄이면 리콜이 무너지는 튜닝이 쉽게 나옵니다. 예를 들어 목표가 “recall@10 0.95 이상” 이라면, 모든 실험은 이 조건을 만족하는 후보 중에서 지연이 최소인 조합을 고르는 식으로 진행해야 합니다.

3) IVF_FLAT 튜닝: nlistnprobe 의 균형

IVF_FLAT 에서 지연을 반으로 줄이는 가장 흔한 패턴은 다음입니다.

  • nlist 를 적정 수준까지 올려서 클러스터를 더 잘게 나눈다
  • 그 다음 nprobe 를 내려도 리콜이 유지되는 구간을 찾는다

3-1) nlist 추천 출발점

정답은 없지만, 실무에서 많이 쓰는 출발점은 아래처럼 잡습니다.

  • 데이터 개수 N 에 대해 nlistsqrt(N) 근처로 시작
  • 또는 N / 1000 근처에서 시작(너무 거칠면 리콜이 흔들림)

예시로 N = 1,000,000 이면 sqrt(N) 는 대략 1000입니다. 여기서 nlist = 1024 같은 값으로 시작하고, 리콜이 부족하면 2048, 4096으로 올리며 nprobe 를 함께 조정합니다.

3-2) nprobe 는 “리콜을 지키는 최소값” 을 찾는다

  • nprobe 를 올리면 리콜은 좋아지지만 지연이 선형에 가깝게 증가
  • nprobe 를 내리면 지연은 줄지만 리콜이 급격히 떨어지는 임계점이 있음

실전에서는 nprobe 를 64에서 시작해 32, 16, 8로 내리면서 recall@k를 같이 봅니다. nlist 를 충분히 키우면 nprobe 를 낮춰도 리콜이 유지되는 구간이 생기고, 그때 지연이 크게 내려갑니다.

3-3) Milvus 인덱스 생성 예시(IVF_FLAT)

아래는 Python SDK 스타일의 예시입니다. 환경에 따라 API는 다를 수 있지만, 핵심은 index_typeparams 입니다.

from pymilvus import Collection

col = Collection("items")

index_params = {
    "metric_type": "COSINE",
    "index_type": "IVF_FLAT",
    "params": {"nlist": 2048},
}

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

# 검색 시 nprobe 튜닝
search_params = {"metric_type": "COSINE", "params": {"nprobe": 16}}

res = col.search(
    data=[query_vec],
    anns_field="embedding",
    param=search_params,
    limit=10,
    output_fields=["id"],
)

주의할 점은 nlist 를 너무 크게 잡으면 빌드 시간이 늘고, 세그먼트가 잘게 쪼개지면서 오히려 운영 복잡도가 올라갈 수 있다는 것입니다. 따라서 “리콜이 유지되는 최소 nprobe 를 찾기 위한 수단” 으로 nlist 를 조정한다는 관점이 좋습니다.

4) HNSW 튜닝: ef 를 낮추기 위한 M 과 빌드 품질

HNSW는 검색 시 ef 가 지연을 크게 좌우합니다. 지연 50% 절감은 보통 다음 루트로 나옵니다.

  • MefConstruction 을 적절히 올려 그래프 품질을 확보
  • 그 결과 같은 리콜을 내기 위한 ef 를 낮춘다

즉, “빌드를 좀 더 무겁게 해서 서빙을 가볍게” 만드는 전략입니다.

4-1) 파라미터 역할 요약

  • M: 노드당 이웃 링크 수. 클수록 리콜이 좋아질 가능성이 크지만 메모리 증가
  • efConstruction: 인덱스 빌드 시 탐색 폭. 클수록 그래프 품질이 좋아질 가능성이 크지만 빌드 시간 증가
  • ef: 검색 시 탐색 폭. 클수록 리콜 상승, 지연 상승

4-2) 추천 접근

  1. M 을 16 또는 32에서 시작
  2. efConstruction 을 200 또는 400에서 시작
  3. 목표 리콜을 만족하는 최소 ef 를 찾기

예를 들어 기존이 M = 16, efConstruction = 200, ef = 128 이었다면, M = 32, efConstruction = 400 으로 올린 뒤 ef = 64 로 내려도 리콜이 유지되는 구간이 종종 있습니다. 이 경우 지연이 체감상 크게 줄어듭니다.

4-3) Milvus 인덱스 생성 예시(HNSW)

from pymilvus import Collection

col = Collection("items")

index_params = {
    "metric_type": "IP",
    "index_type": "HNSW",
    "params": {
        "M": 32,
        "efConstruction": 400,
    },
}

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

# 검색 시 ef 튜닝
search_params = {"metric_type": "IP", "params": {"ef": 64}}

res = col.search(
    data=[query_vec],
    anns_field="embedding",
    param=search_params,
    limit=10,
    output_fields=["id"],
)

메트릭 타입은 데이터 정규화 여부에 따라 COSINE 또는 IP 를 선택합니다. 코사인 유사도를 쓰면서 임베딩을 정규화했다면 IP 로 동일한 결과를 얻는 구성이 가능하고, 이때 구현 및 성능 측면에서 유리해지는 경우도 있습니다.

5) “튜닝했는데도 느리다” 를 만드는 운영 병목 6가지

인덱스 튜닝만으로는 p95가 안 내려가는 경우가 있습니다. 아래는 현장에서 자주 만나는 병목입니다.

5-1) topk 가 과도하게 큼

topk 를 10에서 100으로 올리면 단순히 결과 개수만 늘어나는 게 아니라 후보 정렬과 후처리 비용도 커집니다. 리랭킹이 뒤에 붙는 파이프라인이라면 1차 검색의 topk 는 “리랭커가 필요로 하는 최소 후보” 로 제한해야 합니다.

5-2) 필터링으로 인해 ANN이 사실상 무력화

필터가 너무 강하면 후보가 적어져서 ANN 이점이 줄고, 너무 약하면 후보가 많아져서 지연이 증가합니다. 필터 조건의 선택도(카디널리티)를 관찰하고, 가능하면 필터 키를 기준으로 파티셔닝 또는 컬렉션 설계를 재검토합니다.

5-3) 세그먼트 로딩 상태와 캐시 미스

로드가 덜 된 상태에서 검색이 들어오면 디스크 접근이 늘고 지연이 튑니다. 배포 직후나 스케일 아웃 직후 p95가 튀는 패턴이라면 워밍업 쿼리, 프리로드, 오토스케일 기준을 재조정해야 합니다.

5-4) 동시성에서만 지연이 튀는 경우

단일 쿼리 벤치에서는 빠른데, 동시성에서 p95가 급증하면 CPU 스로틀링, 메모리 대역폭, GC, 스레드풀 경쟁 같은 문제가 숨어있을 확률이 큽니다. 이때는 애플리케이션 레벨 타임아웃도 같이 점검해야 합니다. gRPC 데드라인 초과가 보인다면 Go gRPC context deadline exceeded 9가지 원인 의 체크리스트가 도움이 됩니다.

5-5) 검색 결과 후처리 비용

output_fields 를 과하게 가져오거나, 검색 결과를 받은 뒤 DB를 추가 조회하는 N+1 패턴이 있으면 “검색은 빨랐는데 전체 API는 느린” 상태가 됩니다. 필요한 필드만 가져오고, 후처리 단계의 비용을 분리 측정하세요.

5-6) 캐시 스탬피드로 인한 간헐적 폭증

동일 쿼리가 몰리는 서비스라면 결과 캐시를 두는 경우가 많습니다. 캐시 만료 시점에 동시 요청이 한꺼번에 원본 검색을 때리면 p95가 튈 수 있습니다. 이 문제는 벡터 검색 자체 튜닝과 별개로 캐시 정책으로 풀어야 합니다. 유사한 패턴의 대응은 Spring Boot 3 Redis 캐시 스탬피드 해결법 의 아이디어를 참고할 수 있습니다.

6) 지연 50% 절감에 자주 성공하는 “레시피”

아래는 실무에서 성공 확률이 높았던 조합을 레시피 형태로 정리한 것입니다.

레시피 A: IVF_FLAT에서 nprobe 를 절반으로

  • 목표: recall@10 0.95 유지, p95 50% 절감
  • 방법
    • nlist 를 2배로 올린다(예: 1024에서 2048)
    • recall이 유지되는 범위에서 nprobe 를 절반으로 내린다(예: 32에서 16)
  • 기대 효과
    • 스캔 클러스터 수 감소로 지연이 크게 감소
    • 빌드 시간은 늘지만 서빙 비용이 줄어듦

레시피 B: HNSW에서 빌드를 무겁게, ef 를 낮게

  • 목표: 같은 리콜을 더 낮은 ef 로 달성
  • 방법
    • M 을 16에서 32로 올려본다
    • efConstruction 을 200에서 400으로 올려본다
    • 목표 리콜을 만족하는 최소 ef 를 재탐색한다(예: 128에서 64)
  • 기대 효과
    • 서빙 지연 감소, 대신 메모리와 빌드 시간이 증가

7) 실험 결과를 남기는 템플릿(재현 가능하게)

튜닝은 “다음 분기에도 다시 할 수 있어야” 가치가 있습니다. 아래처럼 실험 로그를 남기면 재현이 쉬워집니다.

- dataset: items_v3
- N: 1_200_000, dim: 768, metric: COSINE
- hardware: 16 vCPU, 64 GB RAM
- concurrency: 16
- target: recall@10 >= 0.95

[IVF_FLAT]
- nlist: 1024, nprobe: 32 -> p95 120ms, recall 0.962
- nlist: 2048, nprobe: 16 -> p95 62ms,  recall 0.958
- nlist: 4096, nprobe: 8  -> p95 58ms,  recall 0.949 (fail)

Decision: nlist 2048, nprobe 16

이 정도만 남겨도 “왜 이 값을 선택했는지” 를 팀이 공유할 수 있고, 데이터가 늘었을 때도 같은 방식으로 재튜닝할 수 있습니다.

8) 마무리: 인덱스 튜닝은 지연 최적화의 가장 싼 카드

Milvus 지연을 50% 줄이는 가장 현실적인 방법은 “리콜 목표를 먼저 고정하고, 그 리콜을 만족하는 최소 비용 파라미터를 찾는 것” 입니다.

  • IVF_FLATnlist 로 공간을 잘게 나누고 nprobe 를 최소화하는 방향
  • HNSW 는 그래프 품질을 올려 같은 리콜을 더 낮은 ef 로 달성하는 방향

마지막으로, 지연은 인덱스만의 문제가 아닙니다. 동시성, 필터링, 후처리, 캐시, 네트워크, 타임아웃까지 함께 봐야 “운영에서 체감되는 p95” 가 내려갑니다. 이 글의 튜닝 루프와 레시피를 기반으로, 여러분의 워크로드에 맞는 파라미터 조합을 빠르게 좁혀보면 좋겠습니다.