Published on

Milvus HNSW 튜닝 - recall↑·latency↓ 실전

Authors

서론: HNSW는 왜 튜닝이 곧 성능인가

Milvus에서 HNSW는 “대부분의 케이스에서 빠르고 정확한 근사 최근접 탐색(ANN)”을 제공하지만, 기본값만으로는 원하는 recalllatency를 동시에 얻기 어렵습니다. 이유는 간단합니다.

  • HNSW는 그래프 기반 인덱스이며, 그래프의 밀도(연결 수)와 탐색 폭(확장 정도)에 따라 정확도와 지연시간이 반비례로 움직입니다.
  • 데이터 분포(클러스터링 정도), 벡터 차원, 필터링(스칼라 조건), 동시성(QPS), 하드웨어(메모리 대역폭)까지 모두 영향을 줍니다.

이 글은 “HNSW 파라미터를 무엇부터 만져야 하는가”를 운영 관점에서 정리합니다. 목표는 다음 두 가지를 동시에 달성하는 것입니다.

  • recall@k를 목표치까지 올리기
  • p95/p99 latency를 운영 허용 범위로 내리기

추가로, 운영 중 성능 회귀를 막기 위한 측정 루프와 체크리스트까지 포함합니다.

HNSW 핵심 파라미터 3종 세트

Milvus HNSW 튜닝은 사실상 아래 3개가 전부라고 봐도 됩니다.

1) M: 그래프의 연결 수(밀도)

  • 의미: 각 노드가 갖는 최대 이웃 수(근사적으로)
  • 효과:
    • M 증가: 그래프가 촘촘해져 탐색이 쉬워져 recall이 오르기 쉬움
    • 단점: 인덱스 메모리 사용량 증가, 빌드 시간 증가
  • 운영 팁:
    • M은 “인덱스 품질의 바닥”을 결정합니다. ef만 올려서 해결이 안 되는 낮은 recallM이 너무 낮은 경우가 많습니다.

2) efConstruction: 인덱스 빌드 품질

  • 의미: 인덱스를 만들 때 후보를 얼마나 넓게 보며 연결을 만들지
  • 효과:
    • efConstruction 증가: 더 좋은 그래프 생성, recall 상승 가능
    • 단점: 빌드 시간 증가(때로는 크게), 빌드 시 CPU 사용량 증가
  • 운영 팁:
    • 온라인 트래픽이 중요한 서비스라면, 빌드(또는 리빌드) 시간을 감안해 “야간 배치”나 “롤링 리빌드” 전략이 필요합니다.

3) ef: 검색 시 탐색 폭

  • 의미: 검색 과정에서 유지하는 후보 리스트 크기
  • 효과:
    • ef 증가: recall 상승
    • 단점: latency 상승(대체로 선형에 가까움)
  • 운영 팁:
    • ef는 런타임 파라미터라서 실험이 쉽습니다. 따라서 첫 튜닝은 ef부터 시작하는 것이 안전합니다.

정리하면,

  • MefConstruction은 “인덱스 자체의 품질과 비용”
  • ef는 “쿼리마다 지불하는 비용”

입니다.

목표 지표 정의: recall과 latency를 어떻게 측정할까

튜닝은 감이 아니라 지표로 해야 합니다. 최소한 아래를 정하세요.

  • 정확도 지표: recall@k (예: k=10)
  • 지연 지표: p50/p95/p99 latency
  • 처리량: QPS 또는 동시성(예: 동시 32)

그리고 “정답(ground truth)”이 필요합니다. 보통은 아래 중 하나를 씁니다.

  • 소량 샘플에 대해 brute-force(정확한 top-k) 결과를 미리 계산
  • 혹은 FAISS Flat 같은 정확 검색으로 오프라인 정답 생성

간단한 recall 계산 코드(파이썬)

아래는 검색 결과 ID 리스트와 정답 ID 리스트로 recall@k를 계산하는 예시입니다.

def recall_at_k(pred_ids, true_ids, k=10):
    """pred_ids/true_ids: list[list[int]]"""
    hit = 0
    total = 0
    for p, t in zip(pred_ids, true_ids):
        p_k = set(p[:k])
        t_k = set(t[:k])
        hit += len(p_k & t_k)
        total += len(t_k)
    return hit / total if total else 0.0

