Published on

Milvus IVF_FLAT vs HNSW 튜닝 - recall↑ p99↓

Authors

서로 다른 근사 최근접 탐색(ANN) 인덱스인 IVF_FLAT과 HNSW는 “recall을 올리면 느려지고, 빠르게 하면 recall이 떨어진다”는 동일한 트레이드오프를 공유하지만, 튜닝 레버의 성격이 완전히 다릅니다. Milvus에서는 이 차이가 더 크게 체감되는데, 인덱스 파라미터뿐 아니라 세그먼트 구조, 필터링 방식, 검색 파이프라인의 동시성이 p99를 좌우하기 때문입니다.

이 글은 다음 목표를 기준으로 정리합니다.

  • 목표 1: recall을 올리되(예: recall@10 0.95 이상)
  • 목표 2: p99 지연을 낮추기(예: 200ms 이하)
  • 목표 3: 운영에서 흔한 “필터 포함 검색”, “동시성 증가”, “데이터 지속 유입” 상황에서 흔들리지 않기

중간중간 실험을 자동화하는 코드도 제공합니다.

IVF_FLAT과 HNSW의 핵심 차이(튜닝 관점)

IVF_FLAT: “버킷을 잘 나누고, 더 많이 뒤진다”

IVF 계열은 벡터를 nlist 개의 클러스터(버킷)로 나누고, 검색 시 nprobe 개의 버킷만 뒤지는 방식입니다.

  • nlist(인덱스 빌드 시): 버킷 개수. 많을수록 각 버킷이 작아져 recall이 좋아질 수 있지만, 학습/빌드 비용과 관리 오버헤드가 증가합니다.
  • nprobe(검색 시): 뒤질 버킷 수. 올리면 recall이 올라가지만 p99가 증가합니다.

즉 IVF_FLAT은 검색 시 비용을 nprobe로 선형 조절하기 쉬운 대신, nlist를 잘못 잡으면 전체 성능이 무너집니다.

HNSW: “그래프를 촘촘히 만들고, 더 멀리 걷는다”

HNSW는 다층 그래프를 만들고 탐색 시 후보 집합을 확장해가며 근접 이웃을 찾습니다.

  • M(빌드 시): 각 노드의 최대 연결 수. 그래프가 촘촘해져 recall이 좋아지지만 메모리 사용량과 빌드 시간이 증가합니다.
  • efConstruction(빌드 시): 빌드 품질. 높을수록 recall이 좋아지지만 빌드가 느려집니다.
  • ef(검색 시): 검색 시 후보 확장 폭. 높을수록 recall이 올라가지만 p99가 증가합니다.

HNSW는 메모리를 더 쓰는 대신 높은 recall을 비교적 낮은 latency로 달성하기 쉬운 편이지만, 필터링이 섞이거나 데이터가 커질 때 p99가 튈 수 있습니다(캐시 미스, 그래프 탐색 확장, 동시성 경쟁 등).

어떤 상황에 무엇을 고를까(현업 선택 기준)

아래는 “정답”이 아니라, 운영에서 자주 맞는 경험칙입니다.

IVF_FLAT이 유리한 경우

  • 데이터가 매우 큼(수천만 이상) + 메모리 여유가 제한적
  • p99를 nprobe로 예측 가능하게 관리하고 싶음
  • 인덱스 빌드를 주기적으로 다시 해도 되는 배치성 워크로드
  • 검색에 강한 필터가 자주 붙고, 필터 결과가 작아지는 편(후술)

HNSW가 유리한 경우

  • 높은 recall을 낮은 p50/p95로 달성해야 함
  • 데이터가 중간 규모(수백만~수천만)이고 메모리 여유가 있음
  • 인덱스 빌드 품질을 높게 가져가고, 검색은 ef로 미세 조정하고 싶음
  • 필터가 약하거나(거의 전체를 탐색) 필터가 있어도 후보가 크게 줄지 않는 경우

공정 비교를 위한 실험 설계(지표와 함정)

튜닝 글에서 가장 흔한 함정은 “recall과 latency를 다른 조건에서 잰 것”입니다. 아래 4가지를 고정하세요.

  1. 동일 데이터(차원, 분포, 정규화 여부)
  2. 동일 metric(L2 또는 IP 또는 COSINE)
  3. 동일 topK
  4. 동일 필터 조건(없거나, 동일한 selectivity)

