Published on

RAG 성능 2배 - Qdrant HNSW 튜닝 실전

Authors

RAG에서 체감 성능을 좌우하는 건 대개 LLM이 아니라 retrieval 입니다. 특히 Qdrant를 벡터 DB로 쓰는 구성에서는 HNSW 설정이 검색 지연과 recall, 그리고 운영 비용(메모리/CPU)을 동시에 결정합니다.

이 글은 “HNSW 파라미터 몇 개 바꾸면 빨라진다” 수준이 아니라, RAG 품질을 유지하면서 p95 지연을 절반 수준으로 떨어뜨리는 실전 튜닝 흐름을 다룹니다. 목표는 다음 두 가지입니다.

  • 동일한 recall(또는 정답률)에서 검색 지연 2배 개선
  • 트래픽이 올라가도 메모리 폭증/OOM 없이 안정 운영

운영 중 메모리 압박이 있다면, 튜닝 전에 OOM 징후를 먼저 잡는 것도 중요합니다. 리눅스 환경이라면 리눅스 OOM Killer로 프로세스 죽을 때 진단법도 함께 참고하면 좋습니다.

HNSW 튜닝이 RAG에 미치는 영향

HNSW는 근사 최근접 탐색(ANN) 알고리즘으로, 다음 두 단계에서 성능이 갈립니다.

  1. 인덱스 구성(그래프 품질): m, ef_construct
  2. 검색 시 그래프 탐색 범위: ef_search

RAG에서 중요한 지표를 HNSW 파라미터와 연결하면 다음과 같습니다.

  • recall@k 또는 “정답 문서가 top-k에 포함되는 비율”은 주로 ef_search에 민감
  • p95/p99 지연은 ef_search와 동시성, CPU 캐시 효율에 민감
  • 메모리 사용량은 m에 크게 비례
  • 인덱싱 시간과 인덱싱 CPU는 ef_construct에 민감

즉, 가장 흔한 실패 패턴은 이렇습니다.

  • 성능이 안 나와서 ef_search를 크게 올림
  • recall은 좋아지지만 지연이 급증하고 CPU가 포화
  • 더 많은 Pod/VM을 붙이다가 메모리도 같이 증가해 OOM 또는 비용 폭탄

핵심은 mef_construct인덱스 자체의 품질을 올려, 같은 recall을 더 작은 ef_search로 달성하는 것입니다.

Qdrant에서 조정 가능한 핵심 파라미터

Qdrant 컬렉션의 HNSW 설정은 보통 다음 필드로 관리합니다.

  • m: 노드당 연결 수(그래프의 차수). 높을수록 recall이 좋아지기 쉬우나 메모리 증가
  • ef_construct: 인덱스 빌드 시 후보 탐색 폭. 높을수록 그래프 품질이 좋아지나 인덱싱 비용 증가
  • full_scan_threshold: 인덱스 대신 풀스캔으로 전환하는 임계값(작은 컬렉션에서 오히려 유리)

검색 시에는

  • ef_search: 검색 단계 후보 탐색 폭. 높을수록 recall 증가, 지연 증가

그리고 스토리지/메모리 계층에서는

  • on_disk: 벡터/인덱스를 디스크로 내릴지 여부(메모리 절약 vs 지연)

이 글에서는 “RAG 성능 2배”를 노리기 위해 다음 전략을 씁니다.

  • m, ef_construct를 올려 인덱스 품질을 개선
  • 그 결과로 ef_search를 내려 검색 지연을 크게 줄임

실험 설계: 튜닝 전 반드시 해야 할 측정

튜닝은 감이 아니라 실험입니다. 최소한 아래를 수집해야 결과가 흔들리지 않습니다.

1) 오프라인 품질 지표

  • 평가 쿼리 200~1,000개(프로덕션 로그에서 샘플링 추천)
  • 정답 문서(또는 정답 청크) 라벨링
  • recall@k (보통 k=10 또는 k=20)

라벨이 없다면, 임시로 “정확한 BM25 결과” 또는 “현재 운영 설정의 고 ef_search 결과”를 pseudo-label로 삼아 상대 비교를 할 수 있습니다.

2) 온라인 성능 지표

  • p50/p95/p99 latency
  • QPS 대비 CPU 사용률
  • 메모리 RSS, 페이지 캐시, OOM 이벤트

