Published on

Milvus IVF_FLAT vs HNSW 리콜·지연 튜닝

Authors

벡터 검색을 운영에 올리면 결국 두 가지 숫자로 귀결됩니다. 리콜(정확도)과 지연(latency) 입니다. Milvus에서는 대표적으로 IVF_FLAT(인덱스 기반 후보군 축소 + 정확한 거리 계산)과 HNSW(그래프 탐색 기반 근사 최근접) 두 축이 많이 쓰이는데, 둘 다 “근사 검색”이라는 공통점이 있어도 지연이 튀는 지점, 메모리/디스크 사용 패턴, 튜닝 레버가 꽤 다릅니다.

이 글은 다음 질문에 답하는 방식으로 구성합니다.

  • 같은 데이터에서 IVF_FLATHNSW 중 무엇을 선택해야 하는가
  • 리콜을 올릴 때 지연이 어떻게 증가하는가(튜닝 곡선)
  • Milvus에서 실제로 조절하는 파라미터(nlist, nprobe, M, efConstruction, efSearch)가 무엇을 의미하는가
  • 운영 환경에서 “지연 SLO를 지키면서 리콜을 끌어올리는” 튜닝 절차

또한 서비스 관점에서 검색 API가 느려지면 프론트까지 체감이 커집니다. 사용자 입력 이후 상호작용 지연을 다루는 글로는 React INP 급락 원인 - 긴 Task 분해·useTransition도 함께 참고하면 좋습니다.

1) 전제: 리콜·지연을 공정하게 비교하는 법

IVF_FLATHNSW를 비교할 때 흔한 실수는 “기본 파라미터로 한 번 돌려보고 빠른 쪽을 채택”하는 것입니다. 공정한 비교를 위해 아래를 먼저 고정하세요.

  • 데이터 분포: 임베딩 모델, 정규화 여부(L2 normalize), 차원 수
  • 거리 함수(metric): L2, IP, COSINE 중 무엇인지
  • topK: topK=10topK=100은 탐색 비용이 다릅니다
  • 필터링 여부: scalar filter가 들어가면 후보군이 줄거나(빠름) 역으로 플랜이 복잡해질 수 있음
  • 동시성: 단일 쿼리 지연과 P95/P99는 다른 문제

리콜은 보통 FLAT(brute force) 결과를 정답으로 두고 근사 인덱스 결과와의 일치율로 측정합니다.

  • recall@k = |ANN_topk ∩ GT_topk| / k

Milvus에서 “정답”을 만들려면 작은 샘플에 대해 FLAT 컬렉션을 별도로 만들거나, 동일 컬렉션에서 인덱스를 FLAT로 두고 측정하는 방식이 실무에서 많이 쓰입니다.

2) IVF_FLAT 개념과 튜닝 레버

2.1 IVF_FLAT이 빠른 이유

IVF_FLAT은 크게 두 단계입니다.

  1. 코어스(centroid)로 후보 버킷 선택: 전체 벡터를 nlist개의 클러스터로 나눔
  2. 선택된 버킷에서만 정확 거리 계산(FLAT): 근사 없이 정확 계산이지만, 대상이 줄어드니 전체는 빨라짐

즉, IVF_FLAT은 “근사”가 버킷 선택 단계에서 발생하고, 버킷 내부는 정확합니다.

2.2 핵심 파라미터: nlistnprobe

  • nlist: 클러스터 개수(인덱스 빌드 시 결정)
    • 너무 작으면 버킷이 커져서 후보가 많아짐(느림)
    • 너무 크면 버킷이 너무 잘게 쪼개져서 학습/메모리/오버헤드가 증가하고, 분포에 따라 리콜이 떨어질 수도 있음
  • nprobe: 검색 시 몇 개의 버킷을 탐색할지
    • nprobe를 올리면 리콜이 올라가지만 지연이 증가
    • nprobe=1은 빠르지만 리콜이 낮기 쉬움

경험칙으로는 데이터 규모 N에 대해 nlist를 대략 sqrt(N) 근방에서 시작하고, nprobe1부터 올리며 리콜-지연 곡선을 그립니다. 다만 이는 “시작점”일 뿐이고, 임베딩 분포(클러스터링 가능성)에 따라 최적점이 크게 바뀝니다.

2.3 IVF_FLAT이 유리한 상황

  • 디스크/메모리 예산이 빡빡하고, 그래프 인덱스의 메모리 오버헤드가 부담될 때
  • 데이터가 클러스터링이 잘 되는 분포일 때(예: 도메인이 좁고 유사도가 뚜렷)
  • 리콜을 nprobe로 “선형에 가깝게” 조절하고 싶을 때(운영 튜닝이 단순)

반대로, 데이터가 균일하게 퍼져 클러스터링이 잘 안 되면 nprobe를 꽤 올려야 해서 지연이 쉽게 증가합니다.

3) HNSW 개념과 튜닝 레버

3.1 HNSW가 빠른 이유

HNSW는 벡터들을 그래프 형태로 연결해두고, 검색 시 그래프를 타고 이동하며 근사 최근접을 찾습니다. “좋은 진입점에서 시작해, 더 가까운 이웃으로 점프”하는 방식이라 전체를 보지 않습니다.

