AI 공부

11. RAG, 잘 만들었는지 어떻게 알지? — RAG 평가 시스템 구축 (테스트셋 구축 및 검색/답변 평가 진행)

kdb1248 2026. 5. 9. 21:31

목차

0. 들어가며
1. 평가가 전부다 (Evaluations are Everything)
2. 골든 테스트셋 만들기
3. 검색 평가 메트릭 (Retrieval Evaluation)
4. 답변 평가 메트릭 (LLM-as-a-Judge)
5. 평가 결과로 본 한계
6. 1편 마치며


0. 들어가며

지난 글에서 가상의 보험회사의 사내문서를 기반으로 답변을 할 수 있는 Rag챗봇을

단순 키워드 매칭 방식부터 LangChain 기반 RAG까지 만들어봤다.

그 과정에서 마지막에 한계를 볼 수 있었다.
(복합질문에 답변이 어려운 것, 각 청크에 해당 인물의 풀네임이 적혀있진 않아서 답변에서도 풀네임을 갖고 오지 못한 것 등)

 

이번 글은 이런 RAG의 한계를 극복하기 위한 방법을 고민해나가는 글이고 1가지 질문에서 출발한다.

"근데 RAG가 잘 만들어졌는지 어떻게 알지?"

 

이를 위해 아래 2가지 내용을 중심으로 작성하려고 한다.
(1) RAG 구성이 잘 됐는지 측정할 수 있는 RAG 평가 시스템
(2) 평가 결과를 본 뒤, 기존 단순 RAG의 한계를 극복하기 위한 Advanced RAG 활용 및 성능 향상

 

분량이 길어져 (1) 평가편과 (2) Advanced RAG편으로 글을 나눠서 다루려고 한다. 이번 글은 첫 번째인 평가편이다.

 


1. 평가가 전부다 

1) 왜 평가 없이는 개선이 안 되는가

RAG를 어떻게 좋게 만들어볼까? 라는 질문에 사람들이 흔히 떠올리는 답은 이런 식이다.

  • chunk size를 좀 줄여볼까?
  • 임베딩 모델을 더 좋은 걸로 바꿔볼까?
  • top-k를 늘려볼까?
  • reranker를 붙여볼까?

이 시도들이 의미 없는 건 아니다. 진짜 문제는, 이렇게 바꿨을 때 좋아졌는지 나빠졌는지를 객관적으로 모른다는 점이다.

테스트셋이 없으면 "어 이번엔 좀 잘 답하는 것 같은데?" 같은 느낌으로 판단하게 되고, RAG 튜닝이 감으로 흘러간다.

강의에서 가장 강하게 강조한 메시지가 이거였다. "Evaluations are everything." (평가가 전부다)

2) 3단계 평가 프레임

평가는 막연히 "잘 답하는지 본다"가 아니라 다음 3단계로 분리해서 봐야 한다.

- 1단계 - 테스트셋 큐레이션 : 평가의 기준이 될 골든 데이터셋을 만든다.

- 2단계 - 검색 평가 (Retrieval Evaluation) : 질문이 들어왔을 때 RAG가 관련 있는 청크를 잘 찾아오는가?

- 3단계 - 답변 평가 (Answer Evaluation) : 가져온 청크들로 LLM이 실제로 좋은 답변을 만들어내는가?

 

이걸 한 덩어리로 보면 안 되는 이유가 있다.

RAG가 답을 못할 때 원인이 두 군데에서 올 수 있기 때문이다.

  • 검색이 못 잡았다 → 아무리 LLM이 좋아도 답할 수가 없다.
  • 검색은 잘 됐는데 LLM이 답을 못 만들었다 → 프롬프트나 생성 쪽 문제다.

평가를 분리해야 어디를 손봐야 할지 알 수 있다.

검색이 70% 정확한데 답변이 50%라면 검색이 아니라 프롬프트나 컨텍스트 구성 쪽을 봐야 한다.

반대로 검색이 30%인데 답변 점수도 30%면 검색부터 고쳐야 한다.

강의의 3단계 평가 프레임 도식

3) 느낀 점

  • 단순히 청크사이즈를 바꾸고, 임베딩 모델을 바꾸고 등의 시도를 하는 것이 아니라 평가 체계를 잘 만들어야 한다는 점이 인상적이었다. 
  • 그 과정에서 테스트셋과 메트릭이 먼저 만들어져야 한다는 점
  • 회사에서도 RAG 기반 에이전트를 만들고 있는데, 이번 강의를 보면서 결국 평가 데이터셋과 측정 메트릭을 먼저 잡고, 코드나 아키텍처상 변경점이 있을 때마다 때마다 점수로 비교하면서 지속적으로 버저닝하는 게 중요하다는 걸 다시 느꼈다.

2. 골든 테스트셋 만들기

평가의 출발점은 테스트셋이다.

RAG에게 물어볼 질문과, 그 질문에 대한 정답이 무엇인지를 미리 정의해둔 데이터셋이다.

1) 무엇을 담는가