EKS 같은 환경에서는 DiskPressure도 같이 보세요. 인덱스 파일이 커지면 노드 디스크 압박으로 연쇄 장애가 납니다. 필요하면 EKS DiskPressure로 Pod Evicted 폭주 해결 10가지도 참고하세요.

재현 가능한 튜닝 실습: 컬렉션 생성과 인덱스 설정

아래 예시는 Qdrant HTTP API 기준입니다. 본문에 > 문자가 노출되면 MDX가 JSX로 오인할 수 있으니, 모든 요청은 코드 블록으로 제공합니다.

1) 컬렉션 생성

curl -X PUT "http://localhost:6333/collections/rag_chunks" \
  -H 'Content-Type: application/json' \
  -d '{
    "vectors": {
      "size": 1536,
      "distance": "Cosine"
    },
    "hnsw_config": {
      "m": 16,
      "ef_construct": 128,
      "full_scan_threshold": 10000
    }
  }'

초기값으로 흔히 m=16, ef_construct=128을 잡습니다. 이 상태에서 ef_search를 크게 올려서 품질을 맞추는 팀이 많습니다.

2) 검색 시 ef_search 지정

curl -X POST "http://localhost:6333/collections/rag_chunks/points/search" \
  -H 'Content-Type: application/json' \
  -d '{
    "vector": [0.01, 0.02, 0.03],
    "limit": 10,
    "params": {
      "hnsw_ef": 128
    },
    "with_payload": true
  }'

Qdrant에서는 요청 단위로 hnsw_ef를 지정할 수 있어, A/B 테스트가 쉽습니다.

“2배 개선”이 나오는 대표 튜닝 레시피

여기서 말하는 2배는 보통 p95 기준입니다. 아래 조합이 실무에서 가장 자주 먹힙니다.

레시피 A: 인덱스 품질을 올리고 ef_search를 낮춘다

  • 기존: m=16, ef_construct=128, 운영 ef_search=256
  • 변경: m=32, ef_construct=256, 운영 ef_search=96 또는 128

효과는 다음 메커니즘으로 발생합니다.

  • m 증가로 그래프 연결성이 좋아져, 같은 정답을 더 빨리 찾음
  • ef_construct 증가로 “나쁜 연결”이 줄어 탐색 낭비 감소
  • 결과적으로 ef_search를 낮춰도 recall 유지

단, 비용이 있습니다.

  • 메모리 증가: m이 2배면 그래프 엣지 메모리도 대체로 증가
  • 인덱싱 시간 증가: ef_construct가 2배면 빌드 CPU도 증가

그래서 이 레시피는 인덱스 리빌드가 가능하고, 메모리 여유가 있거나 scale-out이 가능한 팀에 적합합니다.

레시피 B: m은 유지하고 ef_construct만 올려 “싸게” 개선

  • 기존: m=16, ef_construct=128, 운영 ef_search=256
  • 변경: m=16, ef_construct=256, 운영 ef_search=160

메모리 증가를 최소화하면서 인덱스 품질을 일부 개선해 ef_search를 내리는 방식입니다. “메모리 때문에 m을 못 올리는” 환경에서 현실적인 선택입니다.

레시피 C: 필터가 많은 RAG면 payload index와 분리 전략을 먼저

RAG 검색에서 tenant_id, product_id, lang, doc_type 같은 필터를 강하게 걸면, HNSW 튜닝보다 필터로 후보가 잘리는 구조가 더 중요해집니다.

  • 필터가 강하면 HNSW 그래프 탐색이 비효율적일 수 있음
  • 컬렉션을 테넌트별로 분리하거나(샤딩/멀티컬렉션)
  • payload 인덱스를 제대로 구성해 필터링 비용을 줄이는 게 먼저

이 글의 범위는 HNSW 중심이지만, 필터가 강한 시스템에서 “HNSW만 만져서” 성능이 안 나오는 이유가 여기에 있습니다.

튜닝을 코드로 자동화: 파라미터 스윕과 평가

아래는 Python으로 ef_search를 스윕하며 latency와 recall을 동시에 측정하는 최소 예시입니다.

import time
import statistics
import requests

QDRANT = "http://localhost:6333"
COL = "rag_chunks"

