Published on

Rust+Qdrant RAG - HNSW 튜닝으로 지연 50%↓

Authors

서빙 RAG에서 체감 성능을 결정하는 건 대개 LLM이 아니라 retrieve 단계의 p95 지연입니다. 특히 Qdrant 같은 벡터 DB에서 HNSW 인덱스를 쓰면, 파라미터 몇 개만 제대로 잡아도 검색 지연을 절반 수준까지 낮추는 경우가 흔합니다. 이 글은 Rust 서비스에서 Qdrant를 붙여 RAG를 운영한다는 전제 하에, HNSW 튜닝으로 지연을 50% 가까이 줄이는 접근을 “어떤 지표를 보고, 어떤 순서로, 어떤 값을” 바꿔야 하는지 정리합니다.

또한 운영 중에는 캐시/헬스체크/리소스 이슈가 성능 문제처럼 보이는 경우가 많습니다. 예를 들어 K8s에서 재시작 루프가 걸리면 지연이 튀고, 캐시가 꼬이면 재현이 어려워집니다. 관련해서는 K8s CrashLoopBackOff와 livenessProbe 503 해결법, Next.js App Router 캐시 꼬임, revalidateTag로 끝내기도 함께 참고하면 좋습니다.

목표: 지연을 줄이되 품질은 유지

HNSW 튜닝은 결국 속도와 품질(정확도/recall)의 트레이드오프입니다. “지연 50%↓”가 의미 있으려면, 다음을 함께 고정하거나 최소한 기록해야 합니다.

  • 데이터: 문서 수, 벡터 차원, payload 필터 사용 여부
  • 쿼리: 실제 트래픽에서 샘플링한 질의 세트(최소 수백 건)
  • 품질 지표: recall@k 또는 “정답 문서가 top-k에 포함되는 비율”
  • 성능 지표: p50/p95/p99 latency, QPS, CPU 사용률, 메모리 사용률

RAG 관점에서는 보통 top_k=5~20 범위에서 recall@k가 중요합니다. 지연만 줄여서 top-k가 엉망이 되면 LLM이 “그럴듯한 헛소리”를 더 잘하게 됩니다.

HNSW 핵심 파라미터 4개 (그리고 역할)

Qdrant의 HNSW는 크게 다음 네 가지가 지연에 직접적인 영향을 줍니다.

1) hnsw_config.m (그래프 연결 수)

  • 의미: 각 노드가 갖는 링크 수(근사)
  • 효과: m을 키우면 정확도는 올라가고, 인덱스 크기/빌드 비용/메모리 사용이 증가
  • 지연: 검색 시 탐색 공간이 커질 수 있어 지연이 늘 수 있으나, 경우에 따라 더 “좋은 길”을 빨리 찾아 지연이 줄기도 합니다(데이터 분포에 따라 다름)

2) hnsw_config.ef_construct (인덱스 생성 품질)

  • 의미: 인덱스 구축 시 탐색 폭
  • 효과: ef_construct가 높을수록 인덱스 품질이 좋아져 검색 시 적은 탐색으로도 높은 recall을 얻을 가능성이 증가
  • 지연: 런타임 지연보다 인덱싱 시간/CPU에 영향이 큼

3) search_params.hnsw_ef (검색 시 탐색 폭)

  • 의미: 쿼리마다 탐색하는 후보 수(대략적인 감)
  • 효과: hnsw_ef를 올리면 recall이 올라가지만 지연이 증가
  • 지연: 런타임에 가장 직접적으로 먹히는 레버

4) quantization (선택: 메모리/캐시 효율)

  • 의미: 벡터를 압축(예: scalar quantization)해 메모리 대역폭을 줄임
  • 효과: recall이 약간 떨어질 수 있지만, CPU 캐시 효율이 좋아져 지연이 크게 줄 수 있음
  • 지연: 데이터가 RAM에 “잘 붙도록” 만드는 쪽에 강력

정리하면,

  • 빠르게 효과 보는 순서는 보통 hnsw_ef 조정 → (필요 시) quantizationm 재조정 → ef_construct 재인덱싱 입니다.

튜닝 전 체크리스트: 성능 병목이 HNSW가 맞나

