- Published on
Milvus IVF_FLAT·HNSW 튜닝으로 Recall 0.95 달성
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 벡터 검색 워크로드에서 목표 Recall 0.95를 맞추려면, 인덱스 선택보다 측정 방식과 튜닝 루프가 더 중요합니다. Milvus는 IVF 계열과 HNSW를 모두 지원하지만, 둘은 성격이 완전히 다릅니다.
이 글에서는 IVF_FLAT과 HNSW의 핵심 파라미터를 Recall 0.95를 목표로 어떻게 조정하는지, 그리고 운영 환경에서 재현 가능한 실험 절차를 어떻게 구성하는지 정리합니다.
전제: Recall 0.95는 “검색 파라미터”로 맞춘다
Milvus에서 Recall은 대체로 아래 3요소의 함수입니다.
- 데이터/임베딩 품질: 동일 모델이라도 정규화 여부, 차원, 도메인 드리프트에 따라 상한이 달라집니다.
- 인덱스 빌드 파라미터: IVF의
nlist, HNSW의M,efConstruction등. - 검색 시 파라미터: IVF의
nprobe, HNSW의ef.
실무적으로 “Recall 0.95를 맞춘다”는 말은 보통 다음을 의미합니다.
- 인덱스 빌드는 한 번에 크게 바꾸기 어렵기 때문에(리빌드 비용), 검색 파라미터로 목표 Recall을 맞추고
- 그래도 부족하면 인덱스 빌드 파라미터를 조정해 탐색 공간 자체를 개선합니다.
측정 준비: Ground Truth 없이 Recall을 논하면 실패한다
Recall을 튜닝하려면 최소한 아래 중 하나가 필요합니다.
- 정확 탐색(브루트포스) 결과를 정답으로 삼기
- 서비스에 이미 존재하는 “정답 클릭/구매/라벨” 기반의 오프라인 평가(이 경우 Recall@K 정의를 명확히)
가장 단순하고 재현 가능한 방법은 “샘플 쿼리 집합”에 대해 FLAT(정확) 검색 결과를 Ground Truth로 만들고, 각 인덱스/파라미터 조합의 결과와 비교하는 것입니다.
아래는 Milvus Python SDK 기준으로, 동일 컬렉션에서 FLAT 검색을 Ground Truth로 쓰는 예시입니다.
from pymilvus import (
connections, FieldSchema, CollectionSchema, DataType,
Collection
)
connections.connect("default", host="localhost", port="19530")
COL = "items"
collection = Collection(COL)
collection.load()
def search_flat(query_vecs, topk=10):
# FLAT은 인덱스 타입이라기보다 search param에서 강제하는 방식이 아니라,
# 보통 별도 컬렉션/인덱스를 FLAT으로 두거나, 인덱스 없이 brute force로 측정합니다.
# 여기서는 예시로 search param만 단순화합니다.
res = collection.search(
data=query_vecs,
anns_field="embedding",
param={"metric_type": "COSINE", "params": {}},
limit=topk,
output_fields=["id"],
)
return [[hit.id for hit in hits] for hits in res]
def recall_at_k(pred_ids, gt_ids, k=10):
# gt_ids: 정답 topk, pred_ids: 예측 topk
# Recall@K = |pred ∩ gt| / |gt|
r = []
for p, g in zip(pred_ids, gt_ids):
pset = set(p[:k])
gset = set(g[:k])
r.append(len(pset & gset) / max(1, len(gset)))
return sum(r) / len(r)
운영 관점 팁: 튜닝은 “장애”로도 이어진다
튜닝 중에는 로드/메모리/CPU가 급증해 Pod가 죽거나 지연이 늘어날 수 있습니다. 특히 HNSW는 메모리 사용량이 커서 리소스가 타이트하면 OOMKilled로 이어지기 쉽습니다. 쿠버네티스에서 이런 증상을 겪는다면 먼저 아래 글의 체크리스트로 원인을 분리해두면 튜닝 효율이 좋아집니다.
IVF_FLAT: nlist와 nprobe가 전부라고 생각하면 된다
IVF_FLAT은 “coarse quantizer로 클러스터를 나누고, 선택된 클러스터 내부는 FLAT(정확)으로 스캔”하는 구조입니다.
nlist: 클러스터 개수(버킷 수)nprobe: 검색 시 탐색할 클러스터 개수
직관은 다음과 같습니다.
nlist가 커질수록 각 클러스터가 작아져서 빠르지만, 잘못된 클러스터를 고르면 Recall이 떨어질 수 있습니다.nprobe를 늘리면 더 많은 클러스터를 보므로 Recall이 올라가지만, 지연과 CPU가 증가합니다.
IVF_FLAT 인덱스 생성 예시
index_params = {
"index_type": "IVF_FLAT",
"metric_type": "COSINE",
"params": {
"nlist": 4096
}
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
IVF_FLAT 검색 파라미터 예시
def search_ivf(query_vecs, topk=10, nprobe=32):
res = collection.search(
data=query_vecs,
anns_field="embedding",
param={
"metric_type": "COSINE",
"params": {"nprobe": nprobe}
},
limit=topk,
output_fields=["id"],
)
return [[hit.id for hit in hits] for hits in res]
Recall 0.95를 위한 IVF_FLAT 튜닝 가이드(실전)
아래는 “데이터 규모 N, topK, 지연 예산”이 정해져 있을 때의 경험적 접근입니다.
- 초기
nlist를 합리적으로 잡기- 흔한 출발점:
nlist를sqrt(N)근처로 두고 시작 - N이 1,000만이면
sqrt(N)은 약 3162이므로2048~8192범위에서 탐색
- 흔한 출발점:
nprobe로 Recall 목표를 먼저 맞춘다nprobe를 8, 16, 32, 64, 128 식으로 올리며 Recall@K를 측정- Recall이 0.95에 도달하는 최소
nprobe를 찾는다
- 지연이 너무 크면
nlist를 조정- 같은 Recall이라도
nlist가 너무 작으면 클러스터가 커져서 느려질 수 있음 - 반대로
nlist가 너무 크면nprobe를 크게 해야 해서 느려질 수 있음
- 같은 Recall이라도
흔한 실패 패턴
nlist를 너무 크게 잡고nprobe를 작게 유지: 속도는 빠른데 Recall이 0.95에 못 미침nprobe를 과도하게 키움: Recall은 맞지만 p95 지연이 폭증
HNSW: M, efConstruction, ef의 역할을 분리하라
HNSW는 그래프 기반 근사 최근접 탐색(ANN)입니다.
M: 노드당 연결(edge) 수에 가까운 값. 커질수록 Recall이 좋아질 가능성이 크지만 메모리와 빌드 비용 증가efConstruction: 인덱스 빌드 시 탐색 폭. 클수록 더 좋은 그래프가 생기지만 빌드 시간/메모리 증가ef: 검색 시 탐색 폭. 클수록 Recall 증가, 지연 증가
핵심은 다음입니다.
- 빌드 파라미터인
M,efConstruction은 인덱스 품질의 상한을 결정 - 검색 파라미터인
ef는 그 상한에 얼마나 근접할지를 결정
HNSW 인덱스 생성 예시
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {
"M": 16,
"efConstruction": 200
}
}
collection.create_index(
field_name="embedding",
index_params=index_params
)
HNSW 검색 파라미터 예시
def search_hnsw(query_vecs, topk=10, ef=64):
res = collection.search(
data=query_vecs,
anns_field="embedding",
param={
"metric_type": "COSINE",
"params": {"ef": ef}
},
limit=topk,
output_fields=["id"],
)
return [[hit.id for hit in hits] for hits in res]
Recall 0.95를 위한 HNSW 튜닝 가이드(실전)
- 먼저
ef를 올려서 목표 Recall이 가능한지 확인ef를 32, 64, 128, 256…으로 증가ef를 올려도 Recall이 0.95 근처에서 plateau면, 인덱스 품질 상한이 낮은 것
- plateau라면
M을 올리고 리빌드 고려M=12에서 안 되면M=16,M=24순으로 시도- 메모리 사용량이 민감하게 늘 수 있으니 노드 메모리/Pod limit을 먼저 확인
- 그래도 부족하면
efConstruction을 올린다- 일반적으로
efConstruction은 100~400 범위에서 실험 - 빌드 시간이 늘어나므로 배치 윈도우를 확보
- 일반적으로
HNSW의 운영상 주의점
- 메모리:
M이 커질수록 그래프가 촘촘해져 메모리 압박이 커집니다. - 빌드/컴팩션 타이밍: 대량 insert 이후 인덱스 빌드 또는 세그먼트 증가가 겹치면 지연이 흔들립니다.
IVF_FLAT vs HNSW: 어떤 때 무엇을 선택할까
둘 다 Recall 0.95는 가능하지만, “어떤 비용을 감수할지”가 다릅니다.
IVF_FLAT이 유리한 경우
- 디스크/메모리 제약이 큰 환경에서 예측 가능한 비용이 필요
nprobe로 성능을 비교적 직관적으로 제어하고 싶음- 데이터가 크고, 정확 스캔 비용을 클러스터링으로 줄이고 싶음
HNSW가 유리한 경우
- 낮은 지연에서 높은 Recall을 원하고, 메모리를 더 쓸 수 있음
- 쿼리당 탐색 비용을
ef로 부드럽게 조절하고 싶음 - topK가 작고(예: 10~50), 온라인 서비스에서 p95를 낮추고 싶음
튜닝 루프: “파라미터 스윕 + p95 + Recall”을 동시에 본다
Recall만 올리면 결국 비용이 폭발합니다. 그래서 아래 3가지를 같이 기록해야 합니다.
- Recall@K (목표 0.95)
- 지연: p50, p95, p99
- 비용 지표: CPU 사용률, 메모리 사용률, QPS당 코어 소모
간단한 스윕 코드는 아래처럼 구성할 수 있습니다.
import time
import numpy as np
def benchmark(search_fn, query_vecs, gt_ids, k=10):
t0 = time.time()
pred = search_fn(query_vecs, topk=k)
dt = time.time() - t0
r = recall_at_k(pred, gt_ids, k=k)
return {
"recall": r,
"total_sec": dt,
"qps": len(query_vecs) / max(1e-9, dt),
}
# 예시: IVF nprobe 스윕
# gt_ids는 미리 FLAT으로 만들어둔 정답
# query_vecs는 numpy array list 형태라고 가정
for nprobe in [8, 16, 32, 64, 128]:
out = benchmark(lambda q, topk: search_ivf(q, topk=topk, nprobe=nprobe), query_vecs, gt_ids)
print("IVF", "nprobe=", nprobe, out)
for ef in [32, 64, 128, 256]:
out = benchmark(lambda q, topk: search_hnsw(q, topk=topk, ef=ef), query_vecs, gt_ids)
print("HNSW", "ef=", ef, out)
여기서 중요한 건 “최고 Recall”이 아니라, Recall 0.95를 만족하는 최소 비용 지점을 찾는 것입니다.
Recall이 안 오를 때 점검 체크리스트
튜닝을 해도 0.95에 못 미치는 경우, 인덱스 파라미터보다 먼저 아래를 확인해야 합니다.
1) metric과 정규화가 맞는가
COSINE을 쓰는데 벡터를 정규화하지 않거나, 모델이 내적 기반인데 metric을 혼용하면 Recall이 흔들립니다.L2와COSINE은 데이터 분포에 따라 결과가 크게 달라집니다.
2) 세그먼트/로드 상태가 일관적인가
- 일부 세그먼트만 로드된 상태에서 측정하면 Recall이 낮아집니다.
- 튜닝 중에는
collection.load()상태와 파티션 로딩 정책을 고정하세요.
3) 필터링(스칼라 조건)과 결합되었는가
- 벡터 검색 후 필터인지, 필터 후 벡터 검색인지에 따라 후보군이 달라집니다.
- 필터가 강하면 어떤 인덱스든 Recall@K가 구조적으로 낮아질 수 있습니다.
4) 리소스 제한으로 검색이 타임아웃/부분 실패하는가
타임아웃이나 부분 실패가 있으면 결과 수가 줄어 Recall이 떨어진 것처럼 보입니다.
쿠버네티스 환경에서 토큰/권한/네트워크 이슈가 성능 문제로 위장되기도 합니다. EKS에서 IAM 연동을 쓰는 경우 IRSA 설정도 함께 점검하세요.
추천 시작점(보수적)과 목표 도달 전략
워크로드가 아직 불명확할 때, 아래 조합은 “일단 0.95에 도달 가능한지”를 보기 좋은 시작점입니다.
IVF_FLAT 시작점
nlist:2048~8192중 데이터 크기에 맞춰 선택nprobe:16에서 시작해 2배씩 증가- 목표:
nprobe만으로 0.95가 되면 운영 튜닝이 쉬움
HNSW 시작점
M=16,efConstruction=200- 검색
ef=64에서 시작해128,256으로 증가 - 목표:
ef를 과도하게 키우지 않고 0.95가 되는 지점 찾기
마무리: Recall 0.95는 “숫자”가 아니라 “합의된 SLO”다
Recall 0.95를 맞추는 가장 현실적인 방법은 다음 순서입니다.
- Ground Truth를 정의하고(FLAT 또는 라벨), Recall@K를 자동 측정한다
- IVF_FLAT은
nprobe, HNSW는ef로 먼저 목표를 맞춘다 - plateau가 오면 IVF는
nlist, HNSW는M과efConstruction을 조정한다 - 최종적으로 p95 지연과 비용을 함께 보고, 0.95를 만족하는 최소 비용 구간을 선택한다
이 과정을 파이프라인으로 만들면, 데이터가 늘거나 임베딩 모델이 바뀌어도 “다시 0.95로 복구”하는 시간이 크게 줄어듭니다.