3.2 핵심 파라미터: M, efConstruction, efSearch

  • M: 노드당 연결 수(대략적인 그래프 밀도)
    • M이 크면 리콜이 좋아지는 경향, 대신 메모리 사용량과 빌드 비용 증가
  • efConstruction: 빌드 시 탐색 폭
    • 높을수록 인덱스 품질이 좋아져 리콜이 올라가지만 빌드 시간이 증가
  • efSearch: 검색 시 탐색 폭
    • 높을수록 리콜 상승, 지연 증가

efSearch는 실무에서 가장 자주 만지는 레버입니다. IVF_FLATnprobe와 비슷하게 “리콜을 지연과 교환”합니다.

3.3 HNSW가 유리한 상황

  • 낮은 지연을 강하게 요구하고(예: 온라인 추천/검색), 메모리를 더 쓸 수 있을 때
  • 데이터가 클러스터링이 애매해도 그래프 탐색으로 상대적으로 안정적인 리콜을 얻고 싶을 때
  • 튜닝 시 efSearch 하나로 리콜을 올리는 단순한 운영을 선호할 때

다만 HNSW는 메모리 오버헤드가 커질 수 있고, 대규모 데이터에서 빌드/컴팩션/세그먼트 관리가 운영 포인트가 됩니다.

4) 리콜·지연 튜닝을 “곡선”으로 접근하기

튜닝의 핵심은 “정답을 추측”하는 게 아니라, 리콜-지연 곡선을 실제로 찍고 목표 지점을 고르는 것입니다.

  • x축: P95 latency(또는 평균)
  • y축: recall@k
  • 곡선: nprobe(IVF) 또는 efSearch(HNSW)를 증가시키며 측정

실무 팁:

  • 평균 지연은 좋아 보이는데 P99가 튀면, 스케줄링/GC/IO/락 경합 등 시스템 요인이 섞였을 수 있습니다.
  • 검색 API는 종종 “한 번 느려지면 사용자 체감이 급락”합니다. 프론트에서의 상호작용 지표까지 포함해 병목을 보려면 React INP 급락 원인 - 긴 Task 분해·useTransition처럼 UI 스레드 관점도 같이 확인하세요.

5) Milvus에서 IVF_FLAT vs HNSW 설정 예시

아래 예시는 Python SDK 기준의 전형적인 형태입니다. (환경에 따라 import 경로/초기화는 다를 수 있습니다.)

5.1 공통: 컬렉션 생성과 데이터 삽입

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=False),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=dim),
]

schema = CollectionSchema(fields, description="ivf_vs_hnsw")
col = Collection(name="vec_demo", schema=schema)

# 예시 데이터
ids = list(range(1, 10001))
embeddings = [[0.0] * dim for _ in range(10000)]

col.insert([ids, embeddings])
col.flush()

5.2 IVF_FLAT 인덱스 생성과 검색 파라미터

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

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

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

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

튜닝은 보통 nprobe1, 2, 4, 8, 16, 32, 64 식으로 올리며 리콜과 지연을 같이 기록합니다.

5.3 HNSW 인덱스 생성과 검색 파라미터

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {
        "M": 16,
        "efConstruction": 200
    }
}

col.drop_index()  # 이미 인덱스가 있으면 교체
col.create_index(field_name="embedding", index_params=index_params)
col.load()

search_params = {
    "metric_type": "COSINE",
    "params": {
        "ef": 64  # Milvus 문서/버전에 따라 efSearch 또는 ef 로 표기
    }
}

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

HNSW는 ef(또는 efSearch)를 16, 32, 64, 128, 256 식으로 올리며 곡선을 찍는 접근이 일반적입니다.

주의: Milvus 버전에 따라 검색 파라미터 키가 ef로 노출되거나 efSearch로 문서화될 수 있습니다. 운영 코드에서는 “현재 클러스터 버전의 공식 문서/SDK 타입”을 기준으로 고정하세요.

6) 실전 튜닝 절차(운영 친화)

6.1 목표를 숫자로 고정하기

  • 리콜 목표: 예) recall@10 >= 0.95
  • 지연 목표: 예) P95 <= 50ms, P99 <= 120ms
  • 비용 제약: 예) 메모리 상한, 노드 수 상한

목표가 없으면 튜닝은 끝이 없습니다.

6.2 IVF_FLAT 튜닝 루틴

  1. nlist를 2~3개 후보로 정함(예: 2048, 4096, 8192)
  2. nlist에서 nprobe를 증가시키며 리콜-지연을 측정
  3. 목표 리콜을 만족하는 최소 nprobe를 찾고, 그중 지연이 가장 안정적인 nlist를 선택

운영 팁:

  • nprobe를 올렸는데 리콜이 거의 안 오르면, 데이터가 해당 클러스터링 가정에 잘 맞지 않는 신호일 수 있습니다.
  • 샤딩/세그먼트가 많아질수록 “버킷 선택 + 후보 스캔”이 분산되어 tail latency가 늘 수 있으니, P95/P99 중심으로 보세요.

