Published on

Milvus HNSW 튜닝 - recall↑ 지연↓ 실전

Authors

Milvus를 벡터 검색 백엔드로 붙이면 대부분의 성능 이슈는 결국 두 가지로 수렴합니다.

  • recall이 기대보다 낮다(정답을 못 찾는다)
  • p95/p99 latency가 치솟는다(서비스가 느리다)

HNSW는 이 두 문제를 파라미터 조합으로 상당 부분 해결할 수 있지만, 무작정 숫자를 올리면 메모리와 CPU가 폭발합니다. 이 글은 Milvus에서 HNSW를 측정 가능한 방식으로 튜닝하는 절차를 정리합니다.

참고로 HNSW 자체 원리와 튜닝 감각은 Qdrant 사례와도 유사합니다. 비교 관점이 필요하면 이 글도 함께 보면 좋습니다: Rust+Qdrant RAG - HNSW 튜닝으로 지연 50%↓

목표와 전제: 무엇을 고칠지 먼저 고정하기

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

  • 목표 topK (예: 10, 50, 100)
  • 목표 recall@K (예: >= 0.95)
  • 허용 지연: p50/p95/p99 (예: p95 50ms)
  • 데이터 규모(벡터 수 N), 차원(dim), metric(COSINE/IP/L2)
  • 쿼리 QPS, 동시성(concurrency)

여기서 가장 흔한 실수는 recall을 올리는 실험지연을 줄이는 실험을 섞어서 결과를 해석하는 것입니다. 실험은 항상 한 축씩 진행하세요.

Milvus HNSW 파라미터 맵

Milvus에서 HNSW는 크게 두 단계 파라미터로 나뉩니다.

  • 인덱스 빌드(오프라인 성격)
    • M
    • efConstruction
  • 검색(온라인 성격)
    • ef 또는 search_k 계열(버전/SDK에 따라 표기 차이)

용어가 섞여 혼동되는 지점이 많아, 실무적으로는 다음처럼 이해하면 안전합니다.

M: 그래프의 연결도(메모리와 recall의 바닥)

  • 의미: 각 노드가 유지하는 이웃 수(대략적인 연결도)
  • 효과:
    • M 증가: recall 상승 가능, 검색 안정성 증가
    • 비용: 인덱스 크기(메모리) 증가, 빌드 시간 증가

경험적으로 M은 너무 낮으면 ef를 올려도 recall이 안 나오는 “바닥”이 생깁니다. 즉, M가능한 recall의 상한을 어느 정도 결정합니다.

efConstruction: 빌드 품질(빌드 시간 vs 검색 품질)

  • 의미: 인덱스 생성 시 탐색 폭
  • 효과:
    • 증가: 그래프 품질 개선, 동일 ef에서 recall 상승
    • 비용: 빌드 시간 증가(상당히 큼)

운영에서는 보통 efConstruction을 충분히 주고, 온라인에서는 ef로 지연을 맞추는 패턴이 안정적입니다.

ef(search): 온라인 지연과 recall의 레버

  • 의미: 검색 시 후보 탐색 폭
  • 효과:
    • 증가: recall 상승
    • 비용: CPU 사용량 증가, 지연 증가

ef는 온라인에서 쉽게 조절 가능한 레버라서 A/B나 점진적 롤아웃에도 적합합니다.

가장 먼저 해야 할 것: Ground Truth 만들기

HNSW 튜닝은 결국 “근사 검색”의 품질을 조절하는 일입니다. 따라서 정답(ground truth) 없이는 recall을 측정할 수 없습니다.

가장 단순한 방법은 같은 쿼리 셋에 대해:

  • 정확 검색(브루트포스): topK
  • HNSW 검색: topK

을 비교해 recall@K를 계산하는 것입니다.

정확 검색은 비용이 크니, 전체 데이터가 아니라도 됩니다.

  • 샘플: N이 수천만이면 50만~200만 정도 샘플 컬렉션을 별도로 만들기
  • 쿼리: 실제 트래픽에서 대표 쿼리 1천~1만개 추출

Milvus 컬렉션/인덱스 생성 예시 (Python)

아래 예시는 pymilvus 기준의 전형적인 HNSW 인덱스 생성 흐름입니다. (환경마다 클래스/함수 시그니처는 다를 수 있으니 개념 위주로 보세요.)

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

DIM = 768
COLLECTION = "docs"

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=DIM),
]

schema = CollectionSchema(fields, description="HNSW tuning demo")
col = Collection(COLLECTION, schema)

