- Published on
Milvus IVF/HNSW 튜닝으로 RAG 검색지연 50%↓
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 중인 RAG에서 “모델이 느리다”는 민원이 들어오면, 실제 병목은 LLM이 아니라 벡터 검색인 경우가 많습니다. 특히 Milvus는 설정 가능한 레버가 많아 기본값으로도 잘 돌아가지만, 워크로드(문서 길이, chunk 수, 동시성, top-k, 필터링)에 맞춰 IVF/HNSW를 조정하면 검색 지연을 50% 이상 줄이는 케이스가 흔합니다.
이 글은 “무작정 파라미터를 올리거나 내리기”가 아니라, 지연(latency)과 정확도(recall)를 함께 계측하면서 Milvus의 IVF/HNSW를 튜닝하는 절차를 정리합니다. pgvector 쪽 튜닝 경험이 있다면 비교 관점에서도 도움이 될 텐데, 필요하면 pgvector RAG 인덱스가 느릴 때 IVFFlat·HNSW 튜닝도 함께 참고하면 좋습니다.
1) RAG 검색지연을 먼저 분해하자
RAG의 검색 단계 지연은 대개 아래 합으로 나타납니다.
- 네트워크 왕복 + 게이트웨이/SDK 오버헤드
- Milvus 내부
- 필터(스칼라 조건) 평가
- ANN 탐색(IVF/HNSW)
- re-ranking 또는 후처리(옵션)
- 디스크/메모리(캐시 히트율)
여기서 IVF/HNSW 튜닝은 “ANN 탐색” 구간을 줄이는 것이 목적이지만, 실제로는 메모리 압박을 줄여 캐시 히트율을 올리거나, 필터와 인덱스의 상호작용을 개선해서 전체가 줄어드는 경우도 많습니다.
지표를 최소 3개는 잡아야 한다
튜닝은 숫자 게임입니다. 최소 아래 3가지는 고정적으로 보세요.
p50,p95latency (ms)- recall proxy (예: brute force 대비 top-k overlap)
- QPS / 동시성에서의 tail latency (p95, p99)
recall은 “정답셋”이 없으면 측정이 애매합니다. 실무에서는 샘플 쿼리 200~1000개를 뽑아, 작은 subset 컬렉션에서만이라도 brute force(정확 탐색)를 돌려 top-k 교집합 비율로 proxy를 잡는 방식이 현실적입니다.
2) Milvus에서 IVF와 HNSW를 언제 쓰나
Milvus는 컬렉션/필드 단위로 인덱스를 구성합니다. 대표적으로 다음이 많이 쓰입니다.
- IVF 계열:
IVF_FLAT,IVF_PQ,IVF_SQ8- 장점: 대규모에서 메모리/속도 밸런스 좋음, 파라미터가 직관적
- 단점: 파라미터를 잘못 잡으면 recall이 급락하거나 tail이 튐
- HNSW: 그래프 기반
- 장점: 높은 recall을 비교적 낮은 지연으로 달성하기 쉬움
- 단점: 인덱스 빌드 비용/메모리 사용량이 커질 수 있음
RAG에서 흔한 패턴은 아래입니다.
- “문서가 많고, top-k가 5~20, 동시성이 높다”
=IVF 계열이 비용 효율적 - “recall을 최대한 지키면서도 빠르게”
=HNSW가 편함 - “필터가 강하다(테넌트, 문서 타입, 날짜 등)”
=필터 전략이 더 중요해짐(인덱스만으로 해결 안 됨)
3) IVF 튜닝 핵심: nlist 와 nprobe
IVF는 전체 벡터를 nlist개의 클러스터(리스트)로 나누고, 쿼리 시 nprobe개 리스트만 탐색합니다.
nlist(클러스터 수)- 너무 작으면: 한 리스트에 너무 많은 벡터가 들어가 탐색 비용 증가
- 너무 크면: 학습/빌드 비용 증가, 데이터가 희소해져 recall이 흔들릴 수 있음
nprobe(탐색할 리스트 수)- 클수록: recall 증가, latency 증가
- 작을수록: latency 감소, recall 감소
경험칙(출발점)
정답은 없지만 출발점은 필요합니다.
nlist는 대략sqrt(N)근처에서 시작- 예:
N = 1,000,000이면nlist ≈ 1000
- 예:
nprobe는8,16,32를 후보로 A/B
그리고 “50% 지연 감소” 같은 목표는 보통 nprobe를 낮추면서 달성됩니다. 대신 recall이 떨어질 수 있으니, 다음 섹션의 방법으로 recall 하한을 지키는 선을 찾아야 합니다.
Milvus 인덱스 생성 예시(IVF_FLAT)
아래 코드는 pymilvus 예시입니다. 본문에 부등호가 들어가면 MDX에서 깨질 수 있으니, 비교 연산은 코드 블록 안에서만 사용합니다.
from pymilvus import (
connections, FieldSchema, CollectionSchema, DataType,
Collection
)
connections.connect(alias="default", host="milvus", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="tenant_id", dtype=DataType.INT64),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="rag_chunks")
col = Collection(name="rag_chunks", schema=schema)
index_params = {
"index_type": "IVF_FLAT",
"metric_type": "COSINE",
"params": {"nlist": 1024},
}
col.create_index(field_name="embedding", index_params=index_params)
col.load()
검색 시 nprobe 조절 예시
Milvus는 검색 시 파라미터를 넘겨 런타임에서 nprobe를 조정할 수 있습니다.
search_params = {
"metric_type": "COSINE",
"params": {"nprobe": 16},
}
results = col.search(
data=[query_vec],
anns_field="embedding",
param=search_params,
limit=10,
expr="tenant_id == 42",
output_fields=["tenant_id"],
)
여기서 핵심은 nprobe를 “낮춰서 빠르게”가 아니라, 부하 구간(p95)에서 안정적으로 빠르게입니다. 동시성이 올라가면 CPU 스케줄링/캐시/메모리 영향으로 tail이 더 튈 수 있어, p50만 보고 결론 내리면 실패합니다.
4) HNSW 튜닝 핵심: M 과 efConstruction, ef
HNSW는 그래프 품질을 M과 efConstruction으로 만들고, 검색 시 ef로 탐색 폭을 조절합니다.
M: 노드당 연결 수(대략 그래프의 촘촘함)- 높을수록 recall 유리, 메모리/빌드 비용 증가
efConstruction: 인덱스 빌드 품질- 높을수록 빌드 느림, recall 유리
ef: 검색 품질- 높을수록 recall 증가, latency 증가
경험칙(출발점)
M = 16또는M = 24부터 시작efConstruction = 200내외에서 시작ef는32,64,128을 후보로
RAG에서 “지연 50%↓”를 노리면 보통 ef를 낮추는 방향인데, IVF의 nprobe와 마찬가지로 **recall 하한을 지키는 최소 ef**를 찾는 과정이 됩니다.
Milvus 인덱스 생성 예시(HNSW)
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {
"M": 16,
"efConstruction": 200,
},
}
col.create_index(field_name="embedding", index_params=index_params)
col.load()
검색 시 ef 조절 예시
search_params = {
"metric_type": "COSINE",
"params": {"ef": 64},
}
results = col.search(
data=[query_vec],
anns_field="embedding",
param=search_params,
limit=10,
expr="tenant_id == 42",
)
5) “50% 지연 감소”를 만드는 실전 튜닝 플로우
무작정 파라미터를 바꾸면, 좋아졌는지 나빠졌는지 모호해집니다. 아래 순서가 재현성이 좋습니다.
1단계: 고정된 벤치 쿼리셋과 부하 조건 만들기
- 쿼리 500개 정도를 샘플링
top_k고정(예: 10)- 필터 패턴 고정(예:
tenant_id단일 조건) - 동시성 시나리오 2개
- 저부하: 동시성 1~4
- 고부하: 동시성 16~64(서비스 환경에 맞게)
부하 도구는 k6, locust, vegeta 등 무엇이든 좋습니다. 중요한 건 동일 조건 반복입니다.
2단계: 기준선(baseline) 계측
- 현재 인덱스 타입과 파라미터
p50,p95,p99- recall proxy
3단계: “검색 파라미터”부터 조정(재빌드 없는 레버)
재색인 없이 바꿀 수 있는 값부터 만집니다.
- IVF:
nprobe - HNSW:
ef
목표는 간단합니다.
p95가 유의미하게 내려가고- recall proxy가 허용 하한(예: 0.95) 이상이면 통과
여기서 이미 30~50%가 줄어드는 경우가 많습니다.
4단계: 인덱스 파라미터 조정(재빌드 레버)
서빙 중 재빌드가 부담이면, 스테이징에서 먼저 결론을 내고 운영에 반영합니다.
- IVF:
nlist재조정 - HNSW:
M,efConstruction재조정
IVF는 nlist를 올리고 nprobe를 낮추는 조합이 종종 먹힙니다. 예를 들어, nlist를 키워 클러스터를 더 잘게 나누면, 적은 nprobe로도 충분한 후보를 얻어 지연은 줄고 recall은 유지되는 지점을 찾을 수 있습니다.
6) 튜닝에서 자주 터지는 함정 6가지
함정 1: p50만 보고 “빨라졌다” 결론
RAG는 사용자 체감이 tail에 민감합니다. 특히 동시성에서 p95가 중요합니다.
함정 2: 필터가 강한데 ANN만 튜닝
expr 필터가 선택도를 크게 좌우합니다. 필터가 좁으면 ANN 탐색보다 필터 평가/후처리가 병목이 될 수 있습니다.
함정 3: top-k를 바꾸면 결론이 뒤집힘
top_k가 5에서 20으로 바뀌면 필요한 후보 수가 달라지고, 최적 nprobe/ef도 달라집니다. 서비스의 실제 top_k를 기준으로 튜닝하세요.
함정 4: 인덱스 빌드/로드 비용을 무시
HNSW는 특히 빌드 시간이 길어질 수 있습니다. 운영 배포 전략(블루/그린, 듀얼 컬렉션)까지 포함해서 의사결정해야 합니다.
함정 5: 메모리 압박으로 성능이 랜덤하게 흔들림
메모리가 부족하면 페이지 캐시/세그먼트 로딩이 흔들리며 tail이 튑니다. “어제는 빨랐는데 오늘은 느림”은 종종 리소스 문제입니다.
함정 6: 튜닝 결과를 CI처럼 재현하지 못함
튜닝은 실험입니다. 파라미터, 데이터 스냅샷, 쿼리셋, 부하 조건을 버전 관리하세요. 빌드/배포 파이프라인 최적화 관점은 Docker BuildKit 캐시·멀티스테이지로 CI 빌드 70% 단축에서 다룬 방식과 유사하게, “재현 가능한 실험”이 성패를 가릅니다.
7) 추천 조합 예시(출발 템플릿)
아래는 “대규모 RAG chunk 컬렉션”에서 자주 쓰는 시작점입니다. 그대로가 정답은 아니고, 실험을 위한 베이스라인으로 쓰세요.
시나리오 A: 비용 효율 우선(IVF_FLAT)
- 인덱스:
IVF_FLAT,nlist = 1024또는2048 - 검색:
nprobe = 8부터 시작해서 recall이 부족하면16 - 목표: p95를 낮추고, recall proxy 0.95 이상 유지
시나리오 B: recall 우선(HNSW)
- 인덱스:
HNSW,M = 16,efConstruction = 200 - 검색:
ef = 64부터 시작, 필요 시128 - 목표: p95를 안정화하면서도 높은 recall 유지
8) 간단 벤치마크 스크립트 예시(지연/리콜 프록시)
아래 예시는 “정확 탐색 대체”를 완벽히 구현하진 않지만, 운영에서 빠르게 방향성을 잡는 데 유용합니다. 작은 샘플 컬렉션을 따로 만들거나, 동일 데이터에서 nprobe/ef를 매우 크게 두고 “준-정확” 기준을 만드는 식으로 proxy를 잡을 수 있습니다.
import time
import numpy as np
def run_search(col, queries, search_params, limit=10, expr=None):
latencies = []
all_ids = []
for q in queries:
t0 = time.perf_counter()
res = col.search(
data=[q],
anns_field="embedding",
param=search_params,
limit=limit,
expr=expr,
output_fields=[],
)
dt = (time.perf_counter() - t0) * 1000
latencies.append(dt)
hits = res[0]
ids = [h.id for h in hits]
all_ids.append(ids)
return np.array(latencies), all_ids
def recall_proxy(candidate_ids, reference_ids):
# top-k 교집합 비율 평균
scores = []
for cand, ref in zip(candidate_ids, reference_ids):
cand_set = set(cand)
ref_set = set(ref)
if len(ref_set) == 0:
scores.append(1.0)
else:
scores.append(len(cand_set & ref_set) / len(ref_set))
return float(np.mean(scores))
# reference는 더 높은 품질 파라미터로 만든 결과(예: IVF nprobe 64, HNSW ef 256)
이렇게 기준(reference)과 후보(candidate)를 만들어 p95와 recall proxy를 같이 보면, “지연 50%↓”를 만들면서도 검색 품질을 방어할 수 있습니다.
9) 마무리: 튜닝은 파라미터가 아니라 프로세스다
Milvus에서 IVF/HNSW 튜닝으로 RAG 검색지연을 절반 수준으로 줄이려면, 핵심은 다음입니다.
- IVF는
nlist(구조)와nprobe(탐색폭)를 분리해서 접근 - HNSW는
M/efConstruction(구조)과ef(탐색폭)를 분리해서 접근 p50이 아니라 **동시성에서p95**를 기준으로 판단- recall proxy를 반드시 같이 두고, 허용 하한을 정해 최적점을 탐색
인덱스 튜닝은 “한 번 하고 끝”이 아니라 데이터 분포, chunk 전략, 필터 패턴이 바뀌면 다시 최적점이 이동합니다. 특히 RAG는 제품이 성장하면서 테넌트/문서 타입이 늘고, 필터가 복잡해지는 경향이 있으니, 실험 셋과 계측 파이프라인을 만들어 주기적으로 점검하는 것을 권합니다.