- Published on
Qdrant RAG 성능 2배 - HNSW 튜닝 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 “체감 성능”을 결정하는 구간은 대개 벡터 검색(ANN)입니다. LLM 호출 시간은 캐시·스트리밍·모델 선택으로 어느 정도 상쇄할 수 있지만, 검색이 느리면 전체 파이프라인이 매번 느려집니다. Qdrant는 HNSW 기반 인덱스를 제공하고, 이 HNSW 파라미터 몇 개만 제대로 맞춰도 p95 지연이 크게 줄거나 같은 지연에서 recall이 눈에 띄게 올라갑니다.
이 글은 “RAG 성능 2배”를 목표로, Qdrant HNSW 튜닝을 체크리스트로 정리합니다. 단, 2배는 데이터 분포·필터 사용 여부·하드웨어에 따라 달라지는 상대값이므로, 반드시 벤치마크 루프를 함께 돌려야 합니다.
참고로 HNSW 개념과 튜닝 방향은 Milvus 사례와도 유사합니다. 비교 관점이 필요하면 Milvus IVF_FLAT/HNSW 인덱스 튜닝 - recall↑ 비용↓도 같이 보면 파라미터 감이 더 빨리 잡힙니다.
먼저 정리: RAG 성능을 갉아먹는 3가지 병목
1) HNSW 탐색 비용(ef_search) 과다
ef_search가 필요 이상으로 크면 recall은 약간 좋아지지만, CPU 사용량과 지연이 비선형으로 증가합니다. 특히 동시 요청이 많을 때 큐잉이 생기며 p95/p99가 급격히 악화됩니다.
2) 인덱스 품질 부족(m, ef_construct)으로 인한 재시도/후처리 비용
인덱스가 “거칠면” 같은 recall을 얻기 위해 ef_search를 더 키워야 하고, 결국 지연이 늘어납니다. 즉, 빌드 단계에서 조금 더 투자(ef_construct)하면 런타임 비용을 줄일 수 있습니다.
3) 필터(페이로드 조건)로 인한 후보군 축소/확장 문제
필터가 걸린 벡터 검색은 “필터 후 ANN” 또는 “ANN 후 필터”의 형태로 구현되며, 데이터 분포에 따라 효율이 크게 달라집니다. 필터가 강하면 후보가 부족해 탐색이 길어지거나, 반대로 필터가 약하면 불필요 후보가 늘어납니다.
Qdrant HNSW 핵심 파라미터 4종 세트
Qdrant에서 HNSW 튜닝은 사실상 아래 4개 조합입니다.
m: 각 노드가 가지는 링크(이웃) 수. 클수록 그래프가 촘촘해져 recall이 좋아지지만 메모리와 빌드/검색 비용이 증가합니다.ef_construct: 인덱스 구축 시 탐색 폭. 클수록 인덱스 품질이 좋아져 런타임ef_search를 줄일 여지가 생깁니다.ef_search: 검색 시 탐색 폭. 클수록 recall이 좋아지지만 지연이 증가합니다.full_scan_threshold: 인덱스 대신 스캔으로 전환되는 임계값(컬렉션/세그먼트 크기 등에 따라 동작 체감이 달라질 수 있음). 작은 컬렉션에서 인덱스 오버헤드를 피하는 데 유용합니다.
여기서 “성능 2배”가 나오는 대표 패턴은 다음입니다.
ef_construct를 올려 인덱스 품질을 끌어올림- 그 결과 동일 recall에서
ef_search를 낮춤 - 동시성 구간에서 CPU/큐잉이 줄어 p95가 크게 개선
체크리스트 0: 벤치마크 루프부터 만든다
튜닝은 감으로 하면 끝이 없습니다. 최소한 아래를 자동화하세요.
- 고정된 쿼리 세트(예: 200~2000개)
- 정답(ground truth) 또는 근사 정답(예: brute force top-k를 샘플링으로 생성)
- 지표: recall@k, mrr, p50/p95 latency, QPS, CPU, RSS
간단한 Python 측정 예시입니다. Qdrant Python 클라이언트 버전에 따라 import 경로가 다를 수 있으니, 아래는 “형태”에 집중하세요.
import time
import statistics
from qdrant_client import QdrantClient
client = QdrantClient(url="http://localhost:6333")
COL = "docs"
def run(queries, top_k=10, ef_search=128):
lat = []
for v in queries:
t0 = time.perf_counter()
client.search(
collection_name=COL,
query_vector=v,
limit=top_k,
search_params={"hnsw_ef": ef_search},
)
lat.append((time.perf_counter() - t0) * 1000)
return {
"p50": statistics.median(lat),
"p95": sorted(lat)[int(len(lat) * 0.95)],
"avg": sum(lat) / len(lat),
}
# queries = [...] # 미리 준비한 임베딩 벡터 리스트
# print(run(queries, ef_search=64))
# print(run(queries, ef_search=128))
중요한 점은 ef_search만 바꿔도 지연이 크게 흔들리므로, “인덱스 파라미터 변경”과 “검색 파라미터 변경”을 분리해서 실험하는 것입니다.
체크리스트 1: 목표를 먼저 숫자로 고정한다
다음 중 하나로 목표를 고정하면 튜닝이 빨라집니다.
- 목표 A: recall@10
0.92이상에서 p95 최소화 - 목표 B: p95
50ms이하에서 recall@10 최대화
RAG의 품질은 검색 recall만으로 결정되진 않지만, 검색이 흔들리면 생성 품질도 흔들립니다. 특히 top-k가 작을수록(예: k=5) recall 변화가 답변 품질로 바로 번집니다.
체크리스트 2: m은 “메모리 예산”으로 결정한다
m을 올리면 대체로 recall이 좋아지고, 같은 recall을 위해 필요한 ef_search를 낮출 수 있습니다. 대신 메모리 사용량이 증가합니다.
권장 접근:
- 현재 컬렉션의 벡터 개수
N, 벡터 차원D, 동시성, 메모리 예산을 확인 m후보를 16, 32(필요 시 48)로만 좁혀서 실험
실무에서 자주 쓰는 출발점:
- 범용:
m=16 - recall이 특히 중요하거나 필터가 복잡:
m=32
m을 올렸는데도 recall이 크게 안 오르면, 데이터 분포(중복/클러스터링)나 임베딩 품질 문제가 더 클 가능성이 큽니다.
체크리스트 3: ef_construct는 “빌드 시간 vs 런타임 비용” 교환
ef_construct를 올리면 인덱스 품질이 좋아져 런타임 ef_search를 낮추는 방향으로 최적화할 수 있습니다. 운영 환경에서 검색이 수시로 발생한다면, 빌드 단계에 투자하는 편이 총비용이 낮아지는 경우가 많습니다.
추천 범위:
- 빠른 구축:
ef_construct=64 - 균형:
ef_construct=128 - 고품질:
ef_construct=256또는512
전략:
- 먼저
ef_construct=128로 고정하고ef_search를 스윕 - recall이 목표에 못 미치면
ef_construct를 올리고 다시ef_search를 낮추는 방향으로 재탐색
Qdrant 컬렉션 생성 시 HNSW 설정 예시(REST)입니다.
curl -X PUT "http://localhost:6333/collections/docs" \
-H "Content-Type: application/json" \
-d '{
"vectors": {"size": 768, "distance": "Cosine"},
"hnsw_config": {
"m": 16,
"ef_construct": 128,
"full_scan_threshold": 10000
}
}'
주의: 이미 생성된 컬렉션의 인덱스 파라미터를 바꾸면 리빌드가 필요할 수 있습니다. 운영에서는 “새 컬렉션 생성 → 백필 → 스위치” 전략이 안전합니다.
체크리스트 4: ef_search는 “동시성에서 무너지는 지점”을 찾는다
ef_search는 검색 품질과 지연을 가장 직접적으로 바꿉니다. 하지만 단일 요청 기준 평균 지연만 보고 올리면, 동시성에서 CPU가 포화되어 p95가 폭발할 수 있습니다.
권장 절차:
ef_search를 32, 64, 96, 128, 192, 256 정도로 스윕- 단일 스레드 지연이 아니라, 목표 동시성(예: 16, 32, 64)에서 p95를 측정
- 목표 recall을 만족하는 최소
ef_search를 선택
Qdrant 검색 요청에서 ef_search를 지정하는 예시(REST)입니다.
curl -X POST "http://localhost:6333/collections/docs/points/search" \
-H "Content-Type: application/json" \
-d '{
"vector": [0.01, 0.02, 0.03],
"limit": 10,
"params": {"hnsw_ef": 96}
}'
운영 팁:
- 피크 시간대에만
hnsw_ef를 낮추는 “동적 품질 조절”도 가능합니다. - RAG에서 1차 검색은
ef_search를 낮추고, 재랭킹 단계(크로스 인코더 등)에서 품질을 보정하는 설계가 종종 더 싸게 먹힙니다.
체크리스트 5: 필터가 있다면 “필터 선택도”를 먼저 측정한다
RAG에서는 보통 다음과 같은 필터가 붙습니다.
tenant_id멀티테넌시- 문서 타입, 날짜 범위
- 권한/ACL
필터가 검색 성능을 망치는 흔한 패턴:
- 선택도가 너무 높음(너무 적게 남음): 후보가 부족해 탐색이 길어지고, 결과가 불안정
- 선택도가 너무 낮음(거의 안 거름): 필터 비용만 추가되고 후보는 그대로라 지연만 증가
대응:
- 테넌트별로 컬렉션 분리 또는 샤딩 키 설계 고려
- 날짜처럼 “시간 축” 필터가 많으면, 최신 데이터 전용 컬렉션을 따로 두는 핫/콜드 분리도 효과적
필터 검색 예시입니다.
curl -X POST "http://localhost:6333/collections/docs/points/search" \
-H "Content-Type: application/json" \
-d '{
"vector": [0.01, 0.02, 0.03],
"limit": 10,
"filter": {
"must": [
{"key": "tenant_id", "match": {"value": "t-001"}},
{"key": "doc_type", "match": {"value": "policy"}}
]
},
"params": {"hnsw_ef": 96}
}'
필터가 강한 워크로드에서 성능이 안 나오면, HNSW 파라미터보다 데이터 모델(분리/샤딩/인덱싱)이 먼저인 경우가 많습니다.
체크리스트 6: 세그먼트/샤딩과 병렬성을 확인한다
Qdrant는 내부적으로 세그먼트 단위로 데이터가 관리되고, 샤딩/레플리카 구성에 따라 병렬성이 달라집니다. 다음을 점검하세요.
- 단일 노드에서 CPU 코어를 충분히 쓰는지(검색 스레드가 병목인지)
- 샤드 수가 과도하게 많아 오버헤드가 커지지 않았는지
- 레플리카가 단순 가용성 용도인지, 읽기 확장에도 쓰는지
실무적으로는 “읽기 트래픽이 많으면 레플리카로 수평 확장”이 가장 단순하고 효과가 큽니다. HNSW를 극단으로 튜닝하기 전에, 병렬 처리 구조가 먼저 받쳐줘야 합니다.
체크리스트 7: 메모리 모드(on-disk)와 OS 캐시를 구분한다
Qdrant는 설정에 따라 벡터/인덱스를 디스크 기반으로 둘 수 있습니다. 디스크 기반은 메모리를 절약하지만, 랜덤 접근이 많으면 지연이 튀기 쉽습니다.
점검 포인트:
- p95가 주기적으로 튀면(스파이크) 디스크 페이지 폴트/캐시 미스 가능성
- “메모리 충분”한데도 on-disk를 쓰고 있지 않은지
- 컨테이너 환경에서 페이지 캐시가 제한되어 있지 않은지
RAG는 보통 “짧은 지연”이 중요하므로, 가능하면 인덱스/핫 벡터는 메모리에 두는 쪽이 유리합니다.
체크리스트 8: top-k와 후보 확장을 분리한다
많은 팀이 limit=20 같은 top-k를 크게 잡고 “그중에서 LLM이 알아서 고르겠지”로 가는데, 이는 검색 비용과 컨텍스트 비용을 동시에 올립니다.
권장 패턴:
- ANN top-k는 작게(예:
k=10) - 필요하면 재랭킹에서만 후보 확장(예: ANN
k=10+ BM25k=10하이브리드) - 컨텍스트 윈도우에는 최종
k=4~8정도만 넣기
이렇게 하면 ef_search를 무리하게 올리지 않고도 최종 품질을 유지하기 쉽습니다.
체크리스트 9: 튜닝 결과를 “운영 가드레일”로 고정한다
튜닝이 끝나면 다음을 운영 가드레일로 박아두세요.
- 피크 시간대
hnsw_ef상한 - 테넌트/필터 조합별 최악 지연 알림
- 데이터 증가에 따른 재튜닝 트리거(예: 벡터 수가 2배가 되면 재측정)
관측(Observability)이 약하면 튜닝이 금방 퇴행합니다. 특히 RAG는 데이터가 계속 추가되므로 “한 번 튜닝하고 끝”이 아닙니다.
운영 관점에서 Next.js 기반 RAG UI/서버를 함께 운영한다면, 검색 지연이 SEO/렌더링에 간접 영향을 주는 경우도 있습니다. 프론트/서버 렌더링 이슈까지 같이 보는 관점은 Next.js 14 RSC로 SEO 깨짐? 원인·해결 7가지도 참고할 만합니다.
추천 튜닝 레시피(빠르게 시작하기)
아래는 “대부분의 RAG”에서 출발점으로 괜찮은 조합입니다.
레시피 A: 균형형(대부분의 프로덕션)
m=16ef_construct=128- 런타임
ef_search=64~128스윕 후 최소값 채택
레시피 B: recall 우선(재랭킹이 없거나, 정답 민감)
m=32ef_construct=256- 런타임
ef_search=128부터 시작해 목표 recall까지 올림
레시피 C: 비용/지연 우선(트래픽이 많고 재랭킹 있음)
m=16ef_construct=128- 런타임
ef_search=48~96범위에서 p95를 우선 맞춤
마무리: “인덱스 품질로 ef_search를 낮추는” 방향이 2배를 만든다
Qdrant RAG 성능을 크게 올리는 가장 흔한 성공 패턴은 다음 한 줄로 요약됩니다.
ef_construct와m을 적절히 올려 인덱스 품질을 확보하고, 그만큼ef_search를 낮춰 동시성에서 p95를 안정화한다.
여기에 필터 선택도(데이터 모델)와 샤딩/레플리카(병렬성)까지 맞물리면, “검색만 2배 빨라지는” 케이스는 충분히 나옵니다. 다음 단계로는 하이브리드 검색(BM25+벡터), 재랭킹, 캐시까지 붙여서 RAG 전체 비용 곡선을 다듬어 보세요.