- Published on
Milvus HNSW 튜닝 - recall↑ 지연↓ 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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는 크게 두 단계 파라미터로 나뉩니다.
- 인덱스 빌드(오프라인 성격)
MefConstruction
- 검색(온라인 성격)
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-지연 곡선을 만든다
M과 efConstruction을 고정한 뒤, 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 normalize후IP사용
정규화 예:
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에서 맞춘 ef로 topK=100을 때리면 recall이 급락하는 경우가 많습니다.
- 경험칙:
ef는 최소한topK보다 충분히 커야 함
5) 세그먼트/샤드 구조로 인해 “검색 단위”가 바뀜
Milvus는 내부적으로 데이터가 세그먼트로 나뉘고, 분산이면 노드/샤드 단위로 검색이 이뤄집니다.
- 세그먼트가 너무 잘게 쪼개져 있으면 오버헤드가 커지고, 같은
ef라도 체감 recall/지연이 달라질 수 있음
6) 필터링(스칼라 조건)과 결합되며 후보가 줄어듦
스칼라 필터가 강하면 HNSW 그래프 탐색 중 후보가 제거되어 recall이 떨어질 수 있습니다.
- 해결: 필터 선택도를 낮추거나(조건 완화), 필터 전용 인덱스/파티션 전략 고려
지연이 높은데 recall은 충분할 때: 무엇을 깎을지
recall이 목표를 만족한다면 지연은 보통 아래 순서로 줄입니다.
ef를 내린다(가장 즉각적)topK를 줄이거나, 후처리(리랭킹) 구조를 바꾼다- 동시성/QPS를 기준으로 리소스(코어, 노드 수)를 맞춘다
- 인덱스 구조(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: 32efConstruction: 200ef: 128부터 시작해 목표 recall에 맞춰 내리기
지연 우선 프리셋
M: 16efConstruction: 100~200ef: 32~64에서 시작해 recall이 부족하면 올리기
중요: 위 값은 정답이 아니라 “탐색 시작점”입니다. 반드시 ground truth 기반으로 곡선을 만들어 결정하세요.
체크리스트: 튜닝 실험을 망치지 않는 법
- 동일한 쿼리 셋, 동일한 topK로 비교했는가
- metric과 정규화가 기대와 일치하는가
M/efConstruction변경 시 인덱스를 재빌드했는가ef실험은 단일 변수로 진행했는가- p95뿐 아니라 p99와 CPU 사용률도 같이 봤는가
- 필터 조건이 있는 쿼리와 없는 쿼리를 분리해 측정했는가
결론
Milvus HNSW 튜닝은 감으로 하는 작업이 아니라, M과 efConstruction으로 인덱스 품질의 바닥을 만든 뒤, ef로 운영 지연과 recall을 교환하는 문제로 단순화할 수 있습니다.
- recall이 안 오르면:
M또는efConstruction을 의심 - 지연이 높으면: 먼저
ef를 내리고, 그 다음 시스템 리소스와 데이터 볼륨을 점검
이 순서대로만 접근해도 “recall은 낮고 지연은 높은” 최악의 상태에서 빠르게 벗어날 수 있습니다.