- Published on
Milvus 인덱스 선택 실수로 검색 10배 느려질 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 중인 벡터 검색이 갑자기 느려졌는데 CPU나 메모리는 멀쩡하고, 쿼리 로직도 그대로라면 가장 먼저 의심해야 할 지점이 index_type과 그 파라미터 조합입니다. Milvus는 "인덱스를 만들었다"는 사실만으로 성능이 보장되지 않습니다. 오히려 데이터 규모·차원·거리 함수·필터 조건·세그먼트 상태에 맞지 않는 인덱스 선택은 검색을 10배 이상 느리게 만들 수 있습니다.
이 글에서는 Milvus에서 자주 발생하는 인덱스 선택 실수 패턴과, 느려졌을 때 어디를 확인하고 어떤 순서로 튜닝해야 하는지 실무 관점으로 정리합니다.
10배 느려지는 전형적인 시나리오
다음 조합은 실제로 자주 보고, 체감 성능이 크게 악화되기 쉽습니다.
- IVF 계열에서
nlist를 과하게 키우고, 쿼리nprobe는 낮게 유지 - HNSW에서
M,efConstruction은 크게 잡았는데, 쿼리ef를 너무 낮게 설정 - 데이터가 아직 대부분 "growing segment"(미봉인)인데 인덱스 효과를 기대
- scalar 필터(
expr)가 강한데, 벡터 인덱스만 보고 튜닝(필터가 병목) - distance metric과 임베딩 특성이 맞지 않는데(예: cosine 필요인데
L2) 인덱스로 해결하려고 함
특히 IVF에서 nlist를 무작정 크게 잡는 실수는 흔합니다. nlist가 커지면 각 리스트의 평균 벡터 수가 줄어들어 "이론상" 탐색이 효율적일 수 있지만, 실제로는 다음 비용이 증가합니다.
- centroid(클러스터 중심) 탐색 비용 증가
- 세그먼트/파티션 단위로 분산된 리스트를 더 많이 터치
- 캐시 미스 증가
- 작은 리스트가 많아지면서 오히려 오버헤드가 커짐
결과적으로 nprobe를 충분히 올리지 않으면 recall이 떨어지고, 올리면 latency가 급상승하는 "양쪽 다 손해" 상태가 됩니다.
Milvus 인덱스 계열: 무엇이 언제 유리한가
Milvus에서 자주 쓰는 인덱스는 크게 IVF 계열과 HNSW 계열로 나뉩니다.
IVF_FLAT / IVF_SQ8 / IVF_PQ
- 장점: 대규모 데이터에서 메모리/속도 균형을 맞추기 쉬움
- 단점:
nlist/nprobe튜닝 난이도가 높고, 데이터 분포에 민감
대략적인 감각으로는 다음처럼 접근합니다.
IVF_FLAT: 정확도 우선(압축 없음), 메모리는 많이 씀IVF_SQ8: 8-bit 양자화로 메모리 절감, 정확도 약간 손해IVF_PQ: 더 강한 압축, 메모리 크게 절감 가능하지만 정확도/튜닝 난이도 상승
HNSW
- 장점: 높은 recall을 비교적 안정적으로 확보, 튜닝이 직관적
- 단점: 빌드 비용과 메모리 사용량이 큼, 업데이트/대규모 환경에서 부담
HNSW는 보통 다음 파라미터가 핵심입니다.
M: 그래프 연결 수(클수록 정확도/메모리/빌드비용 증가)efConstruction: 빌드 시 탐색 폭(클수록 빌드 느리지만 품질 증가)- 쿼리
ef: 검색 시 탐색 폭(클수록 느리지만 recall 증가)
"검색이 10배 느려졌다"면, 인덱스 자체보다 search_params가 워크로드와 어긋난 경우가 많습니다.
실수 1: IVF에서 nlist만 크게 키우는 튜닝
IVF에서 nlist는 클러스터 수입니다. 흔히 "데이터가 1천만이니 nlist를 65536 같은 큰 값으로" 잡는데, 이게 항상 이득이 아닙니다.
- 리스트가 너무 잘게 쪼개지면, centroid 탐색/리스트 관리 오버헤드가 커집니다.
- 세그먼트가 많거나 파티션을 많이 쪼갠 경우, 분산된 리스트를 찾아가는 비용이 증가합니다.
Python(pymilvus) 예시: 잘못된 조합과 개선
아래는 흔한 실수 패턴입니다.
from pymilvus import Collection
col = Collection("items")
# 실수: nlist만 크게 잡고(오버헤드 증가), nprobe는 낮게 둠(리콜 저하)
index_params = {
"index_type": "IVF_FLAT",
"metric_type": "IP",
"params": {"nlist": 65536},
}
col.create_index(field_name="embedding", index_params=index_params)
# 검색은 nprobe=8 같은 낮은 값으로 유지
results = col.search(
data=[query_vec],
anns_field="embedding",
param={"metric_type": "IP", "params": {"nprobe": 8}},
limit=20,
)
개선은 보통 "nlist를 합리화"하고 "nprobe를 워크로드에 맞게" 잡는 쪽에서 시작합니다.
# 개선: nlist를 현실적으로(예: 4096~16384 범위에서 실측), nprobe를 단계적으로 올려 측정
index_params = {
"index_type": "IVF_FLAT",
"metric_type": "IP",
"params": {"nlist": 8192},
}
col.drop_index()
col.create_index(field_name="embedding", index_params=index_params)
for nprobe in [8, 16, 32, 64]:
results = col.search(
data=[query_vec],
anns_field="embedding",
param={"metric_type": "IP", "params": {"nprobe": nprobe}},
limit=20,
)
# 여기서 latency, recall(오프라인 GT), CPU 사용률을 함께 기록
핵심은 "이론"이 아니라 "실측"입니다. 같은 데이터라도 분포/차원/세그먼트 상태에 따라 최적점이 크게 달라집니다.
실수 2: HNSW에서 쿼리 ef를 방치
HNSW는 인덱스 생성 파라미터(M, efConstruction)를 잘 잡아도, 쿼리 ef가 너무 낮으면 후보 탐색 폭이 좁아져 recall이 떨어지고, 반대로 너무 높으면 latency가 급증합니다.
또 하나의 함정은 "필터가 있는 쿼리"입니다. 필터로 인해 유효 후보가 줄어들면, 같은 ef에서도 더 많은 탐색이 필요해져 latency가 튈 수 있습니다.
# HNSW 인덱스
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
col.drop_index()
col.create_index("embedding", index_params)
# 검색에서 ef를 워크로드에 맞게 조정
results = col.search(
data=[query_vec],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=20,
expr="tenant_id == 3 and is_active == true",
)
운영에서 "갑자기 10배 느려짐"이 발생했다면, 다음을 확인하세요.
- 최근 배포에서
ef기본값이 바뀌었는지 - 필터 조건이 강화되어 유효 후보가 줄었는지
limit이 커졌는지(탑K가 커지면 탐색 비용 증가)
실수 3: 인덱스가 적용되지 않는 상태(세그먼트/로드)
Milvus는 데이터가 "sealed"(봉인)된 세그먼트에 대해 인덱스를 적용합니다. 즉, 데이터가 계속 들어오는 컬렉션에서 많은 데이터가 growing 세그먼트에 머물러 있으면, 인덱스를 만들었더라도 검색이 상당 부분 brute-force에 가까워질 수 있습니다.
체크리스트:
- 컬렉션이 메모리에 로드되어 있는지(
load_collection) - 인덱스 빌드가 완료되었는지
- flush/compaction 이후 sealed 세그먼트 비중이 충분한지
운영 장애처럼 보이지만 사실은 "인덱스 미적용 구간이 커진" 데이터 운영 이슈인 경우가 많습니다. 이런 종류의 문제는 원인 추적 루틴이 중요합니다. 비슷한 결로, 인프라/런타임에서 원인 파악을 체계화하는 방법은 systemd 서비스 재시작 무한루프 원인추적 글의 접근이 참고가 됩니다.
실수 4: 필터(expr)가 병목인데 벡터 인덱스만 만짐
Milvus 쿼리는 종종 "벡터 유사도 + scalar 필터" 조합입니다.
expr가 선택도가 낮거나(대부분 통과)- 필터 컬럼의 인덱스/구조가 비효율적이거나
- 파티션 설계가 tenant 단위와 맞지 않으면
벡터 인덱스를 아무리 튜닝해도 전체 latency가 줄지 않습니다. 이때는 다음을 고려합니다.
- tenant 또는 time-range 단위로 partitioning
- 필터 컬럼의 인덱싱/데이터 타입 정리
- "먼저 필터로 후보를 줄이고 벡터 검색"이 가능한 구조인지 점검
데이터베이스에서 vacuum/정리 작업이 성능에 직접 영향을 주듯, 벡터DB도 "데이터 구조"가 성능을 좌우합니다. 관계형에서 bloat가 성능을 망치는 과정은 PostgreSQL VACUUM 안먹힘? bloat 진단·해결 글의 문제 형태와 유사하게 이해할 수 있습니다.
실수 5: metric과 임베딩 정규화 불일치
Milvus에서 metric은 보통 L2, IP, COSINE을 씁니다.
- cosine 유사도를 원하면 보통
COSINE또는 정규화된 벡터에IP - 정규화가 안 된 상태에서
IP를 쓰면 크기(norm)가 큰 벡터가 유리해져 품질이 흔들림
이 경우 성능(속도)만의 문제가 아니라 품질(recall/precision) 문제로 이어지고, 품질을 끌어올리려고 nprobe/ef를 올리다 보니 latency가 폭증하는 패턴이 나옵니다.
정규화 예시:
import numpy as np
def l2_normalize(v: np.ndarray) -> np.ndarray:
n = np.linalg.norm(v)
if n == 0:
return v
return v / n
query_vec = l2_normalize(np.array(query_vec, dtype=np.float32)).tolist()
느려졌을 때의 진단 순서(운영 루틴)
"인덱스 때문에 10배 느려졌다"를 빠르게 판별하려면, 다음 순서가 효율적입니다.
1) 쿼리 형태를 고정하고 재현 가능한 벤치 만들기
- 동일한 쿼리 벡터 세트(예: 100개)
- 동일한
limit, 동일한expr - p50/p95/p99 latency 기록
2) 인덱스가 실제로 사용되는지 확인
- 컬렉션 로드 여부
- 인덱스 빌드 완료 여부
- growing 세그먼트 비중이 과도하지 않은지
3) IVF면 nlist/nprobe를 같이 본다
nlist를 바꾸면 인덱스를 다시 만들어야 함nprobe는 런타임에서 조절 가능하므로 먼저 스윕
4) HNSW면 ef를 스윕한다
- 필터가 있으면
ef최적점이 달라질 수 있음
5) 필터/파티션 설계를 점검한다
- tenant 쿼리가 대부분이면 tenant partition이 효과적인 경우가 많음
이런 "단계적 격리" 방식은 웹 성능에서 long task를 쪼개 병목을 찾는 접근과 비슷합니다. 문제를 한 번에 해결하려 하기보다, 병목을 분리해 재현/측정하는 습관이 중요합니다. 관련해서는 Chrome INP 폭증? Long Task를 50ms로 쪼개는 법도 같은 사고방식을 제공합니다.
운영에서 자주 쓰는 튜닝 가이드(경험칙)
정답 공식은 없지만, 실무에서 안전하게 출발하는 가이드라인은 있습니다.
- IVF
nlist는 너무 과격하게 키우지 말고, 4096~16384 범위에서 실측으로 고르기nprobe는 8부터 시작해 16, 32, 64로 올리며 p95와 recall을 같이 보기- 필터가 강하면
nprobe를 올려도 유효 후보가 부족할 수 있으니 파티션/필터 설계부터 점검
- HNSW
- 인덱스는
M=16내외,efConstruction=200내외에서 시작 - 쿼리
ef는 32~128 범위에서 스윕 - 메모리 예산이 부족하면 HNSW는 급격히 불리해질 수 있음
- 인덱스는
결론: 인덱스는 "선택"이 아니라 "워크로드 계약"
Milvus에서 인덱스 선택은 단순히 IVF_FLAT vs HNSW 같은 메뉴 고르기가 아닙니다. 데이터 분포, 필터 선택도, 세그먼트 상태, 메모리 예산, 목표 recall/latency가 함께 맞물린 "워크로드 계약"에 가깝습니다.
검색이 10배 느려졌다면, 인덱스 파라미터를 무작정 바꾸기 전에 다음을 먼저 고정하세요.
- 재현 가능한 벤치 쿼리 세트
- 인덱스 적용 여부(로드/빌드/세그먼트)
- IVF는
nlist와nprobe를 한 쌍으로, HNSW는ef를 필터와 함께
이 루틴만 지켜도 "인덱스는 있는데 왜 느리지?" 같은 함정에서 훨씬 빨리 빠져나올 수 있습니다.