이 강의에서는 한 테스트 케이스에 다음 4가지를 담는다.

  • question : RAG에게 물어볼 질문
  • keywords : RAG가 검색해 온 청크들 안에 반드시 등장해야 할 단어들. 검색이 정답 자료를 잘 잡아왔는지 자동 판정하기 위한 정답 키 (예: "Maxine", "Thompson", "IIOTY")
  • reference_answer : 사람이 작성한 모범 답안 (LLM 심판이 답변 평가할 때 비교 기준으로 사용)
  • category : 질문의 유형 (direct_fact, spanning, holistic 등)

여기서 흥미로운 게 keywords와 category 필드다.

keywords는 검색 평가에만 쓰인다. RAG가 검색해 온 청크(retrieved context)의 본문에 이 키워드들이 등장하는지를 검사하면, 검색이 정답에 필요한 자료를 잘 잡아왔는지를 자동으로 판정할 수 있다. 코드에서는 doc.page_content.lower()에 keyword.lower()가 포함되는지로 판정한다(대소문자 무시).

category는 "어떤 유형에서 약한지"를 진단하는 데 쓰인다. direct_fact 점수는 높은데 spanning(여러 청크에 걸친 질문) 점수가 낮으면, 검색 시스템이 "한 청크 안에 답이 있는 질문"엔 강하지만 "여러 청크를 통합해야 하는 질문"엔 약하다는 뜻이다. 그러면 청킹 전략을 바꾸거나 hierarchical RAG 같은 기법을 검토해야 한다.

테스트 셋 구축시 또 하나 중요한 팁은, 가능하면 실제 사용자 질문 데이터를 활용해 만드는 것이다. CS 데이터나 챗봇 로그가 있으면 거기서 추리는 게 가장 좋다. 없으면 합성 데이터로 시작하되, 한 번에 완벽하게 만들려고 하지 말고 운영하면서 점진적으로 확장하는 방식을 권장한다. 한 가지 주의할 점은, 테스트셋에 너무 맞춰서 튜닝하면 모델이 그 테스트셋에만 잘 작동하게 될 수 있다는 거다(과적합).

2) jsonl 포맷

이 강의에서는 테스트셋을 jsonl 포맷으로 저장한다. JSON과 살짝 다르다. 일반적인 JSON은 전체가 하나의 큰 객체나 배열이지만, jsonl은 한 줄에 하나씩 독립된 JSON 객체가 들어간다.

{"question": "Who won the IIOTY award in 2023?", "keywords": ["Maxine", "Thompson", "IIOTY"], "reference_answer": "Maxine Thompson won...", "category": "direct_fact"}
{"question": "When was Insurellm founded?", "keywords": ["2015"], "reference_answer": "Insurellm was founded in 2015.", "category": "temporal"}

장점이 명확하다.

  • 한 줄씩 추가하기 편하다 (전체 파일을 다시 쓸 필요 없음)
  • 한 줄씩 스트리밍으로 읽을 수 있다 (메모리 절약)
  • 배치 처리가 편하다

OpenAI Batch API 같은 LLM 배치 호출 인터페이스도 jsonl을 표준으로 쓰는데, 이런 이유 때문이다.

3) 코드 - test.py

테스트 케이스를 코드에서 다루기 위해서 Pydantic 모델을 하나 정의한다.

import json
from pathlib import Path
from pydantic import BaseModel, Field

TEST_FILE = str(Path(__file__).parent / "tests.jsonl")


class TestQuestion(BaseModel):
    """A test question with expected keywords and reference answer."""

    question: str = Field(description="The question to ask the RAG system")
    keywords: list[str] = Field(description="Keywords that must appear in retrieved context")
    reference_answer: str = Field(description="The reference answer for this question")
    category: str = Field(description="Question category (e.g., direct_fact, spanning, temporal)")


def load_tests() -> list[TestQuestion]:
    """Load test questions from JSONL file."""
    tests = []
    with open(TEST_FILE, "r", encoding="utf-8") as f:
        for line in f:
            data = json.loads(line.strip())
            tests.append(TestQuestion(**data))
    return tests

여기서 처음 보는 분들을 위해 Pydantic을 잠깐 짚고 넘어가겠다.

Pydantic은 BaseModel을 상속받아서 데이터 모델을 만드는 라이브러리다. 위 코드에서 TestQuestion이 그 예다.

일반 dict와 비교했을 때 장점이 세 가지 있다.

  • 타입 검증 : question은 str이어야 하고 keywords는 list[str]이어야 한다는 식의 타입을 강제한다. 잘못된 타입이 들어오면 즉시 에러가 난다.
  • 데이터 파싱 : TestQuestion(**data)처럼 dict를 그대로 넘기면, Pydantic이 검증하면서 객체로 만들어준다.
  • 스키마 정보 : Field(description="...")가 단순 주석이 아니라 자동 문서화·OpenAI structured output 같은 데에서 LLM 가이드로도 쓰인다.

이 강의 전반에 걸쳐 Pydantic이 굉장히 자주 등장하는데, 그 이유는 뒤에 답변 평가에서 자세히 다룬다.

