- Published on
Rust+Qdrant RAG 지연 2초→200ms 튜닝법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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를 뽑아야 합니다.
- 요청 파싱/인증/레이트리밋
- 임베딩 생성(로컬 모델 or 원격 API)
- Qdrant 검색(네트워크 + 검색 + payload 반환)
- 재랭킹(크로스 인코더 등)
- 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종 캐시를 분리하면 효과가 큽니다.
- 임베딩 캐시: 동일/유사 질의의 임베딩 재사용
- 검색 결과 캐시:
(tenant_id, normalized_query)기준 top 후보 ID 캐시 - 프롬프트 컨텍스트 캐시: 최종 선택된 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로: 권장 적용 순서
마지막으로, 효과 대비 작업 난이도를 고려한 “현실적인” 순서입니다.
- 구간별 계측(tracing)으로 병목 확정
- Qdrant payload 최소화 + 벡터 반환 끄기
top_k축소 + 중복 제거 + 2-stage 후보 축소- HTTP 클라이언트 전역 재사용 + 커넥션 풀 튜닝
- 캐시(임베딩/검색/컨텍스트) 도입
- 타임아웃/폴백으로 p99 제어
- Qdrant 인덱스/필터 인덱싱/HNSW 파라미터 실험
- 재랭킹을 분리 워커로 옮기거나 동시성 제한으로 런타임 안정화
이 순서대로 하면, “검색이 40ms인데 전체가 2초” 같은 케이스는 대개 200~400ms까지는 비교적 빠르게 내려옵니다. 이후 200ms를 안정적으로 맞추려면 캐시와 타임아웃 정책으로 p99를 다듬는 단계가 핵심입니다.
추가로, 서비스 레벨에서 p99 지연이 튈 때의 접근법(관측, 튜닝, 병목 분리)은 언어가 달라도 유사합니다. 서버 지연 스파이크 관점은 Spring Boot 3.x p99 지연 폭증? JVM·GC·Netty 튜닝도 참고할 만합니다.