포스트

RAG 기초 (7) - Evaluation and Operations: 나눠서 채점하기

검색과 생성을 따로 채점해 hit rate와 근거 준수 여부를 측정하는 스크립트를 만들고 문서 갱신, 비용, 자주 밟는 함정을 정리합니다.

RAG 기초 (7) - Evaluation and Operations: 나눠서 채점하기

RAG 기초 시리즈의 마지막 편입니다. 전체 목차는 0편에 있습니다.

나눠서 채점하는 이유

답이 틀렸을 때 물어야 할 질문은 하나입니다. 근거를 못 찾아온 것인가, 근거는 맞는데 답을 잘못 쓴 것인가.

이 둘을 구분하지 못하면 검색을 고쳐야 할 상황에서 프롬프트를 만지게 됩니다. 그래서 채점을 두 번 합니다.

단계질문지표
검색정답이 들어 있는 chunk를 가져왔는가hit rate, MRR
생성가져온 근거대로 답했는가근거 준수, 정답 일치

검색 채점

질문과 정답 문서를 짝지은 데이터를 만듭니다. chunk 단위가 아니라 문서 파일 단위로 두면 chunk 크기를 바꿔도 그대로 쓸 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
# eval_set.py
EVAL = [
    {"q": "구매 후 며칠까지 환불할 수 있나요?",      "source": "refund.md"},
    {"q": "반품 배송비는 누가 부담하나요?",          "source": "refund.md"},
    {"q": "환불 신청하면 언제 돈이 들어오나요?",      "source": "refund.md"},
    {"q": "제주도인데 배송비가 얼마인가요?",          "source": "shipping.md"},
    {"q": "송장번호가 조회되지 않아요.",             "source": "shipping.md"},
    {"q": "오늘 주문하면 언제 받을 수 있나요?",       "source": "shipping.md"},
    {"q": "골드 등급 혜택이 무엇인가요?",            "source": "membership.md"},
    {"q": "등급은 언제 다시 계산되나요?",            "source": "membership.md"},
]

hit rate는 상위 k개 안에 정답 문서가 들어 있는 비율이고, MRR은 정답이 몇 위에 있었는지를 반영합니다. 1위로 찾아오면 1점, 3위면 0.33점입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# eval_retrieval.py
from eval_set import EVAL

def evaluate_retrieval(search_fn, name: str, k: int = 3):
    hits, rr = 0, 0.0
    misses = []
    for case in EVAL:
        results = search_fn(case["q"], k=k)
        sources = [r["source"] for r in results]
        if case["source"] in sources:
            hits += 1
            rr += 1 / (sources.index(case["source"]) + 1)
        else:
            misses.append((case["q"], sources))
    n = len(EVAL)
    print(f"[{name}] hit@{k} {hits}/{n} ({hits/n:.0%})  MRR {rr/n:.3f}")
    for q, got in misses:
        print(f"   놓침: {q} -> {got}")

4편의 벡터 검색과 6편의 방법들을 같은 데이터로 비교합니다.

1
2
3
4
5
6
7
8
9
evaluate_retrieval(search, "벡터만")
evaluate_retrieval(hybrid_search, "하이브리드")
evaluate_retrieval(search_with_rerank, "하이브리드 + rerank")

# [벡터만] hit@3 6/8 (75%)  MRR 0.646
#    놓침: 반품 배송비는 누가 부담하나요? -> ['shipping.md', 'shipping.md', 'membership.md']
#    놓침: 등급은 언제 다시 계산되나요? -> ['refund.md', 'shipping.md', 'membership.md']
# [하이브리드] hit@3 7/8 (88%)  MRR 0.771
# [하이브리드 + rerank] hit@3 8/8 (100%)  MRR 0.917

놓친 사례가 유용합니다. “반품 배송비”라는 질문이 배송 문서로 간 것은 embedding이 “배송비”라는 단어에 끌린 결과입니다. 문서를 늘리기 전에는 이런 혼동이 눈에 잘 띄지 않습니다.

같은 방식으로 chunk 크기(2편)와 embedding 모델(3편)도 비교합니다. 감으로 고르던 설정을 표로 정할 수 있게 됩니다.

생성 채점

검색이 정답 chunk를 가져온 경우만 놓고, 답이 근거를 지켰는지 봅니다. 사람이 채점하기 어려운 항목이므로 LLM judge를 씁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# eval_generation.py
import json

JUDGE_SCHEMA = {
    "type": "object",
    "properties": {
        "grounded": {"type": "boolean"},      # 자료에 있는 내용만 썼는가
        "complete": {"type": "boolean"},      # 질문에 답했는가
        "numbers_ok": {"type": "boolean"},    # 금액과 기간이 자료와 일치하는가
        "reason": {"type": "string"},
    },
    "required": ["grounded", "complete", "numbers_ok", "reason"],
    "additionalProperties": False,
}

JUDGE_SYSTEM = """<자료>와 <질문>과 <답변>을 읽고 답변을 채점한다.

- grounded: 답변의 모든 내용이 자료에 근거하는가. 자료에 없는 사실이 하나라도 있으면 false
- complete: 질문에 실제로 답했는가. 자료에 답이 없어 "자료에 없습니다"라고 한 경우는 true
- numbers_ok: 답변의 금액, 기간, 비율이 자료의 숫자와 일치하는가

자료에 적힌 것만 기준으로 판정한다. 상식으로 보충하지 않는다."""

