Published on

Milvus IVF_FLAT·HNSW 튜닝으로 Recall 0.95 달성

Authors

서로 다른 벡터 검색 워크로드에서 목표 Recall 0.95를 맞추려면, 인덱스 선택보다 측정 방식과 튜닝 루프가 더 중요합니다. Milvus는 IVF 계열과 HNSW를 모두 지원하지만, 둘은 성격이 완전히 다릅니다.

이 글에서는 IVF_FLAT과 HNSW의 핵심 파라미터를 Recall 0.95를 목표로 어떻게 조정하는지, 그리고 운영 환경에서 재현 가능한 실험 절차를 어떻게 구성하는지 정리합니다.

전제: Recall 0.95는 “검색 파라미터”로 맞춘다

Milvus에서 Recall은 대체로 아래 3요소의 함수입니다.

  • 데이터/임베딩 품질: 동일 모델이라도 정규화 여부, 차원, 도메인 드리프트에 따라 상한이 달라집니다.
  • 인덱스 빌드 파라미터: IVF의 nlist, HNSW의 M, efConstruction 등.
  • 검색 시 파라미터: IVF의 nprobe, HNSW의 ef.

실무적으로 “Recall 0.95를 맞춘다”는 말은 보통 다음을 의미합니다.

  • 인덱스 빌드는 한 번에 크게 바꾸기 어렵기 때문에(리빌드 비용), 검색 파라미터로 목표 Recall을 맞추고
  • 그래도 부족하면 인덱스 빌드 파라미터를 조정해 탐색 공간 자체를 개선합니다.

측정 준비: Ground Truth 없이 Recall을 논하면 실패한다

Recall을 튜닝하려면 최소한 아래 중 하나가 필요합니다.

  1. 정확 탐색(브루트포스) 결과를 정답으로 삼기
  2. 서비스에 이미 존재하는 “정답 클릭/구매/라벨” 기반의 오프라인 평가(이 경우 Recall@K 정의를 명확히)

가장 단순하고 재현 가능한 방법은 “샘플 쿼리 집합”에 대해 FLAT(정확) 검색 결과를 Ground Truth로 만들고, 각 인덱스/파라미터 조합의 결과와 비교하는 것입니다.

아래는 Milvus Python SDK 기준으로, 동일 컬렉션에서 FLAT 검색을 Ground Truth로 쓰는 예시입니다.

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

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

COL = "items"
collection = Collection(COL)
collection.load()

def search_flat(query_vecs, topk=10):
    # FLAT은 인덱스 타입이라기보다 search param에서 강제하는 방식이 아니라,
    # 보통 별도 컬렉션/인덱스를 FLAT으로 두거나, 인덱스 없이 brute force로 측정합니다.
    # 여기서는 예시로 search param만 단순화합니다.
    res = collection.search(
        data=query_vecs,
        anns_field="embedding",
        param={"metric_type": "COSINE", "params": {}},
        limit=topk,
        output_fields=["id"],
    )
    return [[hit.id for hit in hits] for hits in res]

def recall_at_k(pred_ids, gt_ids, k=10):
    # gt_ids: 정답 topk, pred_ids: 예측 topk
    # Recall@K = |pred ∩ gt| / |gt|
    r = []
    for p, g in zip(pred_ids, gt_ids):
        pset = set(p[:k])
        gset = set(g[:k])
        r.append(len(pset & gset) / max(1, len(gset)))
    return sum(r) / len(r)

운영 관점 팁: 튜닝은 “장애”로도 이어진다

튜닝 중에는 로드/메모리/CPU가 급증해 Pod가 죽거나 지연이 늘어날 수 있습니다. 특히 HNSW는 메모리 사용량이 커서 리소스가 타이트하면 OOMKilled로 이어지기 쉽습니다. 쿠버네티스에서 이런 증상을 겪는다면 먼저 아래 글의 체크리스트로 원인을 분리해두면 튜닝 효율이 좋아집니다.

IVF_FLAT: nlistnprobe가 전부라고 생각하면 된다

IVF_FLAT은 “coarse quantizer로 클러스터를 나누고, 선택된 클러스터 내부는 FLAT(정확)으로 스캔”하는 구조입니다.

  • nlist: 클러스터 개수(버킷 수)
  • nprobe: 검색 시 탐색할 클러스터 개수

직관은 다음과 같습니다.

  • nlist가 커질수록 각 클러스터가 작아져서 빠르지만, 잘못된 클러스터를 고르면 Recall이 떨어질 수 있습니다.
  • nprobe를 늘리면 더 많은 클러스터를 보므로 Recall이 올라가지만, 지연과 CPU가 증가합니다.

IVF_FLAT 인덱스 생성 예시

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

collection.create_index(
    field_name="embedding",
    index_params=index_params
)