운영에서는 이걸 “튜닝 배치”로 자동화해, 파라미터 변경 시 recallp95 latency가 같이 기록되도록 만드는 게 좋습니다.

튜닝의 기본 순서(실전 루프)

현장에서 가장 실패가 적은 순서는 아래입니다.

  1. ef를 올리며 recall 목표 달성 지점을 찾는다
  2. 목표 recall을 만족하는 최소 ef를 고른다
  3. 그때 latency가 너무 높으면 MefConstruction을 올려 “그래프 품질”을 개선하고, 다시 ef를 낮춰본다
  4. 데이터가 커지거나 분포가 바뀌면 1~3을 재실행한다

즉, 먼저 런타임 파라미터로 상한선을 확인하고, 그 다음 인덱스 파라미터로 비용을 최적화합니다.

Milvus에서 HNSW 인덱스 생성 예시

환경마다 Milvus 버전과 SDK가 다르지만, 핵심은 인덱스 파라미터와 metric을 명시하는 것입니다.

아래 예시는 pymilvus 스타일의 전형적인 패턴입니다(필드/컬렉션 생성 코드는 생략).

from pymilvus import Collection

collection = Collection("items")

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

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

collection.load()

검색 시에는 efsearch_params에 넣습니다.

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

res = collection.search(
    data=query_vectors,
    anns_field="embedding",
    param=search_params,
    limit=10,
    output_fields=["item_id"]
)

주의할 점은 metric_type을 데이터 생성 시의 정규화 정책과 일치시키는 것입니다.

  • COSINE을 쓸 거면 임베딩 정규화 여부를 일관되게
  • IP를 쓰는 경우도 “정규화된 벡터의 내적”이면 코사인과 유사하게 동작

파라미터별 실전 가이드(숫자 감각)

절대적인 정답은 없지만, 운영에서 자주 쓰는 범위를 기준으로 감각을 잡을 수 있습니다.

ef부터 스윕하기

  • 시작점: ef=32 또는 ef=64
  • 스윕: 32 → 64 → 128 → 256
  • 관찰:
    • recall이 빠르게 포화되는 구간이 있음
    • p95 latency는 대체로 ef에 민감하게 증가

실전 팁:

  • 목표 recall@10=0.95라면, 그 목표를 만족하는 **최소 ef**를 먼저 찾으세요.
  • ef를 무작정 올려 recall=0.99를 만들면 p99가 터지기 쉽습니다.

M을 올려 ef를 낮추는 전략

latency가 문제라면, 다음은 보통 이렇게 접근합니다.

  • 현 상태: M=16, efConstruction=200, 목표 recall을 위해 ef=256이 필요
  • 개선: M=32로 올리고 재빌드
  • 기대: 같은 recallef=128 또는 ef=96에서 달성

즉, 인덱스에 비용을 선투자해서 쿼리 비용을 절약하는 방식입니다.

주의:

  • M을 올리면 메모리 사용량이 늘어납니다. 메모리 압박이 생기면 오히려 latency가 나빠질 수 있습니다(페이지 폴트, 캐시 미스 증가).

efConstruction은 “빌드 시간과의 교환”

  • 일반적인 범위: 100~400
  • M을 올렸는데도 recall이 기대만큼 안 나오면 efConstruction을 올려볼 가치가 큽니다.

운영 팁:

  • 리빌드 시간이 길어지면 배포/롤백이 어려워집니다. Argo CD로 배포 자동화 중이라면 동기화 실패나 헬스체크 지연이 운영 리스크가 될 수 있어, 배포 파이프라인 측면도 같이 점검하세요. 관련해서는 Argo CD sync 실패 - 비교/헬스체크 원인 9가지도 함께 참고할 만합니다.

필터링(스칼라 조건)이 있을 때의 함정

Milvus에서 벡터 검색에 스칼라 필터가 붙으면, 엔진은 “후보를 찾고 필터를 적용”하거나 “필터를 고려한 탐색”을 하게 됩니다. 이때 흔한 현상은 다음입니다.

  • 필터가 매우 선택적(selective)일수록, 원하는 개수 k를 채우기 위해 더 많은 후보를 봐야 함
  • 결과적으로 같은 ef에서도 latency가 더 튐