또한 p99는 부하에 민감하므로 **동시성(QPS, 동시 요청 수)**을 고정하고 측정해야 합니다. p99 튐은 종종 인덱스가 아니라 서버 스레드/큐잉/GC/캐시에서 발생합니다. API 레이트리밋이나 재시도 전략이 p99를 왜곡하는 경우도 많으니, 외부 호출이 섞인 파이프라인이라면 재시도 정책을 먼저 점검하세요. 관련해서는 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉 같은 패턴이 p99를 “좋아 보이게” 또는 “나빠 보이게” 만들 수 있습니다.

Milvus에서 IVF_FLAT 튜닝: recall을 올리면서 p99를 낮추는 법

1) nlist를 데이터 규모에 맞춰 잡기(기본 레일)

경험적으로는 다음 범위에서 시작하는 경우가 많습니다.

  • 데이터 N이 1,000,000일 때 nlist를 1,024~8,192
  • N이 10,000,000일 때 nlist를 8,192~65,536

너무 작은 nlist는 각 버킷이 커져서 nprobe를 올려도 recall이 잘 안 오르고, 결국 p99만 나빠집니다. 반대로 너무 큰 nlist는 빌드/메모리/관리 오버헤드가 커지고, 워밍업/캐시 미스가 늘어 p99가 튈 수 있습니다.

전략: nlist를 2~3개 후보로 정하고, 각 nlist에서 nprobe 스윕으로 목표 recall을 맞추는 조합을 찾습니다.

2) nprobe는 “목표 recall까지 최소”로

IVF_FLAT의 latency는 대체로 nprobe에 비례합니다. 따라서 목표 recall을 만족하는 최소 nprobe를 찾는 것이 핵심입니다.

  • recall이 부족하면 nprobe를 올립니다.
  • p99가 과하면 nprobe를 내리고 nlist를 올리는 방향을 검토합니다.

3) 필터가 있는 검색에서는 “필터 selectivity”를 먼저 측정

Milvus에서 expr 필터가 붙으면, 인덱스 탐색 후 후보에서 필터를 적용하는 형태로 비용이 추가되거나(버전에 따라 최적화 차이), 필터 때문에 “유효 후보”가 부족해져 더 많은 탐색이 필요해질 수 있습니다.

  • 필터가 강해서 결과가 1% 이하로 줄면, ANN 탐색 비용보다 필터 비용/재탐색 비용이 지배할 수 있습니다.
  • 이때는 파티션/클러스터 키 설계로 필터를 물리적으로 분리하는 것이 p99에 더 효과적입니다.

4) p99를 낮추는 운영 팁: 세그먼트/컴팩션/워밍업

IVF_FLAT은 세그먼트가 많아질수록(ingestion이 잦고 compaction이 덜 된 경우) 검색 시 여러 세그먼트를 훑게 되어 p99가 악화될 수 있습니다.

  • 주기적 compaction 정책 확인
  • 핫 컬렉션은 워밍업(초기 p99 스파이크 방지)
  • 동시성 증가 시 CPU pinning, search thread pool 설정 점검

Milvus에서 HNSW 튜닝: recall을 올리면서 p99를 낮추는 법

1) 빌드 파라미터로 “기본 recall 바닥”을 올린다

HNSW는 빌드 품질이 낮으면 검색 ef를 올려도 p99만 늘고 recall이 기대만큼 안 오릅니다.

  • M: 16 또는 32부터 시작
  • efConstruction: 128~512 범위에서 시작

메모리가 허용한다면 M을 올리는 편이 검색 p99에 유리한 경우가 많습니다. 이유는 더 촘촘한 그래프가 같은 recall을 더 작은 ef로 달성하게 해주기 때문입니다.

2) 검색 파라미터 ef는 “목표 recall까지 최소”로

  • ef는 HNSW의 nprobe 같은 역할을 합니다.
  • ef를 크게 올리면 recall은 올라가지만 p99가 증가합니다.

전략: MefConstruction으로 충분한 그래프 품질을 확보한 뒤, ef를 낮은 값부터 올려 목표 recall을 맞춥니다.