load_tests() 함수의 흐름은 단순하다.

  1. tests.jsonl을 한 줄씩 읽는다.
  2. json.loads로 dict로 만든다.
  3. TestQuestion(**data)로 Pydantic 검증·객체화한다.
  4. 리스트로 반환한다.

이렇게 만들어진 TestQuestion 리스트가 이후 검색 평가, 답변 평가의 입력이 된다.

4) 실제 데이터 살펴보기

from evaluation import test
tests = test.load_tests()
len(tests)

출력: 

150

-> 테스트셋은 총 150개가 들어 있다.

 

example = tests[0]
print(example.question)
print(example.category)
print(example.reference_answer)
print(example.keywords)

 

출력:

Who won the prestigious IIOTY award in 2023?
direct_fact
Maxine Thompson won the prestigious Insurellm Innovator of the Year (IIOTY) award in 2023.
['Maxine', 'Thompson', 'IIOTY']

질문, 카테고리, 정답, 정답에 등장해야 할 키워드 3개가 한 케이스로 묶여 있는 게 보인다.

카테고리가 어떻게 분포되어 있는지도 확인해본다.

from collections import Counter
count = Counter([t.category for t in tests])
count

 

출력:

Counter({
  'direct_fact': 70,
  'temporal': 20,
  'spanning': 20,
  'comparative': 10,
  'numerical': 10,
  'relationship': 10,
  'holistic': 10
})

각 카테고리의 의미를 간단히 정리하면 이렇다.

  • direct_fact (70개): 한 청크 안에 답이 들어 있는 사실형 질문
  • temporal (20개): 시점·기간 관련 질문 ("언제", "얼마나 오래")
  • spanning (20개): 여러 문서·청크에 걸쳐 답이 흩어져 있는 질문
  • comparative (10개): 비교 질문 ("A와 B 중 어느 쪽이 더...")
  • numerical (10개): 숫자 계산이 필요한 질문
  • relationship (10개): 관계 기반 질문 ("X와 일한 사람은 누구인가")
  • holistic (10개): 문서 전체를 이해해야 답할 수 있는 질문

direct_fact가 절반 가까이 차지하지만, 일부러 다양한 유형이 섞여 있다. 그래야 RAG의 약점을 다각도로 잡아낼 수 있기 때문이다.

 

5) 느낀 점

  • "정답 발화 예시"만 있으면 된다고 생각했는데 그게 아니었다. 검색 청크에 포함되어야 할 키워드, 그리고 질문 카테고리(유형) 라벨링까지 같이 있어야 메트릭으로 채점이 자동화되고, 취약 카테고리 파악이 가능하다. RAG 기반으로 지식 데이터를 참조하는 에이전트를 만들 때 그대로 차용할 만한 테스트셋 생성 패턴이었다.
  • 카테고리를 다양하게 둔 이유는 나중에 "어떤 유형에서 약한지" 진단하기 위해서다. 회사에서 만약 CS 에이전트를 만든다고 한다면 "지식 데이터만 참조해서 해결되는 단순 FAQ형"과, "지식데이터 단일 매핑이 아니라 여러 데이터를 참조해야하는 복합 FAQ형", "내부 시스템·계정 정보 조회가 필요한 액션형" 같은 식으로 카테고리를 분류해서 골고루 분포시키는 설계가 필요해 보인다. 그래야 어떤 유형의 질문에서 약한지를 파악하고, 거기에 맞는 개선책(추가 데이터? 다른 도구 연결? 프롬프트 보강?)을 찾을 수 있다.
  • jsonl + Pydantic 조합은 데이터셋의 형식을 코드 레벨에서 강제함으로써, 데이터셋 품질을 지켜주는 점이 인상적이었다.

 


3. 검색 평가 메트릭 (Retrieval Evaluation)

테스트셋이 만들어졌으니 이제 메트릭으로 채점할 차례다.

검색 평가는 다음 질문에 답하기 위한 거다.

- "RAG가 가져온 청크들 안에 정답에 필요한 자료가 충분히 있는가?"

이를 보는 각도가 여러 개라서 메트릭도 여러 개를 같이 본다.

1) MRR (Mean Reciprocal Rank) - "정답이 검색해온 청크 리스트 중 몇 번째에 처음 나왔나?"

키워드가 처음 등장한 청크의 순위(rank)를 1/rank로 환산해서 평균낸 값이다.

  • 정답이 1등에 잡히면 1.0
  • 2등이면 0.5
  • 3등이면 0.33

"첫 번째 결과의 품질"에 민감하기 때문에, 답이 한 청크 안에 들어 있는 QA형 질문에서 특히 중요하게 본다.

챗봇이 첫 검색 결과만 보고 답하는 구조라면 MRR이 사실상 시스템 정확도를 결정한다고 봐도 된다.

해석은 직관적이다. MRR이 0.5면 평균적으로 정답이 2번째에 잡힌다는 뜻이고, 0.1 이하면 검색이 거의 실패하고 있다는 신호다.

2) nDCG (Normalized Discounted Cumulative Gain) - "관련 있는 청크들이 위쪽에 잘 정렬됐나?"