대응 전략:

  • 필터가 강한 쿼리에는 ef를 별도로 크게 주는 “쿼리 타입별 튜닝”
  • 혹은 파티션/샤딩 설계로 필터 대상 자체를 줄이기

운영에서 latency를 깎는 체크리스트

HNSW 파라미터만으로 해결이 안 되는 경우가 많습니다. 아래를 같이 보세요.

1) 동시성에서 p95가 튀는가

  • 단일 쿼리 latency만 보지 말고, 실제 동시성에서 측정하세요.
  • CPU 바운드인지, 메모리 바운드인지, 네트워크인지 분리해야 합니다.

2) 메모리 여유가 충분한가

  • HNSW는 메모리를 많이 씁니다.
  • 메모리가 부족해 스왑/압축/캐시 미스가 늘면 ef를 올릴수록 더 악화됩니다.

3) 클라이언트 연결/타임아웃

추천 튜닝 레시피 3가지

서비스 성격별로 자주 쓰는 조합을 정리하면 다음과 같습니다.

레시피 A: 빠른 응답이 최우선(검색/추천 실시간)

  • 목표: p95를 낮게 유지, recall은 합리적 수준
  • 제안:
    • M=16~32
    • efConstruction=100~200
    • ef=32~96 범위에서 목표 recall 맞추기

레시피 B: recall이 최우선(리서치/평가/오프라인)

  • 목표: recall@k 최대화
  • 제안:
    • M=32~64
    • efConstruction=200~400
    • ef=128~512

레시피 C: 데이터가 크고 필터가 많은 운영형

  • 목표: 필터링으로 인한 편차를 줄이기
  • 제안:
    • 파티션/샤딩으로 후보 공간 축소
    • 쿼리 타입별로 ef 분리(필터 강한 쿼리에 더 큰 ef)
    • M은 메모리 예산 내에서 가능한 상단으로

실험 자동화: 파라미터 스윕 스크립트 예시

아래는 ef를 스윕하며 p95와 recall@10을 기록하는 간단한 형태입니다. 실제로는 부하(동시성)와 반복 횟수를 포함시키는 것이 좋습니다.

import time
import numpy as np

EFS = [32, 64, 96, 128, 192, 256]

def run_search(collection, queries, ef, k=10):
    params = {"metric_type": "COSINE", "params": {"ef": ef}}
    t0 = time.perf_counter()
    res = collection.search(
        data=queries,
        anns_field="embedding",
        param=params,
        limit=k,
        output_fields=["item_id"],
    )
    dt = (time.perf_counter() - t0) * 1000

    pred = [[hit.id for hit in r] for r in res]
    return dt, pred

def percentile(xs, p):
    return float(np.percentile(np.array(xs), p))

def sweep_ef(collection, queries, true_ids, k=10, repeat=30):
    for ef in EFS:
        lats = []
        preds = []
        for _ in range(repeat):
            dt, pred = run_search(collection, queries, ef, k=k)
            lats.append(dt)
            preds.extend(pred)

        r = recall_at_k(preds[:len(true_ids)], true_ids, k=k)
        print({
            "ef": ef,
            "p50_ms": percentile(lats, 50),
            "p95_ms": percentile(lats, 95),
            "recall@k": r,
        })

포인트:

  • repeat를 늘려 분산을 보세요.
  • 단일 쿼리 평균이 아니라 p95/p99를 봐야 운영 사고를 줄입니다.

결론: recall과 latency를 동시에 잡는 핵심

Milvus HNSW에서 recall↑latency↓를 동시에 달성하려면, “ef만 올리는 방식”에서 벗어나야 합니다.

  • 1단계: ef 스윕으로 목표 recall을 만족하는 최소 ef를 찾기
  • 2단계: latency가 높으면 MefConstruction을 올려 인덱스 품질을 개선하고, 다시 ef를 낮추기
  • 3단계: 필터링/동시성/메모리 같은 운영 변수를 함께 점검하기

이 과정을 자동화해 릴리즈마다 회귀 테스트를 돌리면, 데이터가 커져도 “어느 순간 검색이 느려졌다” 같은 문제를 훨씬 빨리 잡을 수 있습니다.