3) HNSW에서 p99가 튀는 흔한 원인

  • 동시성 증가로 인한 CPU 캐시 미스, 메모리 대역폭 포화
  • 필터로 유효 후보가 부족해져 탐색이 길어짐
  • 세그먼트가 많아져 그래프 탐색이 분산됨

특히 HNSW는 메모리 접근 패턴이 랜덤에 가깝기 때문에, CPU가 아니라 메모리/NUMA가 병목이 되는 순간 p99가 급격히 악화될 수 있습니다.

실전 튜닝 루프: “목표 recall을 먼저 맞추고 p99를 깎기”

튜닝 순서를 반대로 하면(먼저 빠르게 만들고 recall 올리기) 대개 삽질합니다. 추천 루프는 이렇습니다.

  1. metric/정규화/topK 고정
  2. 목표 recall 설정(예: recall@10 0.95)
  3. IVF_FLAT: nlist 후보 2~3개 선정 후 nprobe 스윕
  4. HNSW: M/efConstruction 후보 2~3개 선정 후 ef 스윕
  5. 목표 recall을 만족하는 후보군 중 p99가 가장 낮은 조합 선택
  6. 운영 조건(필터, 동시성, ingestion)에서 재측정

PyMilvus 예제: IVF_FLAT과 HNSW 생성 및 검색 파라미터 스윕

아래 코드는 “인덱스 생성”과 “검색 파라미터 스윕”의 형태를 보여주는 예시입니다. 실제 운영에선 데이터 적재, flush/load, 워밍업, 동시성 부하 도구를 함께 구성하세요.

from pymilvus import (
    connections, FieldSchema, CollectionSchema, DataType,
    Collection
)

connections.connect(alias="default", host="localhost", port="19530")

DIM = 768
COL = "docs"

# 1) 컬렉션 스키마
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="tenant_id", dtype=DataType.INT64),
    FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=DIM),
]

schema = CollectionSchema(fields, description="ivf_flat vs hnsw")

if COL in [c.name for c in Collection.list()]:
    Collection(COL).drop()

collection = Collection(name=COL, schema=schema)

# (데이터 insert는 생략)
# collection.insert([...])
# collection.flush()

# 2) IVF_FLAT 인덱스 생성
ivf_index = {
    "index_type": "IVF_FLAT",
    "metric_type": "IP",
    "params": {"nlist": 8192},
}
collection.create_index(field_name="vec", index_params=ivf_index)

# 3) HNSW 인덱스 생성(비교하려면 컬렉션을 분리하거나 재생성 권장)
# hnsw_index = {
#     "index_type": "HNSW",
#     "metric_type": "IP",
#     "params": {"M": 32, "efConstruction": 256},
# }
# collection.create_index(field_name="vec", index_params=hnsw_index)

collection.load()

query_vecs = [[0.01] * DIM]  # 예시
expr = "tenant_id == 1"

def search_ivf(nprobe: int, topk: int = 10):
    params = {"metric_type": "IP", "params": {"nprobe": nprobe}}
    return collection.search(
        data=query_vecs,
        anns_field="vec",
        param=params,
        limit=topk,
        expr=expr,
        output_fields=["tenant_id"],
    )

# nprobe 스윕
for nprobe in [4, 8, 16, 32, 64]:
    res = search_ivf(nprobe)
    # 여기서 latency(p50/p95/p99)와 recall을 기록
    print("nprobe=", nprobe, "hits=", len(res[0]))

HNSW는 검색 시 ef를 바꿉니다.

def search_hnsw(ef: int, topk: int = 10):
    params = {"metric_type": "IP", "params": {"ef": ef}}
    return collection.search(
        data=query_vecs,
        anns_field="vec",
        param=params,
        limit=topk,
        expr=expr,
    )

for ef in [16, 32, 64, 128, 256]:
    res = search_hnsw(ef)
    print("ef=", ef, "hits=", len(res[0]))

주의할 점은, IVF_FLAT과 HNSW를 “같은 컬렉션에서 번갈아 인덱스를 갈아끼우며” 비교하면 compaction 상태, 캐시, 로드 상태가 달라져 결과가 흔들립니다. 비교 실험은 보통 컬렉션을 분리하거나, 동일한 스냅샷/로드 조건을 엄격히 맞춥니다.

recall 측정: 그라운드 트루스를 어떻게 만들까

