Published on

Rust+Qdrant RAG 지연 2초→200ms 튜닝법

Authors

RAG(Retrieval-Augmented Generation)에서 “응답이 느리다”는 말의 대부분은 검색 자체가 느려서가 아니라, 파이프라인 전체가 직렬로 묶여 있고 불필요한 비용이 겹쳐서 생깁니다. 특히 Rust+Qdrant 조합은 기본 성능이 좋은 편이지만, 아래 같은 흔한 패턴이 있으면 p95/p99가 쉽게 2초를 넘습니다.

  • 임베딩 생성, Qdrant 검색, 재랭킹, LLM 호출을 전부 직렬로 수행
  • Qdrant에 과도한 payload를 붙여서 네트워크/디코딩 비용이 검색 시간보다 커짐
  • top_k를 크게 잡고 재랭킹까지 돌려서 CPU를 태움
  • Tokio 런타임에서 태스크가 폭증하거나 블로킹 I/O가 섞여 스케줄링 지연이 발생

이 글은 “2초 → 200ms”를 목표로, 어디서 시간이 새는지 측정하고 가장 효과가 큰 순서대로 손보는 방법을 정리합니다.

Tokio 태스크 폭증/CPU 100% 같은 런타임 병목이 의심된다면, 먼저 Rust Tokio에서 task 대기열 폭증·CPU 100% 잡는 법도 함께 보세요.

1) 먼저: 2초의 구성요소를 쪼개서 계측하기

“Qdrant가 느리다”라고 단정하기 전에, 아래 5구간을 분리해서 p50/p95를 뽑아야 합니다.

  1. 요청 파싱/인증/레이트리밋
  2. 임베딩 생성(로컬 모델 or 원격 API)
  3. Qdrant 검색(네트워크 + 검색 + payload 반환)
  4. 재랭킹(크로스 인코더 등)
  5. LLM 호출(프롬프트 구성 + 네트워크)

Rust에서는 tracing으로 구간별 span을 만들고, 각 span에 밀리초를 태깅하는 방식이 가장 단순합니다.

use std::time::Instant;
use tracing::{info, instrument};

#[instrument(skip_all)]
pub async fn rag_answer(query: String) -> anyhow::Result<String> {
    let t0 = Instant::now();

    let t = Instant::now();
    let embedding = embed(&query).await?;
    info!(ms = t.elapsed().as_millis(), "embed_done");

    let t = Instant::now();
    let points = qdrant_search(&embedding).await?;
    info!(ms = t.elapsed().as_millis(), "qdrant_done");

    let t = Instant::now();
    let reranked = rerank(&query, points).await?;
    info!(ms = t.elapsed().as_millis(), "rerank_done");

    let t = Instant::now();
    let answer = call_llm(&query, &reranked).await?;
    info!(ms = t.elapsed().as_millis(), "llm_done");

    info!(ms = t0.elapsed().as_millis(), "total_done");
    Ok(answer)
}

이렇게만 해도 “Qdrant 검색이 40ms인데 payload 디코딩이 300ms” 같은 현실을 바로 보게 됩니다. 목표가 200ms라면, 병목 하나만 잡아서는 안 되고 20~50ms 단위로 여러 군데를 깎아야 합니다.

2) Qdrant: payload는 최소화하고, 필요한 필드만 가져오기

RAG에서 가장 흔한 낭비는 “검색 결과에 문서 원문 전체를 payload로 붙여서 반환”하는 것입니다. Qdrant 검색은 빨라도, 반환 payload가 커지면 다음 비용이 커집니다.

  • 네트워크 전송(특히 크로스 AZ)
  • JSON 디코딩
  • Rust 구조체 역직렬화
  • 프롬프트 구성 시 문자열 복사

2-1) with_payload를 필드 선택으로 제한

Qdrant는 payload 전체를 받지 말고, 프롬프트에 필요한 필드만 선택하세요. 예를 들어 doc_id, chunk_id, text만 필요하다면 나머지는 제외합니다.