HNSW 튜닝을 시작하기 전에 아래를 먼저 확인해야 “헛삽”을 줄입니다.

  1. payload 필터가 과도한가
    • 필터가 복잡하면 ANN 탐색 이전/이후에 스캔 비용이 커질 수 있습니다.
  2. 디스크 I/O가 끼는가
    • 벡터가 메모리에 상주하지 못하면 HNSW보다 페이지 폴트가 지연을 지배합니다.
  3. 동시성에 비해 CPU가 부족한가
    • 검색 스레드가 포화면 hnsw_ef를 줄여도 별로 안 줄 수 있습니다.
  4. 컨테이너 리스타트/헬스체크 이슈가 있는가

Qdrant 컬렉션 생성: HNSW 설정 예시

아래는 Qdrant REST API로 컬렉션을 만들 때 HNSW 파라미터를 명시하는 예시입니다. 본문에 부등호가 노출되면 MDX 빌드가 깨질 수 있어, 모든 특수 표기는 코드 블록 안에 넣었습니다.

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
    }
  }'
  • m=16은 범용적으로 많이 쓰는 시작점입니다.
  • ef_construct=128은 “기본보다 조금 신경 쓴” 수준의 시작점입니다.
  • full_scan_threshold는 소규모일 때 brute-force로 갈지의 임계값인데, 데이터 규모가 크면 보통 큰 의미는 없습니다.

Rust에서 검색: hnsw_ef를 쿼리별로 조절하기

운영 RAG에서는 “모든 쿼리에 동일한 hnsw_ef”가 비효율적일 때가 많습니다. 예를 들어 짧고 모호한 쿼리는 recall이 더 필요하고, 긴 쿼리는 낮은 hnsw_ef로도 충분할 수 있습니다.

아래는 Rust에서 Qdrant에 검색 요청을 보내며 hnsw_ef를 지정하는 예시입니다(HTTP 기반).

use reqwest::Client;
use serde_json::json;

pub async fn qdrant_search(
    endpoint: &str,
    collection: &str,
    query_vector: Vec<f32>,
    top_k: usize,
    hnsw_ef: usize,
) -> anyhow::Result<serde_json::Value> {
    let url = format!("{}/collections/{}/points/search", endpoint, collection);

    let body = json!({
        "vector": query_vector,
        "limit": top_k,
        "with_payload": true,
        "params": {
            "hnsw_ef": hnsw_ef,
            "exact": false
        }
    });

    let client = Client::new();
    let resp = client
        .post(url)
        .json(&body)
        .send()
        .await?
        .error_for_status()?
        .json::<serde_json::Value>()
        .await?;

    Ok(resp)
}

포인트는 이겁니다.

  • hnsw_ef런타임에서 가장 쉽게/자주 바꿀 수 있는 레버
  • exact=false로 근사 탐색을 사용
  • RAG에서는 top_k를 너무 키우면 검색 시간도 늘고, LLM 컨텍스트도 비싸집니다. 먼저 top_k를 합리화한 뒤 HNSW를 튜닝하세요.

튜닝 절차: “지연 50%↓”를 만들기 위한 순서

여기서는 가장 재현성이 높은 순서를 제안합니다.

1단계: 기준선 측정 (현재 p95recall@k)

  • 실제 질의 N개(예: 500~2000)를 고정
  • 현재 설정에서 p50/p95recall@k를 기록
  • 가능하면 filter가 있는 질의/없는 질의를 분리

Rust에서 간단히 지연을 측정하는 예시는 다음과 같습니다.

use std::time::Instant;

pub async fn timed_search(...) -> anyhow::Result<(u128, serde_json::Value)> {
    let t0 = Instant::now();
    let resp = qdrant_search(...).await?;
    let elapsed_ms = t0.elapsed().as_millis();
    Ok((elapsed_ms, resp))
}

2단계: hnsw_ef를 내려서 지연을 즉시 줄이기

  • 예: 128 → 96 → 64 → 48 → 32 순으로 낮추며 측정
  • 각 단계에서 recall@k가 허용 범위 내인지 확인

실무에서 자주 나오는 패턴은 이렇습니다.

  • hnsw_ef가 과하게 큰 상태로 운영 중인 경우가 많음
  • hnsw_ef를 절반으로 줄여도 recall@k가 크게 안 떨어지는 데이터셋이 존재
  • 이 경우 지연이 30~60%까지 줄어드는 케이스가 나옵니다

주의할 점:

  • hnsw_ef를 너무 낮추면 결과가 불안정해지고, 특정 질의에서만 품질이 급락합니다(꼭 p95 품질도 보세요).

3단계: m을 재조정해 “낮은 hnsw_ef에서도 recall 유지” 만들기