# HNSW 인덱스 파라미터
index_params = {
    "index_type": "HNSW",
    "metric_type": "IP",  # COSINE은 Milvus에서 IP로 정규화하여 쓰는 경우가 많음
    "params": {
        "M": 32,
        "efConstruction": 200
    }
}

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

검색 시 파라미터는 아래처럼 분리해서 설정합니다.

search_params = {
    "metric_type": "IP",
    "params": {
        "ef": 64
    }
}

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

중요한 점은, 인덱스 파라미터(M, efConstruction)검색 파라미터(ef) 를 실험 설계에서 분리해야 한다는 것입니다.

실전 튜닝 순서(권장): 빌드 품질 먼저, 온라인은 ef로 맞추기

1) M 후보를 2~3개만 고른다

처음부터 그리드 서치를 하면 시간만 버립니다. 보통 다음 정도면 충분히 시작할 수 있습니다.

  • M: 16 / 32 / 48 (또는 64)

데이터가 크고 메모리가 빡빡하면 16부터, 품질이 중요하면 32부터 시작하세요.

체크 포인트:

  • 메모리 사용량(인덱스 로드 후 RSS)
  • 빌드 시간
  • 동일 ef에서의 recall 변화

2) efConstruction은 “충분히” 주고 고정한다

운영에서 흔한 실패는 efConstruction을 낮게 잡아놓고, 온라인 ef를 아무리 올려도 recall이 안 나오는 경우입니다.

권장 접근:

  • efConstruction: 100 / 200 / 400 중에서 선택
  • 빌드 시간이 허용되면 200 이상을 먼저 시도

결론적으로 efConstruction인덱스 품질의 하한을 올리는 비용이고, ef온라인 비용으로 recall을 사는 버튼입니다.

3) 온라인은 ef로 recall-지연 곡선을 만든다

MefConstruction을 고정한 뒤, ef만 바꿔서 아래를 측정합니다.

  • ef: 16, 32, 64, 128, 256
  • 측정: recall@K, p50/p95/p99 latency, CPU 사용률

이때 얻고 싶은 것은 “곡선”입니다.

  • recall이 0.90에서 0.95로 오르는데 지연이 2배가 되는 지점
  • recall이 이미 포화인데 지연만 증가하는 지점

그 지점이 바로 운영 파라미터입니다.

recall이 안 오를 때의 전형적인 원인 6가지

1) M이 너무 낮다

증상:

  • ef를 256, 512까지 올려도 recall이 특정 값에서 멈춤

해결:

  • M을 16에서 32로 올리는 것만으로도 곡선이 달라지는 경우가 많음

2) efConstruction이 너무 낮다

증상:

  • 새로 만든 인덱스에서만 유독 recall이 낮고 변동이 큼

해결:

  • efConstruction을 올리고 재빌드

3) metric/정규화가 잘못됐다

COSINE을 기대했는데 IP로 넣고 벡터 정규화를 안 하면 품질이 흔들립니다.

  • COSINE 유사도 목적: 보통 벡터를 L2 normalizeIP 사용

정규화 예:

import numpy as np

def l2_normalize(v: np.ndarray) -> np.ndarray:
    v = v.astype(np.float32)
    return v / (np.linalg.norm(v) + 1e-12)

4) topK가 커졌는데 ef를 그대로 둠

topK=10에서 맞춘 eftopK=100을 때리면 recall이 급락하는 경우가 많습니다.

  • 경험칙: ef는 최소한 topK보다 충분히 커야 함

5) 세그먼트/샤드 구조로 인해 “검색 단위”가 바뀜

Milvus는 내부적으로 데이터가 세그먼트로 나뉘고, 분산이면 노드/샤드 단위로 검색이 이뤄집니다.

  • 세그먼트가 너무 잘게 쪼개져 있으면 오버헤드가 커지고, 같은 ef라도 체감 recall/지연이 달라질 수 있음

6) 필터링(스칼라 조건)과 결합되며 후보가 줄어듦

스칼라 필터가 강하면 HNSW 그래프 탐색 중 후보가 제거되어 recall이 떨어질 수 있습니다.

  • 해결: 필터 선택도를 낮추거나(조건 완화), 필터 전용 인덱스/파티션 전략 고려

지연이 높은데 recall은 충분할 때: 무엇을 깎을지

recall이 목표를 만족한다면 지연은 보통 아래 순서로 줄입니다.

  1. ef를 내린다(가장 즉각적)
  2. topK를 줄이거나, 후처리(리랭킹) 구조를 바꾼다
  3. 동시성/QPS를 기준으로 리소스(코어, 노드 수)를 맞춘다
  4. 인덱스 구조(M)를 낮추는 건 마지막(품질 바닥이 내려감)