IVF_FLAT 검색 파라미터 예시

def search_ivf(query_vecs, topk=10, nprobe=32):
    res = collection.search(
        data=query_vecs,
        anns_field="embedding",
        param={
            "metric_type": "COSINE",
            "params": {"nprobe": nprobe}
        },
        limit=topk,
        output_fields=["id"],
    )
    return [[hit.id for hit in hits] for hits in res]

Recall 0.95를 위한 IVF_FLAT 튜닝 가이드(실전)

아래는 “데이터 규모 N, topK, 지연 예산”이 정해져 있을 때의 경험적 접근입니다.

  1. 초기 nlist를 합리적으로 잡기
    • 흔한 출발점: nlistsqrt(N) 근처로 두고 시작
    • N이 1,000만이면 sqrt(N)은 약 3162이므로 2048~8192 범위에서 탐색
  2. nprobe로 Recall 목표를 먼저 맞춘다
    • nprobe를 8, 16, 32, 64, 128 식으로 올리며 Recall@K를 측정
    • Recall이 0.95에 도달하는 최소 nprobe를 찾는다
  3. 지연이 너무 크면 nlist를 조정
    • 같은 Recall이라도 nlist가 너무 작으면 클러스터가 커져서 느려질 수 있음
    • 반대로 nlist가 너무 크면 nprobe를 크게 해야 해서 느려질 수 있음

흔한 실패 패턴

  • nlist를 너무 크게 잡고 nprobe를 작게 유지: 속도는 빠른데 Recall이 0.95에 못 미침
  • nprobe를 과도하게 키움: Recall은 맞지만 p95 지연이 폭증

HNSW: M, efConstruction, ef의 역할을 분리하라

HNSW는 그래프 기반 근사 최근접 탐색(ANN)입니다.

  • M: 노드당 연결(edge) 수에 가까운 값. 커질수록 Recall이 좋아질 가능성이 크지만 메모리와 빌드 비용 증가
  • efConstruction: 인덱스 빌드 시 탐색 폭. 클수록 더 좋은 그래프가 생기지만 빌드 시간/메모리 증가
  • ef: 검색 시 탐색 폭. 클수록 Recall 증가, 지연 증가

핵심은 다음입니다.

  • 빌드 파라미터인 M, efConstruction인덱스 품질의 상한을 결정
  • 검색 파라미터인 ef그 상한에 얼마나 근접할지를 결정

HNSW 인덱스 생성 예시

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

collection.create_index(
    field_name="embedding",
    index_params=index_params
)

HNSW 검색 파라미터 예시

def search_hnsw(query_vecs, topk=10, ef=64):
    res = collection.search(
        data=query_vecs,
        anns_field="embedding",
        param={
            "metric_type": "COSINE",
            "params": {"ef": ef}
        },
        limit=topk,
        output_fields=["id"],
    )
    return [[hit.id for hit in hits] for hits in res]

Recall 0.95를 위한 HNSW 튜닝 가이드(실전)

  1. 먼저 ef를 올려서 목표 Recall이 가능한지 확인
    • ef를 32, 64, 128, 256…으로 증가
    • ef를 올려도 Recall이 0.95 근처에서 plateau면, 인덱스 품질 상한이 낮은 것
  2. plateau라면 M을 올리고 리빌드 고려
    • M=12에서 안 되면 M=16, M=24 순으로 시도
    • 메모리 사용량이 민감하게 늘 수 있으니 노드 메모리/Pod limit을 먼저 확인
  3. 그래도 부족하면 efConstruction을 올린다
    • 일반적으로 efConstruction은 100~400 범위에서 실험
    • 빌드 시간이 늘어나므로 배치 윈도우를 확보

HNSW의 운영상 주의점

  • 메모리: M이 커질수록 그래프가 촘촘해져 메모리 압박이 커집니다.
  • 빌드/컴팩션 타이밍: 대량 insert 이후 인덱스 빌드 또는 세그먼트 증가가 겹치면 지연이 흔들립니다.

IVF_FLAT vs HNSW: 어떤 때 무엇을 선택할까

둘 다 Recall 0.95는 가능하지만, “어떤 비용을 감수할지”가 다릅니다.

IVF_FLAT이 유리한 경우

  • 디스크/메모리 제약이 큰 환경에서 예측 가능한 비용이 필요
  • nprobe로 성능을 비교적 직관적으로 제어하고 싶음
  • 데이터가 크고, 정확 스캔 비용을 클러스터링으로 줄이고 싶음

HNSW가 유리한 경우

  • 낮은 지연에서 높은 Recall을 원하고, 메모리를 더 쓸 수 있음
  • 쿼리당 탐색 비용을 ef로 부드럽게 조절하고 싶음
  • topK가 작고(예: 10~50), 온라인 서비스에서 p95를 낮추고 싶음