recall을 측정하려면 정답(ground truth)이 필요합니다. 일반적으로는 다음 중 하나를 씁니다.

  • 작은 샘플에 대해 FLAT(브루트포스)로 topK를 구해 정답으로 사용
  • 오프라인에서 Faiss 등으로 exact topK를 만들어 저장

Milvus에서 exact 비교를 위해 별도 컬렉션에 FLAT 인덱스를 두는 방법이 실무에서 많이 쓰입니다.

p99를 진짜로 낮추는 체크리스트(인덱스 밖의 변수)

인덱스 파라미터만 만져서는 p99가 안 내려가는 경우가 많습니다. 아래는 “효과가 큰 순서”로 자주 맞는 항목들입니다.

1) 필터를 파티션/샤딩 키로 끌어내리기

expr로 매번 필터링하는 대신, tenant나 카테고리처럼 강한 조건은 파티션/컬렉션 분리로 물리적 스캔 범위를 줄이면 p99가 크게 개선됩니다.

2) 동시성에서의 큐잉 제거

p99는 대개 “실제 검색 시간”보다 “대기 시간”이 섞입니다. API 서버에서의 큐잉, 스레드풀 포화, 재시도 폭주 등을 제거하세요. RAG 파이프라인처럼 여러 단계를 거치는 경우, 단계별 타임아웃과 재시도 정책이 p99를 좌우합니다. 출력 품질을 위해 스키마 강제를 도입했다면, 그만큼 토큰/시간이 늘어 p99가 변할 수 있으니 전체 파이프라인 관점에서 보세요. 관련해서는 RAG 환각을 줄이는 JSON Schema 강제 출력법도 함께 참고할 만합니다.

3) 워밍업과 캐시

배포 직후/스케일아웃 직후 p99 스파이크는 “정상”처럼 보이지만, 사용자 입장에선 장애입니다. 핫 쿼리 워밍업, 컬렉션 preload, 노드 재시작 시나리오를 포함해 측정하세요.

4) 관측 가능성: p99를 쪼개서 본다

  • Milvus 검색 시간
  • 네트워크 왕복
  • API 서버 큐잉
  • 후처리(리랭킹, DB 조회)

이 중 어디가 p99를 만드는지 모르면 튜닝이 랜덤워크가 됩니다. 특히 리랭킹이나 추가 DB 조회가 있다면 커넥션 풀 고갈 같은 문제로 p99가 급격히 튈 수 있습니다. 이런 경우는 벡터 인덱스 튜닝이 아니라 애플리케이션 레벨 병목이므로, Spring Boot HikariCP 커넥션 고갈 원인과 해결 같은 관점으로 접근해야 합니다.

추천 시작점(빠르게 수렴하는 초기값)

데이터 차원 768, metric IP, topK 10, 수백만~천만 규모를 가정한 “출발점”입니다.

IVF_FLAT

  • nlist: 8192(수백만) 또는 16384~32768(천만)
  • nprobe: 8부터 시작해 8, 16, 32로 증가

HNSW

  • M: 16 또는 32
  • efConstruction: 256
  • ef: 32부터 시작해 64, 128로 증가

목표 recall을 만족하는 최소 nprobe 또는 최소 ef를 찾고, p99가 높으면 빌드 파라미터(IVF의 nlist, HNSW의 M/efConstruction)로 “같은 recall을 더 싼 검색 파라미터로” 달성하도록 유도하는 것이 핵심입니다.

결론: recall은 올리고 p99는 내리는 현실적인 접근

  • IVF_FLAT은 nlist로 바닥을 깔고, nprobe로 운영 중 recall/latency를 제어하는 구조입니다. p99를 안정적으로 관리하기 좋지만, nlist를 잘못 잡으면 nprobe를 올려도 효과가 적고 p99만 나빠집니다.
  • HNSW는 MefConstruction으로 그래프 품질을 확보하면, 상대적으로 작은 ef로 높은 recall을 달성할 수 있습니다. 다만 메모리/NUMA/동시성/필터에 의해 p99가 튈 수 있어 운영 환경 측정이 필수입니다.

마지막으로, 가장 큰 성능 향상은 종종 인덱스 파라미터가 아니라 **필터를 물리적으로 줄이는 데이터 모델링(파티션/샤딩)**과 **큐잉 제거(동시성 제어)**에서 나옵니다. 인덱스 튜닝은 그 다음입니다.