아래 예시는 qdrant-client를 쓴다는 가정의 형태(개념 코드)입니다.

use qdrant_client::qdrant::{
    SearchPoints, WithPayloadSelector, PayloadIncludeSelector, WithVectorsSelector,
};

let req = SearchPoints {
    collection_name: "docs".to_string(),
    vector: embedding,
    limit: 20,
    with_payload: Some(WithPayloadSelector {
        selector_options: Some(
            qdrant_client::qdrant::with_payload_selector::SelectorOptions::Include(
                PayloadIncludeSelector {
                    fields: vec!["doc_id".into(), "chunk_id".into(), "text".into()],
                },
            ),
        ),
    }),
    // 벡터 자체는 응답에서 필요 없으면 끄기
    with_vectors: Some(WithVectorsSelector {
        selector_options: Some(
            qdrant_client::qdrant::with_vectors_selector::SelectorOptions::Enable(false),
        ),
    }),
    ..Default::default()
};

핵심은 두 가지입니다.

  • payload는 필드 include로 최소화
  • 결과 벡터는 후처리에 필요 없다면 반환 비활성화

이 한 가지로도 “2초 중 500ms”가 줄어드는 케이스가 많습니다.

2-2) 원문은 Qdrant에 두지 말고 “핫 필드 vs 콜드 필드” 분리

대규모 문서 원문을 Qdrant payload에 넣어두면, 검색은 빨라도 응답이 무거워집니다. 실무에서는 대개 아래처럼 분리합니다.

  • Qdrant payload: doc_id, chunk_id, title, snippet 같은 짧은 필드
  • 원문 본문: S3, Postgres, Elasticsearch 등에서 doc_id추가 조회

추가 조회가 오히려 느려질 것 같지만, 실제로는

  • 대부분의 요청에서 top-k최종 채택되는 chunk는 3~6개
  • 큰 payload를 한 번에 20개 받는 것보다, 최종 3개만 원문 로드하는 게 빠름

이 패턴이 200ms 목표에서 매우 중요합니다.

3) 검색 품질을 유지하면서 top_k를 줄이는 법: 2-stage retrieval

top_k = 50로 뽑고 재랭킹을 돌리면 정확도는 좋아지지만, 지연이 크게 늘어납니다. 대신 2-stage로 바꿉니다.

  • 1단계: Qdrant에서 limit = 20 정도로 빠르게 후보 생성
  • 2단계: 재랭킹은 20개 전체가 아니라, 휴리스틱으로 8~12개만 수행

휴리스틱은 간단해도 효과가 큽니다.

  • 점수 상위 N개만 재랭킹
  • 동일 문서에서 연속 chunk는 1~2개로 제한(중복 제거)
  • BM25(키워드)와 벡터 점수를 혼합한 간단한 스코어로 후보를 더 줄임

Rust 예시(중복 문서 제한):

use std::collections::HashSet;

fn dedup_by_doc(mut points: Vec<Point>, max_docs: usize, max_per_doc: usize) -> Vec<Point> {
    points.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());

    let mut seen_docs = HashSet::new();
    let mut per_doc = std::collections::HashMap::<String, usize>::new();
    let mut out = Vec::new();

    for p in points {
        let doc_id = p.doc_id.clone();
        let entry = per_doc.entry(doc_id.clone()).or_insert(0);
        if *entry >= max_per_doc {
            continue;
        }
        if !seen_docs.contains(&doc_id) && seen_docs.len() >= max_docs {
            continue;
        }
        seen_docs.insert(doc_id.clone());
        *entry += 1;
        out.push(p);
    }

    out
}

이렇게 후보를 줄인 뒤 재랭킹을 적용하면, 정확도 손실을 최소화하면서 지연을 크게 줄일 수 있습니다.

4) Qdrant 인덱스/세그먼트/검색 파라미터 튜닝 포인트

Qdrant 자체가 200ms를 잡아먹는 경우는 보통 아래 중 하나입니다.

  • HNSW 파라미터가 보수적이어서 탐색이 과도함
  • 컬렉션이 너무 큰데 샤딩/세그먼트 설정이 비효율적
  • 필터링이 비싼데 payload 인덱싱이 부족함

