Skip to content
Published on

RAG 파이프라인 고도화 전략: 청킹, 리랭킹, 하이브리드 검색 최적화

Authors
  • Name
    Twitter
RAG 파이프라인 고도화 전략

들어가며

RAG(Retrieval-Augmented Generation)를 프로덕션에 적용해 본 팀이라면 한 번쯤 이런 경험을 했을 것이다. "검색은 되는데 답변이 엉뚱하다", "관련 문서가 분명히 있는데 검색에 안 잡힌다", "짧은 질문에는 잘 되는데 복잡한 질문에는 헛소리를 한다". 이러한 문제의 근본 원인은 대부분 검색 품질에 있다. LLM이 아무리 똑똑해도 잘못된 컨텍스트를 받으면 잘못된 답변을 생성할 수밖에 없다.

이 글에서는 RAG 파이프라인의 검색 품질을 극대화하기 위한 세 가지 핵심 축을 다룬다.

  1. 청킹(Chunking): 문서를 어떻게 쪼갤 것인가
  2. 하이브리드 검색(Hybrid Search): Dense 벡터와 Sparse 키워드 검색을 어떻게 결합할 것인가
  3. 리랭킹(Reranking): 검색 결과를 어떻게 재정렬할 것인가

각 전략별로 실전 코드, 벤치마크 수치, 운영 시 주의사항을 구체적으로 살펴본다. 2026년 3월 기준 최신 모델과 도구를 기반으로 작성했다.

RAG 파이프라인 아키텍처 개요

고도화된 RAG 파이프라인의 전체 흐름은 다음과 같다.

[인덱싱 단계]
문서 수집 → 전처리 → 청킹 → 임베딩 → 벡터 DB 저장 + 역인덱스 저장

[쿼리 단계]
질문 → 쿼리 변환 → 하이브리드 검색 (Dense + Sparse)
     → 리랭킹 → 상위 K개 선택 → 프롬프트 구성 → LLM 생성

기본 RAG와 비교했을 때 핵심적인 차이점은 세 가지다.

  • 청킹 전략 세분화: 단순 고정 크기가 아닌 의미 단위, 재귀적, 에이전틱 청킹 적용
  • 하이브리드 검색: 벡터 유사도만 의존하지 않고 BM25 키워드 검색을 병합
  • 리랭킹 레이어 추가: 초기 검색 결과를 Cross-Encoder로 재평가하여 정밀도 향상

이 세 가지를 조합하면 검색 정확도(Precision@K)를 30~50% 이상 개선할 수 있다. 아래에서 각각을 심층적으로 살펴보자.

청킹 전략 심화

청킹은 RAG 파이프라인에서 가장 먼저 결정해야 하는 요소이면서, 전체 성능에 미치는 영향이 가장 크다. 잘못된 청킹은 이후 어떤 고도화를 해도 복구하기 어렵다.

청킹 전략 비교표

전략원리적합한 상황청크 크기장점단점
Fixed-size고정 토큰/문자 수로 분할균일한 구조의 문서256-512 토큰구현 단순, 빠름의미 단위 무시
Recursive구분자 계층으로 재귀 분할범용 텍스트512-1024 토큰문단/문장 경계 존중구분자 설정 필요
Semantic임베딩 유사도로 의미 경계 탐지주제 전환이 잦은 문서가변의미 보존 우수임베딩 비용, 느림
AgenticLLM이 문서 구조 분석 후 분할복잡한 기술 문서가변최고 품질비용 높음, 느림

Fixed-size 청킹

가장 단순하지만 여전히 효과적인 전략이다. 2026년 벤치마크에서도 512 토큰 고정 크기 청킹이 복잡한 시맨틱 청킹보다 나은 결과를 보이는 경우가 보고되고 있다.

from langchain_text_splitters import CharacterTextSplitter

# 고정 크기 청킹 - 가장 기본적인 방식
fixed_splitter = CharacterTextSplitter(
    separator="\n",
    chunk_size=512,
    chunk_overlap=50,       # 10% 오버랩 권장
    length_function=len,
    is_separator_regex=False,
)