튜닝 루프: “파라미터 스윕 + p95 + Recall”을 동시에 본다

Recall만 올리면 결국 비용이 폭발합니다. 그래서 아래 3가지를 같이 기록해야 합니다.

  • Recall@K (목표 0.95)
  • 지연: p50, p95, p99
  • 비용 지표: CPU 사용률, 메모리 사용률, QPS당 코어 소모

간단한 스윕 코드는 아래처럼 구성할 수 있습니다.

import time
import numpy as np

def benchmark(search_fn, query_vecs, gt_ids, k=10):
    t0 = time.time()
    pred = search_fn(query_vecs, topk=k)
    dt = time.time() - t0
    r = recall_at_k(pred, gt_ids, k=k)
    return {
        "recall": r,
        "total_sec": dt,
        "qps": len(query_vecs) / max(1e-9, dt),
    }

# 예시: IVF nprobe 스윕
# gt_ids는 미리 FLAT으로 만들어둔 정답
# query_vecs는 numpy array list 형태라고 가정

for nprobe in [8, 16, 32, 64, 128]:
    out = benchmark(lambda q, topk: search_ivf(q, topk=topk, nprobe=nprobe), query_vecs, gt_ids)
    print("IVF", "nprobe=", nprobe, out)

for ef in [32, 64, 128, 256]:
    out = benchmark(lambda q, topk: search_hnsw(q, topk=topk, ef=ef), query_vecs, gt_ids)
    print("HNSW", "ef=", ef, out)

여기서 중요한 건 “최고 Recall”이 아니라, Recall 0.95를 만족하는 최소 비용 지점을 찾는 것입니다.

Recall이 안 오를 때 점검 체크리스트

튜닝을 해도 0.95에 못 미치는 경우, 인덱스 파라미터보다 먼저 아래를 확인해야 합니다.

1) metric과 정규화가 맞는가

  • COSINE을 쓰는데 벡터를 정규화하지 않거나, 모델이 내적 기반인데 metric을 혼용하면 Recall이 흔들립니다.
  • L2COSINE은 데이터 분포에 따라 결과가 크게 달라집니다.

2) 세그먼트/로드 상태가 일관적인가

  • 일부 세그먼트만 로드된 상태에서 측정하면 Recall이 낮아집니다.
  • 튜닝 중에는 collection.load() 상태와 파티션 로딩 정책을 고정하세요.

3) 필터링(스칼라 조건)과 결합되었는가

  • 벡터 검색 후 필터인지, 필터 후 벡터 검색인지에 따라 후보군이 달라집니다.
  • 필터가 강하면 어떤 인덱스든 Recall@K가 구조적으로 낮아질 수 있습니다.

4) 리소스 제한으로 검색이 타임아웃/부분 실패하는가

  • 타임아웃이나 부분 실패가 있으면 결과 수가 줄어 Recall이 떨어진 것처럼 보입니다.

  • 쿠버네티스 환경에서 토큰/권한/네트워크 이슈가 성능 문제로 위장되기도 합니다. EKS에서 IAM 연동을 쓰는 경우 IRSA 설정도 함께 점검하세요.

  • EKS IRSA인데 AccessDenied? OIDC·TrustPolicy·SA 점검

추천 시작점(보수적)과 목표 도달 전략

워크로드가 아직 불명확할 때, 아래 조합은 “일단 0.95에 도달 가능한지”를 보기 좋은 시작점입니다.

IVF_FLAT 시작점

  • nlist: 2048~8192 중 데이터 크기에 맞춰 선택
  • nprobe: 16에서 시작해 2배씩 증가
  • 목표: nprobe만으로 0.95가 되면 운영 튜닝이 쉬움

HNSW 시작점

  • M=16, efConstruction=200
  • 검색 ef=64에서 시작해 128, 256으로 증가
  • 목표: ef를 과도하게 키우지 않고 0.95가 되는 지점 찾기

마무리: Recall 0.95는 “숫자”가 아니라 “합의된 SLO”다

Recall 0.95를 맞추는 가장 현실적인 방법은 다음 순서입니다.

  1. Ground Truth를 정의하고(FLAT 또는 라벨), Recall@K를 자동 측정한다
  2. IVF_FLAT은 nprobe, HNSW는 ef로 먼저 목표를 맞춘다
  3. plateau가 오면 IVF는 nlist, HNSW는 MefConstruction을 조정한다
  4. 최종적으로 p95 지연과 비용을 함께 보고, 0.95를 만족하는 최소 비용 구간을 선택한다

이 과정을 파이프라인으로 만들면, 데이터가 늘거나 임베딩 모델이 바뀌어도 “다시 0.95로 복구”하는 시간이 크게 줄어듭니다.