4-1) HNSW의 ef를 동적으로 조절

ef는 “탐색 폭”이라서 높을수록 정확하지만 느립니다. 실무에서는 질문 유형에 따라 ef를 다르게 주는 전략이 효과적입니다.

  • 짧고 모호한 질의: ef 높게
  • 길고 구체적인 질의: ef 낮게

또는 SLO 기반으로 “p95가 200ms를 넘으면 ef를 낮추는” 적응형도 가능합니다.

주의: 이 글에서는 Qdrant 설정 JSON을 그대로 붙이면 부등호 같은 문자가 섞일 수 있어, 설정 예시는 개념만 적습니다. 실제 값은 벡터 차원/데이터 분포/정확도 목표에 맞춰 실험으로 정하세요.

4-2) 필터를 쓰면 payload 인덱싱을 확인

예를 들어 tenant_id, lang, source 같은 필터를 자주 건다면, 해당 payload 필드가 인덱싱되어 있지 않으면 검색 전에 후보 집합을 좁히는 비용이 커집니다.

  • 멀티테넌트: tenant_id는 거의 필수 인덱스
  • 문서 타입 분리: source/doctype 인덱스

필터가 느릴 때는 “벡터 검색이 아니라 필터 스캔이 병목”인 경우가 많습니다.

5) Rust 클라이언트: 커넥션/HTTP 설정이 지연을 만든다

Qdrant는 보통 HTTP(gRPC도 가능)로 붙습니다. 지연이 들쭉날쭉하다면 다음을 점검하세요.

  • 커넥션 풀 재사용이 안 되고 매 요청마다 TCP/TLS 핸드셰이크
  • DNS 리졸브가 매번 발생
  • HTTP/2 설정 미흡(특히 gRPC)

5-1) reqwest::Client는 반드시 전역 재사용

use once_cell::sync::Lazy;

static HTTP: Lazy<reqwest::Client> = Lazy::new(|| {
    reqwest::Client::builder()
        .pool_idle_timeout(std::time::Duration::from_secs(30))
        .pool_max_idle_per_host(32)
        .tcp_keepalive(std::time::Duration::from_secs(60))
        .build()
        .unwrap()
});

그리고 Qdrant 호출 함수에서 Client::new()를 만들지 마세요. 이 실수 하나로 p95가 수백 ms씩 튈 수 있습니다.

6) 병렬화: “임베딩/검색/재랭킹”을 안전하게 겹치기

RAG는 구조상 완전 병렬은 아니지만, 겹칠 수 있는 구간이 있습니다.

  • 캐시 히트 시: 임베딩/검색을 건너뛰고 바로 답변
  • 멀티 쿼리 확장(query expansion)을 한다면: 여러 검색을 동시에
  • top 후보의 원문 로드: 여러 doc_id를 동시에 fetch

Rust에서는 tokio::try_join!로 I/O를 겹치는 게 기본입니다.

use tokio::try_join;

async fn fetch_contexts(doc_ids: Vec<String>) -> anyhow::Result<Vec<String>> {
    let futs = doc_ids.into_iter().map(|id| async move {
        // 예: S3/DB에서 원문 일부 로드
        load_doc_text(&id).await
    });

    // join_all도 가능하지만, 실패 처리/타임아웃 정책을 명확히 하세요.
    let texts = futures::future::try_join_all(futs).await?;
    Ok(texts)
}

단, 병렬화를 늘리면 런타임 태스크가 폭증해 역효과가 날 수 있습니다. 큐가 쌓이고 CPU가 치솟는 증상이 있으면 동시성 제한(Semaphore)을 걸어야 합니다.

7) 캐시: 200ms를 만드는 가장 확실한 방법