chunks = fixed_splitter.split_text(document_text)
print(f"총 {len(chunks)}개 청크 생성")

권장 설정:

  • 팩토이드(사실 확인) 쿼리: 256~512 토큰
  • 분석형 쿼리: 1024+ 토큰
  • 오버랩: 전체 청크 크기의 10~20%

Recursive 청킹

LangChain의 RecursiveCharacterTextSplitter는 구분자 계층을 따라 재귀적으로 분할한다. 문단, 문장, 단어 순서로 경계를 존중하면서 목표 크기에 맞춘다.

from langchain_text_splitters import RecursiveCharacterTextSplitter

# 재귀적 청킹 - 프로덕션 기본 추천
recursive_splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", ". ", " ", ""],
    chunk_size=512,
    chunk_overlap=64,
    length_function=len,
    add_start_index=True,   # 원문 위치 추적용
)

chunks = recursive_splitter.split_documents(documents)

# 각 청크에 메타데이터 자동 포함
for chunk in chunks[:3]:
    print(f"크기: {len(chunk.page_content)}, "
          f"시작 위치: {chunk.metadata.get('start_index')}")

실전 팁: 대부분의 프로덕션 환경에서는 Recursive 청킹으로 시작하는 것을 권장한다. 단순하면서도 문단 경계를 존중하기 때문에 비용 대비 효과가 가장 좋다.

Semantic 청킹

임베딩을 사용하여 인접한 문장 간 의미 유사도를 계산하고, 유사도가 급격히 떨어지는 지점에서 분할한다.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

# 시맨틱 청킹 - 의미 단위 분할
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

semantic_splitter = SemanticChunker(
    embeddings=embeddings,
    breakpoint_threshold_type="percentile",  # percentile, standard_deviation, interquartile
    breakpoint_threshold_amount=75,          # 75 퍼센타일 이상 차이나면 분할
)

semantic_chunks = semantic_splitter.split_text(document_text)
print(f"시맨틱 청크 수: {len(semantic_chunks)}")
print(f"평균 청크 길이: {sum(len(c) for c in semantic_chunks) / len(semantic_chunks):.0f}")

주의: 시맨틱 청킹은 모든 문장 쌍에 대해 임베딩을 생성해야 하므로, 대규모 문서에서는 비용과 시간이 상당히 증가한다. 10만 건 이상의 문서를 처리할 때는 Recursive 청킹이 더 현실적이다.

Agentic 청킹

LLM을 활용하여 문서의 논리적 구조를 파악하고 최적의 분할 지점을 결정한다. 가장 정교하지만 비용이 높다.

from openai import OpenAI

client = OpenAI()

def agentic_chunk(text: str, max_chunks: int = 20) -> list[dict]:
    """LLM 기반 에이전틱 청킹"""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    "주어진 텍스트를 논리적 단위로 분할하세요. "
                    "각 청크는 하나의 완결된 주제를 다뤄야 합니다. "
                    "JSON 배열로 반환하세요: "
                    '[{"title": "청크 제목", "content": "청크 내용", "summary": "한줄 요약"}]'
                ),
            },
            {"role": "user", "content": text[:8000]},  # 토큰 제한 주의
        ],
        response_format={"type": "json_object"},
        temperature=0,
    )
    import json
    result = json.loads(response.choices[0].message.content)
    return result.get("chunks", [])

# 사용 예시
chunks = agentic_chunk(long_document_text)
for chunk in chunks:
    print(f"[{chunk['title']}] {chunk['summary']}")

비용 고려: Agentic 청킹은 문서당 LLM API 호출이 발생하므로, 대량 인덱싱에는 적합하지 않다. 고가치 문서(계약서, 기술 사양서 등) 소량 처리에 적합하다.

임베딩 모델 선택과 최적화

청킹 이후 벡터 임베딩 모델의 선택은 검색 품질에 직접적으로 영향을 미친다.

