- Published on
Milvus IVF_FLAT vs HNSW 성능 튜닝 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 ANN 인덱스인 IVF_FLAT과 HNSW는 “정확도-지연시간-메모리-빌드시간”의 균형점이 완전히 다릅니다. Milvus에서는 데이터 분포, 쿼리 패턴, 필터링 여부, 그리고 운영 제약(메모리 상한, 재색인 빈도)에 따라 정답이 바뀌기 때문에, 단순 벤치마크 숫자만 보고 선택하면 운영에서 흔히 무너집니다.
이 글은 Milvus에서 IVF_FLAT과 HNSW를 비교하고, 실제로 튜닝할 때 어떤 순서로 접근해야 하는지, 그리고 파라미터를 어떻게 “계산 가능한 방식”으로 좁혀가는지에 초점을 둡니다.
또한 관측(Observability)이 없으면 튜닝은 감으로 끝나므로, 분산 환경에서 지표를 어떻게 잡을지에 대해서도 간단히 연결합니다. 필요하다면 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전 글의 방식대로 쿼리 경로를 추적해두면 “Milvus가 느린지, 전처리/후처리가 느린지”를 빠르게 분리할 수 있습니다.
1) IVF_FLAT과 HNSW의 핵심 차이 (Milvus 관점)
IVF_FLAT
- 아이디어: 벡터 공간을
nlist개의 클러스터(centroid)로 나누고, 쿼리 시nprobe개의 클러스터만 탐색 - 장점
- 메모리 사용이 상대적으로 예측 가능(원본 벡터 중심)
- 빌드가 비교적 빠르고 안정적
- 데이터가 매우 크고(수천만 이상) 메모리가 빡빡할 때 “타협 가능한” 선택지
- 단점
nprobe를 올려야 recall이 올라가고, 그만큼 지연시간이 증가- 분포가 나쁘거나(클러스터가 불균형) 업데이트/세그먼트가 쪼개져 있으면 성능이 흔들림
HNSW
- 아이디어: 그래프 기반 근사 최근접 탐색.
M과efConstruction으로 그래프 품질을 만들고, 검색 시ef로 탐색 폭을 조절 - 장점
- 높은 recall을 낮은 지연시간으로 달성하기 쉬움
- 튜닝이 비교적 직관적(검색 시에는
ef만으로도 조절 가능)
- 단점
- 메모리를 많이 사용(그래프 엣지와 오버헤드)
- 빌드가 무겁고, 대규모 데이터에서 인덱스 생성 시간이 길어짐
정리하면, “메모리가 충분하고 낮은 지연시간/높은 recall이 중요”하면 HNSW, “메모리가 제한적이고 대규모 데이터에서 비용을 통제”하려면 IVF_FLAT이 유리한 경우가 많습니다.
2) 튜닝 전에 반드시 정해야 하는 4가지
(1) 목표 지표: recall@k와 p95/p99
- ANN에서 평균 지연시간만 보면 함정입니다. 운영에서는
p95,p99가 중요합니다. - 정확도는 보통
recall@k로 정의합니다. 예:k=10일 때 정답 top10에 포함되는 비율.
(2) 쿼리 패턴: topK, 동시성, 필터링
- topK가 커질수록 두 인덱스 모두 비용이 커지지만, 체감은 다릅니다.
- 스칼라 필터(예:
tenant_id,category)가 강하게 걸리면, 후보군이 줄어드는 방향으로 인덱스 전략이 달라질 수 있습니다.
(3) 데이터 규모와 차원
- 벡터 차원
dim이 커지면 거리 계산 비용이 커지고,IVF_FLAT은 후보군이 커질수록 비용이 선형적으로 증가합니다. - 데이터가 수천만을 넘어가면
HNSW의 메모리/빌드 시간이 운영 제약이 될 수 있습니다.
(4) 운영 제약: 재색인, 증분 업데이트, 메모리 상한
- “매일 전체 재색인 가능”인지, “실시간 삽입이 많아 세그먼트가 자주 늘어나는지”가 중요합니다.
- 메모리 상한이 명확하면
HNSW는 빠르게 한계에 부딪힙니다.
3) Milvus 컬렉션/인덱스 생성 예제
아래 예시는 Python SDK 기준이며, 핵심은 인덱스 파라미터와 검색 파라미터를 분리해서 관리하는 것입니다.
IVF_FLAT 생성
from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection
connections.connect(alias="default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="ivf_flat_demo")
col = Collection(name="demo_ivf", schema=schema)
index_params = {
"index_type": "IVF_FLAT",
"metric_type": "COSINE",
"params": {"nlist": 4096},
}
col.create_index(field_name="embedding", index_params=index_params)
col.load()
HNSW 생성
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {
"M": 16,
"efConstruction": 200
}
}
col = Collection(name="demo_hnsw", schema=schema)
col.create_index(field_name="embedding", index_params=index_params)
col.load()
검색 시에는 인덱스마다 다른 search_params를 줍니다.
query_vectors = [[0.01] * 768]
# IVF_FLAT search
res_ivf = Collection("demo_ivf").search(
data=query_vectors,
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"nprobe": 16}},
limit=10,
)
# HNSW search
res_hnsw = Collection("demo_hnsw").search(
data=query_vectors,
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=10,
)
4) IVF_FLAT 튜닝 실전: nlist와 nprobe
4-1. nlist를 먼저 잡는 이유
nlist는 “공간을 얼마나 잘게 쪼갤지”입니다. 너무 작으면 한 클러스터에 데이터가 몰려 후보군이 커지고, 너무 크면 클러스터가 너무 잘게 쪼개져 학습/관리 오버헤드가 늘고 분포가 불안정해질 수 있습니다.
실무에서 자주 쓰는 출발점은 다음과 같습니다.
- 데이터 개수
N에 대해nlist를sqrt(N)근처에서 시작 - 예:
N=10,000,000이면sqrt(N)은 대략3162이므로2048,4096,8192를 후보로
중요한 점은 nlist를 먼저 고정하고, 그 다음 nprobe로 recall과 지연시간을 맞추는 것입니다.
4-2. nprobe는 “지연시간 레버”
nprobe는 탐색할 클러스터 수입니다.
nprobe를 올리면 recall 상승- 하지만 후보군이 늘어 거리 계산이 늘고 지연시간 증가
실전 절차(권장):
nlist를 2~3개 후보로 고정- 각
nlist에 대해nprobe를1, 2, 4, 8, 16, 32, 64로 스윕 - 목표
recall@k를 만족하는 최소nprobe를 선택 - 그 지점에서
p95,p99가 SLA를 만족하는지 확인
4-3. 흔한 장애 패턴
nprobe를 과도하게 올려서 CPU가 포화되고 tail latency가 폭발- 세그먼트가 많이 쪼개져 있는 상태에서
nprobe가 커지면 “클러스터 탐색”이 아니라 “세그먼트 스캔”처럼 변질
운영에서 지연시간이 갑자기 튀면, 인덱스 파라미터만 보지 말고 “데이터 적재/세그먼트 상태”와 “동시성”을 같이 봐야 합니다.
5) HNSW 튜닝 실전: M, efConstruction, ef
5-1. M은 메모리와 recall의 바닥을 결정
M은 노드당 연결 수(대략적인 그래프 밀도)입니다.
M이 크면 recall이 좋아지기 쉽지만 메모리 증가- 일반적인 시작점:
M=16또는M=32
대규모 데이터에서 M=32는 메모리 압박이 빠르게 오므로, 먼저 M=16으로 시작해 목표 recall이 안 나오면 ef를 올리고, 그래도 부족하면 M을 올리는 순서가 안전합니다.
5-2. efConstruction은 빌드 시간과 품질
- 빌드 시 탐색 폭
- 보통
100~400사이에서 타협 - 증분 삽입이 많고 인덱스 빌드가 자주 일어나면
efConstruction을 지나치게 키우면 운영 비용이 커집니다.
5-3. 검색 파라미터 ef가 핵심 레버
ef를 키우면 recall 상승, 지연시간 증가- 실전에서는
ef를 “요청 등급”에 따라 다르게 주는 패턴이 강력합니다.- 예: 일반 검색은
ef=64, 재랭킹 후보 생성은ef=128
- 예: 일반 검색은
주의할 점은 ef를 무작정 올리면 CPU가 아니라 메모리 접근 패턴 때문에 tail latency가 악화될 수 있다는 것입니다.
6) 어떤 상황에서 무엇을 고를까: 의사결정 가이드
HNSW가 유리한 경우
p95지연시간이 매우 중요하고, recall도 높게 가져가야 함- 메모리가 충분하거나, 노드 스케일아웃이 가능
- 인덱스 빌드/재빌드 시간이 운영에 큰 부담이 아님
IVF_FLAT이 유리한 경우
- 데이터가 매우 크고 메모리 비용이 최우선
- 약간의 recall 손해를 감수하고 비용을 통제해야 함
- 인덱스 빌드가 자주 필요하거나 파이프라인이 단순해야 함
비슷한 비교를 PostgreSQL 확장에서도 정리한 적이 있는데, RAG 관점에서의 튜닝 사고방식은 Milvus에도 그대로 적용됩니다. 함께 보면 기준을 잡는 데 도움이 됩니다: PostgreSQL+pgvector RAG 인덱스 튜닝 - HNSW vs IVF
7) 벤치마크를 “운영에 가까운 형태”로 만드는 법
7-1. 골든셋 만들기(정확도 측정)
- 쿼리 벡터
Q개를 뽑고, brute force로 topK 정답을 저장 - ANN 결과와 비교해
recall@k를 계산
데이터가 너무 크면 전체 brute force가 어렵습니다. 이때는
- 샘플링된 서브셋에서만 골든 생성
- 또는 오프라인에서만 고비용 평가를 수행
7-2. 동시성 포함한 부하 테스트
단일 쿼리 latency는 좋게 나오기 쉽습니다. 운영에서는 동시성이 들어오면
- CPU 포화
- 캐시 미스 증가
- GC/메모리 압박 으로 tail이 늘어납니다.
가능하면 k6나 Locust 같은 도구로 동시성(예: 50, 100, 200)을 올려가며 p95, p99를 보세요.
7-3. 관측: 쿼리 경로를 분해
Milvus 쿼리만 느린지, 아니면
- 임베딩 생성
- 필터 조건 생성
- 후처리/재랭킹 이 느린지 분리해야 합니다. 분산 추적은 이때 큰 도움이 됩니다. 필요하면 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전 방식으로 “검색 요청 1건”을 end-to-end로 쪼개 보세요.
8) 튜닝 체크리스트(실전용)
IVF_FLAT
nlist후보 2~3개를 먼저 선정(2048,4096,8192같은 형태)- 각 후보에서
nprobe스윕으로 목표recall@k만족 최소값 선택 - 동시성 부하에서
p95,p99재확인 - tail이 튀면
nprobe만 보지 말고 세그먼트 수, compaction, 데이터 분포 확인
HNSW
- 시작점:
M=16,efConstruction=200, 검색ef=64 - 목표 recall이 안 나오면 검색
ef를 먼저 올림 - 그래도 부족하면
M을 올리되 메모리 증가를 계산하고(노드당 한계) 재검증 - 빌드 시간이 문제면
efConstruction을 낮추고, 대신 검색ef로 보정
9) 결론: 튜닝의 정답은 “목표 지표를 만족하는 최소 비용”
IVF_FLAT과 HNSW는 우열이 아니라 “운영 제약에 대한 최적화 방향”이 다릅니다. 실전에서는 다음 한 줄로 귀결됩니다.
IVF_FLAT:nlist로 구조를 잡고nprobe로 비용을 통제HNSW:M과efConstruction으로 품질 바닥을 만들고ef로 실시간 조절
마지막으로, 튜닝은 한 번으로 끝나지 않습니다. 데이터가 늘고 분포가 바뀌면 최적점이 이동합니다. 따라서 “정확도-지연시간-비용”을 정기적으로 재측정할 수 있는 벤치마크 파이프라인과 관측(지표/추적)을 함께 구축하는 것이, 가장 확실한 성능 튜닝입니다.