Published on

RAG 평가 자동화 - Ragas+Qdrant로 품질·비용 최적화

Authors

서빙 환경에서 RAG는 “그럴듯한 답”을 잘 만들지만, 운영 관점에서는 두 가지가 늘 발목을 잡습니다. 첫째는 품질 회귀입니다. 임베딩 모델 교체, 청크 전략 변경, 리랭커 추가 같은 개선이 오히려 특정 도메인에서 성능을 떨어뜨리기도 합니다. 둘째는 비용입니다. 더 긴 컨텍스트, 더 많은 후보 문서, 더 강한 모델을 쓰면 품질은 오르지만 토큰과 지연시간이 빠르게 증가합니다.

이 글은 Ragas로 RAG 품질을 자동 평가하고, Qdrant를 평가 데이터셋과 실험 결과의 “벡터 기반 인덱스”로 활용해 품질·비용을 함께 최적화하는 방법을 다룹니다. 목표는 단순히 점수를 내는 것이 아니라, 다음을 자동화하는 것입니다.

  • 변경사항이 들어올 때마다 회귀를 탐지하는 CI 평가 n- 실패 케이스를 자동으로 모으고(클러스터링) 원인을 분류
  • 품질 지표와 토큰 비용 지표를 함께 보고 “가성비”가 좋은 설정을 선택

왜 RAG 평가는 자동화가 어려운가

RAG 평가는 일반 분류/회귀와 달리 정답이 하나로 고정되지 않습니다. 질문에 대한 답이 여러 형태로 가능하고, 문서 컨텍스트가 충분하면 모델이 다양한 표현으로 맞는 답을 낼 수 있습니다. 그래서 단순한 문자열 매칭 기반 정확도는 거의 의미가 없습니다.

또한 RAG는 파이프라인입니다.

  • 검색(리트리버): 임베딩, 인덱스, 필터, top_k
  • 재정렬(리랭커): cross-encoder, LLM rerank
  • 생성(제너레이터): 프롬프트, 컨텍스트 길이, 모델

어느 단계가 바뀌었는지에 따라 실패 양상이 달라집니다. 따라서 “검색이 관련 문서를 가져왔는지”, “답이 컨텍스트에 근거했는지”, “답이 질문을 충족하는지”를 분리해 측정해야 합니다. 이때 유용한 게 Ragas의 RAG 특화 지표들입니다.

Ragas 핵심 지표: 무엇을 보고, 무엇을 버릴지

Ragas는 RAG 파이프라인을 평가하기 위해 여러 지표를 제공합니다. 모든 지표를 다 쓰기보다, 운영에서 자주 쓰는 최소 세트를 먼저 고정하는 편이 좋습니다.

1) Faithfulness(근거성)

답변이 제공된 컨텍스트에 근거하는지 측정합니다. 환각을 줄이는 데 가장 직관적인 지표입니다.

  • 점수가 낮으면: 생성 모델이 컨텍스트 밖 정보를 섞거나, 컨텍스트가 부정확/부족하거나, 프롬프트가 과도하게 추론을 허용할 수 있습니다.

2) Answer Relevancy(답변 적합성)

답변이 질문에 제대로 답했는지 측정합니다. 컨텍스트에만 충실하지만 질문을 빗나가는 경우를 잡습니다.

  • 점수가 낮으면: 검색 결과가 질문 의도와 어긋났거나, 프롬프트가 장황한 설명을 유도했거나, 질문 분해가 필요할 수 있습니다.

3) Context Precision/Recall(컨텍스트 품질)

검색이 “필요한 정보”를 가져왔는지(Recall), 가져온 것 중 “쓸모있는 정보 비율”이 높은지(Precision)를 봅니다.

  • Recall이 낮으면: 인덱싱/청킹/임베딩/필터링 문제
  • Precision이 낮으면: top_k 과다, 필터 약함, 하이브리드 검색 미사용, 리랭커 부재

4) 비용 지표(토큰·지연)

Ragas 자체 지표는 아니지만, 운영에서는 품질 점수와 함께 반드시 기록해야 합니다.

  • 입력 토큰: 질문 + 컨텍스트
  • 출력 토큰: 답변
  • 호출 횟수: 리랭커/평가 LLM 포함
  • 지연시간: p50, p95

이 글의 핵심은 “품질 점수의 상승”이 아니라 “품질 대비 비용 효율”을 최적화하는 것입니다.

Qdrant를 평가 저장소로 쓰는 이유