DCG는 각 자리(rank)의 relevance 값에 1/log2(rank+1) 가중치를 붙여 합산한 값이다.

위쪽 자리일수록 가중치가 크다. nDCG는 이걸 "이상적인 정렬"의 DCG로 나눠서 0~1로 정규화한 값이다.

이 코드에서는 relevance를 이진값으로 잡았다. 키워드가 청크에 포함되면 1, 아니면 0.

MRR이 "첫 번째에만" 집중한다면 nDCG는 "전체 순위가 얼마나 이상적인가"를 본다.

그래서 정답에 필요한 자료가 여러 청크에 흩어져 있을 때, 또는 reranking을 적용한 후 효과를 측정할 때 적합하다.

해석: 1.0에 가까울수록 이상적 정렬. 낮으면 정답 후보가 뒤쪽으로 흩어져 있다는 뜻.

3) Recall@K / Precision@K

이름이 길어 보이지만 의미는 단순하다.

  • Recall@K : 상위 K개 청크 안에 정답에 필요한 자료가 한 번이라도 잡혔나? (있다/없다 비율)
  • Precision@K : 상위 K개 청크 중 실제 관련 있는 청크의 비율

RAG에서는 Recall@K가 Precision@K보다 더 중요하게 다뤄진다. 이유는 단순하다.

LLM이 답을 만들 마지막 기회를 주려면 적어도 자료는 컨텍스트에 들어가 있어야 하기 때문이다.

자료 자체가 안 들어가면 LLM은 답할 도리가 없다.

다만 K를 무한정 늘리면 Precision이 떨어지고 컨텍스트 윈도우만 잡아먹는다.

그래서 Recall과 Precision은 trade-off 관계로, K를 어떻게 잡을지가 RAG 튜닝의 한 축이 된다.

4) Keyword Coverage - "전체 키워드 중 몇 %를 top-K에 담아냈나"

Recall의 변형이라고 봐도 된다. 한 질문에 정답 키워드가 여러 개일 때 (예: "Maxine", "Thompson", "IIOTY") 그중 몇 개가 top-K 안에 한 번이라도 등장했는지를 비율로 계산한다.

100%면 모든 키워드가 잡혔다는 뜻이고, 66%면 3개 중 2개만 잡혔다는 뜻이다.

5) 코드 - eval.py

위 메트릭들을 실제로 계산하는 코드는 evaluation/eval.py에 들어 있다. 핵심만 떼어 놓으면 다음과 같다.

def calculate_mrr(keyword: str, retrieved_docs: list) -> float:
    """Calculate reciprocal rank for a single keyword (case-insensitive)."""
    keyword_lower = keyword.lower()
    for rank, doc in enumerate(retrieved_docs, start=1):
        if keyword_lower in doc.page_content.lower():
            return 1.0 / rank
    return 0.0


def calculate_dcg(relevances: list[int], k: int) -> float:
    """Calculate Discounted Cumulative Gain."""
    dcg = 0.0
    for i in range(min(k, len(relevances))):
        dcg += relevances[i] / math.log2(i + 2)  # i+2 because rank starts at 1
    return dcg


def calculate_ndcg(keyword: str, retrieved_docs: list, k: int = 10) -> float:
    """Calculate nDCG for a single keyword (binary relevance, case-insensitive)."""
    keyword_lower = keyword.lower()

    relevances = [
        1 if keyword_lower in doc.page_content.lower() else 0 for doc in retrieved_docs[:k]
    ]

    dcg = calculate_dcg(relevances, k)

    ideal_relevances = sorted(relevances, reverse=True)
    idcg = calculate_dcg(ideal_relevances, k)

    return dcg / idcg if idcg > 0 else 0.0

- calculate_mrr은 키워드별로 rank를 찾아 1/rank를 반환한다. 키워드가 어떤 청크에도 없으면 0.0.

- calculate_dcg는 i+2 인덱스를 쓰는 게 살짝 헷갈릴 수 있는데, rank가 1부터 시작하기 때문에 i=0에 대해 log2(2)=1, i=1에 대해 log2(3) ... 이런 식으로 가중치가 떨어지는 구조다.

- calculate_ndcg는 위 두 함수를 활용해서 실제 DCG를 이상적 DCG로 나눠 정규화한다.

이렇게 키워드별로 점수를 낸 뒤, evaluate_retrieval에서는 한 질문 안의 키워드 점수들을 평균내서 RetrievalEval이라는 결과 객체로 묶어 반환한다.

class RetrievalEval(BaseModel):
    """Evaluation metrics for retrieval performance."""

    mrr: float = Field(description="Mean Reciprocal Rank - average across all keywords")
    ndcg: float = Field(description="Normalized Discounted Cumulative Gain (binary relevance)")
    keywords_found: int = Field(description="Number of keywords found in top-k results")
    total_keywords: int = Field(description="Total number of keywords to find")
    keyword_coverage: float = Field(description="Percentage of keywords found")