주요 임베딩 모델 비교

모델차원최대 토큰다국어MTEB 점수특징
text-embedding-3-large30728191O64.6OpenAI 최신, 차원 축소 가능
text-embedding-3-small15368191O62.3비용 효율적
BAAI/bge-m310248192O68.2오픈소스, Dense+Sparse 동시
Cohere embed-v41024512O66.1멀티모달 지원
voyage-3-large102432000O67.5긴 컨텍스트 특화
from langchain_openai import OpenAIEmbeddings

# OpenAI 임베딩 - 차원 축소 활용
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-large",
    dimensions=1024,  # 3072 -> 1024로 축소하여 비용/저장소 절약
)

# BGE-M3: Dense + Sparse 동시 생성 (하이브리드 검색에 최적)
from FlagEmbedding import BGEM3FlagModel

bge_model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)

# Dense와 Sparse 벡터를 동시에 생성
output = bge_model.encode(
    ["RAG 파이프라인 최적화 방법론"],
    return_dense=True,
    return_sparse=True,
)

dense_vector = output["dense_vecs"][0]    # (1024,) float 벡터
sparse_vector = output["lexical_weights"][0]  # 희소 벡터 (단어별 가중치)

print(f"Dense 차원: {len(dense_vector)}")
print(f"Sparse 활성 토큰 수: {len(sparse_vector)}")

실전 권장사항:

  • 비용 우선: text-embedding-3-small (OpenAI) 또는 bge-m3 (셀프호스팅)
  • 품질 우선: text-embedding-3-large 또는 voyage-3-large
  • 하이브리드 검색 계획 시: bge-m3 (Dense + Sparse 동시 생성으로 인프라 단순화)

벡터 데이터베이스 비교

벡터 DB 선택은 운영 복잡도, 비용, 성능에 큰 영향을 미친다.

주요 벡터 데이터베이스 비교

항목PineconeWeaviateQdrantMilvus
호스팅관리형(Serverless)관리형 + 셀프호스팅관리형 + 셀프호스팅셀프호스팅 중심(Zilliz Cloud)
하이브리드 검색지원(Sparse 벡터)네이티브 지원지원(Sparse 벡터)지원
메타데이터 필터링기본GraphQL 기반 강력Rust 기반 고성능기본
무료 티어Starter(100K 벡터)Sandbox1GB 무료(영구)오픈소스
쿼리 레이턴시50ms 이하50-100ms50ms 이하30-50ms
확장성자동 스케일링수동 설정 필요수평 확장K8s 네이티브
언어 SDKPython, JS, GoPython, JS, Go, JavaPython, JS, Rust, GoPython, JS, Go, Java
적합한 팀운영 최소화 원하는 팀OSS + 유연성 원하는 팀필터링 복잡한 경우대규모 엔터프라이즈

선택 가이드:

  • 빠른 시작 + 운영 부담 최소화: Pinecone
  • 셀프호스팅 + 복잡한 필터링: Qdrant
  • 오픈소스 + 하이브리드 검색 네이티브: Weaviate
  • 대규모(10억+ 벡터) + GPU 가속: Milvus/Zilliz

하이브리드 검색: Dense + Sparse 결합

벡터 검색만으로는 키워드 정확 매칭이 어렵고, BM25만으로는 의미적 유사도를 잡을 수 없다. 하이브리드 검색은 두 방식을 결합하여 재현율(Recall)을 15~30% 향상시킨다.

하이브리드 검색 아키텍처

질문: "Kubernetes HPA 설정에서 CPU 임계값은?"

Dense 검색 (벡터 유사도):
"컨테이너 오케스트레이션에서 자동 스케일링 설정 방법" (의미적 유사)

Sparse 검색 (BM25 키워드 매칭):
"HPA의 targetCPUUtilizationPercentage를 80으로 설정" (키워드 정확 매칭)

하이브리드 결합 (RRF 또는 가중합):
  → 두 결과를 병합하여 최적의 문서 반환

