- Published on
Qdrant HNSW 튜닝으로 RAG 검색지연 50% 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG 파이프라인에서 체감 지연의 대부분은 retrieval 단계(벡터 검색 + 필터링 + payload 로딩)에서 발생합니다. 특히 Qdrant는 기본 설정만으로도 충분히 빠르지만, 데이터 규모가 커지고(수십만~수천만 벡터), 필터가 붙고(tenant, 문서 상태, 권한), 동시 요청이 늘면 HNSW 그래프 탐색 비용과 payload 로딩 비용이 합쳐져 p95가 급격히 튀기 쉽습니다.
이 글에서는 Qdrant HNSW 튜닝과 인덱싱/페이로드 설계를 통해 RAG 검색 지연을 약 50%까지 낮추는 접근을 단계별로 정리합니다. 목표는 단순히 평균을 낮추는 게 아니라, 운영에서 중요한 p95/p99를 안정화하는 것입니다.
관련해서 HNSW 튜닝의 큰 그림이 필요하면, pgvector 기준으로 정리한 글도 함께 참고하면 좋습니다: pgvector HNSW 튜닝으로 RAG 검색속도 3배
1) 지연을 쪼개서 보지 않으면 튜닝이 실패한다
Qdrant 검색 지연은 대략 다음 항으로 분해됩니다.
- HNSW 탐색 비용:
ef(query-time)와 그래프 품질(m,ef_construct)에 좌우 - 필터 비용: payload index 유무, 조건의 선택도(selectivity), 필터 조합 방식
- payload 로딩 비용: 디스크/메모리, 반환 필드 수, 스토리지 옵션
- 병렬성/리소스 경합:
max_search_threads, CPU 코어, NUMA, 컨테이너 제한
따라서 “HNSW만 만졌는데 빨라지지 않는다”는 경우는 대개 필터/페이로드가 병목입니다. 반대로 “ef를 낮추니 빨라졌는데 답이 틀린다(리콜 저하)”는 경우는 그래프 품질과 ef 균형 문제입니다.
측정 기준을 먼저 고정하기
튜닝 전후 비교는 아래를 고정해야 의미가 있습니다.
- TopK: 예)
limit=10 - 필터: 예)
tenant_id,doc_status,visibility - 리콜 기준: 예) brute-force 대비
recall@10 - 지연 지표:
p50/p95/p99 - 동시성: 예)
concurrency=32
2) Qdrant HNSW 핵심 파라미터: m, ef_construct, ef
Qdrant에서 HNSW는 크게 두 단계에서 비용이 발생합니다.
- 인덱스 빌드(삽입) 비용:
m,ef_construct가 큼직하게 영향 - 검색 비용: query-time
ef(Qdrant에서는hnsw_ef)가 지배
m: 그래프의 연결도(메모리와 품질의 트레이드오프)
m을 올리면 그래프가 촘촘해져 리콜이 좋아지고, 같은 리콜을 얻기 위한ef를 낮출 수 있어 검색이 빨라질 수도 있습니다.- 대신 메모리 사용량이 늘고, 빌드/업서트 비용도 증가합니다.
실무에서 자주 쓰는 범위는 대략 m=16(기본)에서 시작해 24 또는 32까지 실험합니다.
ef_construct: 인덱스 생성 시 탐색 폭
ef_construct가 높을수록 더 좋은 이웃을 찾으며 그래프 품질이 좋아집니다.- 빌드가 느려지고 메모리가 더 필요합니다.
운영 팁:
- 대량 적재(초기 인덱싱) 시에는
ef_construct를 높여 품질을 확보하고, - 이후 증분 업서트가 잦다면, 업데이트 구간에서만 낮추는 전략도 고려합니다(단, 컬렉션 설정 변경/재인덱싱 정책을 함께 설계해야 함).
ef(검색 시): 지연과 리콜을 가장 직접적으로 바꾸는 손잡이
ef를 낮추면 빨라지고, 너무 낮추면 리콜이 떨어집니다.m과ef_construct가 충분히 높다면, 같은 리콜을 더 낮은ef로 달성할 수 있습니다.
즉, “인덱스 품질을 올려서 ef를 낮추는” 방식이 p95 지연 최적화에 특히 유리합니다.
3) 컬렉션 생성 시 HNSW 설정 예시
아래는 Qdrant 컬렉션 생성/설정 예시입니다. (버전에 따라 필드명이 다를 수 있으니, 실제 운영에서는 사용하는 Qdrant 버전의 OpenAPI 스펙을 확인하세요.)
curl -X PUT "http://localhost:6333/collections/rag_chunks" \
-H "Content-Type: application/json" \
-d '{
"vectors": {
"size": 768,
"distance": "Cosine"
},
"hnsw_config": {
"m": 24,
"ef_construct": 256,
"full_scan_threshold": 20000
},
"optimizers_config": {
"default_segment_number": 2,
"memmap_threshold": 200000,
"indexing_threshold": 20000
}
}'
포인트:
m=24,ef_construct=256은 “리콜을 유지하면서 query-timeef를 낮추기”에 유리한 출발점입니다.full_scan_threshold는 데이터가 작을 때는 HNSW 대신 풀스캔이 유리할 수 있어, 워크로드에 맞춰 조정합니다.optimizers_config는 세그먼트 병합/인덱싱 타이밍에 영향을 주어, 지연의 꼬리(p95/p99)를 흔드는 요인이 됩니다.
4) 검색 시 hnsw_ef를 동적으로 조절하는 전략
RAG는 요청마다 난이도가 다릅니다.
- 필터가 강하게 걸리면 후보가 줄어들어 더 큰
ef가 필요할 수 있고 - 필터가 약하거나 없는 경우 낮은
ef로도 충분한 리콜이 나옵니다.
따라서 hnsw_ef를 고정값으로 두기보다, 요청 유형/필터 선택도에 따라 동적으로 조절하면 지연을 크게 줄일 수 있습니다.
from qdrant_client import QdrantClient
from qdrant_client.http.models import Filter, FieldCondition, MatchValue, SearchParams
client = QdrantClient(url="http://localhost:6333")
def search_rag(query_vec, tenant_id: str, limit: int = 10):
flt = Filter(
must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
FieldCondition(key="doc_status", match=MatchValue(value="active")),
]
)
# 필터가 강한 경우(tenant + status 등)에는 리콜 저하를 막기 위해 ef를 약간 올림
# 필터가 약하면 ef를 낮춰 지연을 줄임
hnsw_ef = 96
return client.search(
collection_name="rag_chunks",
query_vector=query_vec,
query_filter=flt,
limit=limit,
search_params=SearchParams(hnsw_ef=hnsw_ef, exact=False),
with_payload=["doc_id", "chunk_id", "text"],
)
실전 튜닝 순서:
m,ef_construct로 그래프 품질을 먼저 확보- 그 다음
hnsw_ef를 단계적으로 낮추며recall@k와p95를 같이 관찰 - 요청 유형별로
hnsw_ef를 다르게 주는 정책을 확정
5) “HNSW 튜닝했는데도 느리다”의 1순위: payload/필터
RAG 검색은 보통 벡터 유사도 상위 K개를 찾은 뒤, 그 결과의 payload(텍스트 chunk)를 읽어 LLM에 넣습니다. 여기서 흔한 실수는:
- payload에 큰 필드(원문 전체, 메타데이터 덩어리)를 같이 저장하고 매번 반환
- 필터에 쓰는 필드에 payload index를 안 만들어서 매번 스캔
payload는 “검색용”과 “반환용”을 분리한다
- 검색 단계에서는 최소 필드만 반환:
doc_id,chunk_id,score정도 - 실제 텍스트는 후속 단계에서 필요한 것만 로드하거나, 별도 스토리지(S3/DB)에서 가져오는 구조를 고려
Qdrant에서는 with_payload를 최소화하는 것만으로도 지연이 확 줄어드는 경우가 많습니다.
# 1차 검색: payload 최소
hits = client.search(
collection_name="rag_chunks",
query_vector=query_vec,
limit=20,
with_payload=["doc_id", "chunk_id"],
)
# 2차 로딩: 필요한 chunk_id만 모아서 별도 저장소에서 텍스트 로딩(예: Postgres, S3)
chunk_ids = [h.payload["chunk_id"] for h in hits]
필터 필드에 payload index를 만든다
tenant, 상태, 권한 같은 필드는 거의 항상 필터에 들어갑니다. 이 필드들에 인덱스를 걸어야 필터 비용이 줄고, HNSW 탐색 과정에서도 불필요한 후보를 덜 보게 됩니다.
# tenant_id에 keyword 인덱스 생성
curl -X PUT "http://localhost:6333/collections/rag_chunks/index" \
-H "Content-Type: application/json" \
-d '{
"field_name": "tenant_id",
"field_schema": "keyword"
}'
# doc_status도 keyword 인덱스
curl -X PUT "http://localhost:6333/collections/rag_chunks/index" \
-H "Content-Type: application/json" \
-d '{
"field_name": "doc_status",
"field_schema": "keyword"
}'
운영에서 자주 보는 패턴:
- 인덱스 없이
must조건이 2~3개만 붙어도p95가 급등 - HNSW
ef를 아무리 줄여도 필터에서 시간을 다 써서 체감 개선이 없음
6) 세그먼트/옵티마이저가 p95를 흔든다
Qdrant는 내부적으로 세그먼트를 병합/최적화합니다. 이 작업이 트래픽 피크와 겹치면 tail latency가 튀기 쉽습니다.
점검 포인트:
- 인덱싱/병합이 너무 자주 일어나지 않는지
- 업서트가 폭주하는 시간대에 최적화가 겹치지 않는지
- 메모리 임계치(
memmap_threshold)가 워크로드에 맞는지
실무 팁:
- 초기 대량 적재 이후에는 최적화가 안정화될 때까지 워밍업 시간을 둡니다.
- 트래픽이 높은 서비스라면, ingestion과 serving을 분리(예: 컬렉션 스왑, 듀얼 컬렉션)하는 전략이 효과적입니다.
7) 튜닝으로 “50% 지연 감소”를 만드는 전형적인 조합
현장에서 자주 재현되는 조합은 다음과 같습니다.
m상향:16에서24또는32ef_construct상향:128에서256또는512- query-time
hnsw_ef하향: 예)128에서64또는80 - 필터 필드 payload index 추가:
tenant_id,doc_status,visibility with_payload최소화: 텍스트 전체를 매번 반환하지 않기
이렇게 하면 리콜은 유지하면서도, 탐색 폭(ef)을 줄이고, 필터/페이로드 비용을 줄여 총 지연이 절반 가까이 감소하는 케이스가 많습니다.
중요한 점은 “ef만 낮추는” 단일 접근이 아니라, 그래프 품질을 올려서 ef를 낮출 수 있게 만든 뒤, 필터/페이로드를 다이어트하는 순서로 가야 한다는 것입니다.
8) 재현 가능한 벤치마크 스크립트(간단 버전)
아래는 Python으로 p95를 비교하는 매우 단순한 형태의 벤치 스니펫입니다. 실제로는 동시성, 워밍업, 캐시 상태를 통제해야 합니다.
import time
import statistics
from concurrent.futures import ThreadPoolExecutor
def run_once(client, vec, tenant_id, ef):
t0 = time.perf_counter()
client.search(
collection_name="rag_chunks",
query_vector=vec,
limit=10,
search_params={"hnsw_ef": ef, "exact": False},
query_filter={
"must": [
{"key": "tenant_id", "match": {"value": tenant_id}},
{"key": "doc_status", "match": {"value": "active"}},
]
},
with_payload=["doc_id", "chunk_id"],
)
return (time.perf_counter() - t0) * 1000
def bench(client, vecs, tenant_id, ef, concurrency=16):
# warmup
for _ in range(50):
run_once(client, vecs[0], tenant_id, ef)
lat = []
with ThreadPoolExecutor(max_workers=concurrency) as ex:
futures = [ex.submit(run_once, client, v, tenant_id, ef) for v in vecs]
for f in futures:
lat.append(f.result())
lat.sort()
p50 = lat[int(len(lat)*0.50)]
p95 = lat[int(len(lat)*0.95)]
p99 = lat[int(len(lat)*0.99)]
return p50, p95, p99
# 사용 예:
# p50, p95, p99 = bench(client, query_vectors, "t1", ef=128)
# p50, p95, p99 = bench(client, query_vectors, "t1", ef=64)
여기서 ef=128과 ef=64의 p95 차이만 보지 말고, 반드시 리콜 비교도 같이 해야 합니다. 리콜 평가는 보통 아래 중 하나로 합니다.
- 샘플 쿼리에 대해
exact=True(브루트포스) 결과를 기준으로recall@k계산 - 오프라인에서 FAISS brute-force로 정답 셋을 만들어 비교
9) 운영 체크리스트
(1) 리콜과 지연의 합의점을 문서화
- “
recall@100.95 이상이면 OK” 같은 SLO를 먼저 합의 - 그 안에서
hnsw_ef최적값을 찾는 방식이 팀 커뮤니케이션 비용이 낮습니다.
(2) 트래픽 피크에서 tail이 튀면 리소스/스레드도 같이 본다
- CPU가 100%에 붙으면
ef를 낮춰도 개선 폭이 제한됩니다. - 컨테이너 환경이라면 CPU limit, NUMA, 디스크 IOPS도 같이 점검하세요.
Kubernetes 환경에서 성능 이슈를 보다가 파드 상태 자체가 흔들리는 경우도 종종 있어, 진단 루틴을 미리 갖춰두면 좋습니다: K8s CrashLoopBackOff - Readiness·Liveness 5분 진단
(3) RAG 전체 지연은 “검색 + LLM” 합산이다
검색 지연을 절반으로 줄였는데도 체감이 적다면, LLM 쪽이 병목일 수 있습니다. 특히 로컬 LLM을 쓸 때 OOM 회피 때문에 배치/컨텍스트를 보수적으로 잡으면 지연이 늘어납니다. 이 경우는 모델 로딩/양자화/캐시 전략도 같이 봐야 합니다: Transformers 로컬 LLM OOM 해결 - 4bit+KV캐시
10) 결론: HNSW는 “그래프 품질로 ef를 낮추는” 게임이다
Qdrant에서 RAG 검색 지연을 크게 줄이려면, 단순히 hnsw_ef를 깎는 방식으로는 한계가 있습니다. m과 ef_construct로 인덱스 품질을 올려 리콜을 확보한 뒤, query-time hnsw_ef를 낮춰 탐색 비용을 줄이고, 동시에 필터 인덱싱과 payload 최소화로 비(非)벡터 비용을 제거해야 p95가 안정적으로 내려갑니다.
정리하면 아래 순서가 가장 재현성이 높습니다.
- 필터 필드 payload index부터 추가
- payload 반환 최소화
m,ef_construct상향으로 그래프 품질 확보hnsw_ef하향으로 지연 최적화- 벤치마크를
p95와recall@k로 함께 검증
이 루틴대로 진행하면, 데이터/필터/동시성이 있는 실제 RAG 서비스에서도 “검색 지연 50% 감소”는 충분히 현실적인 목표입니다.