- Published on
Milvus HNSW 튜닝으로 recall↑ latency↓
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 벡터 검색 품질을 올리려면 recall을 올려야 하고, 비용과 UX를 위해 latency는 내려야 합니다. Milvus에서 HNSW는 이 두 목표를 가장 자주 충돌시키는(그리고 가장 자주 성공시키는) 인덱스입니다.
이 글은 “HNSW 파라미터를 어떻게 바꾸면 어떤 비용이 생기고, 어떤 지표가 어떻게 움직이는지”를 측정 루프 중심으로 정리합니다. 특히 M, efConstruction, ef(search)의 상호작용을 이해하고, 운영에서 흔히 놓치는 병목(세그먼트/메모리/필터/동시성)을 함께 점검합니다.
참고로 벡터DB 운영 안정화(데이터 수명/폭주 제어)가 필요하다면 AutoGPT 메모리 폭주? 벡터DB TTL로 안정화도 같이 보면 좋습니다.
HNSW 튜닝의 목표를 수치로 고정하기
튜닝은 “좋아졌다”가 아니라 목표치가 있어야 합니다. 실무에서 자주 쓰는 형태는 아래처럼 고정합니다.
- 품질 목표:
recall@k예:recall@10 >= 0.95 - 지연 목표:
p95 latency <= 50ms(또는 SLO) - 비용/리소스: 메모리 상한, CPU 코어 수, 동시 쿼리 수(QPS) 등
여기서 recall@k는 보통 “정답(ground truth) top-k 대비 근사 검색 top-k의 포함 비율”로 정의합니다. 정답은 작은 샘플에 대해 brute-force(또는 정확 인덱스)로 미리 계산해 둡니다.
HNSW 핵심 파라미터 3종: M, efConstruction, ef
HNSW를 이해하는 가장 짧은 방법은 “그래프를 얼마나 촘촘히 만들고(M), 만들 때 얼마나 열심히 찾고(efConstruction), 검색할 때 얼마나 열심히 찾을지(ef)”로 요약하는 것입니다.
1) M: 그래프의 평균 연결 수
- 의미: 노드당 이웃(edge) 수(대략적인 연결도)
- 효과
M증가: recall 상승 경향, 하지만 메모리 사용량 증가, 인덱스 빌드 시간 증가, 경우에 따라 검색 latency도 증가M감소: 메모리 절약, 빠를 수 있으나 recall 하락/불안정
실무 감각
- 임베딩 차원 수가 크고 데이터가 복잡할수록
M을 너무 낮게 잡으면 recall이 급격히 흔들립니다. - 다만
M을 올리는 건 “영구 비용(메모리)”이므로, 먼저ef로 해결 가능한지 확인하는 편이 안전합니다.
2) efConstruction: 인덱스 구축 시 탐색 폭
- 의미: 인덱스를 만들 때 이웃을 찾기 위해 유지하는 후보 리스트 크기
- 효과
efConstruction증가: 인덱스 품질(=검색 recall 상한) 상승, 빌드 시간/CPU 증가efConstruction감소: 빌드 빠르지만 recall 상한이 낮아져서ef를 올려도 안 올라가는 구간이 생김
실무 감각
- 운영에서 “검색
ef를 아무리 올려도 recall이 안 오르는” 경우, 원인이efConstruction인 경우가 많습니다. - 초기 구축/배치 빌드 시간이 허용된다면
efConstruction을 넉넉히 주고, 서빙에서는ef로 latency를 조절하는 전략이 일반적입니다.
3) ef (search): 검색 시 탐색 폭
- 의미: 검색할 때 유지하는 후보 리스트 크기
- 효과
ef증가: recall 상승, latency 증가(대체로 선형 또는 준선형)ef감소: 빠르지만 recall 하락
실무 감각
ef는 “가변 비용(쿼리당 비용)”이라 A/B나 동적 튜닝에 적합합니다.k(top-k)보다ef가 너무 작으면 구조적으로 recall이 잘 안 나옵니다. 보통ef >= k는 기본입니다.
Milvus에서 HNSW 인덱스 생성과 검색 파라미터
Milvus 2.x에서 HNSW는 컬렉션/필드에 인덱스를 만들 때 index_params로 M, efConstruction을 주고, 검색 시 search_params로 ef를 줍니다.
아래 예시는 pymilvus 기준입니다.
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=True),
FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=dim),
]
schema = CollectionSchema(fields, description="hnsw_tuning_demo")
col = Collection(name="demo_hnsw", schema=schema)
# HNSW 인덱스 생성
index_params = {
"index_type": "HNSW",
"metric_type": "IP", # cosine이면 보통 정규화 + IP 또는 COSINE
"params": {
"M": 32,
"efConstruction": 200,
},
}
col.create_index(field_name="vec", index_params=index_params)
col.load()
# 검색 시 ef 조절
search_params = {"metric_type": "IP", "params": {"ef": 64}}
# query_vectors: List[List[float]]
# results = col.search(query_vectors, "vec", param=search_params, limit=10)
주의할 점
- 메트릭이
IP일 때 cosine 유사도를 원하면, 보통 임베딩을 L2 정규화한 뒤IP를 사용합니다(모델/파이프라인에 따라 다름). - 인덱스 생성 후
load()로 메모리에 올리는 단계가 latency에 큰 영향을 줍니다.
실전 튜닝 절차: “상한을 만들고, 쿼리 비용을 줄인다”
가장 재현성 좋은 순서는 아래입니다.
- (오프라인)
efConstruction을 올려 recall 상한을 확보 - (오프라인)
M을 조절해 상한을 더 올릴지/메모리를 줄일지 결정 - (온라인/서빙)
ef를 조절해recall과p95 latency를 맞춘다
이 순서가 중요한 이유
ef는 아무리 올려도 인덱스 자체가 빈약하면 한계가 있습니다.M은 메모리/빌드 비용이 크고 롤백이 번거로워, 마지막까지 신중히 다루는 편이 좋습니다.
벤치마크 하네스: recall@k와 latency를 함께 찍기
아래 코드는 “정답 top-k”를 brute-force로 만든 뒤, 여러 ef에 대해 recall@10과 latency를 함께 측정하는 최소 형태입니다.
import time
import numpy as np
def l2_normalize(x: np.ndarray, eps: float = 1e-12) -> np.ndarray:
norm = np.linalg.norm(x, axis=1, keepdims=True)
return x / np.maximum(norm, eps)
def brute_force_topk_ip(base: np.ndarray, queries: np.ndarray, k: int) -> np.ndarray:
# base, queries are normalized; IP == cosine
scores = queries @ base.T
# topk indices
return np.argpartition(-scores, kth=k-1, axis=1)[:, :k]
def recall_at_k(gt: np.ndarray, pred: np.ndarray) -> float:
# gt, pred: shape (nq, k)
hits = 0
for i in range(gt.shape[0]):
hits += len(set(gt[i].tolist()) & set(pred[i].tolist()))
return hits / (gt.shape[0] * gt.shape[1])
def bench_milvus(col, query_vectors, k: int, ef: int):
search_params = {"metric_type": "IP", "params": {"ef": ef}}
t0 = time.perf_counter()
res = col.search(query_vectors, "vec", param=search_params, limit=k)
t1 = time.perf_counter()
# Milvus 결과에서 id 추출
pred = np.array([[hit.id for hit in hits] for hits in res], dtype=np.int64)
latency_ms = (t1 - t0) * 1000
return pred, latency_ms
# 예시 흐름(개념):
# base_vectors_np = l2_normalize(base_vectors_np)
# query_vectors_np = l2_normalize(query_vectors_np)
# gt = brute_force_topk_ip(base_vectors_np, query_vectors_np, k=10)
# for ef in [16, 32, 64, 128, 256]:
# pred, latency = bench_milvus(col, query_vectors_np.tolist(), k=10, ef=ef)
# r = recall_at_k(gt, pred)
# print(ef, r, latency)
팁
- latency는 평균이 아니라
p50/p95/p99로 보세요. 위 코드는 단일 측정이므로, 실제로는 반복 실행해 분포를 구해야 합니다. - Milvus는 워밍업 영향이 큽니다. 동일 쿼리를 몇 번 버린 뒤 측정하는 것이 안전합니다.
파라미터별 “증상”으로 빠르게 진단하기
ef를 올려도 recall이 안 오른다
가능성이 큰 순서
efConstruction이 낮아 인덱스 품질 상한이 낮음M이 너무 낮아 그래프가 성기게 연결됨- metric/정규화가 맞지 않아 유사도 자체가 흔들림
- 필터 조건이 강해서 후보 풀이 줄어듦(필터링 후 재랭킹 구조 확인 필요)
대응
- 먼저
efConstruction을 올린 새 인덱스를 만들어 같은 데이터로 비교합니다. - 그 다음
M을 단계적으로 올립니다(예: 16, 24, 32, 48).
recall은 좋은데 p95 latency가 튄다
가능성이 큰 순서
ef가 과도하게 큼- 동시성 증가로 CPU 포화(스레드/코어 경합)
- 세그먼트 수가 많아 검색 오버헤드 증가(컴팩션/세그먼트 관리)
- 메모리 부족으로 페이지 폴트/스왑/캐시 미스 증가
대응
- 같은 recall 목표에서
ef를 낮추는 방향으로, 대신efConstruction또는M을 조정해 상한을 올리는 식으로 비용을 “빌드 시점”으로 옮깁니다. - 시스템 튜닝 관점은 DB 인덱스 튜닝과 유사합니다. 원리적으로는 PostgreSQL 인덱스 미사용? 통계·파라미터 튜닝처럼 “플랜/통계/리소스”를 같이 봐야 합니다.
추천 스타팅 포인트(경험 기반)
데이터/차원/분포에 따라 달라지지만, “일단 여기서 시작”하기 좋은 조합은 아래입니다.
M: 16 또는 32efConstruction: 100 ~ 300ef: 32 ~ 128 (목표 recall에 맞춰 스윕)
운영 팁
- 인덱스 빌드가 배치로 가능하고, 서빙 latency가 빡빡하면
efConstruction을 올리는 쪽이 유리합니다. - 메모리 예산이 빡빡하면
M을 함부로 올리지 말고, 먼저ef와efConstruction으로 해결 가능한지 확인하세요.
latency를 낮추는 실전 테크닉: 동적 ef와 2단계 검색
동적 ef: 쿼리 난이도에 따라 비용을 다르게
모든 쿼리에 동일 ef를 쓰면, 쉬운 쿼리에도 과금을 합니다. 아래처럼 “1차는 작은 ef로 돌리고, 결과 스코어가 애매하면 ef를 키워 재검색”하는 방식이 실전에서 자주 먹힙니다.
def search_with_adaptive_ef(col, q, k=10, ef1=32, ef2=128, score_margin=0.02):
# score_margin: 1등과 k등의 점수 차가 너무 작으면(=애매하면) 재검색
p1 = {"metric_type": "IP", "params": {"ef": ef1}}
r1 = col.search([q], "vec", param=p1, limit=k)[0]
top1 = r1[0].score
topk = r1[-1].score
if (top1 - topk) < score_margin:
p2 = {"metric_type": "IP", "params": {"ef": ef2}}
r2 = col.search([q], "vec", param=p2, limit=k)[0]
return r2
return r1
장점
- 평균 latency를 낮추면서, 어려운 쿼리에서 recall을 방어합니다.
주의
- 점수 스케일은 metric/정규화에 따라 다릅니다. margin은 데이터로 캘리브레이션해야 합니다.
2단계 검색: 후보는 HNSW, 최종은 정확 재랭킹
- 1단계: HNSW로
topN후보를 빠르게 가져옴(N은k보다 큼, 예: 100) - 2단계: 후보에 대해 정확한 거리 계산 또는 cross-encoder 같은 재랭킹
이 방식은 “벡터 검색 latency는 유지하면서 품질을 확 끌어올리는” 정석입니다. 다만 모델 재랭킹을 붙이면 서빙/배포 복잡도가 올라가므로, 모델 운영이 필요하다면 Triton Inference Server 모델 핫스왑 배포·롤백 실전 같은 운영 패턴도 같이 고려하는 편이 좋습니다.
운영에서 자주 터지는 함정 5가지
1) 컬렉션이 load되지 않아 매번 느리다
- 인덱스가 디스크에 있고, 쿼리마다 로딩/캐시 미스가 발생하면 p95가 급격히 튑니다.
- 해결: 상시
load, 메모리 예산 점검, warm-up 트래픽
2) 세그먼트가 너무 많아 오버헤드가 커진다
- 잦은 insert/delete, TTL, 파티션 전략에 따라 세그먼트가 쪼개지면 검색 시 fan-out이 증가합니다.
- 해결: 컴팩션 정책, 배치 인서트, TTL 설계(관련: AutoGPT 메모리 폭주? 벡터DB TTL로 안정화)
3) 필터(스칼라 조건)와 벡터 검색 결합
- 필터가 강하면 후보가 줄어 recall이 떨어지거나, 반대로 필터 평가 비용 때문에 latency가 튈 수 있습니다.
- 해결: 필터 선택도에 맞춰 파티션/프리필터 전략을 재설계,
topN을 키운 뒤 재랭킹
4) 동시성에서 CPU 포화
- HNSW는
ef가 커질수록 CPU를 더 씁니다. QPS가 올라가면 곱셈으로 터집니다. - 해결:
ef상한 설정, adaptiveef, 샤딩/레플리카, 코어 수에 맞춘 동시성 제한
5) 튜닝을 한 번에 여러 변수로 바꾼다
M,efConstruction,ef를 동시에 바꾸면 원인-결과를 분리하기 어렵습니다.- 해결: 한 번에 하나만 바꾸고, 동일 쿼리셋으로
recall@k와p95를 같이 기록
추천 실험 플랜(바로 따라하기)
- 고정: 데이터 스냅샷, 동일 쿼리셋(예: 5천개), 동일
k - 1차:
M=16,efConstruction=200으로 인덱스 생성 - 2차:
ef를16, 32, 64, 128, 256으로 스윕해recall@10vsp95곡선을 얻기 - 목표 recall이 안 나오면
efConstruction을300으로 올린 인덱스 재생성 후 동일 스윕
- 목표 recall은 나오는데 latency가 높으면
ef를 낮추고 adaptiveef적용- 또는
M을24나32로 올린 새 인덱스로ef를 낮춰 같은 recall을 달성 가능한지 확인
이 과정을 거치면 “우리 서비스에서 recall을 0.02 올리려면 p95가 몇 ms 늘어나는지” 같은 교환비율이 수치로 남습니다. 그 다음부터는 감이 아니라, SLA와 비용으로 의사결정할 수 있습니다.
마무리
Milvus HNSW 튜닝의 핵심은 단순히 ef를 키우는 게 아니라,
efConstruction과M으로 인덱스 품질 상한을 만들고- 서빙에서는
ef(필요하면 adaptiveef)로 쿼리 비용을 제어하며 - 세그먼트/메모리/필터/동시성 같은 운영 변수를 함께 관리
하는 것입니다.
다음 단계로는 “필터 결합 시 설계(파티션/스칼라 인덱스/프리필터)”와 “2단계 재랭킹”을 붙여, recall을 더 올리면서도 latency를 방어하는 아키텍처로 확장해보면 좋습니다.