Weaviate 하이브리드 검색 구현

import weaviate, { WeaviateClient } from 'weaviate-client'

// Weaviate 클라이언트 연결
const client: WeaviateClient = await weaviate.connectToLocal({
  host: 'localhost',
  port: 8080,
})

// 하이브리드 검색 실행
const collection = client.collections.get('Documents')

const result = await collection.query.hybrid('Kubernetes HPA CPU 임계값', {
  alpha: 0.7, // 0 = 순수 BM25, 1 = 순수 벡터, 0.7 = 벡터 70%
  limit: 20, // 리랭킹 전 후보 수
  fusionType: 'RelativeScore', // RelativeScore 또는 Ranked
  returnMetadata: ['score', 'explainScore'],
  returnProperties: ['title', 'content', 'source'],
})

for (const item of result.objects) {
  console.log(`[${item.metadata?.score?.toFixed(3)}] ${item.properties.title}`)
}

Python에서 BM25 + Dense 직접 구현

벡터 DB의 네이티브 하이브리드 검색을 사용할 수 없는 경우, Reciprocal Rank Fusion(RRF)으로 직접 구현할 수 있다.

from rank_bm25 import BM25Okapi
import numpy as np
from typing import List, Tuple

def hybrid_search(
    query: str,
    documents: list[dict],
    dense_scores: np.ndarray,
    k: int = 10,
    alpha: float = 0.7,
    rrf_k: int = 60,
) -> list[dict]:
    """
    RRF 기반 하이브리드 검색
    alpha: Dense 검색 가중치 (1-alpha가 Sparse 가중치)
    rrf_k: RRF 상수 (기본값 60, 논문 권장)
    """
    # Sparse 검색 (BM25)
    tokenized_docs = [doc["content"].split() for doc in documents]
    bm25 = BM25Okapi(tokenized_docs)
    sparse_scores = bm25.get_scores(query.split())

    # Dense 순위 계산
    dense_ranks = np.argsort(-dense_scores) + 1  # 1-indexed rank
    sparse_ranks = np.argsort(-sparse_scores) + 1

    # RRF 점수 계산
    rrf_scores = []
    for i in range(len(documents)):
        dense_rrf = alpha / (rrf_k + dense_ranks[i])
        sparse_rrf = (1 - alpha) / (rrf_k + sparse_ranks[i])
        rrf_scores.append(dense_rrf + sparse_rrf)

    # 상위 K개 반환
    top_indices = np.argsort(rrf_scores)[::-1][:k]
    return [
        {**documents[i], "hybrid_score": rrf_scores[i]}
        for i in top_indices
    ]

# 사용 예시
results = hybrid_search(
    query="Kubernetes HPA CPU threshold",
    documents=all_documents,
    dense_scores=embedding_similarity_scores,
    k=20,       # 리랭킹 전이므로 넉넉하게
    alpha=0.7,  # Dense 70%, Sparse 30%
)

Alpha 값 튜닝 가이드

쿼리 유형권장 alpha이유
전문 용어가 많은 기술 쿼리0.3~0.5키워드 정확 매칭 중요
자연어 질문0.7~0.8의미적 유사도 중요
코드 검색0.2~0.4함수명, 변수명 정확 매칭
일반 FAQ0.5~0.6균형 있는 검색

핵심: 쿼리 유형별로 alpha를 동적으로 조정하면 정적 설정 대비 Precision@1에서 2~7.5% 포인트 향상을 기대할 수 있다.

리랭킹 모델 적용

하이브리드 검색으로 후보를 넓힌 뒤, 리랭킹 모델로 최종 순위를 정밀하게 조정한다. 리랭커는 쿼리와 문서를 함께 입력받아(Cross-Encoding) 직접 관련도 점수를 산출하므로, Bi-Encoder 임베딩보다 정확도가 높다.

리랭킹 모델 비교