여기서 등장하는 RetrievalEval도 Pydantic 모델이지만, 앞에서 본 TestQuestion과는 역할이 살짝 다르다.

TestQuestion은 외부 jsonl 데이터를 검증하는 입력 스키마였다면, RetrievalEval은 코드가 직접 계산한 결과를 담는 그릇(DTO) 역할이다. LLM과는 무관하다. 이 차이는 다음 섹션의 답변 평가에서 LLM의 출력을 강제하는 용도로 쓰일 때 더 분명해진다.

6) 실제로 돌려보기

한 케이스로 돌려보면 이런 결과가 나온다.

from evaluation.eval import evaluate_retrieval, evaluate_answer
evaluate_retrieval(example)

출력:

RetrievalEval(mrr=0.16666666666666666, ndcg=0.28711770538226206, keywords_found=2, total_keywords=3, keyword_coverage=66.66666666666666)

해석하면 이렇다.

  • mrr=0.166 → 키워드들의 평균 reciprocal rank. 키워드별로 보면 어떤 건 6번째에 처음 잡혔고, 어떤 건 아예 못 잡혔을 가능성이 크다.
  • ndcg=0.287 → 정답 키워드 청크가 위쪽에 잘 정렬되어 있다고 보기 어렵다.
  • keyword_coverage=66.6% → 전체 키워드 3개 중 2개만 top-K에 잡혔다.

기본 RAG가 IIOTY 같은 단순해 보이는 질문에서도 검색 품질이 그렇게 좋지 않다는 게 숫자로 드러난다.

 

7) 느낀 점

  • 메트릭이 여러 개인 이유는 보는 각도가 다르기 때문이다. 첫 결과 품질(MRR), 정렬 품질(nDCG), 들어왔는지 여부(Recall), 노이즈 비율(Precision). 한두 개만 봐서 결정 내리면 위험하다.
  • "RAG에서는 recall이 가장 중요하다"는 말이 와닿았다. 검색이 자료를 안 잡아오면 LLM은 답할 도리가 없다. 컨텍스트에 정답 자료가 들어가는 것이 우선이고, 정렬·노이즈 문제는 다음 단계.
  • 메트릭은 단순한 지표가 아니라 "어떤 부분이 약한지"를 가리키는 진단 도구다. MRR은 좋은데 nDCG가 나쁘다면 1등은 잘 잡지만 2~3등 정렬이 엉망이라는 뜻이고, MRR/nDCG 다 나쁜데 Coverage만 높다면 자료가 어딘가 들어와 있긴 하지만 위쪽에 안 잡혀 있다는 뜻이다.

4. 답변 평가 메트릭 (LLM-as-a-Judge)

검색이 잘 됐다고 답이 잘 만들어진다는 보장은 없다. 청크들을 받아 LLM이 실제로 좋은 답을 만들었는지를 따로 평가해야 한다.

1) 왜 LLM-as-a-Judge 인가

전통적인 자동 평가 방식으로는 BLEU나 ROUGE 같은 단어 겹침 기반 메트릭이 있다.

그러나 RAG 답변 평가에는 한계가 명확하다.

예를 들어 정답이 "Maxine Thompson"이고 RAG가 "Maxine"이라고 답했다고 해보자.

의미상 거의 같은 말인데 단어 겹침으로는 50%만 맞은 걸로 처리된다.

반대로 정답과 단어가 많이 겹쳐도 핵심 사실이 틀려 있을 수도 있다. 단어 단위 비교로는 의미를 못 잡는다.

 

그래서 강의에서는 LLM 자체를 평가자로 쓴다. 참고 답안과 RAG 통해 LLM이 만든답을 같이 별도의 평가자용 LLM에 던져 주고, "비교해서 점수 매겨봐"라고 시킨다. LLM이라는 도구가 의미 단위로 비교를 잘 해주는 데다, 사람이 일일이 채점할 수 없는 규모로도 자동화할 수 있다.

2) 3가지 차원

평가자 LLM에게 다음 3가지 차원으로 점수를 매기게 한다 (1~5).

  • Accuracy (정확성) : 사실 정확도. 핵심 사실이 틀렸으면 무조건 1점.
  • Completeness (완전성) : 참고 답안의 모든 정보가 빠짐없이 포함됐는지. 
  • Relevance (관련성) : 질문에 직접 답하고 군더더기 없는지. 장황한 부연 설명도 감점.

세 차원이 분리된 게 중요한데, 이유는 셋이 서로 다른 문제를 잡아내기 때문이다.

  • Accuracy 낮음 → LLM이 환각하거나 잘못된 청크를 골라 답한 상태.
  • Completeness 낮음 → 검색이 부분적으로만 잡아왔거나 LLM이 정보를 누락한 상태.
  • Relevance 낮음 → 프롬프트가 LLM을 자유롭게 풀어놔서 군더더기를 붙인 상태. 

3) 코드 - AnswerEval과 structured output

먼저 LLM 평가자가 출력해야 할 형식을 Pydantic으로 정의한다.

