Published on

Qdrant+OpenSearch로 RAG 리랭킹 지연 50% 줄이기

Authors

RAG 파이프라인에서 체감 지연을 가장 크게 만드는 구간은 의외로 LLM 호출이 아니라 리랭킹(re-ranking)인 경우가 많습니다. 특히 top_k=50~200 후보를 뽑아놓고 Cross-Encoder나 LLM 기반 리랭커로 전부 재평가하면, CPU/GPU·네트워크·직렬 처리 때문에 p95가 급격히 튑니다.

이 글은 Qdrant(벡터 1차 검색)OpenSearch(텍스트/필터/하이브리드+서빙)를 역할 분리해 리랭킹 대상 후보군을 더 빨리, 더 작게 만들고, 리랭킹 자체도 캐시/비동기/배치로 다듬어 리랭킹 지연을 50% 수준까지 낮추는 설계를 정리합니다.

아래 내용은 특정 벤치마크 수치가 아니라, 실무에서 반복적으로 효과가 컸던 병목 제거 포인트를 조합한 “감속 요인” 중심의 가이드입니다.

왜 Qdrant+OpenSearch 조합이 리랭킹에 유리한가

전형적인 단일 벡터DB 기반 RAG는 다음 흐름입니다.

  1. 벡터 검색으로 top_k 후보 확보
  2. 후보 문서 원문/메타데이터 로드
  3. 리랭커로 재정렬
  4. 최종 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: 후보군을 줄이는 규칙을 먼저 고정

리랭킹을 최적화할 때 흔한 실수는 “리랭커를 더 빠르게”만 고민하는 것입니다. 실제로는 다음 순서가 효율적입니다.

  1. 후보군을 줄이는 하드 필터를 먼저 확정
  2. 후보군을 줄이는 소프트 시그널(최신성, 인기도, doc_type 가중)을 OpenSearch에서 적용
  3. 그 다음에 리랭커 배치/캐시/모델 최적화

예를 들어 아래 조건은 리랭커가 해결해주기 어렵고, 앞단에서 강제하는 게 맞습니다.

  • 테넌트/권한/비공개 문서
  • 삭제/만료 문서
  • 언어 불일치
  • 동일 문서에서 과도한 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%↓를 만드는 조합

아래 조합이 가장 재현성이 좋았습니다.

  1. Qdrant에서 top_k는 넓게(예: 200) 가져오되 payload 최소화
  2. OpenSearch에서 terms 재조회 + 강한 필터 + collapsetop_m을 20~60으로 축소
  3. 리랭커는 타임아웃 + 캐시 + 폴백(리랭킹 없이도 응답)
  4. 가능하면 비동기 2패스로 UX를 분리
  5. 데이터 수명주기(TTL/요약/중복 제거)로 후보 공간을 줄이기

결론적으로, Qdrant+OpenSearch 조합의 본질은 “검색 품질을 유지하면서 리랭커에 들어가는 입력을 구조적으로 줄이는 것”입니다. 리랭커를 더 빠르게 만드는 최적화는 그 다음 단계이고, 후보 생성 단에서 병목을 먼저 제거하면 지연을 절반 수준으로 낮추는 데 훨씬 가깝게 갑니다.