모델유형파라미터다국어레이턴시 (100건)비용
Cohere Rerank 4API비공개100+ 언어200-400ms종량제
BAAI/bge-reranker-v2-m3오픈소스0.6BO500-800ms (GPU)무료
BAAI/bge-reranker-large오픈소스560M제한적400-600ms (GPU)무료
cross-encoder/ms-marco-MiniLM-L-12-v2오픈소스33MX(영어)100-200ms (GPU)무료

Cohere Rerank 적용

import cohere

co = cohere.ClientV2(api_key="your-cohere-api-key")

def rerank_with_cohere(
    query: str,
    documents: list[str],
    top_n: int = 5,
) -> list[dict]:
    """Cohere Rerank 4로 문서 재순위"""
    response = co.rerank(
        model="rerank-v3.5",
        query=query,
        documents=documents,
        top_n=top_n,
        return_documents=True,
    )

    results = []
    for item in response.results:
        results.append({
            "index": item.index,
            "score": item.relevance_score,
            "text": item.document.text if item.document else documents[item.index],
        })
    return results

# 사용 예시: 하이브리드 검색 결과 20건을 리랭킹하여 상위 5건 선택
hybrid_results = hybrid_search(query, documents, dense_scores, k=20)
reranked = rerank_with_cohere(
    query="Kubernetes HPA CPU 임계값 설정 방법",
    documents=[r["content"] for r in hybrid_results],
    top_n=5,
)

for r in reranked:
    print(f"[{r['score']:.4f}] {r['text'][:80]}...")

BGE Reranker 셀프호스팅

API 비용을 피하고 싶거나 데이터 외부 전송이 불가능한 환경에서는 오픈소스 리랭커를 직접 호스팅한다.

from FlagEmbedding import FlagReranker

# BGE Reranker v2 M3 - 다국어 지원 경량 리랭커
reranker = FlagReranker(
    "BAAI/bge-reranker-v2-m3",
    use_fp16=True,  # GPU 메모리 절약
)

def rerank_with_bge(
    query: str,
    documents: list[str],
    top_n: int = 5,
) -> list[dict]:
    """BGE Reranker로 문서 재순위"""
    # 쿼리-문서 쌍 생성
    pairs = [[query, doc] for doc in documents]

    # 관련도 점수 계산
    scores = reranker.compute_score(pairs, normalize=True)

    # 점수 기준 정렬
    scored_docs = [
        {"index": i, "score": s, "text": d}
        for i, (s, d) in enumerate(zip(scores, documents))
    ]
    scored_docs.sort(key=lambda x: x["score"], reverse=True)

    return scored_docs[:top_n]

# 사용 예시
results = rerank_with_bge(
    query="RAG 파이프라인에서 청킹 크기를 결정하는 기준은?",
    documents=candidate_documents,
    top_n=5,
)

for r in results:
    print(f"[{r['score']:.4f}] {r['text'][:100]}...")

전체 파이프라인 통합

청킹, 하이브리드 검색, 리랭킹을 하나의 파이프라인으로 통합하는 예시다.

from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

class OptimizedRAGPipeline:
    """고도화된 RAG 파이프라인"""

    def __init__(self):
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=512, chunk_overlap=64
        )
        self.embeddings = OpenAIEmbeddings(
            model="text-embedding-3-large", dimensions=1024
        )
        self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
        self.reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)

    def query(self, question: str, top_k: int = 5) -> str:
        # 1. 하이브리드 검색: 후보 20건
        candidates = self._hybrid_search(question, k=20)

        # 2. 리랭킹: 상위 5건 선택
        reranked = self._rerank(question, candidates, top_n=top_k)

        # 3. 프롬프트 구성 및 LLM 생성
        context = "\n\n---\n\n".join([doc["text"] for doc in reranked])
        prompt = ChatPromptTemplate.from_messages([
            ("system", "다음 컨텍스트를 기반으로 질문에 답변하세요.\n\n{context}"),
            ("human", "{question}"),
        ])
        chain = prompt | self.llm
        response = chain.invoke({"context": context, "question": question})
        return response.content

    def _hybrid_search(self, query: str, k: int = 20) -> list[dict]:
        # Dense + Sparse 하이브리드 검색 (위 코드 참조)
        ...

    def _rerank(self, query: str, docs: list[dict], top_n: int) -> list[dict]:
        pairs = [[query, doc["text"]] for doc in docs]
        scores = self.reranker.compute_score(pairs, normalize=True)
        for doc, score in zip(docs, scores):
            doc["rerank_score"] = score
        docs.sort(key=lambda x: x["rerank_score"], reverse=True)
        return docs[:top_n]