평가 데이터와 결과를 단순히 Postgres에 저장해도 되지만, Qdrant를 끼우면 자동화가 쉬워지는 지점이 있습니다.

  1. 실패 케이스 유사도 검색
  • 낮은 Faithfulness 샘플끼리 묶어서 대표 케이스를 뽑고, 원인을 빠르게 파악할 수 있습니다.
  1. 회귀 테스트 세트 자동 확장
  • 신규 로그에서 “기존 실패 케이스와 비슷한 질문”을 자동으로 찾아 평가 세트에 편입
  1. 실험 비교의 재현성
  • 실험 설정(임베딩 모델, 청크 크기, top_k, 프롬프트 버전)을 payload로 저장해두면, 점수 변화의 원인을 추적하기 쉽습니다.

Qdrant는 벡터 + payload 필터링이 강점이므로, env=prod, pipeline=v3, embed=text-embedding-3-large 같은 조건으로 실험군을 쉽게 분리할 수 있습니다.

전체 아키텍처: 로그 → 샘플링 → Ragas → Qdrant → 리포트

권장 파이프라인은 다음과 같습니다.

  1. RAG 서빙에서 로그 수집
  • question, answer, contexts(문서 조각들), doc_ids, latency_ms, prompt_version, retriever_params, model
  1. 평가 샘플링
  • 전량 평가가 부담되면, 트래픽 기반 stratified sampling
  • “낮은 사용자 피드백”, “긴 응답”, “특정 도메인” 우선 평가
  1. Ragas 평가 실행
  • 지표 계산 결과 + 토큰 비용 추정치 생성
  1. Qdrant 저장
  • 질문 임베딩을 벡터로 저장
  • payload에 점수/비용/실험 설정 저장
  1. 리포트/게이트
  • PR 또는 배포 파이프라인에서 기준 미달 시 실패 처리

운영 환경에서 비슷한 자동화는 관측성 설계가 중요합니다. 비동기 로그 추적이 필요하다면 Python 데코레이터+ContextVar로 async 로그 추적 같은 패턴을 참고해 “요청 단위”로 평가 대상 데이터를 안정적으로 묶어두는 것이 좋습니다.

구현 1: Qdrant에 평가 샘플 스키마 설계

Qdrant collection 하나에 “평가 샘플”을 저장한다고 가정합니다.

  • vector: 질문 임베딩
  • payload:
    • question, answer
    • contexts: 문자열 배열 또는 문서 요약
    • scores: faithfulness, answer_relevancy, context_precision, context_recall
    • cost: input_tokens, output_tokens, eval_llm_tokens, usd_estimate
    • tags: env, pipeline_version, embed_model, llm_model, chunk_size, top_k
    • ts: 타임스탬프

아래는 Python에서 Qdrant에 upsert하는 예시입니다.

from qdrant_client import QdrantClient
from qdrant_client.http import models
import time

client = QdrantClient(url="http://localhost:6333")
COLLECTION = "rag_eval_samples"

def ensure_collection(vector_size: int):
    collections = [c.name for c in client.get_collections().collections]
    if COLLECTION in collections:
        return

    client.create_collection(
        collection_name=COLLECTION,
        vectors_config=models.VectorParams(
            size=vector_size,
            distance=models.Distance.COSINE,
        ),
        optimizers_config=models.OptimizersConfigDiff(
            indexing_threshold=20000,
        ),
    )

def upsert_sample(point_id: str, question_vec: list[float], payload: dict):
    client.upsert(
        collection_name=COLLECTION,
        points=[models.PointStruct(
            id=point_id,
            vector=question_vec,
            payload=payload,
        )],
    )

# payload 예시
payload = {
    "question": "환불 정책이 어떻게 되나요?",
    "answer": "구매 후 7일 이내 미개봉 제품은 전액 환불됩니다...",
    "contexts": [
        "[doc:refund_v2] 환불은 7일 이내 미개봉 시 가능...",
        "[doc:shipping] 배송 관련 안내...",
    ],
    "scores": {
        "faithfulness": 0.82,
        "answer_relevancy": 0.77,
        "context_precision": 0.55,
        "context_recall": 0.71,
    },
    "cost": {
        "input_tokens": 1850,
        "output_tokens": 220,
        "eval_llm_tokens": 900,
        "usd_estimate": 0.0123,
    },
    "tags": {
        "env": "staging",
        "pipeline_version": "v3",
        "embed_model": "text-embedding-3-large",
        "llm_model": "gpt-4.1-mini",
        "chunk_size": 800,
        "top_k": 8,
    },
    "ts": int(time.time()),
}

