Published on

Milvus 인덱스 선택 실수로 검색 10배 느려질 때

Authors

서빙 중인 벡터 검색이 갑자기 느려졌는데 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는 nlistnprobe를 한 쌍으로, HNSW는 ef를 필터와 함께

이 루틴만 지켜도 "인덱스는 있는데 왜 느리지?" 같은 함정에서 훨씬 빨리 빠져나올 수 있습니다.