# 예시: (query_vector, ground_truth_ids)
EVAL_SET = [
    ([0.01, 0.02, 0.03], {"doc_123"}),
    ([0.05, 0.01, 0.09], {"doc_999"}),
]


def search(vec, ef, limit=10):
    url = f"{QDRANT}/collections/{COL}/points/search"
    payload = {
        "vector": vec,
        "limit": limit,
        "params": {"hnsw_ef": ef},
        "with_payload": True,
    }
    t0 = time.perf_counter()
    r = requests.post(url, json=payload, timeout=5)
    r.raise_for_status()
    dt = (time.perf_counter() - t0) * 1000
    hits = r.json()["result"]
    return dt, hits


def recall_at_k(hits, gt_ids, k=10):
    top = hits[:k]
    # payload에 doc_id가 있다고 가정
    got = {h["payload"].get("doc_id") for h in top}
    return 1.0 if len(gt_ids & got) > 0 else 0.0


def evaluate(ef):
    lats = []
    recalls = []
    for vec, gt in EVAL_SET:
        dt, hits = search(vec, ef)
        lats.append(dt)
        recalls.append(recall_at_k(hits, gt, k=10))

    return {
        "ef": ef,
        "p50_ms": statistics.median(lats),
        "p95_ms": statistics.quantiles(lats, n=20)[18],
        "recall": sum(recalls) / len(recalls),
    }


for ef in [32, 64, 96, 128, 160, 256]:
    print(evaluate(ef))

포인트는 단순합니다.

  • ef_search를 낮추면서 recall이 얼마나 떨어지는지 측정
  • 떨어진다면 m 또는 ef_construct를 올려 인덱스 품질을 개선
  • 다시 같은 스윕을 돌려 “더 낮은 ef_search에서도 recall 유지”를 확인

이 루프를 자동화하면, 튜닝이 “감”에서 “데이터”로 바뀝니다.

운영에서 자주 겪는 병목과 해결

1) CPU 포화: ef_search 과다 또는 동시성 과다

증상

  • QPS가 조금만 올라가도 p95가 급증
  • CPU 사용률이 90% 근처에서 고정

해결 순서

  1. 같은 recall을 목표로 m, ef_construct를 올려 ef_search를 낮출 여지를 만든다
  2. 애플리케이션 레벨에서 검색 동시성을 제한한다(서킷 브레이커, 큐잉)
  3. 쿼리당 limit을 과도하게 키우지 않는다(예: top-100을 가져와 rerank하는 습관 점검)

2) 메모리 급증과 OOM: m 증가의 부작용

증상

  • 인덱스 리빌드 후 RSS가 크게 증가
  • 재시작 루프, OOMKilled

해결

3) 디스크 압박: 인덱스 파일 커짐

증상

  • 노드에서 DiskPressure
  • Pod Evicted가 연쇄적으로 발생

해결

실전 권장값 가이드(출발점)

데이터 분포와 차원, 임베딩 모델에 따라 달라지지만, RAG 청크 검색에서 자주 쓰는 출발점은 다음입니다.

  • 소규모(수만): m=16, ef_construct=128, ef_search=64부터 측정
  • 중규모(수십만~수백만): m=24 또는 32, ef_construct=256, ef_search=64~128 스윕
  • 필터 강함: HNSW보다 컬렉션 분리/필터 인덱싱을 먼저 점검

중요한 건 “값 자체”가 아니라,

  • 목표 recall을 정하고
  • 그 recall을 만족하는 최소 ef_search를 찾고
  • 그 최소 ef_search를 더 낮추기 위해 m, ef_construct로 인덱스 품질을 끌어올리는

이 순서입니다.

마무리: 2배 개선을 만드는 체크리스트

  • 오프라인 평가셋과 recall@k를 먼저 만든다
  • ef_search 스윕으로 latency-recall 곡선을 얻는다
  • m, ef_construct를 조정해 같은 recall을 더 작은 ef_search로 달성한다
  • 메모리와 디스크 압박을 함께 본다(특히 m 증가 시)
  • 변경은 요청 단위 hnsw_ef로 A/B 한 뒤, 컬렉션 설정을 확정한다

RAG는 “검색이 빨라지면 LLM 호출 수가 줄고, 전체 비용이 내려가고, UX가 좋아지는” 구조가 많습니다. Qdrant HNSW 튜닝은 그 중 가장 ROI가 큰 레버 중 하나입니다.