200ms 목표에서 캐시는 “옵션”이 아니라 “전제”에 가깝습니다. 아래 3종 캐시를 분리하면 효과가 큽니다.

  1. 임베딩 캐시: 동일/유사 질의의 임베딩 재사용
  2. 검색 결과 캐시: (tenant_id, normalized_query) 기준 top 후보 ID 캐시
  3. 프롬프트 컨텍스트 캐시: 최종 선택된 chunk 텍스트 묶음을 캐시

7-1) 질의 정규화가 캐시 적중률을 좌우

  • 공백 정리, 소문자화, 특수문자 제거
  • 숫자/날짜 패턴 정규화(예: 2026-02-26 같은 변동 값)

캐시 키에 원문 질의를 그대로 쓰면 적중률이 낮습니다.

8) 타임아웃/폴백: p99를 200ms로 만드는 운영 패턴

평균을 줄이는 것과 p99를 줄이는 것은 다릅니다. p99가 튀는 이유는 보통 외부 API(임베딩/LLM)나 순간적인 런타임 혼잡입니다. 그래서 “200ms 이내에 끝내는” 정책이 필요합니다.

  • Qdrant 검색 타임아웃: 예를 들어 60~80ms
  • 재랭킹 타임아웃: 예를 들어 40~60ms
  • 초과 시 폴백: 재랭킹 생략, top-k 축소, 캐시 결과 사용

Rust 예시(타임아웃):

use tokio::time::{timeout, Duration};

async fn qdrant_search_timed(embedding: Vec<f32>) -> anyhow::Result<Vec<Point>> {
    let res = timeout(Duration::from_millis(80), qdrant_search(&embedding)).await;
    match res {
        Ok(inner) => inner,
        Err(_) => {
            // 폴백: 빈 결과 혹은 캐시 결과
            Ok(vec![])
        }
    }
}

이때 중요한 건 “타임아웃이 잦아지면 품질이 떨어진다”는 점이므로, 타임아웃 발생률을 지표로 삼아야 합니다.

9) 자주 하는 실수 체크리스트(2초의 주범)

  • Qdrant 응답에 원문 전체 payload 포함
  • limit를 과도하게 크게 잡고 재랭킹도 전부 수행
  • 매 요청마다 HTTP 클라이언트를 새로 생성
  • Tokio 런타임에 블로킹 작업(동기 파일 I/O, 무거운 CPU 재랭킹)을 그대로 올림
  • 멀티테넌트 필터에 인덱스가 없어 필터 스캔이 발생
  • 관측 없이 “느리다”만 반복

Rust에서 빌림/참조 때문에 불필요한 clone()이 늘어나 문자열 복사가 커지는 경우도 있습니다. 관련해서는 Rust E0502·E0499 빌림 충돌 5분 해결처럼 소유권 구조를 정리하면 성능에도 도움이 됩니다.

10) 2초에서 200ms로: 권장 적용 순서

마지막으로, 효과 대비 작업 난이도를 고려한 “현실적인” 순서입니다.

  1. 구간별 계측(tracing)으로 병목 확정
  2. Qdrant payload 최소화 + 벡터 반환 끄기
  3. top_k 축소 + 중복 제거 + 2-stage 후보 축소
  4. HTTP 클라이언트 전역 재사용 + 커넥션 풀 튜닝
  5. 캐시(임베딩/검색/컨텍스트) 도입
  6. 타임아웃/폴백으로 p99 제어
  7. Qdrant 인덱스/필터 인덱싱/HNSW 파라미터 실험
  8. 재랭킹을 분리 워커로 옮기거나 동시성 제한으로 런타임 안정화

이 순서대로 하면, “검색이 40ms인데 전체가 2초” 같은 케이스는 대개 200~400ms까지는 비교적 빠르게 내려옵니다. 이후 200ms를 안정적으로 맞추려면 캐시와 타임아웃 정책으로 p99를 다듬는 단계가 핵심입니다.

추가로, 서비스 레벨에서 p99 지연이 튈 때의 접근법(관측, 튜닝, 병목 분리)은 언어가 달라도 유사합니다. 서버 지연 스파이크 관점은 Spring Boot 3.x p99 지연 폭증? JVM·GC·Netty 튜닝도 참고할 만합니다.