class AnswerEval(BaseModel):
    """LLM-as-a-judge evaluation of answer quality."""

    feedback: str = Field(
        description="Concise feedback on the answer quality, comparing it to the reference answer and evaluating based on the retrieved context"
    )
    accuracy: float = Field(
        description="How factually correct is the answer compared to the reference answer? 1 (wrong. any wrong answer must score 1) to 5 (ideal - perfectly accurate). An acceptable answer would score 3."
    )
    completeness: float = Field(
        description="How complete is the answer in addressing all aspects of the question? 1 (very poor - missing key information) to 5 (ideal - all the information from the reference answer is provided completely). Only answer 5 if ALL information from the reference answer is included."
    )
    relevance: float = Field(
        description="How relevant is the answer to the specific question asked? 1 (very poor - off-topic) to 5 (ideal - directly addresses question and gives no additional information). Only answer 5 if the answer is completely relevant to the question and gives no additional information."
    )

여기가 이 강의에서 말하는 structured output이 빛을 발하는 지점이다.

structured output이란 LLM이 자유 텍스트가 아니라 미리 정의해둔 스키마(필드와 타입)에 맞춘 JSON으로 응답하게 강제하는 기능이다. OpenAI나 LiteLLM 같은 라이브러리가 이걸 지원한다. 장점이 명확하다.

  • 파싱 실패가 거의 없다. (LLM 응답에서 JSON 추출하다가 깨지는 일이 사라진다)
  • 누락 필드 검증이 자동으로 된다. (Pydantic이 검증)
  • 후속 처리가 안정적이다. (점수 집계, DB 저장 등)

여기서 흥미로운 게 한 가지 있다. 지금까지 본 세 Pydantic 모델의 역할이 모두 다르다는 점이다.

  • TestQuestion (test.py) : 외부 jsonl 데이터가 코드 안으로 들어올 때 검증하는 입력 스키마.
  • RetrievalEval (eval.py) : 코드가 직접 계산한 결과를 담는 결과 그릇.
  • AnswerEval (eval.py) : LLM의 출력을 미리 정해진 형식으로 강제하는 출력 스키마.

같은 BaseModel을 상속받아 만들지만, 어디에 어떻게 쓰이는지가 모두 다르다. Pydantic이 이 강의에서 자주 등장하는 이유가 여기에 있다. 데이터·코드·LLM 사이의 인터페이스를 모두 같은 도구로 정의할 수 있기 때문이다.

이 AnswerEval을 LLM 호출에 결합하는 코드는 다음과 같다.

def evaluate_answer(test: TestQuestion) -> tuple[AnswerEval, str, list]:
    # Get RAG response using shared answer module
    generated_answer, retrieved_docs = answer_question(test.question)

    # LLM judge prompt
    judge_messages = [
        {
            "role": "system",
            "content": "You are an expert evaluator assessing the quality of answers. Evaluate the generated answer by comparing it to the reference answer. Only give 5/5 scores for perfect answers.",
        },
        {
            "role": "user",
            "content": f"""Question:
{test.question}

Generated Answer:
{generated_answer}

Reference Answer:
{test.reference_answer}

Please evaluate the generated answer on three dimensions:
1. Accuracy: How factually correct is it compared to the reference answer? Only give 5/5 scores for perfect answers.
2. Completeness: How thoroughly does it address all aspects of the question, covering all the information from the reference answer?
3. Relevance: How well does it directly answer the specific question asked, giving no additional information?

Provide detailed feedback and scores from 1 (very poor) to 5 (ideal) for each dimension. If the answer is wrong, then the accuracy score must be 1.""",
        },
    ]

    # Call LLM judge with structured outputs
    judge_response = completion(model=MODEL, messages=judge_messages, response_format=AnswerEval)

    answer_eval = AnswerEval.model_validate_json(judge_response.choices[0].message.content)

    return answer_eval, generated_answer, retrieved_docs

핵심은 두 줄이다.

  • completion(..., response_format=AnswerEval) : LLM에게 "이 형식으로 응답해라"라고 강제.
  • AnswerEval.model_validate_json(...) : LLM이 준 JSON을 Pydantic으로 검증·파싱.

프롬프트에는 "Only give 5/5 scores for perfect answers" 같은 제약을 명시해서 점수가 인플레이션되지 않게 한다. LLM 심판은 친절하게도 점수를 후하게 주는 경향이 있어, 이런 명시적 제약이 없으면 모든 답변이 4~5점에 몰린다.

4) 실제로 돌려보기

IIOTY 케이스로 돌려보면 이런 결과가 나온다.

eval, answer, chunks = evaluate_answer(example)
print(eval.feedback)
print(eval.accuracy)
print(eval.completeness)
print(eval.relevance)

출력:

The answer correctly identifies Maxine as the winner and mentions the IIOTY award in 2023, but it omits Thompson's full name, which is present in the reference. This affects completeness. The relevance is high, as it directly addresses the question about the award winner.
5.0
4.0
5.0

해석:

  • Accuracy 5.0 → 사실 자체는 정확하다.
  • Completeness 4.0 → "Thompson"이 빠진 것에 대한 감점. (이건 지난 글에서 발견한 한계와 정확히 일치한다. 청크에 풀네임이 있지 않아 답에 풀네임이 안 나오는 문제.)
  • Relevance 5.0 → 군더더기 없이 질문에만 답했다.