hnsw_ef를 내렸더니 recall이 떨어진다면, 다음 선택지는 m 조정입니다.

  • m을 올리면 그래프가 촘촘해져 낮은 탐색 폭에서도 좋은 후보를 찾을 확률이 올라갑니다.
  • 다만 메모리 사용이 늘고, 인덱스 빌드 비용이 증가합니다.

권장 접근:

  • m=16 시작
  • recall이 부족하면 m=24 또는 m=32로 올린 새 컬렉션을 만들고 A/B로 측정
  • 그 다음 hnsw_ef를 다시 낮춰서 목표 지연을 달성

즉, m을 올려 품질을 확보하고, hnsw_ef를 내려 지연을 줄이는 조합이 자주 먹힙니다.

4단계: ef_construct는 “재인덱싱 여력이 있을 때”만 올리기

ef_construct는 런타임 지연보다 “인덱스 품질”에 영향을 줍니다.

  • 인덱스가 대충 만들어져 있으면, 같은 recall을 얻기 위해 더 큰 hnsw_ef가 필요해지고 지연이 늘 수 있습니다.
  • 이때 ef_construct를 올려 재색인하면, 런타임에서 더 작은 hnsw_ef로도 동일 recall을 얻어 지연이 내려갈 수 있습니다.

실전 팁:

  • 운영 중 컬렉션을 새로 만들고 백필한 뒤 트래픽을 스위칭하는 방식이 안전합니다.

5단계: Quantization으로 메모리 대역폭 병목을 깨기

데이터가 커질수록 “CPU 연산”보다 “메모리에서 벡터를 읽어오는 비용”이 지연을 지배합니다. 이때 quantization은 강력한 선택지입니다.

  • 메모리 사용량이 줄어 캐시 적중률이 올라감
  • 같은 하드웨어에서 QPS가 늘고 p95가 줄어드는 경우가 많음
  • 대신 recall이 소폭 떨어질 수 있어, RAG 정답률과 함께 평가해야 함

Qdrant의 설정은 버전에 따라 차이가 있으니, 운영 버전의 문서를 기준으로 “scalar quantization” 또는 “product quantization” 옵션을 확인하고 적용하세요.

“지연 50%↓”가 잘 나오는 전형적인 케이스

다음 조합에서 성과가 크게 나옵니다.

  1. 초기 설정이 보수적으로 큼
    • hnsw_ef가 128~256으로 높게 잡혀 있음
  2. 데이터가 충분히 크고(수십만~수백만), 필터가 단순함
  3. recall 여유가 있음
    • recall@10이 이미 0.98 이상인데 더 올릴 필요가 없는 상태

이때 hnsw_ef를 128 → 64로 줄이고, 품질 하락이 보이면 m을 16 → 24로 올리는 식으로 맞추면, p95가 체감되게 내려가는 경우가 많습니다.

운영 팁: 벤치마크/관측/캐시를 같이 설계

HNSW 튜닝은 한 번으로 끝나지 않습니다. 데이터가 늘고, 임베딩 모델이 바뀌고, 필터 조건이 늘면 최적점이 이동합니다.

  • 벤치마크 자동화: 샘플 질의 세트를 고정해 nightly로 p95recall@k를 측정
  • 관측 지표: Qdrant 쿼리 latency를 앱에서 별도 히스토그램으로 기록(예: Prometheus)
  • 캐시 전략: “질의 문자열 기반 캐시”는 효과가 제한적이고, 오히려 캐시 꼬임이 디버깅을 어렵게 만들 수 있습니다. 프론트/서버 캐시 정책은 Next.js App Router 캐시 꼬임, revalidateTag로 끝내기 같은 방식으로 명확히 하세요.

마무리: 튜닝의 본질은 hnsw_ef를 낮추는 설계

정리하면, Qdrant HNSW에서 지연을 크게 줄이는 가장 현실적인 전략은 다음 한 줄입니다.

  • m과 인덱스 품질(ef_construct)로 기반 recall을 확보한 뒤, 런타임 hnsw_ef를 가능한 낮게 운영한다.

이 접근을 “측정 가능한 벤치마크”와 함께 반복하면, Rust 기반 RAG에서도 과장 없이 검색 지연 50% 감소를 노릴 수 있습니다. 중요한 건 한 번의 마법 값이 아니라, p95recall@k를 동시에 잡는 튜닝 루프를 만드는 것입니다.