Qdrant에 저장해두면, 이후에 scores.faithfulness가 낮은 샘플만 필터링해서 유사 질문을 묶고, 대표 실패 케이스를 뽑는 흐름을 만들 수 있습니다.

구현 2: Ragas로 지표 계산 자동화

Ragas는 버전별로 API가 바뀌는 경우가 있으니, 실제 적용 시에는 설치한 버전의 문서를 확인하세요. 여기서는 “평가 파이프라인 구성”에 집중한 예시를 보여드립니다.

핵심은 평가 입력을 다음 형태로 정규화하는 것입니다.

  • question
  • answer
  • contexts: 리스트(문서 조각)
  • ground_truth: 있으면 사용(FAQ나 정답 데이터가 있는 경우)
import os
from dataclasses import dataclass

# 예시: 평가 대상 레코드
@dataclass
class RagRecord:
    qid: str
    question: str
    answer: str
    contexts: list[str]
    ground_truth: str | None = None

def normalize_contexts(raw_contexts: list[str], max_chars: int = 8000) -> list[str]:
    # 너무 긴 컨텍스트는 평가 LLM 비용을 폭발시키므로 제한
    out = []
    total = 0
    for c in raw_contexts:
        if total + len(c) > max_chars:
            break
        out.append(c)
        total += len(c)
    return out

records = [
    RagRecord(
        qid="2026-02-26-001",
        question="환불 정책이 어떻게 되나요?",
        answer="구매 후 7일 이내 미개봉 제품은 전액 환불됩니다.",
        contexts=normalize_contexts([
            "환불은 7일 이내 미개봉 시 가능하며...",
            "특가 상품은 환불 불가...",
        ]),
        ground_truth=None,
    )
]

# 여기서부터는 Ragas 평가 호출(의사 코드 성격)
# 실제 함수/클래스명은 설치 버전에 맞게 조정하세요.

def run_ragas(records: list[RagRecord]) -> list[dict]:
    results = []
    for r in records:
        # metrics: faithfulness, answer_relevancy, context_precision, context_recall
        # llm/embeddings 설정은 환경에 맞게 주입
        scores = {
            "faithfulness": 0.0,
            "answer_relevancy": 0.0,
            "context_precision": 0.0,
            "context_recall": 0.0,
        }
        # TODO: ragas evaluate 호출로 scores 채우기
        results.append({
            "qid": r.qid,
            "question": r.question,
            "answer": r.answer,
            "contexts": r.contexts,
            "scores": scores,
        })
    return results

실무 팁은 “평가 비용”을 별도로 최적화하는 것입니다.

  • 평가 전용 LLM은 저렴한 모델로 분리(gpt-4.1-mini 등)
  • 평가 컨텍스트 길이 제한
  • 샘플링 비율 조절
  • 캐싱: 동일한 question + contexts 조합은 재평가하지 않기

이런 평가 워커를 Kubernetes로 돌릴 때는 리소스/재시작 이슈가 자주 생깁니다. 장애 대응 체크리스트는 K8s CrashLoopBackOff 원인별 진단 체크리스트도 함께 참고해두면 좋습니다.

구현 3: Qdrant로 실패 케이스 클러스터링과 디버깅 가속

평가가 쌓이면 “점수 낮은 샘플을 한 줄씩 읽는” 방식은 금방 한계가 옵니다. Qdrant를 쓰는 가장 큰 이유는 유사도 검색으로 실패 케이스를 묶는 것입니다.

예를 들어 Faithfulness가 0.5 미만인 샘플을 대상으로, 특정 샘플과 유사한 질문들을 찾아 원인 패턴을 뽑습니다.

from qdrant_client.http import models

def find_similar_failures(question_vec: list[float], limit: int = 20):
    return client.search(
        collection_name=COLLECTION,
        query_vector=question_vec,
        limit=limit,
        query_filter=models.Filter(
            must=[
                models.FieldCondition(
                    key="scores.faithfulness",
                    range=models.Range(lt=0.5),
                ),
                models.FieldCondition(
                    key="tags.env",
                    match=models.MatchValue(value="staging"),
                ),
            ]
        ),
        with_payload=True,
    )

이 결과를 보면 보통 다음 유형으로 묶입니다.

  • 정책/약관처럼 “문장 뉘앙스”가 중요한데 청크가 잘려 근거가 깨짐
  • 날짜/버전이 있는 문서에서 오래된 문서가 검색됨(필터/메타데이터 문제)
  • 질문이 다의적이라 검색이 흔들림(쿼리 리라이트 필요)