평가 지표와 벤치마킹

RAG 파이프라인을 최적화하려면 정량적 평가가 필수다. "감으로 좋아진 것 같다"는 프로덕션에서 통하지 않는다.

RAGAS 프레임워크

RAGAS(Retrieval-Augmented Generation Assessment Suite)는 RAG 전용 평가 프레임워크로, Ground Truth 없이도 LLM을 평가자로 활용하여 자동 채점한다.

from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from datasets import Dataset

# 평가 데이터셋 구성
eval_data = {
    "question": [
        "Kubernetes HPA의 CPU 임계값 기본값은?",
        "RAG에서 청킹 크기를 결정하는 기준은?",
    ],
    "answer": [
        "HPA의 CPU 임계값 기본값은 80%입니다.",
        "쿼리 유형에 따라 256-1024 토큰 범위에서 결정합니다.",
    ],
    "contexts": [
        ["HPA는 targetCPUUtilizationPercentage 기본값 80을 사용합니다."],
        ["팩토이드 쿼리는 256-512, 분석형은 1024+ 토큰을 권장합니다."],
    ],
    "ground_truth": [
        "기본값은 80%이다.",
        "쿼리 유형과 문서 특성에 따라 결정한다.",
    ],
}

dataset = Dataset.from_dict(eval_data)

# 평가 실행
results = evaluate(
    dataset=dataset,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)

print(results)
# faithfulness: 0.92, answer_relevancy: 0.88,
# context_precision: 0.85, context_recall: 0.90

DeepEval로 단위 테스트

DeepEval은 Pytest 스타일로 RAG를 테스트할 수 있어 CI/CD 파이프라인에 통합하기 좋다.

from deepeval import assert_test
from deepeval.test_case import LLMTestCase
from deepeval.metrics import (
    FaithfulnessMetric,
    ContextualRelevancyMetric,
    AnswerRelevancyMetric,
)

def test_rag_faithfulness():
    """RAG 응답이 컨텍스트에 충실한지 테스트"""
    test_case = LLMTestCase(
        input="Kubernetes HPA CPU 임계값은?",
        actual_output="HPA의 CPU 임계값 기본값은 80%입니다.",
        retrieval_context=[
            "HPA는 targetCPUUtilizationPercentage 기본값 80을 사용합니다."
        ],
    )

    faithfulness = FaithfulnessMetric(threshold=0.8)
    relevancy = ContextualRelevancyMetric(threshold=0.7)
    answer_rel = AnswerRelevancyMetric(threshold=0.7)

    assert_test(test_case, [faithfulness, relevancy, answer_rel])

# pytest로 실행: pytest test_rag.py -v

핵심 평가 지표 정리

지표측정 대상기대값도구
Context Precision검색된 컨텍스트의 관련성0.8 이상RAGAS
Context Recall필요한 컨텍스트의 검색 비율0.85 이상RAGAS
Faithfulness응답이 컨텍스트에 충실한 정도0.9 이상RAGAS, DeepEval
Answer Relevancy응답이 질문에 적합한 정도0.85 이상RAGAS, DeepEval
MRR@K첫 번째 관련 문서의 역순위 평균0.7 이상커스텀
NDCG@K순위 품질의 정규화 할인 누적 이득0.75 이상커스텀

운영 시 주의사항과 트러블슈팅

