- Published on
RAG 검색품질 폭망? Qdrant HNSW 튜닝 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 “답이 이상해졌다”는 증상은 대개 LLM 문제가 아니라 검색 단계(리트리벌) 에서 시작됩니다. 특히 Qdrant를 쓰면서 HNSW 설정을 기본값으로 두거나, 데이터 특성에 맞지 않게 바꾸면 Recall이 급락하거나, 반대로 Latency가 폭증하면서 운영이 망가집니다.
이 글은 “검색품질 폭망” 상황에서 빠르게 원인 범위를 좁히고, Qdrant의 HNSW를 재현 가능하게 튜닝하는 체크리스트를 제공합니다.
성능 디버깅 글을 자주 쓰는 이유는, 결국 문제는 “추측”이 아니라 “관측”으로 푸는 게 제일 빠르기 때문입니다. 캐시/병목을 추적하는 접근은 RAG에도 그대로 적용됩니다. 예: Next.js 14 캐시 때문에 ISR 갱신 안 될 때 디버깅
1) 먼저 확인: 이게 진짜 HNSW 문제인가
HNSW 튜닝에 들어가기 전에, 아래 4가지를 먼저 고정하세요. 여기서 흔히 “검색이 망가졌다”고 착각합니다.
1-1. 임베딩 모델/차원/정규화가 바뀌지 않았나
- 임베딩 모델이 바뀌면 벡터 공간 자체가 바뀝니다. 기존 인덱스에 새 벡터를 섞으면 검색이 급격히 나빠질 수 있습니다.
- 차원(
size) 불일치, 정규화 방식 변경(예: cosine인데 L2 정규화 유무)이 있으면 품질이 흔들립니다.
체크:
- 컬렉션 생성 시
vectors.size가 현재 임베딩 차원과 동일한지 - distance가 cosine인지 dot인지 euclid인지
1-2. distance 선택이 맞나
- cosine: 문장 임베딩에서 흔함(정규화 전제)
- dot: 모델이 dot 기반으로 학습된 경우 유리
- euclid: 특정 도메인에서 쓰지만 문장 임베딩엔 보통 덜 씀
distance를 바꿨다면, 기존 점수 분포가 완전히 달라집니다. 스코어 임계값(threshold)을 하드코딩했다면 바로 품질 폭망으로 이어집니다.
1-3. chunking/overlap 변경 여부
RAG 품질은 HNSW보다 청킹 정책 영향이 더 큰 경우가 많습니다.
- chunk가 너무 크면 관련 문장이 묻혀서 검색이 부정확
- chunk가 너무 작으면 문맥이 끊겨서 LLM이 답을 못 만듦
1-4. 필터 조건이 Recall을 죽이지 않나
Qdrant는 필터가 강력하지만, 다음 실수로 후보군이 0에 수렴합니다.
must에 너무 많은 조건- 날짜 범위, tenant, 권한 필터가 잘못 들어가서 검색 대상이 사라짐
이 단계가 끝났는데도 “검색은 되는데 관련 문서가 안 나온다”면, 이제 HNSW를 봅니다.
2) HNSW 이해: 품질과 비용을 가르는 3개 레버
Qdrant의 HNSW 튜닝은 크게 3가지가 핵심입니다.
m: 그래프 연결 수(인덱스 메모리와 품질에 영향)ef_construct: 인덱스 생성 시 탐색 폭(인덱싱 시간과 품질)ef(검색 파라미터): 검색 시 탐색 폭(Recall과 latency)
직관적으로:
m을 올리면 그래프가 촘촘해져 Recall이 올라가지만 메모리 증가ef_construct를 올리면 인덱스가 더 “잘” 만들어져 Recall이 올라가지만 인덱싱 느려짐ef를 올리면 검색이 더 깊게 탐색해 Recall이 올라가지만 검색 느려짐
운영에서 제일 흔한 실패는:
ef를 너무 낮게 둬서 Recall 급락m을 과하게 올려 메모리/캐시 미스가 늘어 latency 폭증
3) 튜닝 전 준비물: 측정 지표와 골든셋
HNSW는 “체감”으로 만지면 망합니다. 최소한 아래는 준비하세요.
3-1. 골든 쿼리셋 만들기
- 실제 사용자 질문 50~200개
- 각 질문에 대해 “정답 문서 ID” 또는 “정답이 포함된 문서 집합” 라벨링
라벨링이 어렵다면 차선책:
- 기존에 품질이 괜찮았던 시점의 top
k결과를 스냅샷으로 저장 - 변경 후 결과와 비교
3-2. 지표 정의
- Recall@
k: 정답 문서가 상위k에 들어오는 비율 - MRR@
k: 정답이 위로 올수록 점수 상승 - P95 latency: 운영 체감에 중요
3-3. 실험 설계 원칙
- 한 번에 하나만 바꾸기
- 동일한 데이터/동일한 필터/동일한 쿼리로 비교
- 인덱스 재생성 필요 여부를 구분(
m,ef_construct는 재인덱싱 영향)
4) Qdrant 설정 체크리스트(인덱스 생성)
4-1. 컬렉션 생성 예시
아래는 Qdrant 컬렉션 생성 시 HNSW 설정을 명시하는 예시입니다.
curl -X PUT "http://localhost:6333/collections/docs" \
-H "Content-Type: application/json" \
-d '{
"vectors": {
"size": 1536,
"distance": "Cosine"
},
"hnsw_config": {
"m": 16,
"ef_construct": 128,
"full_scan_threshold": 10000
},
"optimizers_config": {
"default_segment_number": 2
}
}'
포인트:
m=16,ef_construct=128은 무난한 출발점입니다.full_scan_threshold는 소규모 컬렉션에서 HNSW 대신 스캔을 선택할 수 있는 경계입니다. 너무 낮거나 높으면 예측 불가능한 성능이 나올 수 있어, 운영 정책에 맞게 고정하는 게 좋습니다.
4-2. m 체크
- 데이터가 커지고(수십만~수백만), 유사한 문서가 많아 “가까운 이웃” 구분이 어려워질수록
m을 올리는 게 도움이 될 수 있습니다. - 하지만
m은 메모리를 먹고, 캐시 효율을 떨어뜨릴 수 있습니다.
권장 접근:
m=16에서 시작- Recall이 계속 낮고
ef를 올려도 한계가 보이면m=24또는m=32를 A/B
4-3. ef_construct 체크
- 인덱싱 시간이 허용된다면
ef_construct를 올리는 게 대체로 안전한 품질 개선입니다. - 단, 인덱스 생성/업데이트 비용이 커집니다.
권장 접근:
ef_construct=128에서 시작- 품질이 민감하면
256까지 올려보고, 인덱싱 파이프라인 시간과 타협점 찾기
5) Qdrant 검색 파라미터 체크리스트(런타임)
5-1. 검색 시 ef를 명시하라
많은 팀이 검색 요청에서 ef를 명시하지 않고 기본값에 기대다가, 데이터가 커진 뒤 Recall이 무너집니다.
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,
"with_payload": true,
"params": {
"hnsw_ef": 128,
"exact": false
}
}'
튜닝 순서(현실적으로 제일 효과적):
hnsw_ef부터 올려서 Recall@k가 어디까지 회복되는지 확인- latency가 감당 안 되면 그때
m이나 샤딩/하드웨어를 검토
대략적인 감:
hnsw_ef는 보통limit보다 훨씬 크게 둡니다. 예를 들어limit=10이라면ef=64,128,256을 단계적으로 테스트합니다.
5-2. exact=true로 “정답선” 확인
HNSW 튜닝은 항상 “이론적 상한”을 알아야 합니다. Qdrant는 exact 검색(브루트포스에 가까운 방식)을 지원합니다.
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": {
"exact": true
}
}'
- exact에서도 결과가 별로면: HNSW 문제가 아니라 임베딩/청킹/데이터 문제일 확률이 큽니다.
- exact는 좋은데 HNSW만 나쁘면:
ef,m,ef_construct쪽 문제일 확률이 큽니다.
5-3. 필터와 HNSW의 조합
필터가 강할수록 후보군이 줄어들고, HNSW 탐색이 불리해질 수 있습니다.
- 필터로 남는 문서 수가 매우 적으면: HNSW 이점이 줄고 오히려 오버헤드가 될 수 있음
- 필터가 중간 정도면:
ef를 올려야 Recall이 유지되는 경우가 많음
운영 팁:
- “필터 결과 문서 수”를 메트릭으로 남기고, 구간별로
ef를 다르게 주는 전략도 가능합니다.
6) 세그먼트/인덱싱 운영에서 터지는 함정
HNSW를 잘 튜닝해도 운영에서 다음 이슈로 품질과 성능이 흔들립니다.
6-1. 인덱스가 아직 덜 만들어진 상태에서 검색
대량 업서트 직후:
- 세그먼트가 쪼개져 있고
- 최적화(머지)가 덜 되어
- 캐시가 차지 않아
일시적으로 latency가 튀거나 결과가 불안정해 보일 수 있습니다.
대응:
- 배치 적재 후 최적화가 완료된 시점에 트래픽을 붙이기
- 적재 파이프라인과 서빙을 분리(블루/그린 컬렉션 교체)
6-2. 메모리 부족으로 인한 스로틀링
m을 올리면 메모리 사용량이 증가합니다. 메모리가 빡빡하면 OS 페이지 캐시가 깨지고 P95가 급격히 나빠집니다.
이때 증상은 “검색 품질 폭망”처럼 보이기도 합니다. 타임아웃, 부분 실패, fallback 로직이 섞이기 때문입니다.
인프라 문제를 진단하는 접근은 비슷합니다. 예를 들어 OOM이나 리소스 한계는 이런 식으로 잡습니다: GitLab Runner Docker executor OOM·Exit 137 해결
6-3. 멀티테넌시에서 필터가 성능을 잡아먹음
테넌트별로 강한 필터가 들어가면, 사실상 “작은 컬렉션 여러 개”처럼 동작합니다.
- 이 경우 컬렉션을 테넌트 단위로 분리하는 게 더 단순하고 빠를 수 있습니다.
- 또는 payload 인덱싱 정책을 조정해 필터 평가 비용을 줄입니다.
7) 실전 튜닝 플로우(추천 순서)
아래 순서대로 하면 시행착오를 크게 줄일 수 있습니다.
7-1. exact로 상한 확인
- exact에서 Recall@
k가 낮으면 HNSW 튜닝으로 해결 안 됩니다. - 임베딩/청킹/데이터 정합성을 먼저 고치세요.
7-2. hnsw_ef를 2배씩 올리며 지표 확인
64→128→256같은 식으로- Recall이 목표치에 도달하는 지점과 P95가 터지는 지점을 같이 기록
7-3. 그래도 부족하면 ef_construct 상향 후 재인덱싱
- 인덱스 품질 자체를 올리는 접근
- 인덱싱 시간과 배포 전략(블루/그린)이 필요
7-4. 마지막으로 m 조정
- 메모리 비용이 커서 “마지막 카드”로 두는 편이 안전합니다.
8) 코드로 보는: 튜닝 실험 자동화 스니펫
아래는 Node.js에서 골든 쿼리셋을 돌며 hnsw_ef별 Recall@10을 대충 측정하는 예시입니다.
type GoldenQuery = {
id: string;
queryVector: number[];
relevantPointIds: string[];
};
async function searchQdrant(params: {
vector: number[];
limit: number;
hnswEf?: number;
exact?: boolean;
}): Promise<string[]> {
const res = await fetch("http://localhost:6333/collections/docs/points/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vector: params.vector,
limit: params.limit,
params: {
hnsw_ef: params.hnswEf,
exact: params.exact ?? false
}
})
});
const json = (await res.json()) as {
result: Array<{ id: string }>;
};
return json.result.map((r) => String(r.id));
}
function recallAtK(retrieved: string[], relevant: Set<string>): number {
for (const id of retrieved) {
if (relevant.has(id)) return 1;
}
return 0;
}
export async function evalRecallAt10(golden: GoldenQuery[], hnswEf: number) {
let hit = 0;
for (const q of golden) {
const top10 = await searchQdrant({
vector: q.queryVector,
limit: 10,
hnswEf
});
hit += recallAtK(top10, new Set(q.relevantPointIds));
}
return hit / golden.length;
}
이걸로 최소한 다음을 할 수 있습니다.
exact=true일 때의 Recall@10hnsw_ef를 올릴 때 Recall@10이 어디까지 따라오는지- 목표 Recall을 만족하는 최소
ef찾기
9) 자주 나오는 “폭망 패턴”과 처방
패턴 A: 데이터가 커지면서 갑자기 관련 문서가 안 나옴
- 원인 후보:
hnsw_ef가 너무 낮음(기본값 의존) - 처방:
ef를 올리고, 필요 시ef_construct재인덱싱
패턴 B: ef를 올리니 latency가 폭발
- 원인 후보: 메모리 부족,
m과다, 디스크 I/O 증가 - 처방:
m을 되돌려 보고, 메모리/CPU 증설 또는 컬렉션 분리 검토
패턴 C: exact도 별로다
- 원인 후보: 임베딩/청킹/정규화/거리함수/데이터 품질
- 처방: 검색 인덱스가 아니라 파이프라인을 고쳐야 함
10) 마무리: 체크리스트 요약
- 임베딩 모델/차원/정규화/거리함수 변경 여부 확인
- 필터가 후보군을 과도하게 줄이지 않는지 확인
- exact 검색으로 “이론 상한”부터 측정
- 런타임은
hnsw_ef부터 단계적으로 올리며 Recall과 P95를 같이 본다 - 그래도 부족하면
ef_construct상향 후 재인덱싱 - 마지막으로
m조정(메모리 비용 큼) - 대량 업서트 이후 최적화/세그먼트 상태에 따라 성능이 흔들릴 수 있음을 감안
RAG 검색품질이 무너졌을 때, HNSW는 분명 중요한 레버지만 “가장 먼저”가 아니라 “정확히 측정한 뒤” 만져야 하는 레버입니다. 골든셋과 exact 비교만 갖춰도, 튜닝은 감이 아니라 엔지니어링이 됩니다.