6.3 HNSW 튜닝 루틴

  1. M을 2개 정도 후보로 시작(예: 16, 32)
  2. efConstruction은 빌드 시간과 품질의 트레이드오프이므로, 오프라인 빌드가 가능하면 다소 높게 시작(예: 200 또는 그 이상)
  3. 검색에서는 ef를 올리며 목표 리콜을 만족하는 최소 값을 찾음

운영 팁:

  • M을 올리면 메모리 사용량이 증가합니다. 노드 메모리 압박은 결국 지연 튐으로 돌아옵니다.
  • 인덱스 빌드/리빌드가 잦은 환경(자주 전량 재색인)이라면 efConstruction을 너무 공격적으로 올리기 어렵습니다.

7) 선택 가이드: 어떤 경우에 무엇을 쓰나

정답은 없지만, 의사결정이 빨라지는 체크리스트는 있습니다.

7.1 IVF_FLAT을 먼저 고려할 때

  • 메모리 예산이 제한적이고, 디스크 기반 세그먼트 운영이 중요
  • 리콜을 nprobe로 비교적 직관적으로 조절하고 싶음
  • 데이터가 도메인 특성상 군집이 잘 잡히는 편

7.2 HNSW를 먼저 고려할 때

  • 온라인 트래픽에서 낮은 지연이 최우선
  • 리콜을 높게 유지하면서도 지연을 안정적으로 만들고 싶음
  • 메모리 사용량 증가를 감당 가능

실무적으로는 “둘 다 측정”이 가장 안전합니다. 특히 topK, 필터 조건, 동시성에 따라 우열이 바뀌는 경우가 많습니다.

8) 지연이 튀는 원인과 관측 포인트

리콜 튜닝만 하다가 지연이 튀면 보통 아래 중 하나입니다.

  • 메모리 압박: HNSW에서 M을 과도하게 올리거나, 세그먼트가 커져 캐시 미스가 증가
  • 동시성 경합: query node CPU 포화, 스레드 풀 경쟁
  • I/O 패턴 변화: warm-up 전후, compaction 시점
  • 필터 + ANN 플랜: 필터가 선택도를 망치면 오히려 후보가 늘어남

서비스 전체 관점에서 지연을 다룰 때는 애플리케이션 레벨에서도 타임아웃/서킷브레이커/캐시를 함께 설계해야 합니다. 인프라 운영 이슈(예: Pod 재시작 루프)까지 겹치면 지연 분산이 급격히 넓어지므로, 쿠버네티스 환경이라면 K8s Pod CrashLoopBackOff 원인 7가지와 해결도 같이 점검하는 편이 좋습니다.

9) 최소 실험 템플릿: 리콜-지연 벤치 스크립트

아래는 “그라운드 트루스(topK)”를 FLAT로 만들고, IVF_FLAT 또는 HNSW 결과와 비교해 recall@k를 계산하는 매우 단순화된 예시입니다.

import time

def recall_at_k(gt_ids, ann_ids, k):
    gt = set(gt_ids[:k])
    ann = set(ann_ids[:k])
    return len(gt & ann) / float(k)

# gt_col: FLAT 인덱스(또는 brute force) 컬렉션
# ann_col: IVF_FLAT 또는 HNSW 컬렉션

def benchmark(col_gt, col_ann, query_vecs, k=10, ann_params=None):
    latencies = []
    recalls = []

    for q in query_vecs:
        gt = col_gt.search([q], "embedding", {"metric_type": "COSINE", "params": {}}, limit=k)[0]
        gt_ids = [hit.id for hit in gt]

        t0 = time.perf_counter()
        ann = col_ann.search([q], "embedding", ann_params, limit=k)[0]
        t1 = time.perf_counter()

        ann_ids = [hit.id for hit in ann]
        latencies.append((t1 - t0) * 1000.0)
        recalls.append(recall_at_k(gt_ids, ann_ids, k))

    latencies.sort()
    p95 = latencies[int(len(latencies) * 0.95)]
    return {
        "p95_ms": p95,
        "avg_ms": sum(latencies) / len(latencies),
        "recall": sum(recalls) / len(recalls),
    }

이 템플릿을 기반으로 nprobe 또는 ef를 바꿔가며 결과를 CSV로 남기면, 팀 내 합의가 훨씬 빨라집니다.

10) 결론: 튜닝의 핵심은 “레버 1개씩”과 “곡선”

  • IVF_FLATnlist(빌드)와 nprobe(검색)로 후보군을 조절하고, 내부는 정확 계산이라 예측 가능성이 높습니다.
  • HNSWM/efConstruction으로 그래프 품질을 만들고, ef로 탐색 폭을 조절해 리콜-지연을 맞춥니다.
  • 둘 중 무엇이든, 운영에서 중요한 건 “한 번의 벤치 결과”가 아니라 리콜-지연 곡선P95/P99 안정성입니다.

다음 단계로는 (1) 실제 데이터 샘플로 topK별 곡선을 만들고, (2) 필터 조건을 포함한 쿼리 세트를 따로 측정하며, (3) 동시성 하에서 tail latency를 재점검하는 순서를 추천합니다.