- Published on
Milvus HNSW 튜닝으로 recall·latency 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 요구사항이 충돌할 때가 많습니다. 검색 품질을 끌어올리면 지연시간이 늘고, 지연시간을 줄이면 recall이 떨어집니다. Milvus에서 HNSW를 쓸 때 이 trade-off를 가장 직접적으로 제어하는 레버가 바로 M, efConstruction, efSearch입니다. 이 글에서는 각 파라미터가 실제로 무엇을 바꾸는지, 어떤 순서로 측정하고 조정해야 시행착오를 줄일 수 있는지, 운영 환경에서 안정적으로 적용하는 방법을 정리합니다.
HNSW 기본: 그래프 품질과 탐색 폭이 전부다
HNSW는 벡터들을 그래프로 연결하고, 질의 시 그래프를 따라가며 근사 최근접 이웃을 찾습니다.
- 인덱스 빌드 단계에서 그래프를 “얼마나 촘촘하고 좋은 구조로” 만들지 결정하는 값이
M,efConstruction입니다. - 검색 단계에서 “얼마나 넓게 탐색할지” 결정하는 값이
efSearch입니다.
정리하면 다음 공식이 성립합니다.
M증가: 메모리 사용량 증가, 빌드 시간 증가, 대체로 recall 상승, latency는 케이스에 따라 증가(탐색 후보가 늘어날 수 있음)efConstruction증가: 빌드 시간 증가, 인덱스 품질 상승, recall 상승(특히 어려운 데이터 분포에서), 검색 latency는 간접적으로만 영향efSearch증가: 검색 latency 증가, recall 상승(가장 즉각적)
Milvus에서 HNSW가 특히 민감한 조건
다음 조건에서는 기본값으로는 품질이나 지연시간이 기대와 크게 다를 수 있습니다.
- 벡터 차원 수가 큰 경우(예: 768, 1024)
- 데이터가 멀티모달/다도메인으로 섞여 분포가 복잡한 경우
cosine유사도를 쓰는데 벡터 정규화가 일관되지 않은 경우- 필터링(스칼라 조건)과 벡터 검색을 함께 쓰는 경우
필터링이 들어가면 후보 풀이 줄어들어 recall이 급락할 수 있습니다. 이때 efSearch만 올려도 회복되지 않는 경우가 있어, 인덱스 품질(M, efConstruction) 자체를 올려야 합니다.
핵심 파라미터 3종 완전 정복
1) M: 노드당 연결 간선 수
M은 각 벡터가 그래프에서 가지는 최대 연결 수에 가깝습니다. 직관적으로는 “길이 더 많은 고속도로를 깔아두는 것”입니다.
- 장점: 그래프가 촘촘해져서 지역 최적에 갇힐 확률이 줄고 recall이 좋아집니다.
- 단점: 메모리와 빌드 시간이 증가합니다.
실무 팁:
M은 보통 8, 16, 32가 많이 쓰입니다.- 필터링이 많거나 데이터 분포가 복잡하면
M=16에서 시작해32까지 고려합니다. - 메모리 예산이 빡빡하면
M을 무작정 올리기보다efSearch로 먼저 해결 가능한지 확인합니다.
2) efConstruction: 인덱스 빌드 품질
efConstruction은 인덱스를 만들 때 후보를 얼마나 넓게 보며 연결을 최적화할지 결정합니다.
- 장점: 더 좋은 그래프 구조를 만들어 recall이 좋아집니다.
- 단점: 빌드 시간이 크게 늘어납니다(대량 데이터에서 체감 큼).
실무 팁:
efConstruction은 100~400 범위에서 많이 조정합니다.- 운영에서 인덱스를 자주 재빌드하지 않는다면, 빌드 시간 비용을 지불하고
efConstruction을 충분히 높이는 것이 장기적으로 유리합니다.
3) efSearch: 검색 시 탐색 폭
efSearch는 검색 단계에서 유지하는 후보 리스트 크기입니다. 가장 빠르게 recall을 올릴 수 있는 노브입니다.
- 장점: 즉각적으로 recall이 올라갑니다.
- 단점: latency가 늘고, QPS가 떨어질 수 있습니다.
실무 팁:
efSearch는 “요구 recall을 달성하는 최소값”을 찾는 방식으로 잡아야 합니다.topK가 커질수록 필요한efSearch도 커지는 경향이 있습니다.
튜닝 절차: 한 번에 하나씩, 측정 기반으로
다음 순서를 추천합니다.
- 목표 정의:
topK, 목표 recall(예:recall@10 >= 0.95), p95/p99 latency 목표 - 고정 변수 통제: 동일한 데이터 샘플, 동일한 쿼리 셋, 동일한 필터 비율
- 1차:
efSearch만 스윕해서 목표 recall을 달성하는 최소efSearch찾기 - 2차: latency가 목표를 초과하면
M과efConstruction을 올려 “더 좋은 인덱스”로efSearch를 다시 낮출 여지를 확보 - 3차: 필터링 조건이 있는 워크로드에서 재검증
이 방식이 중요한 이유는, M과 efConstruction은 인덱스 재생성이 필요해 비용이 큽니다. 반면 efSearch는 런타임에서 상대적으로 쉽게 조정 가능합니다.
Milvus 인덱스 생성 예시(HNSW)
아래 예시는 Milvus Python SDK 기준의 전형적인 패턴입니다.
from pymilvus import connections, Collection
connections.connect(alias="default", host="localhost", port="19530")
col = Collection("items")
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {
"M": 16,
"efConstruction": 200
}
}
col.create_index(field_name="embedding", index_params=index_params)
col.load()
검색 시에는 efSearch를 바꿔가며 측정합니다.
search_params = {
"metric_type": "COSINE",
"params": {
"efSearch": 64
}
}
results = col.search(
data=[query_vec],
anns_field="embedding",
param=search_params,
limit=10,
output_fields=["id"]
)
주의: Milvus 버전에 따라 파라미터 키가 다를 수 있습니다. 문서/버전 릴리스 노트를 확인하고, 실제로 적용된 값은 describe_index 류 API로 검증하는 습관을 추천합니다.
recall과 latency를 같이 잡는 실전 전략 5가지
1) 먼저 efSearch로 목표 recall 달성선을 찾는다
가장 싸고 빠른 실험입니다. efSearch를 16, 32, 64, 128, 256처럼 로그 스케일로 올리며 recall 곡선을 봅니다.
- recall이 빠르게 포화되면: 인덱스 품질은 충분하고
efSearch로만 조절 가능 - recall이 천천히 올라가거나 특정 구간에서 정체되면:
M또는efConstruction이 병목일 확률이 큼
2) latency가 과하면 M과 efConstruction을 올리고 efSearch를 내린다
검색 latency는 대체로 efSearch에 민감합니다. 인덱스 품질이 좋아지면 같은 recall을 더 작은 efSearch로 달성할 수 있습니다.
권장 접근:
M을16에서32로 올려보고efConstruction을200에서400으로 올린 뒤- 다시
efSearch최소값을 재탐색
3) 필터링이 있다면 “필터 비율”별로 따로 튜닝한다
예를 들어 전체 데이터의 5%만 남기는 강한 필터가 있다면, 벡터 그래프 탐색이 충분히 진행되기 전에 후보가 소진되어 recall이 떨어질 수 있습니다.
대응:
- 강한 필터 워크로드에서는
efSearch를 더 크게 잡아야 하는 경우가 많습니다. - 그래도 안 나오면
M을 올려 그래프 연결성을 강화합니다.
4) 벡터 전처리(정규화) 불일치를 먼저 제거한다
COSINE을 쓰면서 일부 벡터만 정규화가 안 되어 있으면, HNSW 튜닝으로는 해결이 안 되는 품질 저하가 발생합니다.
- 오프라인에서 모든 임베딩을
L2정규화 - 쿼리 벡터도 동일하게 정규화
이런 “데이터 품질 이슈”는 DB 튜닝이 아니라 파이프라인 문제로 봐야 합니다. 운영 파이프라인 디버깅 관점은 Python ArrowInvalid - Parquet 스키마 불일치 해결 같은 글에서 다루는 방식(재현 가능한 샘플, 스키마/전처리 일관성 검증)과 유사합니다.
5) 운영 관측: p95/p99와 QPS를 같이 본다
평균 latency만 보면 efSearch를 올렸을 때 tail latency가 폭증하는 현상을 놓치기 쉽습니다. 또한 CPU 사용률이 임계치를 넘으면 지연이 급격히 늘 수 있습니다.
- 지표: p50/p95/p99 latency, QPS, CPU, RSS 메모리, 디스크 IO(메모리 부족 시)
- 부하 패턴: 동시성(threads), 배치 검색 여부
Kubernetes에서 운영한다면, 리소스 부족이나 이미지 풀 문제 등으로 성능 측정이 왜곡될 수 있습니다. 배포/런타임 안정화는 K8s Pod ImagePullBackOff - ECR 403 해결 가이드 같은 체크리스트를 참고해 “성능 튜닝 이전에 환경을 고정”하는 것이 중요합니다.
튜닝 실험을 자동화하는 간단한 스윕 코드
오프라인에서 쿼리 셋을 고정하고 efSearch를 스윕하며 recall과 latency를 동시에 기록하는 형태가 가장 실용적입니다.
import time
import numpy as np
EF_LIST = [16, 32, 64, 128, 256]
TOPK = 10
def benchmark(col, query_vecs, ground_truth_ids):
rows = []
for ef in EF_LIST:
search_params = {"metric_type": "COSINE", "params": {"efSearch": ef}}
t0 = time.perf_counter()
hits = 0
for i, q in enumerate(query_vecs):
res = col.search(
data=[q],
anns_field="embedding",
param=search_params,
limit=TOPK,
output_fields=["id"],
)[0]
got = [r.entity.get("id") for r in res]
gt = set(ground_truth_ids[i])
hits += len(gt.intersection(got))
t1 = time.perf_counter()
recall_at_k = hits / (len(query_vecs) * TOPK)
avg_ms = (t1 - t0) * 1000.0 / len(query_vecs)
rows.append((ef, recall_at_k, avg_ms))
return rows
# rows = benchmark(col, query_vecs, ground_truth_ids)
# for ef, recall, ms in rows:
# print(ef, recall, ms)
ground_truth_ids는 이상적으로는 exact 검색(브루트포스)로 만든 정답 셋이어야 합니다. 데이터가 크면 샘플링된 소규모 코퍼스에서만 exact를 만들고, 그 쿼리 셋을 대표성 있게 구성하는 방식이 현실적입니다.
추천 시작점(경험적 베이스라인)
아래는 “대부분의 서비스에서 첫 실험”으로 무난한 조합입니다.
M=16efConstruction=200efSearch=64부터 스윕
목표가 높은 recall(예: 0.97+)이고 필터가 강하거나 분포가 어려우면:
M=32efConstruction=400efSearch=64부터 재측정
반대로 latency가 최우선이고 recall 목표가 낮다면:
M=8또는16efConstruction=100또는200efSearch=16부터 시작
흔한 실패 패턴과 디버깅 체크리스트
1) efSearch를 올려도 recall이 거의 안 오르는 경우
- 벡터 정규화/메트릭 불일치 여부 확인
- 데이터에 중복/노이즈가 많아 실제로 구분이 어려운 임베딩인지 확인
- 필터링으로 후보 풀이 과도하게 줄어드는지 확인
2) 특정 시간대에만 latency가 튀는 경우
- CPU throttling, GC, 메모리 압박, 노드 noisy neighbor 확인
- 워밍업 여부(세그먼트 로드, 페이지 캐시) 확인
이런 운영 이슈는 DB만 보는 것보다 시스템 관점에서 접근해야 합니다. 예를 들어 디스크가 가득 차거나 삭제된 파일 핸들이 남아 IO가 이상해지는 문제는 logrotate 후 디스크 100%? 열린 삭제파일 찾기 같은 케이스로 이어질 수 있습니다.
3) 인덱스 빌드가 너무 오래 걸리는 경우
efConstruction을 과도하게 올렸는지 확인- 데이터 삽입 배치 크기, flush/compaction 정책 확인
- 인덱스는 “자주 재빌드하지 않게” 파이프라인을 설계(증분 적재, 야간 빌드 등)
결론: 최적화의 핵심은 “최소 efSearch”를 찾는 것
HNSW 튜닝을 단순화하면 다음 한 줄로 요약됩니다.
- 인덱스 품질(
M,efConstruction)을 적절히 확보한 뒤, 목표 recall을 만족하는 최소efSearch를 찾아 latency를 고정한다.
이 접근을 쓰면 실험 비용이 줄고, 운영에서 트래픽 변화나 필터 비율 변화가 생겨도 efSearch를 중심으로 빠르게 대응할 수 있습니다. 이후에는 워크로드(필터링 강도, topK, 임베딩 모델 변경)별로 프로파일을 분리해 관리하면 recall과 latency를 안정적으로 함께 가져갈 수 있습니다.