1. 임베딩 모델 변경 시 전체 재인덱싱 필요

임베딩 모델을 업그레이드하면 기존 벡터와 새 벡터의 공간이 달라진다. 부분 재인덱싱은 검색 품질을 심각하게 저하시킨다.

대응: Blue-Green 인덱스 전략을 사용한다. 새 모델로 별도 인덱스를 완성한 뒤 트래픽을 전환한다.

# Blue-Green 인덱스 전환 예시
import time

def reindex_with_blue_green(
    old_collection: str,
    new_collection: str,
    new_embedding_model: str,
):
    """무중단 재인덱싱"""
    # 1. 새 컬렉션에 인덱싱 (기존 서비스는 old_collection 사용 중)
    print(f"새 컬렉션 '{new_collection}' 인덱싱 시작...")
    create_and_populate_collection(new_collection, new_embedding_model)

    # 2. 검증: 새 컬렉션에 대해 테스트 쿼리 실행
    test_results = run_evaluation_suite(new_collection)
    if test_results["context_precision"] < 0.8:
        raise ValueError(
            f"새 인덱스 품질 미달: {test_results['context_precision']:.2f}"
        )

    # 3. 트래픽 전환: alias 또는 설정 변경
    update_active_collection(new_collection)
    print(f"트래픽 전환 완료: {old_collection} -> {new_collection}")

    # 4. 이전 컬렉션은 롤백 대비 일정 기간 유지
    schedule_cleanup(old_collection, delay_days=7)

2. 청크 크기와 임베딩 모델의 토큰 제한 불일치

청크가 임베딩 모델의 최대 토큰 수를 초과하면 잘리거나 오류가 발생한다.

대응: 청킹 단계에서 임베딩 모델의 토큰 제한을 고려한 길이 함수를 사용한다.

import tiktoken

enc = tiktoken.encoding_for_model("text-embedding-3-small")

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    length_function=lambda text: len(enc.encode(text)),  # 토큰 수 기준
)

3. 리랭킹 레이턴시 관리

리랭커는 Cross-Encoder이므로 후보 수에 비례하여 레이턴시가 증가한다. 100건 리랭킹 시 500ms~1초가 추가된다.

대응:

  • 하이브리드 검색에서 후보를 20~30건으로 제한
  • 배치 처리 시 비동기 호출 활용
  • 오픈소스 리랭커는 GPU 서버에 배포하여 레이턴시 확보

4. 벡터 DB 인덱스 메모리 관리

HNSW 인덱스는 메모리에 전체 그래프를 유지해야 한다. 100만 벡터(1024차원)는 약 4~8GB 메모리를 사용한다.

대응:

  • 벡터 차원 축소 (3072 -> 1024)
  • 양자화(Quantization) 적용: Scalar, Product, Binary 양자화
  • DiskANN 인덱스 활용 (Milvus 지원)

5. 메타데이터 필터와 검색 성능

메타데이터 필터가 과도하면 벡터 검색 성능이 급격히 저하된다. 특히 카디널리티가 높은 필드(타임스탬프, 사용자 ID 등)에 필터를 걸면 문제가 심해진다.

대응:

  • 카디널리티가 낮은 필드만 필터에 사용 (카테고리, 부서, 문서 유형 등)
  • 날짜 필터는 범위를 넓게 잡고 리랭킹에서 최신성 가중치 적용

실패 사례와 복구 절차

사례 1: 시맨틱 청킹으로 전환 후 검색 품질 하락

상황: Recursive 청킹에서 Semantic 청킹으로 전환했더니 오히려 Context Precision이 0.85에서 0.72로 하락했다.

원인: 시맨틱 청킹이 생성한 청크 크기가 너무 불균일했다. 일부 청크는 50토큰, 일부는 2000토큰으로 임베딩 품질이 들쑥날쑥했다.

