- Published on
Qdrant+OpenSearch로 RAG 리랭킹 지연 50% 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG 파이프라인에서 체감 지연을 가장 크게 만드는 구간은 의외로 LLM 호출이 아니라 리랭킹(re-ranking)인 경우가 많습니다. 특히 top_k=50~200 후보를 뽑아놓고 Cross-Encoder나 LLM 기반 리랭커로 전부 재평가하면, CPU/GPU·네트워크·직렬 처리 때문에 p95가 급격히 튑니다.
이 글은 Qdrant(벡터 1차 검색)와 OpenSearch(텍스트/필터/하이브리드+서빙)를 역할 분리해 리랭킹 대상 후보군을 더 빨리, 더 작게 만들고, 리랭킹 자체도 캐시/비동기/배치로 다듬어 리랭킹 지연을 50% 수준까지 낮추는 설계를 정리합니다.
아래 내용은 특정 벤치마크 수치가 아니라, 실무에서 반복적으로 효과가 컸던 병목 제거 포인트를 조합한 “감속 요인” 중심의 가이드입니다.
왜 Qdrant+OpenSearch 조합이 리랭킹에 유리한가
전형적인 단일 벡터DB 기반 RAG는 다음 흐름입니다.
- 벡터 검색으로
top_k후보 확보 - 후보 문서 원문/메타데이터 로드
- 리랭커로 재정렬
- 최종
top_n을 컨텍스트로 구성
문제는 1단계의 top_k가 커질수록 3단계 비용이 선형으로 증가한다는 점입니다. 반면, 실제로는 다음 조건들이 후보군을 더 일찍 줄일 수 있습니다.
- 쿼리의 키워드/엔티티 매칭(정확 검색)
- 최신성, 권한, 테넌트, 문서 타입 같은 필터
- BM25와 dense를 섞는 하이브리드 스코어링
collapse/dedup같은 중복 제거
이런 기능은 OpenSearch가 강합니다. 즉, Qdrant는 “가까운 것”을 빠르게 찾고, OpenSearch는 “쓸모있는 것”을 빠르게 걸러내는 역할로 두면 리랭킹 입력이 크게 줄어듭니다.
핵심 목표는 하나입니다.
- Cross-Encoder/LLM 리랭커에 들어가는 후보를
200에서40으로 줄이면, 리랭킹 지연이 대체로 절반 이상 줄어듭니다(모델/배치/하드웨어에 따라 편차).
아키텍처: 2단 후보 생성 + 1단 리랭킹
권장 파이프라인은 아래처럼 “후보 생성(candidate generation)”을 2단으로 나눕니다.
1) Qdrant: 넓게 뽑되, 최소 정보만 반환
- 목적: recall 확보
- 반환:
doc_id,chunk_id,vector_score정도만 with_payload는 최소화(네트워크/직렬화 비용 감소)
2) OpenSearch: 필터/하이브리드/중복제거로 후보 축소
- 목적: precision/정합성/정책 반영
- Qdrant에서 받은
chunk_id리스트로terms조회하거나, 반대로 OpenSearch에서 텍스트 기반 후보를 뽑아 교집합을 만들 수도 있습니다.
3) 리랭커: 작은 후보에만 고비용 평가
- 목적: 최종 정렬 품질
- 대상:
20~60정도로 제한
이 구조의 장점은 다음과 같습니다.
- 리랭커 호출 수/토큰/배치 비용이 줄어듭니다.
- OpenSearch에서
tenant_id,acl,doc_type,published_at같은 정책을 강제할 수 있어 “리랭커가 잘 골라주겠지”에 의존하지 않게 됩니다. - 장애 격리가 됩니다. Qdrant가 느리면 Qdrant만, OpenSearch가 느리면 OpenSearch만 프로파일링하면 됩니다.
인덱싱 전략: Qdrant는 chunk 중심, OpenSearch는 문서 중심
리랭킹 병목은 종종 “후보를 뽑아놓고 원문/메타데이터를 가져오는 비용”에서 시작됩니다. 인덱스를 다음처럼 나누면 fetch 비용이 줄어듭니다.
Qdrant 컬렉션:
chunk단위- 필드:
chunk_id,doc_id,embedding, (선택)tenant_id,lang - payload는 최소(필요한 필터만)
- 필드:
OpenSearch 인덱스:
chunk또는doc+chunk혼합- 리랭킹 입력에 필요한
chunk_text를 반드시 포함(최소한의 본문) - 필터/정렬에 필요한 메타데이터 포함
- 리랭킹 입력에 필요한
중요한 포인트는 “리랭킹 시점에 다른 저장소에서 원문을 다시 조회하지 않기”입니다. 리랭커 입력 텍스트는 OpenSearch에서 바로 가져오도록 두면 p95가 안정됩니다.
구현 예시: Qdrant 후보를 OpenSearch에서 재필터링
아래는 Python에서 Qdrant top_k=200을 가져온 뒤, OpenSearch에서 terms로 재조회하면서 필터/최신성/중복 제거를 적용하고, 최종 top_m=40만 리랭커로 넘기는 예시입니다.
from qdrant_client import QdrantClient
from opensearchpy import OpenSearch
qdrant = QdrantClient(url="http://qdrant:6333")
os = OpenSearch(hosts=[{"host": "opensearch", "port": 9200}])
def retrieve_candidates(query_vec, tenant_id: str, top_k: int = 200, top_m: int = 40):
# 1) Qdrant: 넓게 후보 확보 (payload 최소)
hits = qdrant.search(
collection_name="chunks",
query_vector=query_vec,
limit=top_k,
with_payload=["chunk_id", "doc_id", "tenant_id"],
with_vectors=False,
query_filter={
"must": [
{"key": "tenant_id", "match": {"value": tenant_id}},
]
},
)
chunk_ids = [h.payload["chunk_id"] for h in hits]
if not chunk_ids:
return []
# 2) OpenSearch: 정책/필터/정렬/중복 제거
# - terms로 chunk_id를 가져오고
# - 최신성 가중 또는 published_at 필터
# - doc_id collapse로 문서 중복을 줄임
body = {
"size": top_m,
"query": {
"bool": {
"filter": [
{"term": {"tenant_id": tenant_id}},
{"terms": {"chunk_id": chunk_ids}},
{"term": {"is_deleted": False}},
]
}
},
"sort": [
{"published_at": {"order": "desc"}},
{"qdrant_score": {"order": "desc"}}
],
"collapse": {"field": "doc_id"},
"_source": ["chunk_id", "doc_id", "chunk_text", "title", "url", "published_at"]
}
# 참고: qdrant_score는 미리 적재하거나, function_score로 대체 가능
res = os.search(index="rag-chunks", body=body)
return [h["_source"] for h in res["hits"]["hits"]]
qdrant_score는 어떻게 넣나
선택지는 3가지입니다.
- (단순) OpenSearch에서
terms로 가져온 뒤, 애플리케이션에서 Qdrant 점수를 매칭해 최종 정렬 - (하이브리드) OpenSearch에서 BM25로 1차 후보를 만들고, Qdrant 점수는 애플리케이션에서 합산
- (고급) OpenSearch에 dense vector를 함께 넣고
knn+ BM25 하이브리드로 후보 생성 자체를 OS에서 수행
이 글의 주제는 “Qdrant+OpenSearch 조합으로 리랭킹 지연을 줄이는 것”이므로, 가장 흔한 방식인 애플리케이션에서 점수 합산이 구현 대비 효과가 큽니다.
리랭킹 최적화 1: 후보군을 줄이는 규칙을 먼저 고정
리랭킹을 최적화할 때 흔한 실수는 “리랭커를 더 빠르게”만 고민하는 것입니다. 실제로는 다음 순서가 효율적입니다.
- 후보군을 줄이는 하드 필터를 먼저 확정
- 후보군을 줄이는 소프트 시그널(최신성, 인기도, doc_type 가중)을 OpenSearch에서 적용
- 그 다음에 리랭커 배치/캐시/모델 최적화
예를 들어 아래 조건은 리랭커가 해결해주기 어렵고, 앞단에서 강제하는 게 맞습니다.
- 테넌트/권한/비공개 문서
- 삭제/만료 문서
- 언어 불일치
- 동일 문서에서 과도한 chunk 중복
리랭킹 최적화 2: 캐시와 타임아웃을 “리랭커 앞”에 둔다
리랭커는 외부 모델 서버(vLLM, Triton, SageMaker 등)일 가능성이 높고, 간헐적 지연 스파이크가 생깁니다. 따라서 애플리케이션 레벨에서 다음을 기본값으로 두는 게 안전합니다.
- 요청 단위 타임아웃
- 재시도(단, 멱등/과금/중복 처리 고려)
- 결과 캐시(쿼리 정규화 + 후보 서명 기반)
재시도/백오프 패턴은 아래 글의 데코레이터 설계가 그대로 응용됩니다.
간단 예시는 다음과 같습니다.
import hashlib
import json
import time
from functools import lru_cache
def _sig(query: str, candidates: list[dict], model: str) -> str:
payload = {
"q": query.strip().lower(),
"ids": [c["chunk_id"] for c in candidates],
"m": model,
}
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@lru_cache(maxsize=5000)
def rerank_cached(sig: str):
# 실제 구현에서는 sig로 외부 캐시(redis 등) 조회 권장
return None
def rerank_with_timeout(query, candidates, model="bge-reranker", timeout_s=0.8):
sig = _sig(query, candidates, model)
cached = rerank_cached(sig)
if cached is not None:
return cached
t0 = time.time()
# pseudo: call_reranker(query, candidates)
result = []
while time.time() - t0 < timeout_s:
# 실제론 네트워크 호출 1회로 끝나야 함
result = candidates
break
# 타임아웃이면 리랭킹 없이 OpenSearch 정렬 결과를 그대로 사용
if not result:
result = candidates
return result
포인트는 “타임아웃 시 품질 저하를 감수하되, 전체 응답 SLA를 지킨다”입니다. RAG는 대화 UX이므로 p95를 안정화하는 편이 전체 만족도가 높습니다.
리랭킹 최적화 3: 비동기 2패스(즉시 응답 + 후속 갱신)
검색/QA UX에서 자주 쓰는 패턴은 아래입니다.
- 1패스: 리랭킹 없이 빠르게
top_n컨텍스트로 답변 생성 - 2패스: 리랭킹 결과가 도착하면 답변을 재생성하거나, “근거 문서”만 교체
스트리밍 UI라면 특히 효과가 큽니다. 사용자는 즉시 초안을 보고, 1초 내에 근거가 정제되면 신뢰도가 올라갑니다.
구현은 메시지 큐나 백그라운드 작업으로 분리합니다.
- 리랭킹 작업을 큐에 넣고
- 최초 응답에는
request_id를 포함 - 클라이언트는
request_id로 후속 결과를 폴링 또는 SSE로 수신
이때 외부 모델이 과부하일 경우를 대비해 큐잉/재시도 전략을 같이 둬야 합니다. 과부하 대응 패턴은 다음 글의 접근이 참고됩니다.
OpenSearch에서 후보를 더 줄이는 실전 트릭 5가지
1) collapse로 문서 단위 중복 제거
chunk 기반 RAG에서 같은 문서의 여러 chunk가 상위에 몰리면 리랭커가 비슷한 텍스트를 반복 평가합니다. OpenSearch의 collapse를 이용해 doc_id 단위로 먼저 줄이고, 이후 필요하면 문서 내에서 best chunk를 선택하는 방식이 효율적입니다.
2) min_should_match로 느슨한 키워드 가드레일
하이브리드에서도 완전히 무관한 후보가 섞이면 리랭커 비용이 낭비됩니다. 도메인에 따라 “쿼리 토큰 중 일부는 반드시 포함” 같은 가드레일이 효과적입니다.
3) 최신성/권위 점수는 리랭커가 아니라 OS에서
published_at, views, upvotes 같은 필드는 OpenSearch에서 function_score로 반영하고, 리랭커는 순수 관련도 판단에 집중시키는 편이 안정적입니다.
4) 필터는 Qdrant에도 최소한 넣고, OS에서 최종 확정
tenant_id, lang 같은 필터는 Qdrant에서 먼저 걸어 후보를 줄이고, ACL처럼 복잡한 정책은 OpenSearch에서 최종 확정하는 식으로 역할을 나누면 전체 비용이 내려갑니다.
5) _source는 리랭킹에 필요한 필드만
OpenSearch에서 원문 전체를 가져오면 네트워크/JSON 파싱이 병목이 됩니다. 리랭커 입력에 필요한 chunk_text도 길이를 제한하고, 나머지는 후속 클릭/근거 표시 시점에 가져오는 구조가 좋습니다.
성능 측정: p50이 아니라 p95를 보라
리랭킹 지연 최적화는 평균보다 “꼬리 지연”이 중요합니다. 최소한 아래를 분리해서 계측하세요.
- Qdrant search latency
- OpenSearch search latency
- 후보 텍스트 페이로드 크기(바이트)
- 리랭커 호출 latency(모델 서버 포함)
- 리랭커 입력 후보 수
그리고 다음 지표를 같이 봅니다.
top_k대비 리랭커 입력top_m비율- 캐시 히트율
- 타임아웃/폴백 비율
p95가 튀는 원인이 “모델 서버”인지 “OpenSearch fetch payload”인지 “후보 수 폭증”인지 분리되면, 50% 감축은 보통 구조 변경으로 달성됩니다.
운영 팁: 문서 수명주기와 TTL을 설계하면 리랭킹이 더 싸진다
RAG에서 후보군이 불필요하게 커지는 원인은 데이터가 계속 쌓이기만 하기 때문입니다. 오래된 문서/요약본/중복 chunk를 정리하고 TTL을 두면 검색 공간 자체가 줄어 p95가 내려갑니다.
특히 “원문 chunk는 일정 기간 후 요약 chunk로 대체” 같은 전략은 리랭커 입력 텍스트 길이를 줄여 비용을 크게 낮춥니다.
체크리스트: 리랭킹 지연 50%↓를 만드는 조합
아래 조합이 가장 재현성이 좋았습니다.
- Qdrant에서
top_k는 넓게(예: 200) 가져오되 payload 최소화 - OpenSearch에서
terms재조회 + 강한 필터 +collapse로top_m을 20~60으로 축소 - 리랭커는 타임아웃 + 캐시 + 폴백(리랭킹 없이도 응답)
- 가능하면 비동기 2패스로 UX를 분리
- 데이터 수명주기(TTL/요약/중복 제거)로 후보 공간을 줄이기
결론적으로, Qdrant+OpenSearch 조합의 본질은 “검색 품질을 유지하면서 리랭커에 들어가는 입력을 구조적으로 줄이는 것”입니다. 리랭커를 더 빠르게 만드는 최적화는 그 다음 단계이고, 후보 생성 단에서 병목을 먼저 제거하면 지연을 절반 수준으로 낮추는 데 훨씬 가깝게 갑니다.