def judge(question: str, context: str, answer_text: str) -> dict:
    message = client.messages.create(
        model="claude-opus-5",
        max_tokens=2048,
        system=JUDGE_SYSTEM,
        output_config={"format": {"type": "json_schema", "schema": JUDGE_SCHEMA}},
        messages=[{"role": "user", "content":
                   f"<자료>\n{context}\n</자료>\n\n"
                   f"<질문>\n{question}\n</질문>\n\n"
                   f"<답변>\n{answer_text}\n</답변>"}],
    )
    return json.loads(text_of(message))

def evaluate_generation(search_fn, k: int = 3):
    counts = {"grounded": 0, "complete": 0, "numbers_ok": 0}
    for case in EVAL:
        hits = search_fn(case["q"], k=k)
        context = build_context(hits)
        result = answer_structured(case["q"], k=k)
        verdict = judge(case["q"], context, result["answer"])
        for key in counts:
            counts[key] += verdict[key]
        if not verdict["grounded"]:
            print(f"   근거 이탈: {case['q']} -> {verdict['reason']}")
    n = len(EVAL)
    print(f"grounded {counts['grounded']}/{n}  "
          f"complete {counts['complete']}/{n}  numbers_ok {counts['numbers_ok']}/{n}")

judge를 믿기 전에 judge를 검증합니다. 사람이 직접 채점한 사례 열 건 정도로 판정이 일치하는지 확인하고 나서 씁니다. 판정 기준 자체는 프롬프트 엔지니어링 기초 6편의 원칙을 따릅니다. 종합 점수 하나 대신 항목을 쪼개고, 기계로 셀 수 있는 것은 코드로 셉니다.

두 점수를 함께 읽기

검색생성해석
낮음높음검색을 고칩니다 (6편)
높음낮음프롬프트를 고칩니다 (5편)
낮음낮음chunk 분할부터 다시 봅니다 (2편)
높음높음실패 사례를 평가 셋에 추가합니다

마지막 칸이 실무에서 가장 오래 머무는 자리입니다. 사용자가 실제로 실패한 질문을 평가 셋에 계속 추가하는 것이 파이프라인을 개선하는 방법입니다. 5편에서 answerable: false를 로그로 모아둔 이유이기도 합니다.

운영

문서가 바뀌었을 때 4편reindex()를 다시 돌립니다. upsert가 갱신을 처리하고, 사라진 chunk id를 지우는 부분이 삭제를 처리합니다. 문서가 많아지면 파일의 수정 시각을 비교해 바뀐 문서만 다시 임베딩하는 방식으로 넘어갑니다.

설정을 바꿨을 때 chunk 크기나 embedding 모델을 바꾸면 전체를 다시 색인해야 합니다. 저장소를 지우고 새로 만드는 편이 안전합니다.

비용 embedding을 로컬 모델로 돌리면 색인 비용은 전기 요금뿐입니다. 실제 비용은 질의마다 발생하는 LLM 호출이고, 프롬프트에 들어가는 chunk가 그 비용을 좌우합니다. k를 3에서 10으로 늘리면 입력 토큰이 세 배가 됩니다. 6편의 reranking으로 후보를 넓게 가져와 좁게 넣는 구성이 품질과 비용을 동시에 잡는 방법입니다.

caching과의 관계 검색 결과는 질문마다 다르므로 캐시되지 않습니다. 캐시할 수 있는 것은 system의 고정 규칙 부분입니다. 그래서 5편에서 규칙을 system에, 자료를 user에 나눠 두었습니다. 변하는 것을 뒤에 두는 프롬프트 엔지니어링 기초 7편의 원칙과 같습니다.

자주 밟는 함정 정리

증상원인해결
검색 결과가 전부 무의미함색인과 질의의 embedding 모델이 다름모델 이름을 상수 한 곳에서 참조 (3편)
지웠는데 계속 검색됨저장소에 예전 chunk id가 남음재색인 때 사라진 id 삭제 (4편)
유사도 기준값이 갑자기 안 맞음점수 절대값은 모델마다 다름순위만 사용하거나 데이터로 기준을 다시 정함
문장 중간이 잘린 chunk글자 수로만 분할문단과 문장 경계 존중, overlap (2편)
번호와 코드를 못 찾음embedding의 약점BM25 하이브리드 (6편)
멀티턴에서 검색이 실패생략된 질문을 그대로 임베딩질문 재작성 (6편)
문서에 없는 내용을 지어냄근거를 못 박는 지시가 없음“자료에 없으면 없다고 답한다” (5편)
권한 없는 문서 내용이 노출됨검색 단계에서 필터링하지 않음메타데이터 필터로 검색 범위 제한 (4편)

시리즈를 마치며

1편의 문제의식은 “모델은 우리 문서를 모른다”였습니다. 그 뒤로 한 일은 문서를 찾을 수 있는 형태로 만들고, 질문에 맞는 조각을 골라, 근거로 못 박아 답하게 하는 것이었습니다. 2편에서 잘랐고, 3편에서 벡터로 바꿨고, 4편에서 저장하고 찾았고, 5편에서 답하게 했고, 6편에서 검색을 고쳤고, 이번 편에서 그것들이 실제로 나은지 채점했습니다.

여기서 나아가는 방향은 셋입니다. 근거를 넣는 프롬프트 자체를 더 다듬는 쪽은 프롬프트 엔지니어링 기초 시리즈, 이 파이프라인을 프레임워크의 부품으로 조립하는 쪽은 LangChain 기초, 검색과 생성을 반복하며 스스로 판단하는 agent로 확장하는 쪽은 LangGraph 기초입니다.

어느 쪽으로 가든 이 시리즈의 다섯 단어(chunk, embedding, vector store, retrieval, grounding)면 RAG 관련 문서 대부분을 읽을 수 있습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.