- Published on
Milvus HNSW 튜닝 - recall 올리고 p99 낮추기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론: HNSW 튜닝의 목표는 recall↑ 와 p99↓의 동시 달성
Milvus에서 벡터 검색 성능을 이야기할 때 흔히 QPS나 평균 지연만 보다가, 실제 서비스에서는 p95/p99가 SLA를 결정합니다. 문제는 HNSW가 정확도(=recall) 를 올리면 대개 탐색량이 늘어 지연이 증가하고, 지연을 줄이면 recall이 떨어지는 트레이드오프가 뚜렷하다는 점입니다.
하지만 HNSW는 파라미터가 비교적 직관적이고, Milvus는 인덱스/검색 파라미터를 분리해 조절할 수 있어 올바른 순서로 튜닝하면 recall을 올리면서도 p99를 낮추는 구간을 찾을 수 있습니다. 이 글은 그 “순서”와 “관측 포인트”에 집중합니다.
HNSW 핵심 파라미터 3가지와 영향 범위
Milvus에서 HNSW를 사용할 때 가장 중요한 파라미터는 아래 3개입니다.
M: 노드당 이웃(연결) 수. 그래프가 촘촘해져 탐색이 쉬워져 recall이 오르기 쉬움. 대신 메모리 사용량 증가와 빌드 비용 증가.efConstruction: 인덱스 빌드 시 후보 탐색 폭. 높을수록 더 좋은 그래프를 만들 가능성이 커져 recall에 유리. 대신 인덱싱 시간/CPU 증가.ef: 검색 시 후보 탐색 폭. 높을수록 recall이 상승하지만 검색 지연이 증가.
정리하면:
- 빌드 품질을 올리는 축:
M,efConstruction - 런타임 정확도를 올리는 축:
ef
p99 관점에서는 ef가 가장 직접적이지만, M과 efConstruction을 올려 그래프 품질이 좋아지면 같은 recall을 더 낮은 ef로 달성할 수 있어 결과적으로 p99를 낮출 수 있습니다.
실전 튜닝 전략: “빌드 품질 먼저, ef는 나중에”
1) 기준선(Baseline) 고정: 데이터/쿼리/필터/TopK
튜닝이 실패하는 가장 흔한 이유는 실험 조건이 흔들리는 것입니다.
- 데이터 스냅샷 고정(동일한 컬렉션/파티션)
- 쿼리 셋 고정(실서비스 대표 쿼리
N개) topK고정(예:topK=10)- 필터 조건 고정(필터가 있으면 반드시 포함)
특히 필터가 있는 워크로드는 HNSW 자체보다 필터 적용 방식과 후보 수에 의해 p99가 크게 출렁입니다. 필터가 있다면 “필터 선택도(selectivity)”까지 같이 기록하세요.
2) 목표 정의: recall@K와 p99를 동시에 수치화
- 정확도:
recall@K(예:recall@10) - 지연:
p99 latency(ms) - 보조 지표: CPU 사용률, RSS 메모리, 디스크 I/O
튜닝은 결국 “목표 recall을 만족하는 최소 p99”를 찾는 최적화 문제입니다.
인덱스 파라미터 튜닝: M과 efConstruction
권장 접근
M을 먼저 올려 그래프 연결성을 확보efConstruction으로 그래프 품질을 다듬기- 마지막에
ef로 recall/p99를 미세 조정
경험적 가이드(출발점)
M:8에서 시작해16,24,32로 단계적으로efConstruction:100에서 시작해200,400로 단계적으로
데이터 차원/분포/규모에 따라 다르지만, 보통 M을 너무 낮게 잡으면 ef를 아무리 올려도 recall이 잘 안 오르고 p99만 악화됩니다.
Milvus 인덱스 생성 예시
아래 예시는 Python SDK 기준의 형태를 보여줍니다(환경에 따라 import/연결 코드는 달라질 수 있습니다).
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {
"M": 16,
"efConstruction": 200
}
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
metric_type 체크
- 임베딩이 정규화되어 있다면
COSINE혹은IP가 흔합니다. - 정규화가 안 되어 있고 유클리드 거리 기반이면
L2.
메트릭을 잘못 고르면 튜닝으로 해결이 안 되는 recall 문제가 발생합니다.
검색 파라미터 튜닝: ef로 recall/p99 곡선을 만든다
HNSW에서 검색 파라미터는 사실상 ef 하나로 요약됩니다. ef를 올리면 recall은 증가하고, 지연도 증가합니다.
추천 실험 방식
ef를 로그 스케일로 증가:16, 32, 64, 128, 256- 각
ef에서recall@K와p99를 측정 - 목표 recall을 만족하는 최소
ef를 선택
Milvus 검색 예시
search_params = {
"metric_type": "COSINE",
"params": {
"ef": 64
}
}
results = collection.search(
data=query_vectors,
anns_field="embedding",
param=search_params,
limit=10,
expr="status == 1"
)
여기서 중요한 점은 expr(필터)가 있을 때입니다. 필터로 후보가 급격히 줄어들면, ef를 올려도 recall이 잘 안 오르거나 p99가 튀는 현상이 생길 수 있습니다. 이 경우는 “HNSW 탐색”이 아니라 “필터 이후 후보 부족”이 병목일 가능성이 큽니다.
recall↑와 p99↓를 동시에 만드는 레버: “더 좋은 그래프 + 더 낮은 ef”
많은 팀이 ef만 올려서 recall을 맞추려다 p99가 무너집니다. 대신 아래 조합을 시도해보면 같은 recall을 더 낮은 ef로 달성할 가능성이 큽니다.
M을16에서24로 증가efConstruction을200에서400으로 증가- 그 다음
ef를 다시 내려보며 목표 recall 유지되는 최소값 탐색
이 방식은 인덱스 빌드 비용이 늘지만, 서비스 p99를 안정화시키는 데 효과적입니다.
p99를 흔드는 비(非)HNSW 요인: 세그먼트, 캐시, 메모리
HNSW 파라미터를 잘 잡아도 p99가 들쭉날쭉하면 대개 시스템 레이어 문제가 섞여 있습니다.
1) 메모리 부족과 페이지 폴트
HNSW는 구조상 메모리 접근이 랜덤합니다. 메모리가 부족해 스왑/페이지 폴트가 늘면 p99가 급격히 악화됩니다.
- RSS가 워킹셋을 감당하는지 확인
- 컨테이너 환경이면 메모리 제한이 너무 타이트하지 않은지 확인
쿠버네티스에서 메모리/공유메모리 이슈가 있으면 지연이 튈 수 있는데, 워크로드에 따라 /dev/shm 부족이 간접 원인이 되기도 합니다. 관련 진단 관점은 EKS에서 Pod /dev/shm 부족으로 OOM 해결하기 글의 체크리스트가 도움이 됩니다.
2) 파일 디스크립터/네트워크 이슈
Milvus는 내부 컴포넌트 간 통신과 파일 핸들 사용이 많습니다. p99 스파이크가 시스템 리소스 한계로 발생하는 경우도 있습니다.
ulimit -n(open files) 확인- 노드에서
EMFILE발생 여부 확인
리눅스에서 FD 고갈은 증상이 다양하게 나타나므로, 원인 파악은 Linux EMFILE(Too many open files) 원인과 해결 내용을 참고해 점검하는 편이 빠릅니다.
3) CrashLoop/재시작으로 인한 캐시 콜드 스타트
p99가 주기적으로 튀고, 동시에 Pod 재시작이 보인다면 튜닝 이전에 안정성부터 잡아야 합니다. 캐시가 날아가거나 세그먼트 로딩이 반복되면 p99는 구조적으로 나빠집니다.
- 재시작 원인부터 제거
체크리스트는 Kubernetes CrashLoopBackOff 원인 8가지 진단에서 빠르게 훑을 수 있습니다.
필터가 있는 벡터 검색에서의 HNSW 튜닝 포인트
실서비스는 보통 tenant_id, status, category 같은 스칼라 필터가 붙습니다. 이때는 HNSW 파라미터만으로 해결되지 않는 경우가 많습니다.
1) 필터 선택도가 너무 높으면(너무 많이 걸러지면)
- 특정 테넌트/카테고리에서 후보가 적어
topK자체가 불안정 - 결과적으로 recall 측정이 의미 없어지거나, p99가 튀기도 함
가능하면:
- 파티션/클러스터링 전략으로 검색 공간을 줄이거나
- 필터 컬럼 인덱싱/데이터 모델을 재검토
2) topK가 커질수록 p99가 급상승
topK가 커지면 단순히 결과를 더 뽑는 문제가 아니라, 내부적으로 후보 유지/정렬 비용이 증가합니다. topK가 큰 API라면 topK별로 별도 튜닝(혹은 별도 인덱스/컬렉션)을 고려해야 합니다.
튜닝 실험을 자동화하는 방법: 그리드 탐색과 기록
HNSW 튜닝은 감으로 하면 끝이 없습니다. 아래처럼 실험을 자동화해 “곡선”을 얻어야 합니다.
- 인덱스 후보:
(M, efConstruction)조합 3~6개 - 검색 후보:
ef5~7개 - 측정:
recall@K,p95,p99, CPU, 메모리
간단한 의사코드 예시는 다음과 같습니다.
Ms = [8, 16, 24]
efCs = [100, 200, 400]
efs = [16, 32, 64, 128, 256]
for M in Ms:
for efC in efCs:
build_index(M=M, efConstruction=efC)
for ef in efs:
metrics = run_benchmark(ef=ef)
log_result(M, efC, ef, metrics)
이렇게 쌓인 결과에서 “목표 recall을 만족하는 최소 p99”를 고르면 됩니다. 운영 환경에서는 여기에 비용(인덱스 빌드 시간, 메모리 증가분)까지 함께 고려하세요.
추천 튜닝 레시피(출발점) 3가지
레시피 A: 균형형(대부분의 기본값 후보)
M=16efConstruction=200ef=64
레시피 B: recall 우선(오프라인 품질 중요)
M=24efConstruction=400ef=128
레시피 C: p99 우선(온라인 SLA 빡빡)
M=16efConstruction=400(그래프 품질로ef를 낮추기)ef=32부터 시작해 목표 recall까지 최소로 올리기
레시피 C의 핵심은 “빌드에서 투자하고 런타임 탐색량을 줄여 p99를 방어”하는 접근입니다.
결론: HNSW 튜닝은 ef가 아니라 “그래프 품질과 워크로드”의 문제
Milvus HNSW에서 recall↑와 p99↓를 동시에 노리려면,
M과efConstruction으로 좋은 그래프를 만들고ef는 목표 recall을 만족하는 최소값으로 제한하며- 필터/메모리/재시작 같은 시스템 요인으로 p99가 튀지 않게 받쳐줘야 합니다.
마지막으로, 튜닝 결과는 데이터 분포와 쿼리 패턴이 바뀌면 다시 깨집니다. 실서비스라면 대표 쿼리 셋을 주기적으로 갱신하고, recall@K와 p99를 함께 리그레션 테스트로 돌리는 체계를 만드는 것이 가장 큰 성능 개선입니다.