여기서 중요한 건 “개선 액션”을 점수에 연결하는 것입니다.

  • 오래된 문서가 섞인다 → doc_version 필터, 최신 우선 부스팅
  • 청크가 잘린다 → chunk overlap 증가, 섹션 단위 청킹
  • 다의적 질문이 많다 → 질문 분류 후 템플릿 프롬프트 분기

품질·비용 최적화: Pareto frontier로 실험 선택하기

운영에서 흔한 실수는 “품질 점수 최대화”만 보고 설정을 올리는 것입니다. top_k를 20으로 늘리고 컨텍스트를 길게 넣으면 대체로 점수는 오르지만, 비용과 지연시간이 같이 폭증합니다.

권장 방식은 실험별로 다음을 함께 저장하고, Pareto frontier(동일 비용 대비 최고 품질, 동일 품질 대비 최소 비용)를 찾는 것입니다.

  • 품질: faithfulness, answer_relevancy의 가중 평균
  • 비용: usd_estimate 또는 input_tokens + output_tokens
  • 지연: p95_latency_ms

예시로 “가성비 점수”를 정의할 수 있습니다.

  • utility = 0.6 * faithfulness + 0.4 * answer_relevancy
  • efficiency = utility / usd_estimate

그리고 Qdrant payload에 utility, efficiency를 저장해두면, 특정 기간/버전에서 효율이 떨어진 실험을 쉽게 찾을 수 있습니다.

CI 게이트: 배포 전에 회귀를 막는 최소 기준

자동화의 꽃은 PR 단계에서 회귀를 막는 것입니다. 예를 들어 다음 기준을 둡니다.

  • faithfulness_mean이 기준선 대비 0.03 이상 하락하면 실패
  • usd_estimate_mean이 20% 이상 상승하면 실패
  • faithfulness_p10(하위 10% 샘플) 악화 시 실패

여기서 “평균만” 보면 위험합니다. 평균은 좋아졌는데 특정 도메인(예: 환불/보안/결제)에서만 크게 나빠질 수 있습니다. Qdrant payload의 tags.domain 또는 tags.route를 기준으로 slice 리포트를 만들면 회귀를 더 빨리 잡습니다.

운영 팁: 평가 파이프라인에서 자주 터지는 문제들

1) 평가 LLM 비용이 본 서비스 비용을 넘는다

  • 해결: 샘플링, 평가용 저가 모델, 컨텍스트 길이 제한, 캐싱

2) 평가 결과가 들쭉날쭉하다(비결정성)

  • 해결: 평가 프롬프트 고정, temperature=0에 가깝게, 동일 입력 캐시

3) 데이터 드리프트로 과거 기준선이 무의미해진다

  • 해결: 기간별 기준선 관리, 도메인별 기준선 분리, “최근 2주” 같은 rolling baseline

4) 인프라 이슈로 평가 워커가 불안정하다

  • 해결: 작업 큐 기반 재시도, idempotent upsert, 타임아웃/서킷브레이커

클러스터에서 네트워크/타임아웃 문제는 RAG 자체보다 주변 인프라에서 더 자주 발생합니다. 유사한 트러블슈팅 관점은 GCP Cloud Run 504 타임아웃 원인·해결 9가지도 참고할 만합니다.

결론: Ragas는 점수 계산기, Qdrant는 학습하는 실험 노트

Ragas는 RAG 품질을 정량화해 “이 변경이 좋아졌는지/나빠졌는지”를 말해줍니다. 하지만 운영에서 더 중요한 건 “왜 나빠졌는지”를 빠르게 찾고, “비용을 얼마나 더 쓰면 얼마나 좋아지는지”를 의사결정 가능한 형태로 만드는 것입니다.

이를 위해 Qdrant를 평가 샘플 저장소로 두면 다음이 가능해집니다.

  • 실패 케이스 유사도 검색으로 디버깅 시간을 단축
  • 회귀 테스트 세트를 자동으로 확장
  • 품질·비용·지연을 함께 저장해 Pareto 최적 실험을 선택

다음 단계로는 (1) 도메인 태깅 자동화, (2) 사용자 피드백과 Ragas 점수의 상관 분석, (3) 실패 클러스터별 맞춤 개선 액션(필터/청킹/프롬프트)을 룰로 연결하는 자동화까지 확장해보면, RAG 운영이 “감”에서 “지표 기반”으로 바뀝니다.