흥미로운 건 Day 1~3 글에서 "왠지 풀네임이 안 나오네?" 라고 막연히 느꼈던 한계가, 이번에는 Completeness 4점이라는 객관적 숫자로 잡혔다는 점이다. 평가 시스템이 있으니 한계가 정량화되고, 정량화되니 개선 후 비교가 가능해진다.

 

5) 보너스 - 끝단 사용자 피드백

LLM 심판 외에도 실제 서비스에서는 엔드유저의 좋아요/싫어요 신호를 평가에 활용할 수 있다. 골든 데이터셋이 정답 기준이라면, 사용자 피드백은 실제 사용 환경의 정답 기준이 된다. 두 신호를 같이 보는 게 가장 강력한 평가 체계다.

6) 느낀 점

  • 예전에 llm as a judge를 들었을 땐 단순히 모델의 성능에만 의존하는 걸까? 라고 생각했었는데, "참고 답안 + 명확한 채점 기준 + structured output" 등의 구조를 잘 정의 하는게 중요하다는 걸 느꼈었다. 
  • 답변 평가가 자동화 가능하다는 점이 진짜 매력 포인트다. 테스트셋이 1,000개, 10,000개로 커지면 사람이 채점할 수 없는데, LLM 평가자는 그대로 스케일업된다. 

5. 평가 결과로 본 한계 

이제 메트릭이 갖춰졌으니, 한 케이스가 아니라 테스트셋 전체를 돌려보고 RAG의 약점을 찾아낼 차례다.

1) 150개 한 번에 돌리기

evaluation/eval.py에는 한 케이스가 아니라 전체 테스트셋을 순회하면서 평가 결과를 yield 하는 제너레이터 함수도 들어 있다.

def evaluate_all_retrieval():
    """Evaluate all retrieval tests."""
    tests = load_tests()
    total_tests = len(tests)
    for index, test in enumerate(tests):
        result = evaluate_retrieval(test)
        progress = (index + 1) / total_tests
        yield test, result, progress


def evaluate_all_answers():
    """Evaluate all answers to tests using batched async execution."""
    tests = load_tests()
    total_tests = len(tests)
    for index, test in enumerate(tests):
        result = evaluate_answer(test)[0]
        progress = (index + 1) / total_tests
        yield test, result, progress

제너레이터로 만들어놓은 이유는 진행률(progress)을 받아 UI에서 프로그레스 바로 보여주기 위해서다.

150개 케이스를 돌리는 데 시간이 좀 걸리기 때문에 한 케이스 끝날 때마다 결과를 흘려보내는 구조가 편하다.

이 위에 별도 진입점이 있어서, 터미널에서 다음과 같이 실행하면 된다.

cd week5
uv run evaluator.py

evaluator.py는 위 두 제너레이터를 돌면서 검색 평가 점수와 답변 평가 점수를 모두 모은 뒤, 카테고리별로 평균을 내서 한 화면에 표로 보여주는 역할을 한다.

2) 청킹·임베딩 조합 실험

이렇게 한 번에 점수를 뽑아낼 수 있게 되니까,

가장 먼저 해볼 만한 게 RAG 튜닝의 양대 축인 청크 크기임베딩 모델을 바꿔가면서 점수가 어떻게 변하는지 보는 일이다.

 

먼저 OpenAI text-embedding-3-large 임베딩을 고정해두고, 청크 크기와 top-k를 셋으로 바꿔봤다.

A. chunk 1667 / topk 3 - 큰 청크를 적은 개수로

큰 청크에 정보가 많이 들어가지만, top-k가 작아서 다양한 자료를 끌어오기 어렵다.

한 청크 안에 답이 다 들어 있는 direct_fact엔 강하지만 spanning에는 불리하다.

 

(이미지 - chunk 1667 / topk 3 평가 결과)

 

B. chunk 1000 / topk 5 - 중간 크기

청크가 적당히 작아지고 top-k도 늘어났다. 다양성과 집중도의 균형을 맞춘 셋업.

 

(이미지 - chunk 1000 / topk 5 평가 결과)

 

 

C. chunk 500 / topk 10 - 작은 청크를 많은 개수로

청크는 작지만 많이 가져와서 다양한 자료를 컨텍스트에 넣을 수 있다. 다만 청크 하나하나가 너무 짧으면 답에 필요한 맥락이 끊긴다.

 

(이미지 - chunk 500 / topk 10 평가 결과)

 

D. HuggingFace 임베딩 + chunk 500 / topk 10

청크 크기와 top-k는 같지만 임베딩 모델만 OpenAI에서 HuggingFace 오픈소스 모델로 바꿨다.

모델만 갈아끼웠을 때 점수가 어떻게 변하는지를 보기 위한 비교다.

 

(이미지 - HuggingFace 임베딩 평가 결과)

 

 

