7 분 소요

🕸 문서에서 지식 그래프, 그리고 GraphRAG 3부작 — 문서 더미에서 지식 그래프를 뽑고 문서가 바뀌어도 그래프가 따라오게 만드는 생성·유지보수 파이프라인(1편), 그 그래프와 벡터 검색을 한 질문 처리에서 같이 쓰는 GraphRAG(2편), 그리고 골드셋·지표·회귀 루틴으로 개선을 숫자로 감시하는 평가 하네스(3편)까지, "문서가 진실이고 그래프가 따라온다"는 한 목표로 이어집니다. 전체 3편.
  1. 문서에서 지식 그래프 만들기 — 한 번 뽑고 끝이 아니라 계속 관리되게
  2. GraphRAG 제대로 얹기 — 벡터 검색과 그래프 탐색을 한 파이프라인에서 섞기
  3. 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편). 어느 한 편이 빠져도 나머지가 흔들리는 구조라, 셋을 같이 가져가시는 걸 추천드립니다.

일단 오늘은 여기까지…..


← 이전 글: (2/3) GraphRAG 제대로 얹기 — 벡터 검색과 그래프 탐색을 한 파이프라인에서 섞기