(3/3) GraphRAG, 진짜 나아졌나 — 평가 세트로 일반 RAG 와 수치 비교하기
- 문서에서 지식 그래프 만들기 — 한 번 뽑고 끝이 아니라 계속 관리되게
- GraphRAG 제대로 얹기 — 벡터 검색과 그래프 탐색을 한 파이프라인에서 섞기
- GraphRAG, 진짜 나아졌나 — 평가 세트로 일반 RAG 와 수치 비교하기 ← 지금 글
Summary
지난 글에서 지식 그래프 위에 GraphRAG 를 얹었어요. 데모 질문 몇 개를 돌려보면 확실히 답이 좋아진 것 같은데, 여기서 멈추면 위험합니다. “좋아진 것 같다”는 느낌은 스키마를 바꾸거나 별칭 사전을 손보는 순간 아무 근거가 못 되거든요. 파이프라인을 계속 고치려면 고칠 때마다 같은 잣대로 다시 잴 수 있는 평가 세트 가 필요해요.
이 글은 그 잣대를 만듭니다. 방식은 Airflow 개인정보 게이트 평가 루프 글과 같은 철학이에요 — 라벨 달린 세트를 만들고, 지표를 정의하고, 파이프라인 두 개(vector-only / graph-rag)를 같은 세트에 돌려서 숫자로 비교합니다.
💡 이 글에서 다루는 것
- 골드셋 설계 — 정답 문장이 아니라 채점 가능한 기대 요소 로 라벨링
- 질문 유형 3종 분리 — 서술 조회 / 다단계 관계 / 복합
- 골드셋을 그래프에서 반자동 생성 하는 요령과 골드셋이 낡는 문제
- 지표 4개 — context recall · fact recall · citation precision · 환각 카운트
- LLM judge 를 structured output 으로 만들어 채점 자동화
- vector-only vs graph-rag 결과 해석과 회귀 루틴
지난 두 편의 kg.db, answer() 파이프라인을 그대로 이어서 씁니다. 예시 값은 전부 가상이에요.
1. 골드셋 설계 — 정답 문장이 아니라 기대 요소로
RAG 평가에서 제일 흔한 실수가 골드셋에 “모범 답안 문장”을 통째로 넣는 거예요. LLM 답변은 표현이 매번 달라서 문장 비교로는 채점이 안 되고, 사람이 다시 읽게 됩니다. 대신 저는 케이스마다 채점 가능한 두 가지 만 라벨링해요.
must_include— 답에 반드시 들어가야 하는 사실 조각들 (표현 무관, 의미 단위)expected_docs— 컨텍스트에 반드시 들려와야 하는 출처 문서들
여기에 질문 유형을 달아서 JSONL 로 관리합니다.
{"id": "q001", "type": "procedural",
"question": "정산 배치 장애 대응 절차 알려줘",
"must_include": ["주문 시스템 일마감 확인", "운영 담당자 1차 연락"],
"expected_docs": ["wiki/장애대응절차", "wiki/정산가이드"]}
{"id": "q014", "type": "multihop",
"question": "주문 시스템 일마감이 지연되면 뭐가 영향 받아?",
"must_include": ["정산 시스템 지연", "재무리포트 영향"],
"expected_docs": ["wiki/정산가이드", "wiki/재무월마감"]}
{"id": "q023", "type": "composite",
"question": "정산 배치 실패하면 누구한테 연락하고 뭐부터 확인해?",
"must_include": ["박신입", "결제플랫폼팀", "주문 시스템 일마감 확인"],
"expected_docs": ["wiki/정산가이드", "wiki/장애대응절차", "wiki/주문시스템운영"]}
유형을 나누는 이유는 1편부터 반복해온 그 표 때문이에요. 벡터와 그래프는 서로 다른 질문에 강하니까, 전체 평균 하나로 뭉치면 개선이 서로 상쇄돼서 안 보입니다. 유형별로 갈라야 “다단계에서 크게 이기고 서술 조회에선 동급”인지, “다단계에서 이긴 만큼 서술 조회를 망가뜨렸는지”가 구분돼요.
크기는 유형별 10~20개, 전체 30~50개면 시작으로 충분합니다. 100개짜리 완벽한 세트를 만들려다 시작을 못 하는 것보다, 30개로 시작해서 운영 중 발견한 실패 사례를 계속 추가하는 쪽이 실전적이에요.
2. 골드셋 만들기 — 그래프에서 반자동으로
골드셋 라벨링이 귀찮은 작업인 건 사실인데, 우리에겐 치트키가 있어요. 다단계 관계 질문은 그래프 자체에서 뽑아낼 수 있습니다. depends_on 두 홉 체인이 있으면, 그 체인이 곧 질문이고 엣지의 doc_id 가 곧 기대 출처거든요.
def generate_multihop_cases(conn, limit: int = 20) -> list[dict]:
rows = conn.execute("""
SELECT a.src_key, a.dst_key, b.dst_key,
n.label, a.doc_id, b.doc_id
FROM edges a
JOIN edges b ON a.dst_key = b.src_key
JOIN nodes n ON n.node_key = b.dst_key
WHERE a.relation = 'depends_on' AND b.relation = 'depends_on'
AND a.src_key != b.dst_key
LIMIT ?
""", (limit,)).fetchall()
cases = []
for i, (top, mid, bottom, bottom_label, doc_a, doc_b) in enumerate(rows):
top_label = conn.execute(
"SELECT label FROM nodes WHERE node_key = ?", (top,)).fetchone()[0]
cases.append({
"id": f"gen{i:03d}", "type": "multihop",
"question": f"{bottom_label}에 문제가 생기면 {top_label}도 영향을 받아?",
"must_include": [top_label, bottom_label],
"expected_docs": sorted({doc_a, doc_b}),
})
return cases
cases = generate_multihop_cases(conn, limit=3)
print(cases[0]["question"])
print(cases[0]["expected_docs"])
주문 시스템에 문제가 생기면 재무리포트도 영향을 받아?
['wiki/재무월마감', 'wiki/정산가이드']
자동 생성한 케이스는 사람이 한 번 훑어서 어색한 질문만 걸러내면 됩니다. 서술 조회/복합 유형은 실제 사용자 질문 로그에서 모으는 게 제일 좋아요. 지난 글의 라우팅 로그에 질문이 이미 쌓이고 있으니 거기서 추리면 됩니다.
⚠️ 골드셋도 낡습니다. 문서가 갱신되면
expected_docs의 문서가 사라지거나 담당자 이름(must_include)이 바뀔 수 있어요. 평가를 돌리기 전에expected_docs가 전부documents테이블에 존재하는지 확인하고, 없는 문서를 참조하는 케이스는 실패가 아니라 stale 로 따로 표시하세요. 1편에서 그래프가 문서를 따라가게 만든 것처럼, 골드셋도 문서를 따라가야 합니다.
3. 지표 — 검색 단계와 답변 단계를 따로 잰다
지표는 네 개면 충분합니다. 중요한 건 검색 단계와 답변 단계를 분리해서 재는 것 이에요. 답이 나쁠 때 검색이 못 물어온 건지, 물어왔는데 LLM 이 못 쓴 건지를 갈라야 고칠 곳이 보이거든요.
| 지표 | 단계 | 재는 것 | 계산 |
|---|---|---|---|
| context recall | 검색 | 기대 출처가 컨텍스트에 들어왔나 | 포함된 expected_docs / 전체 |
| fact recall | 답변 | 필수 사실이 답에 들어갔나 | 포함된 must_include / 전체 |
| citation precision | 답변 | 인용한 출처가 컨텍스트에 실재하나 | 실재 인용 / 전체 인용 |
| 환각 카운트 | 답변 | 컨텍스트에 없는 주장 수 | LLM judge 판정 |
context recall 과 citation precision 은 문자열 매칭으로 계산됩니다. fact recall 과 환각은 표현이 자유로워서 LLM judge 가 필요해요. judge 는 structured output 으로 만들어서 채점 결과가 항상 기계가 읽을 수 있는 형태로 나오게 합니다.
import json
import anthropic
client = anthropic.Anthropic()
JUDGE_SCHEMA = {
"type": "object",
"properties": {
"facts_covered": {
"type": "array", "items": {"type": "boolean"},
"description": "must_include 순서대로, 답변이 해당 사실을 담고 있는지"
},
"unsupported_claims": {
"type": "array", "items": {"type": "string"},
"description": "컨텍스트에 근거가 없는 답변 속 주장들"
},
},
"required": ["facts_covered", "unsupported_claims"],
"additionalProperties": False,
}
def judge(question: str, answer_text: str, context: str, must_include: list[str]) -> dict:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system=(
"너는 RAG 답변 채점기다. 표현이 달라도 의미가 같으면 covered 로 판정한다. "
"unsupported_claims 에는 컨텍스트 어디에도 근거가 없는 주장만 담는다."
),
output_config={"format": {"type": "json_schema", "schema": JUDGE_SCHEMA}},
messages=[{"role": "user", "content": (
f"질문: {question}\n\n필수 사실 목록: {json.dumps(must_include, ensure_ascii=False)}\n\n"
f"컨텍스트:\n{context}\n\n답변:\n{answer_text}"
)}],
)
return json.loads(response.content[0].text)
verdict = judge(
question="정산 배치 실패하면 누구한테 연락해?",
answer_text="운영 담당자인 박신입 매니저에게 연락하세요. 참고로 새벽엔 당직팀이 받습니다.",
context="- person:박신입 --operates--> system:정산시스템 (근거: 일 배치는 박신입 매니저가 담당한다.)",
must_include=["박신입", "결제플랫폼팀"],
)
print(verdict)
{'facts_covered': [True, False],
'unsupported_claims': ['새벽엔 당직팀이 받는다']}
fact recall 은 1/2 = 0.5 이고, 컨텍스트에 없는 “당직팀” 이야기가 환각으로 잡혔어요. judge 도 LLM 이라 완벽하지 않지만, 같은 judge 로 두 파이프라인을 채점하는 상대 비교 에서는 편향이 양쪽에 똑같이 걸리기 때문에 충분히 쓸 만합니다.
4. 평가 하네스 — 두 파이프라인을 같은 세트에 돌리기
이제 조립입니다. 케이스 하나를 받아서 파이프라인을 돌리고 네 지표를 계산하는 함수, 그리고 세트 전체를 유형별로 집계하는 함수 두 개예요. pipeline 인자로 vector-only 와 graph-rag 를 갈아끼우는 게 전부라, 하네스는 파이프라인 구현을 몰라도 됩니다.
import re
def eval_case(case: dict, pipeline) -> dict:
result = pipeline(case["question"]) # {"answer": str, "context": str, "doc_ids": set}
hit_docs = case["expected_docs"] and [
d for d in case["expected_docs"] if d in result["doc_ids"]]
cited = set(re.findall(r"출처: ([\w/가-힣]+)", result["answer"]))
verdict = judge(case["question"], result["answer"],
result["context"], case["must_include"])
return {
"id": case["id"], "type": case["type"],
"context_recall": len(hit_docs) / len(case["expected_docs"]),
"fact_recall": sum(verdict["facts_covered"]) / len(case["must_include"]),
"citation_precision": (len(cited & result["doc_ids"]) / len(cited)) if cited else 0.0,
"hallucinations": len(verdict["unsupported_claims"]),
}
def run_eval(cases: list[dict], pipeline) -> dict:
rows = [eval_case(c, pipeline) for c in cases]
report = {}
for qtype in {r["type"] for r in rows}:
sub = [r for r in rows if r["type"] == qtype]
report[qtype] = {
"n": len(sub),
"context_recall": round(sum(r["context_recall"] for r in sub) / len(sub), 2),
"fact_recall": round(sum(r["fact_recall"] for r in sub) / len(sub), 2),
"citation_precision": round(sum(r["citation_precision"] for r in sub) / len(sub), 2),
"hallucinations": sum(r["hallucinations"] for r in sub),
}
return report
report_vec = run_eval(goldset, vector_only_pipeline)
report_gr = run_eval(goldset, graph_rag_pipeline)
print(json.dumps(report_gr["multihop"], ensure_ascii=False))
{"n": 15, "context_recall": 0.91, "fact_recall": 0.87, "citation_precision": 0.95, "hallucinations": 2}
judge 호출이 케이스당 한 번이라 골드셋 50개면 LLM 호출 50번이에요. 매일 돌려도 부담 없는 크기지만, 케이스가 수백 개로 늘면 Batches API 로 넘기면 됩니다.
5. 결과 해석 — 어디서 이기고 어디서 비기나
두 리포트를 나란히 놓으면 이런 모양이 나옵니다 (가상의 수치예요).
| 유형 | 지표 | vector-only | graph-rag |
|---|---|---|---|
| 서술 조회 | context recall | 0.90 | 0.92 |
| 서술 조회 | fact recall | 0.85 | 0.84 |
| 다단계 관계 | context recall | 0.41 | 0.91 |
| 다단계 관계 | fact recall | 0.38 | 0.87 |
| 복합 | context recall | 0.62 | 0.88 |
| 복합 | fact recall | 0.55 | 0.83 |
읽는 법을 정리하면 이래요.
- 다단계에서 context recall 이 두 배 넘게 벌어지는 것 이 GraphRAG 의 존재 이유입니다. 벡터만으로는 기대 출처의 절반도 못 물어와요. 여기 격차가 안 벌어지면 그래프 커버리지(1편의 재색인)나 엔티티 링킹부터 의심하세요.
- 서술 조회에서 두 파이프라인이 동급 인 게 두 번째로 중요한 확인이에요. graph-rag 가 여기서 오히려 떨어진다면 서브그래프가 잡음을 넣고 있다는 뜻이라, 지난 글의 라우팅(vector-only 분기)이나 서브그래프
limit을 조입니다. - 환각 카운트는 절대값보다 추세 로 봅니다. 컨텍스트가 풍부해질수록 환각은 줄어드는 게 정상이라, graph-rag 쪽이 더 많다면 컨텍스트 합성 단계에서 뭔가 어긋난 거예요.
검색 단계(context recall)와 답변 단계(fact recall)를 분리해둔 덕에, 어느 줄이 나빠도 “고칠 곳”이 바로 특정됩니다. context recall 은 좋은데 fact recall 이 낮으면 프롬프트 문제, 둘 다 낮으면 검색 문제예요.
6. 회귀 루틴 — 평가를 파이프라인 수정의 관문으로
평가 세트의 진짜 가치는 첫 측정이 아니라 그 다음부터 입니다. 이제 이 시리즈에서 만든 모든 조정 포인트가 회귀 테스트의 보호를 받아요.
- 스키마에 관계를 추가/변경할 때 → 평가 돌리고 diff 확인
- 별칭 사전에 항목을 추가할 때 → 엔티티 링킹이 좋아졌는지 다단계 recall 로 확인
- 서브그래프 홉 수/limit 을 바꿀 때 → 서술 조회 유형이 나빠지지 않는지 확인
- 임베딩 모델이나 청킹을 바꿀 때 → vector-only 기준선부터 다시 측정
주기 실행은 1편의 재색인 태스크 옆에 하나 더 얹으면 끝이에요.
@task
def weekly_rag_eval():
goldset = load_goldset("/data/rag_goldset.jsonl")
fresh = [c for c in goldset if docs_exist(conn, c["expected_docs"])] # stale 제외
report = {
"stale_cases": len(goldset) - len(fresh),
"vector_only": run_eval(fresh, vector_only_pipeline),
"graph_rag": run_eval(fresh, graph_rag_pipeline),
}
post_to_slack(format_report(report)) # 지난 주 대비 diff 를 같이 붙이면 더 좋음
return report
리포트에서 볼 것은 세 숫자예요. stale_cases 가 늘면 골드셋 정비가 필요하고, 유형별 recall 이 지난주보다 떨어지면 이번 주 수정 중 뭔가가 회귀를 만든 것이고, 두 파이프라인 격차가 좁아지면 그래프가 낡고 있다는 신호입니다.
이렇게 해서 3부작이 완성됐어요. 문서가 진실이고 그래프가 따라오게 만들고(1편), 그래프와 벡터를 한 파이프라인에서 섞고(2편), 그 전체를 숫자로 감시한다(3편). 어느 한 편이 빠져도 나머지가 흔들리는 구조라, 셋을 같이 가져가시는 걸 추천드립니다.
일단 오늘은 여기까지…..