각 셋업의 결과를 카테고리별로 보면 패턴이 드러난다.

  • direct_fact는 어떤 셋업에서도 비교적 잘 답한다. 한 청크 안에 답이 있기 때문에 검색이 별로 안 어렵다.
  • spanning, holistic 카테고리는 모든 셋업에서 점수가 낮다. 청크 크기를 1667로 키워도, 500으로 줄이고 top-k를 10까지 늘려도 답이 여러 청크에 흩어져 있는 질문 자체엔 약하다.
  • 임베딩 모델만 바꿔도 결과가 출렁인다. 같은 청크 크기·top-k인데 OpenAI text-embedding-3-large와 HuggingFace 모델이 잡아오는 청크가 달라서 점수가 다르게 찍힌다.

3) 진단

여기서 중요한 결론이 두 가지 나온다.

첫째, 단순 청크 크기 튜닝으로는 못 깨는 한계가 있다.

- spanning/holistic 같은 카테고리는 본질적으로 "여러 청크를 통합해야 답할 수 있는 질문"이다.

- 청크를 작게 쪼개도 그걸 다 끌어올 방법이 없으면 LLM이 답을 만들 수 없고, 청크를 크게 합쳐도 한 청크에 다 담기 어려우면 똑같다.

- 즉, 청크 크기는 trade-off의 위치만 바꿀 뿐 한계 자체를 깨지는 못한다.

둘째, 임베딩 모델 선택만으로도 점수가 출렁인다.

- 같은 RAG 구조에서 임베딩 모델만 바꿨는데 카테고리별 점수가 꽤 다르게 나온다.

- 이건 RAG 튜닝이 한 가지 요소가 아니라 여러 요소의 조합이라는 걸 보여준다.

 

종합하면 이렇다.

하이퍼파라미터 튜닝의 영역을 넘어,

RAG 파이프라인 자체에 새로운 구조를 도입해야 spanning/holistic 같은 카테고리에서의 한계를 깰 수 있다.

 

이게 다음 섹션에서 다룰 Advanced RAG의 출발점이다. 청크를 더 똑똑하게 만들고(시맨틱 청킹), 검색 결과를 한 번 더 거르고(reranking), 사용자 질문을 검색 친화적으로 다듬는(query rewriting) 등의 본질적 개선이 필요하다는 결론이다.

4) 느낀 점

  • 청크 크기, top k 같은 하이퍼파라미터는 평가 파이프라인을 돌리지 않곤 최적의 값을 절대 못 정한다는 게 와닿았다. 직관으로는 "당연히 작은 청크가 정확하지 않을까?" 같은 생각이 드는데, 카테고리별로 보면 큰 청크가 강한 영역과 작은 청크가 강한 영역이 다르다. 측정해서 비교하지 않으면 잘못된 직관에 끌려간다. (또 문서의 특성에 따라서도 다를 수도 있다)
  • 임베딩 모델만 바꿔도 결과가 출렁이는 걸 보면서, 회사에서도 RAG 기반 에이전트를 운영할 때 임베딩 모델 변경이 결코 가벼운 결정이 아니란 걸 다시 느꼈다. 임베딩 모델 업데이트, 청킹 전략 변경, 프롬프트 수정 등 어떤 하나라도 바꾸면 그 즉시 평가 셋업으로 점수를 다시 뽑아 비교하는 프로세스가 있어야 안전하다.
  • 무엇보다 인상적이었던 건 "어디가 약한지를 카테고리 단위로 알 수 있다"는 점이다. 평균 점수만 보면 "그냥 좀 떨어졌다" 정도로 흘려넘겼을 차이가, 카테고리로 쪼개면 spanning에서 약하다는 명확한 진단으로 바뀐다. 이게 곧 "어떤 개선책을 시도해야 하는지"의 단서가 된다. (spanning에 약하다 → hierarchical RAG나 시맨틱 청킹을 검토). 테스트 셋을 만들 때 카테고리를 다양화 해야하는 이유를 느낄 수 있었다. 

 


6. 마치며

 

이번 글에서는 RAG 평가의 두 축을 정리했다.

- 골든 테스트셋으로 채점 기준을 만들고
- 검색 메트릭(MRR / nDCG / Recall@K / Precision@K / Keyword Coverage)과 답변 메트릭(LLM-as-a-Judge + structured output)으로 기존 단순 RAG의 한계를 정량화했다.

평가 결과로 두 가지 한계가 보였다.

- spanning, holistic 카테고리에서 점수가 일관되게 낮다 (단순 청크 크기 튜닝으로 안 풀린다).
- completeness 점수에서 풀네임 누락 같은 디테일이 잡힌다 (이전 글에서 막연히 느꼈던 한계가 객관적 숫자로 드러났다).

다음 글에서는 이 한계를 어떻게 깰지를 다룬다. 시맨틱 청킹 + 문서 전처리 결합, Reranking, Query Rewriting 같은 Advanced RAG 기법을 직접 구현해보고, 적용 전후 점수를 비교해본다.



(꾸준히 공부하고 적을테니 많은 관심 부탁드립니다.)

Profile:
Linkedin