개요
지난 글에서는 RAGAS로 합성 평가셋을 만들어 RAG를 평가하는 방법을 정리했습니다. 이후 한동안 60문항으로 구성된 이 평가셋으로 RAG의 성능을 측정했는데, 검색 개선 작업의 효과를 비교하는 과정에서 문제가 드러났습니다. 개선을 적용한 뒤에도 점수가 거의 변하지 않았고 점수가 일부 변한 경우에도 그 원인이 개선의 효과인지 판단할 수 없었습니다.
확인 결과 문제는 RAG가 아니라 RAGAS 평가셋에 있었습니다. 이번 글에서는 기존 합성 평가셋의 문제와 복합 질의를 측정하기 위해 평가셋과 지표를 재구축한 과정을 정리하겠습니다.
기존 평가셋의 문제
기존 평가셋은 RAGAS Synthesizer로 생성한 60문항입니다.
| 질문 생성기 | 문항 | 문항당 근거 구간 수(평균) |
|---|---|---|
single_hop_specific |
30 | 1.0 |
multi_hop_specific |
15 | 1.2 |
multi_hop_abstract |
15 | 2.5 |
절반이 다중 근거 질의이므로 구성 자체는 적절해 보입니다. 하지만 문항을 하나씩 검토한 결과 다섯 가지 문제가 있었습니다.
1. 정답 미검증
RAGAS Synthesizer는 문서 청크로 질문을 생성하고 Ground Truth(reference)도 LLM으로 생성합니다.
다만 저는 Ground Truth를 당시 RAG 답변으로 작성했습니다.
그래서 RAG가 오답을 생성하면 그 오답이 그대로 정답으로 등록되는 문제가 있었습니다.
실제로 Ground Truth 수정을 여러번 진행했지만, 모든 Ground Truth의 오류나 누락은 확인할 방법이 없었습니다.
2. 다중 근거 질의가 사실상 단일 근거
multi_hop_specific 문항은 근거 구간이 평균 1.2개입니다.
이름은 multi-hop이지만 대부분 한 문서 안의 여러 항목을 묻는 질문이므로 한 번의 검색으로 필요한 근거를 전부 찾을 수 있습니다.
이런 구성으로는 “문서 A를 읽어야 문서 B를 찾을 수 있는” 질문을 측정하지 못했습니다.
관련 연구에서도 같은 문제를 지적합니다. FRAMES 벤치마크 논문에 따르면 LLM으로 생성한 multi-hop 질문은 30% 이상에 환각이 포함되어 대량의 수작업 정제가 필요했습니다. 그래서 최종 824문항은 사람이 직접 작성했습니다. 문서 RAG 평가를 다룬 2025년 논문인 Double-Bench도 독립된 질문을 단순히 이어 붙인 multi-hop 질의가 포함되면 RAG의 추론 능력이 과대평가될 수 있다고 지적합니다.
3. 근거 위치 미기록
Ground Truth 문장만 있고 어느 문서의 어느 부분이 근거인지는 기록하지 않았습니다. 이 때문에 검색 품질 평가는 LLM이 판정하는 Context Recall에 의존해야 했고 다음과 같은 문제가 발생했습니다.
- 같은 입력에도 판정이 달라짐
- 문항마다 판정 호출 비용 발생
- 점수가 낮아도 어떤 근거를 누락했는지 알 수 없음
4. 천장 효과(Ceiling Effect)
기존 평가셋의 Context Recall은 0.92였습니다. 점수가 오를 수 있는 범위가 0.08뿐이므로 검색을 개선해도 점수 차이를 노이즈와 구분할 수 없었습니다.
5. 개선 효과 분리 불가
기존 평가셋의 문항 분류는 RAGAS 생성기 이름뿐이었습니다. 이 이름은 질문을 어떤 방식으로 생성했는지만 나타낼 뿐 그 질문이 검색의 어떤 부분을 평가하는지는 나타내지 않습니다. 예를 들어 청크에 문서 경로 정보를 추가하는 개선을 했다면 경로로 문서를 검색하는 질문의 점수만 별도로 확인해야 합니다. 그러나 기존 평가셋에는 해당 문항을 구분하는 표시가 없어 선별할 수 없었습니다.
새 평가셋 설계
위와 같은 이유로 기존의 RAGAS 평가 데이터셋은 더이상 평가 기준으로 사용하기 어려웠습니다. 그래서 새로운 평가 방식을 도입해야 할 필요성을 느꼈고 바로 구축을 시작했습니다.
새로운 데이터셋은 세 가지를 기준으로 만들었습니다.
- 정답은 원문에서만 인용
- 근거 위치는 스크립트로 채점할 수 있는 형태로 기록
- 복합 질의는 실제로 여러 문서가 필요한 문항으로만 구성
| 항목 | 기존 평가셋 | 새 평가셋 |
|---|---|---|
| 문항 수 | 60 | 80 |
| 복합 질의 | 50% (실질 근거 1개인 문항 다수) | 88% (서로 다른 문서 2개 이상 필수) |
| 문항당 근거 구간 수 | 1.4 | 4.4 |
| Ground Truth 근거 | 당시 RAG 답변 | 원문 인용만 |
| 근거 기록 | 없음 | 문서 ID + 원문 인용 |
| 분류 | 생성기 이름 | 8개 유형 + 개선별 태그 |
문항 스키마
문항 하나는 아래와 같은 형태입니다.
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
{
"id": "q-012",
"user_input": "프로필 이미지 썸네일 생성이 밀릴 때 처리하는 워커의 최대 동시 실행 수는 몇 개인가요?",
"reference": "썸네일 생성 지연은 thumbnail-worker가 처리하며 이 워커의 최대 동시 실행 수는 8개입니다.",
"reference_contexts": ["...", "..."],
"must_have": ["처리 워커는 thumbnail-worker", "최대 동시 실행 수 8개"],
"evidence": [
{
"doc_id": "doc-A",
"title": "이미지 업로드 장애 대응 가이드",
"quote": "썸네일 생성이 밀리면 thumbnail-worker 큐 적체를 먼저 확인한다",
"role": "bridge",
"alt_doc_ids": []
},
{
"doc_id": "doc-B",
"title": "비동기 워커 설정 목록",
"quote": "thumbnail-worker | concurrency 8",
"role": "answer",
"alt_doc_ids": ["doc-C"]
}
],
"min_docs": 2,
"reasoning": "장애 대응 가이드에서 워커 이름을 얻은 뒤 워커 설정 목록에서 해당 워커의 동시 실행 수를 찾는다.",
"type": "bridge",
"tags": ["cross_area"],
"query_style": "natural"
}
| 필드 | 설명 |
|---|---|
reference |
Ground Truth. 근거 원문으로만 작성 |
reference_contexts |
근거 원문 구간. RAGAS 입력 형식 호환용 |
must_have |
정답에 반드시 포함되어야 하는 핵심 사실 (복합 2~5개 / 단일 1~3개) |
evidence |
문서 ID / 제목 / 원문 인용 / 역할 / 같은 사실을 담은 다른 문서 ID |
min_docs |
답변에 필요한 최소 문서 수 |
reasoning |
근거를 연결해 답변에 도달하는 과정 |
근거는 청크 ID 대신 문서 ID와 원문 인용으로 기록했습니다. 청크 ID는 보통 청크 내용의 해시입니다. 따라서 청킹 방식을 변경하거나 청크 앞부분에 정보를 추가하면 청크 ID가 모두 변경되고 인덱싱을 개선할 때마다 평가셋도 재구축해야 합니다.
반면 원문 인용으로 기록하면 채점할 때 검색된 청크 본문에 인용문이 포함되어 있는지만 확인하면 됩니다. 이때 양쪽 모두 공백과 마크다운 이스케이프와 HTML 태그와 링크 주소를 같은 규칙으로 제거한 뒤 부분 문자열 포함 여부로 판정합니다. 이 방식에서는 청크 경계가 변경되어도 평가셋을 그대로 사용할 수 있습니다.
alt_doc_ids는 같은 사실이 여러 문서에 있는 경우를 위한 필드입니다.
예를 들어 사내 위키에는 같은 설정값이 기획 문서와 운영 문서에 함께 기재된 경우가 많습니다.
이 필드가 없으면 다른 문서에서 정답을 찾은 검색도 실패로 처리됩니다.
질의 유형
복합 질의도 종류마다 실패하는 지점이 다릅니다. 이에 따라 근거를 연결하는 방식을 기준으로 8개 유형으로 분류했습니다.
| 유형 | 문항 | 정의 |
|---|---|---|
단일 (single) |
10 | 한 청크만으로 답변 가능. 회귀 확인용 기준선 |
연쇄 (bridge) |
16 | 문서 A에서 얻은 이름/값이 있어야 문서 B를 찾을 수 있음 |
비교 (comparison) |
11 | 같은 속성을 서로 다른 문서의 대상끼리 대조 |
열거/집계 (aggregation) |
9 | 관련 문서를 전부 확인해야 빠짐없이 답변 가능 |
수치 계산 (numeric) |
9 | 여러 근거의 수치로 계산 |
규칙 적용 (rule_application) |
9 | 문서의 규칙을 가상 사례에 적용 |
원인 진단 (causal) |
9 | 증상 → 원인 → 확인 → 조치를 여러 문서에 걸쳐 연결 |
변경/시점 추적 (temporal) |
7 | 이전 버전과 현행 문서가 함께 존재할 때 현행 문서를 판별 |
실제 사용자의 질문은 항상 정돈된 문장이 아니므로 질문 문체도 다양하게 구성했습니다.
- 자연 문장 58문항
- 오타가 섞인 문장 12문항
- 키워드 나열 10문항
개선별 태그
유형과 별도로 특정 개선의 영향을 받는 문항에는 태그를 부여했습니다. 이후 특정 분야를 타겟한 개선을 적용하면 해당 태그가 부여된 문항의 점수만 별도로 비교해서 효과를 확인하는데 활용했습니다.
| 태그 | 문항 | 의미 | 확인하려는 효과 |
|---|---|---|---|
variant_spelling |
16 | 문서와 다른 표기로 질문 (예: Kubernetes ↔ 쿠버네티스) | 엔티티 이름 표기 통일 |
location_hint |
14 | 상위 페이지 이름으로 문서를 지목 | 청크에 문서 경로 정보 추가 |
cross_area |
10 | 서로 다른 최상위 영역의 근거를 연결 | 영역 횡단 검색 |
attachment |
6 | 근거가 첨부파일에 있음 | 첨부파일 색인 |
복합 질의 조건: 단일 청크 검사
복합 질의로 인정하는 조건은 두 가지입니다.
- 서로 다른 문서 2개 이상에 근거가 있어야 함
- 질문을 그대로 검색했을 때 상위 10개 청크 중 하나에 핵심 사실이 모두 있으면 복합 질의에서 제외
근거 문서가 두 개여도 한 청크에 필요한 정보가 전부 포함되어 있으면 한 번의 검색만으로 답변이 가능합니다. 이런 문항으로는 여러 문서를 연결해 검색하는 능력을 측정할 수 없으므로 두 번째 조건을 추가했습니다.
multi-hop QA 데이터셋인 MuSiQue도 같은 방식을 사용합니다. 이 데이터셋은 단일 홉 질문을 조합해 multi-hop 질문을 생성하고, 하위 질문 하나만으로 답변이 가능한 문항은 필터로 제외했습니다. 그 결과 MuSiQue는 단일 홉 모델의 F1이 30점 가까이 낮아지는 데이터셋이 되었습니다.
구축과 검증
구축
문항은 문서 영역별로 배정한 LLM 에이전트 5개가 생성했습니다. 에이전트에는 Agentic RAG 시스템과 같은 검색 도구(문서 목록 / 검색 / 청크 전문 조회 / 앞뒤 청크 조회)를 제공하고 원문을 직접 찾아 인용하게 했습니다. (Agentic RAG와 해당 도구들에 대한 설명은 추후 다른 글에서 자세히 다룰 예정입니다.)
이때 근거는 원문 그대로 인용하고 Ground Truth는 찾은 인용으로만 작성하게 했습니다.
또한 근거로 사용하지 않는 자료도 정했습니다.
- LLM이 작성한 문장: 인덱싱 중 생성된 이미지 설명과 청크 앞부분에 추가된 요약문 등
- 다른 문서를 예시로 인용한 문서: RAG 실험 문서처럼 다른 문서의 내용을 예시로 재인용한 문서. 원본과 사본이 혼재되는 것을 방지하기 위해 제외
블라인드 검증
문항을 생성한 에이전트가 직접 검증하면 같은 실수를 반복할 가능성이 높습니다. 이를 방지하기 위해 문항을 생성하지 않은 검증 에이전트 4개를 별도로 두었습니다. 검증 에이전트는 Ground Truth를 확인하기 전에 직접 답변을 작성하고 그 답변을 원문과 대조했습니다.
- 블라인드 답변 결과: 일치 73문항 / 부분 일치 7문항
| 판정 | 문항 | 주요 사유 |
|---|---|---|
| 통과 | 50 | - |
| 수정 | 28 | 응답 예시 값을 실제 설정값처럼 묻는 질문 질문이 묻지 않는 핵심 사실 포함 한 청크로 끝나는 복합 문항 현행 판단 오류 |
| 교체 | 2 | 같은 수치를 담은 다른 문서 존재 / 다른 문항과 같은 규칙을 묻는 중복 |
80문항 중 30문항을 수정하거나 교체했습니다. 만약 검증을 건너뛰었다면 평가셋의 1/3 이상이 오류가 있는 상태로 평가에 사용되었을 것입니다.
스크립트 검사
아래 항목은 스크립트로 검사합니다.
- 필수 필드 존재 여부
- 근거 문서 수가
min_docs이상인지 - 인용문이 원문에 그대로 있는지
검색 평가 지표
이전에 사용하던 RAGAS의 가장 큰 문제는 검색/생성 성능을 모두 LLM이 판단하여 일관적이지 못한 문제였습니다. 그래서 이번에는 검색 성능은 스크립트를 활용해 항상 같은 점수를 산출할 수 있게 만들었습니다.
지표는 5가지를 함께 사용하고 주 지표는 All Evidence@k로 정했습니다.
원문 대조 준비
지표를 계산하기 전에 두 가지를 준비합니다.
첫째는 정규화입니다. 근거 인용은 위키 원문에서 복사했으므로 HTML 태그와 마크다운 링크와 이스케이프 문자가 포함되어 있습니다. 반면 검색 결과의 청크 본문은 인덱싱 과정에서 이미 정리된 상태입니다. 두 텍스트를 같은 규칙으로 정규화해야 문자열 포함 여부를 정확하게 비교할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import html
import re
# [텍스트](주소)에서 주소 부분 (괄호 한 겹까지 허용)
_LINK_URL = re.compile(r"\]\((?:[^()]|\([^()]*\))*\)")
def norm(t: str) -> str:
"""원문 인용과 검색 청크를 같은 모양으로 정규화한다."""
t = html.unescape(t or "")
t = re.sub(r"<br\s*/?>", "/", t, flags=re.I) # 줄바꿈 태그는 구분자로 변환
t = re.sub(r"</?[a-zA-Z][^>]*>", "", t) # 나머지 HTML 태그 제거
t = _LINK_URL.sub("]", t) # 링크 주소 제거 (텍스트는 유지)
t = re.sub(r"\\([_*\[\]()#`>|-])", r"\1", t) # 마크다운 이스케이프 해제
t = re.sub(r"[*`\[\]]", "", t) # 강조와 코드와 링크 기호 제거
return re.sub(r"\s+", "", t) # 공백은 전부 제거
공백을 전부 제거하는 이유는 표 정렬용 공백이나 줄바꿈 위치가 원문과 청크에서 다르기 때문입니다.
둘째는 정답 문서 묶음입니다.
근거 문서마다 같은 사실을 담은 다른 문서(alt_doc_ids)를 묶어 하나의 집합으로 만듭니다.
검색 결과에 이 집합의 문서가 하나라도 있으면 해당 근거 문서를 찾은 것으로 판정합니다.
1
2
3
4
5
6
7
8
9
10
def gold(item: dict) -> tuple[list[set[str]], list[str]]:
"""(정답 문서 묶음 목록 / 정규화한 근거 인용 목록)을 반환한다."""
groups: dict[str, set[str]] = {}
quotes = []
for e in item["evidence"]:
g = groups.setdefault(e["doc_id"], {e["doc_id"]})
g.update(e.get("alt_doc_ids") or [])
if e.get("quote"):
quotes.append(norm(e["quote"]))
return list(groups.values()), quotes
Doc Hit@k (문서 적중률)
정답 문서가 상위 $k$개 안에 하나라도 포함되었는지 측정합니다.
\[\text{Doc Hit@}k = \frac{1}{N} \sum_{i=1}^{N} hit_i\]- $hit_i$ = 문항 $i$의 상위 $k$개 청크에 정답 문서(또는 같은 사실을 담은 다른 문서)가 있으면 1 / 없으면 0
- $N$ = 전체 문항 수
All Docs@k (전 문서 적중률)
답변에 필요한 문서를 모두 상위 $k$개 안에 검색했는지 측정합니다.
\[\text{All Docs@}k = \frac{1}{N} \sum_{i=1}^{N} alldocs_i\]- $alldocs_i$ = 문항 $i$의 근거 문서가 모두 상위 $k$개 청크에 있으면 1 / 하나라도 없으면 0
Evidence Recall@k (근거 재현율)
근거 원문 인용 중 상위 $k$개 청크에 포함된 비율입니다. 같은 문서라도 인용문이 없는 청크는 인정하지 않으므로 Doc Hit보다 엄격합니다.
\[\text{Evidence Recall@}k = \frac{1}{N} \sum_{i=1}^{N} \frac{n(E_i^{found})}{n(E_i)}\]- $n(E_i)$ = 문항 $i$의 전체 근거 인용 수
- $n(E_i^{found})$ = 그중 상위 $k$개 청크에 포함된 인용 수
All Evidence@k (전 근거 재현율)
근거 인용을 모두 검색한 문항의 비율입니다.
\[\text{All Evidence@}k = \frac{1}{N} \sum_{i=1}^{N} allev_i\]- $allev_i$ = 문항 $i$의 근거 인용이 모두 포함되면($n(E_i^{found}) = n(E_i)$) 1 / 하나라도 누락되면 0
주 지표로 정한 이유는 두 가지입니다.
- 복합 질의는 근거가 하나라도 누락되면 답변이 불완전해짐
- Doc Hit@10은 기준선에서 이미 0.96이므로 개선해도 점수가 오를 수 있는 범위가 거의 없음
MRR (Mean Reciprocal Rank)
첫 정답 문서가 몇 번째 순위로 검색되었는지 측정합니다.
\[\text{MRR} = \frac{1}{N} \sum_{i=1}^{N} \frac{1}{rank_i}\]- $rank_i$ = 문항 $i$에서 정답 문서가 처음 검색된 순위. 정답 문서가 없으면 0으로 계산
지표 계산 코드
다섯 가지 지표는 하나의 함수에서 함께 계산합니다. 입력은 문항 하나와 그 문항의 검색 결과(순위가 매겨진 청크 목록)입니다.
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
def retrieval_metrics(item: dict, chunks: list[dict], ks=(5, 10, 20)) -> dict:
groups, quotes = gold(item)
gold_any = set().union(*groups) if groups else set()
out = {}
# MRR: 정답 문서가 처음 나온 순위의 역수
first = next((c["rank"] for c in chunks if c["doc_id"] in gold_any), None)
out["mrr"] = 1.0 / first if first else 0.0
for k in ks:
top = [c for c in chunks if c["rank"] <= k]
docs = {c["doc_id"] for c in top}
texts = [norm(c["content"]) for c in top]
# Doc Hit: 정답 문서가 하나라도 있는가
out[f"doc_hit@{k}"] = float(bool(docs & gold_any))
# All Docs: 모든 근거 문서 묶음에서 하나 이상씩 검색했는가
out[f"all_docs@{k}"] = float(all(g & docs for g in groups)) if groups else 0.0
# 근거 인용마다 상위 k개 청크 중 하나에 포함되어 있는지 판정
found = [any(q in t for t in texts) for q in quotes]
# Evidence Recall: 포함된 인용의 비율
out[f"evidence_recall@{k}"] = sum(found) / len(found) if found else 0.0
# All Evidence: 인용이 전부 포함되었는가
out[f"all_evidence@{k}"] = float(all(found)) if found else 0.0
return out
docs & gold_any: 문서 ID만 비교합니다. 같은 문서의 다른 청크가 검색되어도 성공으로 판정하므로 Doc Hit가 가장 관대합니다.all(g & docs for g in groups): 근거 문서 묶음마다 하나 이상 검색되어야 합니다. 복합 질의에서 두 번째 문서를 놓치면 이 값이 0이 됩니다.any(q in t for t in texts): 문서가 아니라 인용문 자체가 청크 본문에 포함되어야 합니다. 같은 문서를 검색했더라도 근거가 담긴 청크가 아니면 실패로 판정합니다.
$k$는 5와 10과 20을 함께 계산했고 이 글의 수치는 모두 $k=10$ 기준입니다.
전체 평균은 문항별 결과를 모아 유형과 태그별로 다시 묶어 계산합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from collections import defaultdict
from statistics import mean
def breakdown(items: list[dict], per: dict[str, dict]) -> dict:
"""전체 / 복합 / 단일 / 유형별 / 태그별 평균을 계산한다."""
buckets: dict[str, list[str]] = defaultdict(list)
for it in items:
buckets["ALL"].append(it["id"])
buckets["단일" if it["type"] == "single" else "복합"].append(it["id"])
buckets[f"type:{it['type']}"].append(it["id"])
for tag in it.get("tags") or []:
buckets[f"tag:{tag}"].append(it["id"])
return {
name: {"n": len(ids), **{m: round(mean(per[i][m] for i in ids), 4) for m in per[ids[0]]}}
for name, ids in buckets.items()
}
한 문항이 여러 묶음에 동시에 들어가므로 유형별 결과와 태그별 결과를 같은 실행에서 함께 확인할 수 있습니다.
답변 평가 지표
답변은 문자열 비교로 채점할 수 없으므로 LLM-as-a-Judge를 사용합니다.
다만 RAGAS처럼 LLM이 statement를 나누어 판정하게 하지 않고 미리 정의한 must_have와 채점 기준을 제공해 판정하게 했습니다.
Must-have Recall (핵심 사실 재현율)
핵심 사실마다 yes / partial / no로 판정합니다.
1
2
3
- yes: 답변이 그 사실을 맞게 담았다. 표현이 달라도 뜻이 같으면 yes(예: "7일" ↔ "일주일").
- partial: 일부만 맞다(값 일부 누락 / 조건 일부 누락 / 두 항목 중 하나만).
- no: 없거나 틀렸다. 틀린 값을 말했으면 no 이고 무엇이 틀렸는지 적는다.
- $s_{ij}$ = 문항 $i$의 $j$번째 핵심 사실 점수 (yes 1 / partial 0.5 / no 0)
- $M_i$ = 문항 $i$의 핵심 사실 수
Correctness (정답성)
핵심 사실 판정을 기준으로 답변 전체를 판정합니다.
1
2
3
4
- correct: 질문에 맞게 답했고 핵심 사실이 모두 yes 이며 틀린 주장이 없다.
- partial: 방향은 맞지만 핵심 사실 일부가 partial/no 이거나 사소한 오류가 있다.
- incorrect: 핵심 결론이 틀렸거나 핵심 사실 대부분이 no 다.
- no_answer: "문서에서 찾지 못했다" / 권한 문제 / 오류 메시지 등으로 답하지 않았다.
correct는 1점 / partial은 0.5점 / incorrect와 no_answer는 0점으로 환산해 평균을 계산합니다.
Faithfulness (충실도)
답변에 포함된 사실이 작성 근거에 실제로 있는지 측정합니다. 목적은 RAGAS의 Faithfulness와 같지만 규칙을 하나 더 추가했습니다.
1
2
3
4
5
- faithful: 사실 주장이 모두 근거에 있다.
- minor: 근거에 없는 주장이 있으나 사소하다(일반 상식 수준의 보충이며 결론에 영향 없음).
- major: 근거에 없는 구체적 사실(수치/코드/API 경로/이름)을 지어냈거나 결론이 근거와 어긋난다.
- 근거에서 사실을 찾았으면 그 사실이 질문 대상(같은 API/같은 문서/같은 시스템)에 관한 것인지 확인한다.
이름이 비슷한 다른 대상의 내용을 끌어와 이 대상에 대한 주장으로 쓴 것은 근거 밖 주장이다.
마지막 규칙은 채점 결과를 검토하는 과정에서 추가했습니다.
당시 채점자가 이름이 비슷한 다른 API의 오류 코드가 근거에 있다는 이유로 답변을 faithful로 판정한 경우가 있었습니다.
RAG 답변에서도 같은 오류가 발생합니다.
예를 들어 user-deactivate API를 질문받고 user-delete API 문서 내용으로 답변하는 경우입니다.
따라서 근거에 문장이 있는지뿐 아니라 그 문장이 질문 대상에 관한 내용인지도 확인해야 합니다.
채점 프롬프트
채점자에게 제공한 프롬프트 전문입니다. 위의 세 가지 판정 기준에 입력 형식과 출력 형식과 채점 원칙을 추가해 하나의 지시문으로 구성했습니다. 채점자는 문항 묶음 파일과 함께 이 지시문을 전달받고 답변 작성에 사용된 근거 파일을 직접 확인해 판정합니다.
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
51
52
53
54
55
56
57
58
# 답변 채점 기준
너는 RAG 답변 채점자다. 입력 파일의 문항마다 아래 네 가지를 판정해 출력 파일에 쓴다.
어떤 시스템과 실행의 답변인지는 알려 주지 않는다. 다른 실행과 비교하려 하지 말고 이 답변만 본다.
## 입력 필드
- `question`: 질문
- `reference`: 정답 문장(근거 원문으로 검증된 것)
- `must_have`: 정답이 반드시 담아야 할 핵심 사실 목록
- `answer`: 채점할 답변
- `evidence_file`: 답변 작성 때 시스템이 실제로 본 근거(검색 및 열람 결과). 길면 필요한 부분만 찾아 읽는다.
## 판정 1: 핵심 사실(`must_have`)마다
- `yes`: 답변이 그 사실을 맞게 담았다. 표현이 달라도 뜻이 같으면 yes(예: "7일" ↔ "일주일").
- `partial`: 일부만 맞다(값 일부 누락 / 조건 일부 누락 / 두 항목 중 하나만).
- `no`: 없거나 틀렸다. 틀린 값을 말했으면 no 이고 `wrong` 에 무엇이 틀렸는지 적는다.
- 판정 근거는 `reference` 와 `must_have` 다. 답변이 정답과 다른 사실을 말하면 근거 파일에 그 사실이 있더라도 정답 기준으로 판정한다.
## 판정 2: 정답성(`correctness`)
- `correct`: 질문에 맞게 답했고 핵심 사실이 모두 yes 이며 틀린 주장이 없다.
- `partial`: 방향은 맞지만 핵심 사실 일부가 partial/no 이거나 사소한 오류가 있다.
- `incorrect`: 핵심 결론이 틀렸거나 핵심 사실 대부분이 no 다.
- `no_answer`: "문서에서 찾지 못했다" / 권한 문제 / 오류 메시지 등으로 답하지 않았다.
## 판정 3: 근거 충실성(`faithfulness`)
답변의 사실 주장이 `evidence_file` 에 실제로 있는지 본다(정답 여부와 별개다. 근거에 없는 내용을 지어냈는가).
- `faithful`: 사실 주장이 모두 근거에 있다.
- `minor`: 근거에 없는 주장이 있으나 사소하다(일반 상식 수준의 보충이며 결론에 영향 없음).
- `major`: 근거에 없는 구체적 사실(수치 / 코드 / API 경로 / 이름)을 지어냈거나 결론이 근거와 어긋난다.
- `unsupported_claims` 에 근거에서 찾지 못한 주장을 짧게 적는다(최대 5개).
- 근거 파일에서 사실을 찾았으면 그 사실이 질문 대상(같은 API / 같은 문서 / 같은 시스템)에 관한 것인지 확인한다.
이름이 비슷한 다른 API나 문서의 내용(예: 다른 API 의 오류 코드표)을 끌어와 이 대상에 대한 주장으로 쓴 것은 근거 밖 주장이다.
근거 문서의 제목과 경로 줄로 대상을 확인한다.
- `evidence_file` 이 비어 있으면(근거 미기록) `faithfulness` 는 `minor` 로 두고 `notes` 에 "근거 미기록" 이라 적는다.
## 판정 4: 메모(`notes`)
한두 문장. 판정이 애매했던 이유와 정답이 낡았거나 틀려 보이는 경우(평가셋 쪽 문제 의심)는 반드시 적는다.
## 출력 형식 (입력의 모든 문항을 담은 JSON 배열)
[
{
"id": "q-012",
"must_have": [
{"fact": "핵심 사실 원문 그대로", "verdict": "yes|partial|no", "wrong": ""}
],
"correctness": "correct|partial|incorrect|no_answer",
"faithfulness": "faithful|minor|major",
"unsupported_claims": [],
"notes": ""
}
]
- `must_have` 는 입력과 같은 순서와 같은 개수로 쓴다(합산 스크립트가 개수를 검사한다).
- 쓴 뒤 JSON 파싱이 되는지 확인한다.
## 원칙
- 관대하지도 엄격하지도 않게 같은 기준을 모든 문항에 적용한다. 길이와 말투와 형식은 점수에 넣지 않는다.
- 근거 파일 밖의 지식(네가 아는 일반 지식)으로 정답을 바꾸지 않는다.
- 출력 파일 외에는 아무 파일도 수정하지 않는다.
판정 4의 메모는 점수에 반영하지 않고 채점자가 정답 자체를 의심한 문항을 선별해 원문과 다시 대조하는 용도로 사용했습니다. 이 과정에서 평가셋 오류 4건과 채점자 오류 1건을 찾아 수정했습니다.
측정 방법
측정 기록
평가셋은 한 번 구축한 뒤 변경하지 않습니다.
RAG를 개선할 때마다 평가셋의 질문(user_input)을 개선한 RAG에 그대로 입력하고 그 결과를 문항별로 저장합니다.
이 저장 결과를 측정 기록이라고 하며 지표는 평가셋과 측정 기록을 대조해 계산합니다.
단일 검색은 답변을 생성하지 않고 검색만 실행해 반환된 청크의 순위와 문서 ID와 본문을 기록합니다.
1
2
3
4
5
6
7
{
"id": "q-012",
"chunks": [
{"rank": 1, "doc_id": "doc-B", "content": "thumbnail-worker | concurrency 8 ..."},
{"rank": 2, "doc_id": "doc-D", "content": "..."}
]
}
Agentic 검색은 실제 서비스와 같은 프롬프트로 답변을 생성합니다. 이때 답변과 함께 답변 작성 단계로 전달된 청크 전체를 시스템의 질의 로그에서 조회해 기록합니다.
1
2
3
4
5
6
7
8
{
"id": "q-012",
"answer": "썸네일 생성은 thumbnail-worker가 처리하며 최대 동시 실행 수는 8개입니다.",
"evidence_chunks": [
{"doc_id": "doc-A", "content": "..."},
{"doc_id": "doc-B", "content": "..."}
]
}
단일 검색 기록의 chunks는 앞의 retrieval_metrics에, Agentic 기록의 evidence_chunks는 뒤의 set_metrics에 그대로 전달됩니다.
답변 채점에는 같은 근거를 문항별 파일로 저장해 채점자에게 함께 제공합니다.
측정 기록은 실행마다 별도로 저장하므로 같은 조건을 5회 측정하면 측정 기록도 5개가 됩니다.
실행 간 변동
새 평가지표를 구축한 후 Agentic RAG 도입 결과를 측정하는 과정에서 아무것도 변경하지 않고 다시 측정해도 점수가 크게 달라지는 현상을 확인했습니다. 같은 조건에서도 주 지표가 측정할 때마다 0.250~0.287 사이에서 달라졌습니다.
검색 단계에서 질의 키워드를 LLM으로 추출하며 키워드 캐시가 비활성화되어 있어 같은 질문도 측정할 때마다 키워드가 달라졌고, 여러 번 검색하는 Agentic 방식 특성상 탐색 경로도 실행마다 달라졌기 때문입니다.
| 지표 | 1회 측정 | 반복 측정 평균 (범위) |
|---|---|---|
| 단일 검색 All Evidence@10 | 0.287 | 0.273 (0.250~0.287 / 5회) |
반복 측정과 부호 검정
이에 따라 측정 방식을 변경했습니다.
- 검색 지표는 조건마다 5회 / 답변 지표는 2~3회 반복 측정
- 평균과 최소~최대 범위를 함께 비교하고 범위가 겹치는지 확인
- 문항별 실행 평균을 짝지어 부호 검정
부호 검정은 개선 후 점수가 상승한 문항과 하락한 문항의 수 차이가 우연인지 확인하는 검정입니다. 개선 효과가 없다면 각 문항이 상승할 확률과 하락할 확률은 각각 50%입니다. 점수가 변화한 문항 수를 $n$으로 두고 그중 적은 쪽 문항 수를 $k$라고 하면 양측 p-value는 다음과 같습니다.
\[p = 2 \sum_{i=0}^{k} \binom{n}{i} \left(\frac{1}{2}\right)^n\]50문항의 점수가 변화했고 그중 35문항이 상승하고 15문항이 하락했다면 $p \approx 0.007$이므로 우연으로 판단하기 어렵습니다. 반면 25 대 24라면 $p = 1.0$이므로 차이가 없다고 판단합니다.
1
2
3
4
5
6
7
8
from scipy.stats import binomtest
def sign_test(before: dict, after: dict):
"""문항별 실행 평균({문항 ID: 점수})을 짝지어 부호 검정"""
up = sum(1 for q in before if after[q] > before[q])
down = sum(1 for q in before if after[q] < before[q])
p = binomtest(up, up + down, 0.5).pvalue
return up, down, p
전체 평균이 아니라 문항 단위로 짝지어 비교한 이유는 두 조건이 같은 문항으로 평가되기 때문입니다. Anthropic의 “Adding Error Bars to Evals”도 모델을 비교할 때 전체 평균 대신 문항별 차이로 통계 검정을 수행할 것을 권장합니다.
변동 요인 분리
반복 측정과 함께 변동 요인을 줄이는 방법도 적용했습니다.
- 스냅샷 고정: 운영 데이터를 로컬로 복사해 측정. 이후 RAG 검색/생성 파이프라인 변경 시 고정된 스냅샷을 이용해 일관적인 점수 측정
- 단계 분리: 답변 작성 단계만 변경하는 실험은 저장한 작성 단계 입력을 다시 입력해 검색 단계의 변동을 제거
새 평가셋으로 측정한 결과
기존 평가셋에서 보이지 않던 차이
먼저 새 평가셋으로 기존 RAG의 검색 성능을 측정했습니다. 여기서 단일 검색은 질문으로 검색을 한 번만 실행하고 그 결과 중 상위 10개 청크만으로 지표를 계산하는 방식입니다. LLM이 검색을 반복하지 않으므로 검색 엔진 자체의 성능을 측정합니다.
Context Recall은 지난 글에서 다룬 RAGAS 지표로 검색된 청크가 Ground Truth를 뒷받침하는 비율을 LLM이 판정한 값입니다. 기존 평가셋의 Context Recall은 0.92였지만 새 평가셋의 단일 검색 All Evidence@10은 0.287이었습니다. 이는 검색 성능이 저하된 것이 아니라 기존 평가셋으로는 이 차이를 측정할 수 없었던 것입니다.
아래 표의 전체(80) / 복합(70) / 단일(10)은 앞에서 정의한 질의 유형 기준의 문항 구분입니다.
| 지표 | 전체(80) | 복합(70) | 단일(10) |
|---|---|---|---|
| Doc Hit@10 | 0.963 | 0.957 | 1.000 |
| All Docs@10 | 0.600 | 0.543 | 1.000 |
| Evidence Recall@10 | 0.613 | 0.572 | 0.900 |
| All Evidence@10 | 0.287 | 0.200 | 0.900 |
| MRR | 0.866 | 0.854 | 0.950 |
Doc Hit 기준으로는 거의 모든 문항에서 정답 문서를 검색합니다. 하지만 복합 질의에서 필요한 근거를 모두 검색한 문항은 5개 중 1개뿐입니다. 즉 정답 문서는 검색하더라도 답변에 필요한 근거 청크를 모두 검색하지 못한 문항이 대부분입니다.
유형별 결과
같은 80문항을 단일 검색과 Agentic 검색으로 각각 측정해 질의 유형별로 비교했습니다.
Agentic 검색은 LLM이 검색 결과를 읽고 다음 검색어를 스스로 결정해 여러 번 검색한 뒤 수집한 근거로 답변을 작성하는 방식입니다. 검색 결과에 순위가 없으므로 상위 $k$개로 제한하지 않고 답변 작성 단계로 전달된 근거 전체로 All Evidence를 계산했습니다. 계산 함수도 순위를 사용하지 않도록 단순화했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
def set_metrics(item: dict, chunks: list[dict]) -> dict:
"""순위 없는 근거 집합(작성 단계로 전달된 청크 전체)으로 계산한다."""
groups, quotes = gold(item)
docs = {c["doc_id"] for c in chunks}
texts = [norm(c["content"]) for c in chunks]
found = [any(q in t for t in texts) for q in quotes]
return {
"doc_hit": float(bool(docs & set().union(*groups))) if groups else 0.0,
"all_docs": float(all(g & docs for g in groups)) if groups else 0.0,
"evidence_recall": sum(found) / len(found) if found else 0.0,
"all_evidence": float(all(found)) if found else 0.0,
}
Agentic 검색의 구현 방식은 다음 글에서 자세히 설명하겠습니다.
| 유형 | 문항 | 단일 검색 All Evidence@10 | Agentic 검색 All Evidence |
|---|---|---|---|
| 연쇄 | 16 | 0.062 | 0.500 |
| 변경/시점 추적 | 7 | 0.143 | 0.571 |
| 비교 | 11 | 0.182 | 0.727 |
| 원인 진단 | 9 | 0.222 | 0.667 |
| 규칙 적용 | 9 | 0.222 | 0.444 |
| 열거/집계 | 9 | 0.333 | 0.667 |
| 수치 계산 | 9 | 0.333 | 0.889 |
| 단일 | 10 | 0.900 | 0.800 |
기존 단일 질의 RAG는 연쇄 유형이 0.062로 가장 낮습니다. 문서 A를 확인해야 문서 B의 이름을 알 수 있는 질문은 한 번의 검색으로 필요한 근거를 전부 검색할 수 없기 때문입니다. 반면에 Agentic RAG는 연쇄 유형이 0.5로, 단일 RAG 대비 약 8.3배입니다. 이는 스스로 부족한 근거를 찾고 재질의 하는 Agentic RAG가 여러 문서를 연쇄적으로 탐색하는 질의에 더 강하다는 증거입니다.
반면에 단일 유형에서는 Agentic이 0.9에서 0.8로 오히려 낮았습니다. 단순한 질문에서 오히려 성능이 떨어지는 문제는 단일 기준선 10문항이 없었다면 발견하지 못했을 것입니다. Agentic RAG 구현과 개선 과정은 다음 글에서 다루겠습니다.
태그로 개선 효과 분리
평가셋을 재구축한 계기였던 인덱싱 개선을 적용하고 재측정했습니다. 이 개선에서는 상위 페이지 이름으로 문서를 지목하는 질문을 위해 청크에 문서 경로 정보를 추가하고 엔티티 이름 표기를 통일했습니다. 재인덱싱의 구체적인 내용은 Agentic RAG 글 이후에 별도의 글로 작성할 예정입니다.
표의 두 태그는 앞의 개선별 태그에서 정의한 문항 묶음입니다.
location_hint: 상위 페이지 이름으로 문서를 지목하는 질문. 경로 정보 추가의 효과를 확인하기 위해 정의한 태그variant_spelling: 문서와 다른 표기로 묻는 질문 (예: Kubernetes ↔ 쿠버네티스). 이름 표기 통일의 효과를 확인하기 위해 정의한 태그
| 지표 (각 5회) | 개선 전 | 개선 후 |
| All Evidence@10 (전체) | 0.273 (0.250~0.287) | 0.318 (0.300~0.338) |
location_hint (문서 지목) |
0.200 | 0.457 |
variant_spelling (표기 변형) |
0.250 | 0.175 |
전체 주 지표는 5회 측정 범위가 겹치지 않을 만큼 상승했습니다. 그러나 태그별로 비교하면 결과가 달랐습니다. location_hint 질문은 두 배 이상 상승했지만 variant_spelling 질문은 오히려 하락했습니다. 이처럼 전체 점수가 상승해도 개선 항목마다 효과가 다를 수 있으므로 태그별로 나누어 확인해야 합니다.
정리
- 합성 평가셋은 초기 자료로만 사용하고 생성기가 부여한 multi-hop 라벨은 근거 구간 수와 단일 청크 검사로 다시 확인
- Ground Truth는 원문 인용으로만 작성. RAG 출력으로 생성하면 RAG의 오답이 정답이 됨
- 근거는 문서 ID와 원문 인용으로 기록. 청크 경계가 바뀌어도 평가셋을 그대로 사용할 수 있고 검색 지표를 LLM 없이 계산 가능
- 주 지표는 기준선 점수가 낮아 개선 효과를 확인할 수 있는 지표로 선택 (Doc Hit 0.96 대신 All Evidence@10 0.287)
- 유형과 태그로 개선 효과를 나누어 확인하고 태그 평균이 변하면 문항 단위로 확인
- 1회 측정으로 비교하지 않고 반복 측정과 범위 비교와 문항 단위 부호 검정을 함께 사용
평가셋을 재구축하는 데 시간이 많이 소요되었습니다. 이후 개선 작업은 모두 이 평가셋으로 평가하고 있습니다. 다음 글에서는 이 평가셋으로 측정하며 개선한 Agentic RAG에 대해 작성하겠습니다.
레퍼런스
- MuSiQue: Multihop Questions via Single-hop Question Composition
- MultiHop-RAG: Benchmarking Retrieval-Augmented Generation for Multi-Hop Queries
- Fact, Fetch, and Reason: A Unified Evaluation of Retrieval-Augmented Generation (FRAMES)
- Are We on the Right Way for Assessing Document Retrieval-Augmented Generation?
- RAGChecker: A Fine-grained Framework for Diagnosing Retrieval-Augmented Generation
- Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena
- Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations
- Measuring all the noises of LLM Evals
- Position: Don’t Use the CLT in LLM Evals With Fewer Than a Few Hundred Datapoints
- RAGAS 공식 문서