특히 ef는 p95를 빠르게 흔드는 레버라서, 운영 중에도 “부하가 높을 때만 낮추는” 방어적 전략을 쓸 수 있습니다.

벤치마크 스크립트: recall@K와 p95를 같이 보기

아래는 매우 단순화한 형태의 측정 코드 예시입니다.

import time
import numpy as np

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

def percentile(values, p):
    values = sorted(values)
    idx = int(np.ceil(p/100 * len(values))) - 1
    return values[max(0, min(idx, len(values)-1))]

def benchmark(col, queries, gt_results, ef, k=10):
    lat = []
    rec = []

    search_params = {"metric_type": "IP", "params": {"ef": ef}}

    for q, gt in zip(queries, gt_results):
        t0 = time.perf_counter()
        res = col.search([q], "embedding", search_params, limit=k)
        dt = (time.perf_counter() - t0) * 1000
        lat.append(dt)

        ann_ids = [hit.id for hit in res[0]]
        rec.append(recall_at_k(gt, ann_ids, k))

    return {
        "ef": ef,
        "recall@k": float(np.mean(rec)),
        "p50_ms": percentile(lat, 50),
        "p95_ms": percentile(lat, 95),
        "p99_ms": percentile(lat, 99),
    }

포인트는 단 하나입니다.

  • ef를 바꾸면 recall과 지연이 함께 변하므로, 둘을 같은 테이블/그래프로 봐야 합니다.

운영 팁: 튜닝보다 더 자주 터지는 것들

메모리 부족(OOM)과 인덱스 로드 실패

M을 올리면 인덱스 메모리 사용량이 빠르게 증가합니다. 특히 쿠버네티스 환경에서는 OOMKilled로 이어지기 쉽습니다.

  • 인덱스 로드 시점에 메모리가 순간적으로 더 필요할 수 있음
  • 노드 간 리밸런싱 시에도 피크가 생김

쿠버네티스에서 OOM을 반복적으로 겪는다면, 메모리 리밋/GC/워크로드 특성을 함께 점검해야 합니다: EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝

TTL/보관 정책으로 인덱스 부피 자체를 줄이기

튜닝으로 지연을 잡는 데 한계가 오면, 데이터 볼륨을 줄이는 것이 가장 확실한 해법이 됩니다.

  • 오래된 대화/세션 임베딩은 TTL로 만료
  • “핫” 데이터와 “콜드” 데이터를 컬렉션 분리

RAG나 에이전트 메모리에서 TTL 전략은 비용과 지연을 동시에 줄입니다: AutoGPT 메모리 누수? 벡터DB TTL로 비용 줄이기

추천 시작값(현실적인 프리셋)

데이터 분포와 차원, metric에 따라 달라지지만 “첫 실험”으로는 아래가 무난합니다.

  • 품질 우선 프리셋

    • M: 32
    • efConstruction: 200
    • ef: 128부터 시작해 목표 recall에 맞춰 내리기
  • 지연 우선 프리셋

    • M: 16
    • efConstruction: 100~200
    • ef: 32~64에서 시작해 recall이 부족하면 올리기

중요: 위 값은 정답이 아니라 “탐색 시작점”입니다. 반드시 ground truth 기반으로 곡선을 만들어 결정하세요.

체크리스트: 튜닝 실험을 망치지 않는 법

  • 동일한 쿼리 셋, 동일한 topK로 비교했는가
  • metric과 정규화가 기대와 일치하는가
  • M/efConstruction 변경 시 인덱스를 재빌드했는가
  • ef 실험은 단일 변수로 진행했는가
  • p95뿐 아니라 p99와 CPU 사용률도 같이 봤는가
  • 필터 조건이 있는 쿼리와 없는 쿼리를 분리해 측정했는가

결론

Milvus HNSW 튜닝은 감으로 하는 작업이 아니라, MefConstruction으로 인덱스 품질의 바닥을 만든 뒤, ef운영 지연과 recall을 교환하는 문제로 단순화할 수 있습니다.

  • recall이 안 오르면: M 또는 efConstruction을 의심
  • 지연이 높으면: 먼저 ef를 내리고, 그 다음 시스템 리소스와 데이터 볼륨을 점검

이 순서대로만 접근해도 “recall은 낮고 지연은 높은” 최악의 상태에서 빠르게 벗어날 수 있습니다.