복구:

  1. 시맨틱 청킹 결과에 최소/최대 크기 제한을 추가
  2. 기존 Recursive 청킹 인덱스로 즉시 롤백 (Blue-Green 방식이었으므로 가능)
  3. 최소 200토큰, 최대 800토큰 제한을 건 시맨틱 청킹으로 재시도

사례 2: 하이브리드 검색 alpha 고정으로 인한 특정 쿼리 유형 성능 저하

상황: alpha=0.7 고정으로 운영 중, 코드 검색 쿼리에서 정확한 함수명을 찾지 못하는 문제 다수 발생.

원인: 코드 관련 쿼리는 키워드 정확 매칭이 중요한데, Dense 가중치가 너무 높았다.

복구:

  1. 쿼리 분류기를 추가하여 쿼리 유형 자동 판별
  2. 코드/기술 쿼리는 alpha=0.3, 자연어 질문은 alpha=0.7로 동적 조정
  3. 분류기 자체는 경량 모델(distilbert 기반)로 레이턴시 10ms 미만

사례 3: 리랭커 장애 시 서비스 다운

상황: Cohere Rerank API 장애로 전체 RAG 파이프라인이 응답 불가 상태.

원인: 리랭커 호출을 필수 단계로 구성하고 폴백 로직이 없었다.

복구:

  1. 리랭킹 단계를 Optional로 변경
  2. 타임아웃(2초) 초과 또는 API 오류 시 하이브리드 검색 결과를 그대로 반환
  3. 셀프호스팅 BGE Reranker를 보조 리랭커로 배포하여 이중화
import asyncio

async def rerank_with_fallback(
    query: str,
    documents: list[str],
    top_n: int = 5,
    timeout: float = 2.0,
) -> list[dict]:
    """리랭킹 with 폴백"""
    try:
        # Primary: Cohere Rerank (타임아웃 2초)
        result = await asyncio.wait_for(
            cohere_rerank_async(query, documents, top_n),
            timeout=timeout,
        )
        return result
    except (asyncio.TimeoutError, Exception) as e:
        print(f"Cohere 리랭킹 실패, BGE 폴백: {e}")
        try:
            # Fallback: 셀프호스팅 BGE Reranker
            return rerank_with_bge(query, documents, top_n)
        except Exception as e2:
            print(f"BGE 리랭킹도 실패, 원본 순위 반환: {e2}")
            # 최종 폴백: 하이브리드 검색 결과 그대로 반환
            return [
                {"index": i, "score": 1.0 - i * 0.05, "text": d}
                for i, d in enumerate(documents[:top_n])
            ]

사례 4: 대량 문서 재인덱싱 중 서비스 품질 저하

상황: 10만 건 문서 재인덱싱 중 임베딩 API 호출이 급증하여 Rate Limit에 걸리고, 실시간 쿼리의 임베딩 응답도 지연.

복구:

  1. 인덱싱과 쿼리의 API 키/엔드포인트를 분리
  2. 인덱싱은 배치 크기 제어와 Rate Limit 대응 로직 추가
  3. 야간 시간대에 인덱싱 실행하여 쿼리 트래픽과 경합 회피

마치며

RAG 파이프라인 고도화는 단일 기술이 아니라 청킹, 검색, 리랭킹, 평가의 조합이다. 각 단계를 독립적으로 최적화하되, 전체 파이프라인의 평가 지표를 기준으로 의사결정해야 한다.

실전 적용 순서 권장:

  1. Recursive 청킹 + Dense 검색으로 베이스라인 구축 (1주)
  2. RAGAS/DeepEval로 평가 파이프라인 구축 (1주)
  3. 하이브리드 검색 추가하여 Recall 개선 (1주)
  4. 리랭킹 추가하여 Precision 개선 (1주)
  5. 쿼리별 동적 alpha 및 평가 기반 지속 개선 (지속)

"한 번에 모든 것을 적용하자"는 접근은 실패한다. 각 단계를 추가할 때마다 평가 지표의 변화를 확인하고, 오히려 나빠지면 즉시 롤백하는 것이 프로덕션에서의 정석이다.

참고자료