- Published on
RAG용 Qdrant HNSW 튜닝 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 품질이 애매하게 흔들리거나, 트래픽이 늘면서 p95 지연시간이 갑자기 튀는 RAG 시스템을 운영하다 보면 결국 벡터DB의 근사 최근접 탐색(ANN) 설정을 만지게 됩니다. Qdrant는 HNSW 기반 인덱스를 제공하고, m, ef_construct, ef_search 같은 파라미터로 정확도(Recall)·지연시간·메모리의 균형점을 조절할 수 있습니다.
이 글은 “이론 설명”보다 운영/실험 관점의 튜닝 루틴에 집중합니다. 즉, 어떤 증상에서 어떤 파라미터를 먼저 만져야 하는지, 바꾼 뒤 무엇을 측정해야 하는지, 그리고 RAG 파이프라인에서 자주 놓치는 함정(필터, 페이로드, 리랭커, top-k 설정)을 함께 다룹니다.
관련 운영 디버깅 관점은 아래 글도 함께 참고하면 좋습니다.
- 트래픽/인프라 진단 관점: EKS에서 503 Service Unavailable 원인 10분 진단
- 모델/툴콜 오류가 섞일 때: Assistants API+LangChain 툴콜 오류 디버깅 가이드
- 재시도/백오프 전략: Claude 429 과금폭탄 막는 재시도·백오프 전략
Qdrant HNSW 파라미터가 RAG에 미치는 영향
RAG에서 벡터 검색은 보통 다음 흐름으로 품질을 결정합니다.
- 임베딩 모델이 쿼리를 벡터화
- 벡터DB가 top-k 후보를 ANN으로 빠르게 찾음
- (선택) 메타데이터 필터를 적용
- (선택) 크로스 인코더/리랭커로 재정렬
- LLM이 컨텍스트를 읽고 답변
여기서 Qdrant HNSW 튜닝은 2~3을 직접 좌우합니다.
- Recall(검색 재현율): “정답 문서가 top-k 안에 들어오는 비율”
- Latency: p50/p95 지연시간
- Cost: CPU 사용량, 메모리, 스토리지, 리랭커 호출 비용
핵심 파라미터는 다음 3개입니다.
m: 그래프 연결 수(노드당 이웃 수). 커질수록 recall이 올라가지만 메모리와 빌드 비용이 증가합니다.ef_construct: 인덱스 빌드 시 탐색 폭. 커질수록 인덱스 품질이 좋아지나 빌드 시간이 늘어납니다.ef_search: 검색 시 탐색 폭. 커질수록 recall이 올라가지만 쿼리 지연시간이 증가합니다.
추가로 Qdrant에는 다음도 자주 체감됩니다.
on_disk: 메모리 대신 디스크를 활용해 메모리 압박을 줄이지만, 지연시간이 증가할 수 있습니다.quantization: 메모리/속도를 얻는 대신 정확도를 일부 희생할 수 있습니다(데이터/모델에 따라 다름).
튜닝 전 체크리스트: HNSW를 만지기 전에 확인할 것
HNSW 튜닝은 효과가 크지만, 그 전에 “잘못된 목표”를 잡으면 시간을 많이 씁니다.
1) top-k와 컨텍스트 길이가 먼저다
top_k를 너무 작게 잡으면 어떤 HNSW 설정으로도 recall이 잘 안 나옵니다.top_k를 너무 크게 잡으면 LLM 컨텍스트가 오염되어 답변 품질이 떨어질 수 있습니다.
권장 접근:
- 1차 검색
top_k는 20~100 사이에서 시작 - 리랭커가 있다면 리랭커 입력을 50
200 정도로 두고 최종 520개만 LLM에 넣기
2) 필터가 있는 검색이면 “필터 선택도”가 성능을 지배한다
Qdrant에서 메타데이터 필터(예: tenant_id, doc_type, lang)를 강하게 걸면 후보 풀이 줄어들어 recall이 오히려 좋아질 수도 있지만, 반대로 필터가 넓고 복잡하면 검색 비용이 증가할 수 있습니다.
튜닝 실험은 반드시 다음 두 케이스를 분리하세요.
- 필터 없는 순수 벡터 검색
- 운영과 동일한 필터를 포함한 검색
3) 임베딩 품질 문제를 HNSW로 덮으려 하지 말기
임베딩이 도메인과 안 맞으면 ef_search를 올려도 “가까운 쓰레기”를 더 잘 찾을 뿐입니다. 이 경우는 튜닝보다 임베딩 모델/전처리/청크 전략이 먼저입니다.
실전 튜닝 전략: 무엇을 먼저 만질까
운영에서 가장 흔한 두 증상은 아래입니다.
- 정확도(Recall)가 낮다: 정답 문서가 top-k에 잘 안 들어온다
- 지연시간이 높다: p95가 튄다, 트래픽 증가 시 급격히 느려진다
각 증상별로 “가장 안전한 순서”는 아래와 같습니다.
케이스 A: Recall이 낮을 때
ef_search를 올린다
- 장점: 인덱스 재빌드 없이 즉시 효과를 볼 수 있음
- 단점: 지연시간 증가
- 그래도 부족하면
m과ef_construct를 올려 인덱스를 재구성한다
m을 올리면 메모리 사용량이 눈에 띄게 증가할 수 있음ef_construct는 빌드 시간에 주로 영향
- 그 다음은
top_k와 리랭커 전략을 재검토
케이스 B: 지연시간이 높을 때
ef_search를 내린다
- 가장 즉각적인 레버
- 대신 recall 하락을 감수해야 하므로, 리랭커가 있는지/필터로 후보가 줄어드는지 같이 고려
on_disk설정, 샤딩/레플리카, CPU/메모리 병목을 확인
- 메모리 부족으로 스왑/캐시 미스가 나면 ANN이 급격히 느려집니다.
- 양자화(quantization)로 메모리/속도 최적화 검토
- 품질 손실이 허용 가능한지 반드시 오프라인 평가 필요
권장 초기값(출발점)과 변경 폭
데이터 규모와 차원(예: 768, 1024), 분포에 따라 다르지만, “실전에서 무난한 출발점”은 다음입니다.
m: 16ef_construct: 128ef_search: 64
변경은 한 번에 크게 하지 말고, 아래처럼 단계적으로 합니다.
ef_search: 32 → 64 → 128 → 256m: 8 → 16 → 24 → 32ef_construct: 64 → 128 → 256
주의: m을 32 이상으로 올리는 순간부터 메모리와 빌드 비용이 체감될 가능성이 큽니다. 특히 멀티테넌트/대규모 컬렉션이면 더 그렇습니다.
Qdrant 컬렉션 생성/인덱스 설정 예제
아래는 Qdrant에 컬렉션을 만들고 HNSW 파라미터를 지정하는 예시입니다(HTTP API). 본문에 부등호가 노출되면 MDX에서 오인될 수 있으니, 코드 블록 안에서만 사용합니다.
curl -X PUT "http://localhost:6333/collections/rag_docs" \
-H "Content-Type: application/json" \
-d '{
"vectors": {
"size": 768,
"distance": "Cosine"
},
"hnsw_config": {
"m": 16,
"ef_construct": 128,
"full_scan_threshold": 10000
},
"optimizers_config": {
"default_segment_number": 2
}
}'
검색 시 ef_search는 컬렉션 기본값으로도 줄 수 있고, 쿼리마다 오버라이드할 수도 있습니다.
curl -X POST "http://localhost:6333/collections/rag_docs/points/search" \
-H "Content-Type: application/json" \
-d '{
"vector": [0.01, 0.02, 0.03],
"limit": 20,
"params": {
"hnsw_ef": 128
},
"with_payload": true
}'
운영에서는 “쿼리 유형별로 hnsw_ef를 다르게” 주는 전략이 꽤 유효합니다.
- 짧고 애매한 질문(ambiguity 높음):
hnsw_ef상향 - 명확한 키워드/필터 강함:
hnsw_ef하향
오프라인 평가: Recall을 수치로 만들기
튜닝을 잘하려면 “좋아진 것 같은데?”를 버리고, 정답셋 기반의 recall 지표를 만들어야 합니다.
평가 데이터 준비
- 쿼리
Q개 - 각 쿼리에 대해 정답 문서 id 집합
GT(q) - 검색 결과 상위
k의 id 집합R_k(q)
대표 지표:
Recall@k = (1/Q) * sum( |GT(q) ∩ R_k(q)| / |GT(q)| )
정답이 1개인 경우는 단순히 “top-k에 들어왔는지”의 평균이 됩니다.
Python으로 실험 루프 만들기
아래 예시는 qdrant-client로 ef_search를 바꿔가며 Recall@20과 평균 지연시간을 비교하는 최소 코드입니다.
import time
from statistics import mean
from qdrant_client import QdrantClient
COLLECTION = "rag_docs"
client = QdrantClient(url="http://localhost:6333")
# queries: [(query_vector, ground_truth_ids), ...]
# ground_truth_ids는 set[int] 또는 set[str]
def eval_setting(ef_search: int, k: int = 20):
recalls = []
latencies_ms = []
for vec, gt in queries:
t0 = time.perf_counter()
res = client.search(
collection_name=COLLECTION,
query_vector=vec,
limit=k,
search_params={"hnsw_ef": ef_search},
with_payload=False,
)
dt = (time.perf_counter() - t0) * 1000
latencies_ms.append(dt)
got = {p.id for p in res}
hit = len(got.intersection(gt))
recalls.append(hit / max(len(gt), 1))
return {
"ef_search": ef_search,
"recall@k": mean(recalls),
"avg_ms": mean(latencies_ms),
"p95_ms": sorted(latencies_ms)[int(len(latencies_ms) * 0.95) - 1],
}
for ef in [32, 64, 128, 256]:
print(eval_setting(ef_search=ef, k=20))
포인트:
- 평균 지연시간보다 p95를 반드시 봅니다.
- 필터가 있는 운영 쿼리는 별도로 측정합니다.
with_payload여부도 지연시간을 바꿉니다. RAG에서 payload(본문)를 같이 가져오면 네트워크/직렬화 비용이 커질 수 있으니, 가능하면 id만 가져오고 본문은 별도 스토리지에서 조회하는 구조도 고려하세요.
튜닝 레시피: m, ef_construct, ef_search를 함께 최적화하는 법
실전에서 가장 재현성 있는 접근은 아래 순서입니다.
1) ef_search로 “운영 가능한” 품질/지연의 경계 찾기
- 인덱스 재빌드 없이 즉시 조절 가능
- 목표:
Recall@k가 목표치에 도달하는 최소ef_search를 찾기
예:
- 목표
Recall@200.92 이상 - p95 150ms 이하
이때 ef_search를 올려도 recall이 일정 수준 이상 안 오르면, 인덱스 자체(m, ef_construct)나 임베딩/청킹 문제가 큽니다.
2) 인덱스를 재구성할 때는 m을 먼저, 그 다음 ef_construct
m은 그래프의 “표현력” 자체를 바꿉니다.ef_construct는 그 그래프를 “얼마나 정성껏” 만들지입니다.
권장:
- 먼저
m을 16에서 24로 올려보고, 메모리/빌드 시간을 확인 - 그 다음
ef_construct를 128에서 256으로 올려 품질을 끌어올림
3) ef_search를 다시 낮춰 지연시간을 회수
인덱스 품질이 좋아지면 같은 recall을 더 낮은 ef_search로 달성할 가능성이 있습니다. 이게 튜닝의 “수익 구간”입니다.
필터와 페이로드가 섞일 때의 함정
필터는 반드시 인덱싱 전략과 함께 봐야 한다
Qdrant는 payload 인덱스를 통해 필터 성능을 개선할 수 있습니다. 예를 들어 tenant_id, doc_type처럼 자주 쓰는 필터 키는 payload 인덱스를 고려하세요.
curl -X PUT "http://localhost:6333/collections/rag_docs/index" \
-H "Content-Type: application/json" \
-d '{
"field_name": "tenant_id",
"field_schema": "keyword"
}'
필터가 느린데 HNSW만 만지면 “벡터 탐색은 빨라졌는데 전체 쿼리는 그대로” 같은 상황이 나옵니다.
with_payload는 비용이 크다
RAG에서 흔히 문서 본문을 payload에 넣고 그대로 반환받는데, 이 방식은 다음 비용을 동시에 유발합니다.
- 네트워크 전송량 증가
- JSON 직렬화/역직렬화 증가
- Qdrant 메모리 압박 증가
대안:
- Qdrant에는
doc_id,chunk_id,offset같은 최소 메타만 저장 - 실제 본문은 오브젝트 스토리지나 DB에서 조회
운영 팁: 튜닝 결과를 망치는 3가지
1) 재시도 폭주로 p95가 무너진다
검색이 느려졌을 때 애플리케이션 레벨에서 무작정 재시도를 걸면, Qdrant가 더 밀리면서 p95가 폭발합니다. 재시도는 반드시 지터(jitter) 포함 백오프와 상한을 두세요. 비용 이슈까지 같이 보면 Claude 429 과금폭탄 막는 재시도·백오프 전략의 패턴이 그대로 적용됩니다.
2) 인프라 레벨에서 이미 병목인데 HNSW만 의심한다
CPU throttling, 노드 메모리 압박, 네트워크 문제, 오토스케일 지연 같은 이슈는 HNSW 튜닝으로 해결되지 않습니다. 특히 쿠버네티스 환경에서 5xx가 섞이면 애플리케이션은 “검색 품질 저하”로 오인할 수 있습니다. 이런 경우는 EKS에서 503 Service Unavailable 원인 10분 진단처럼 인프라부터 빠르게 배제하세요.
3) 리랭커가 있는데도 1차 검색을 과하게 정확하게 만들려고 한다
리랭커(크로스 인코더)가 있다면, 1차 검색은 “정답을 후보에 포함시키는 것”이 목표입니다. 즉, Recall@k만 확보하면 되고 1차의 정밀도는 리랭커가 담당합니다.
이때 ef_search를 과하게 올리면:
- Qdrant 비용 증가
- 전체 지연시간 증가
- 결국 리랭커가 처리할 후보 수가 비슷하면 체감 품질은 거의 동일
따라서 리랭커가 있다면 ef_search는 “최소 recall 달성 지점”에서 멈추는 편이 좋습니다.
추천 실험 플랜(재현 가능한 형태)
아래는 팀 단위로 합의하기 좋은 튜닝 플랜입니다.
- 고정: 임베딩 모델, 청크 전략,
top_k, 필터 조건 - 베이스라인:
m=16,ef_construct=128,ef_search=64 - 스윕 1:
ef_search만 바꿔Recall@kvs p95 곡선 확보 - 스윕 2:
m을 8/16/24/32로 바꾸고(재인덱싱), 각m에서 스윕 1 반복 - 스윕 3: 선택된
m에서ef_construct를 64/128/256으로 바꿔 재인덱싱 후 반복 - 결정: 목표 recall과 p95를 만족하는 최소 비용 조합 채택
산출물로 남겨야 할 것:
- 파라미터별
Recall@k, p50/p95, CPU/메모리 사용량 - 필터 유무 케이스별 결과
- 운영 트래픽에서의 에러율/타임아웃율
마무리: HNSW 튜닝의 본질은 “곡선 찾기”
Qdrant HNSW 튜닝은 정답이 있는 암기 문제가 아니라, 내 데이터/내 쿼리/내 필터/내 인프라에서 Recall-지연시간-비용 곡선을 그려서 합리적인 지점을 고르는 작업입니다.
- 빠르게 효과를 보려면
ef_search부터 - 구조적으로 품질을 올리려면
m과ef_construct - 필터/페이로드/리랭커까지 포함해 end-to-end로 측정
이 루틴만 지켜도 “그때그때 감으로” 파라미터를 바꾸다 망하는 확률이 크게 줄어듭니다.