RAG2026-5일26차시 · Verifier Node 설계10 / 19← 이전목차다음 →MetaContents

26차시. Verifier Node 설계

7. 실습 ③ — 단독 테스트

출처: 원본 PDF 26차시_VerifierNode설계.pdf 15~16쪽 · 쪽 그림 15쪽 16쪽

일부러 나쁜 답변을 만들어 잡히는지 확인합니다.

📝 nodes/verifier.py 하단

출력
if __name__ == "__main__":
    from nodes.retriever import retriever_node

    q = "환불은 며칠 이내인가요?"
    s = {"question": q, "query": q}
    s.update(retriever_node(s))

    CASES = [
        # (설명, answer, insufficient, has_citation)
        ("정상 답변",
         "환불은 상품 수령 후 7일 이내에 신청하실 수 있습니다[1].",
         False, True),

        ("환각 포함",
         "환불은 7일 이내입니다[1]. 수수료는 상품가의 10%입니다[1].",
         False, True),

        ("동문서답",
         "고객센터는 평일 09시부터 18시까지 운영합니다[1].",
         False, True),

        ("인용 없음",
         "환불은 7일 이내에 신청하시면 됩니다.",
         False, False),

        ("근거 부족 신고",
         "자료에서 확인할 수 없습니다.",
         True, False),
    ]

    for desc, ans, insuf, cite in CASES:
        s2 = dict(s, answer=ans, insufficient=insuf,
                  has_citation=cite)
        r = verifier_node(s2)
        print(f"\n[{desc}]")
        print(f"  판정: {r['grade']}")
        print(f"  이유: {r['reason'][:70]}")

실행 결과
─────────────────────────────────────────────
─────
[정상 답변]
  판정: pass
  이유: 답변의 모든 내용이 근거 자료에 있고 질문에 대답함

[환각 포함]
  판정: retry                        ← 잡아냄!
  이유: 수수료 10%는 근거 자료에 없는 내용임

[동문서답]
  판정: research                     ← 관련성 부족
  이유: 근거에는 충실하나 환불 기간을 답하지 않음

[인용 없음]
  판정: retry                        ← 규칙 기반, LLM 호출 없음
  이유: 출처 번호가 없거나 유효하지 않음

[근거 부족 신고]
  판정: research                     ← 규칙 기반, LLM 호출 없음
  이유: 근거 부족 자가신고

🎯 확인할 것
① 환각을 잡아냈습니다
25차시 자가 점검은 전부 통과했던 답변입니다
② 뒤 두 케이스는 LLM을 안 불렀습니다
규칙 기반 1차 필터가 작동했습니다
③ 실패 유형이 나뉘었습니다
retry와 research가 구분되어 처방이 달라집니다

실습 코드 · 26/verifier.py

실습 코드: 실습/src/26/verifier.py · 위의 원본 PDF 26차시 15~16쪽 「7️⃣ 실습 ③ — 단독 테스트」과 이어집니다.

Python
# ==============================================================
# 26차시. PPT 26차시_VerifierNode설계. 코드 폴더는 src/26
# PPT: 26차시 Verifier Node
# 역할: Verifier Node. 규칙으로 먼저 거르고, 남은 답만 LLM 심판에게 넘겨 grade를 정합니다.
# 읽는 키: documents, answer, question, gen_error, insufficient, has_citation
# 쓰는 키: grade, reason, log
# 그래프:
#   gen_error 있음 ---------> grade=giveup
#   insufficient 참 -------> grade=research
#   has_citation 거짓 -----> grade=retry
#   답 길이 10자 미만 -----> grade=retry
#   LLM: grounded·relevant -> pass
#   LLM: grounded 거짓 ----> retry
#   LLM: 근거는 있고 질문 불일치 -> research
#   심판 호출 오류 ---------> grade=pass (서비스는 계속)
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
# return에 적지 않은 키는 이전 값이 남습니다.
# log와 tried_queries는 리스트라서, 새로 return한 항목이 뒤에 이어 붙습니다.
# Retriever: 검색 담당. 질문과 비슷한 문서 조각만 고릅니다. 답 문장은 만들지 않습니다.
# Generator: 고른 문서를 근거로 답 문장을 만듭니다. 문서가 없으면 안내 문장만 돌려줍니다.
# Verifier: 답이 근거와 맞는지 검사하고 grade를 적습니다.
#   grade=pass     답을 확정하고 그래프를 끝냅니다.
#   grade=retry    같은 문서로 답 문장만 다시 씁니다. retries가 1 늘어납니다.
#   grade=research 문서가 부족합니다. 검색어를 바꾼 뒤 다시 찾습니다. rewrites가 늘어납니다.
#   grade=giveup   더 진행하지 않고 fallback으로 안내 문장만 돌려줍니다.
# fallback: 실패했을 때 답을 지어내지 않고 안내 문장을 반환하는 노드입니다.
# intent: 질문 종류. greeting 인사, calc 계산, scope 문서 밖, document 문서 검색.
# rewrite: 사용자가 친 question은 유지하고, 검색에 쓰는 query만 바꿉니다.
# safe_node: 노드를 try로 감쌉니다. 오류가 나도 그래프는 멈추지 않고 node_error에 적습니다.
# 1차 검사는 정규식과 길이만 보므로 API를 부르지 않습니다.
# 2차 검사는 근거·질문·답을 심판 프롬프트에 넣고 JSON으로 grade 근거를 받습니다.
# grounded는 답이 문서 안에 있는지, relevant는 질문에 대한 답인지를 뜻합니다.
# parse_json은 응답에서 중괄호 블록만 골라 dict로 바꿉니다.
# ==============================================================

# ============================================================
# Verifier Node
# ============================================================
# Generator가 만든 답변을 근거 자료와 비교하여 검사합니다.
# 문제가 있으면 재생성(retry) 또는 재검색(research)을 요청합니다.
# ============================================================

import os
import sys
import json
import re

# ------------------------------------------------------------
# 현재 파일의 위치를 기준으로 필요한 폴더를 찾습니다.
# ------------------------------------------------------------

CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))

# src 폴더 경로입니다.
SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))

# 25차시 Generator 폴더 경로입니다.
GENERATOR_DIR = os.path.join(CURRENT_DIR, "..", "25")

# JUDGE_PROMPT가 있는 13차시 경로
sys.path.insert(0, os.path.join(CURRENT_DIR, "..", "13"))

# 24차시 Retriever 폴더 경로입니다.
RETRIEVER_DIR = os.path.join(CURRENT_DIR, "..", "24")

# src 폴더를 파이썬 모듈 검색 경로에 추가합니다.
sys.path.insert(0, SRC_DIR)

# Generator 폴더를 검색 경로에 추가합니다.
sys.path.insert(0, GENERATOR_DIR)

# Retriever 폴더를 검색 경로에 추가합니다.
sys.path.insert(0, RETRIEVER_DIR)

# ------------------------------------------------------------
# Verifier에서 사용할 모듈을 가져옵니다.
# ------------------------------------------------------------

# 심판용 프롬프트를 가져옵니다.
from prompts import JUDGE_PROMPT

# Generator에서 만든 문서 조합 함수와 LLM을 가져옵니다.
from generator import build_context, _llm

# LLM 응답을 문자열로 변환하는 파서입니다.
from langchain_core.output_parsers import StrOutputParser

# ------------------------------------------------------------
# 심판용 LLM 체인을 만듭니다.
# ------------------------------------------------------------

# 프롬프트 → LLM 호출 → 문자열 변환 순서로 실행됩니다.
judge_chain = JUDGE_PROMPT | _llm | StrOutputParser()

# ============================================================
# LLM 응답에서 JSON을 추출하는 함수
# ============================================================

def parse_json(raw_text: str) -> dict:
    """
    LLM 응답에서 JSON 부분만 찾아 딕셔너리로 변환합니다.

    LLM이 JSON 앞뒤에 설명을 덧붙여도
    중괄호로 둘러싸인 부분만 추출합니다.
    """

    # 응답 안에서 { }로 둘러싸인 부분을 찾습니다.
    match = re.search(r"\{.*\}", raw_text, re.S)

    # JSON 형식을 찾지 못하면 오류를 발생시킵니다.
    if match is None:
        raise ValueError("JSON 블록을 찾을 수 없습니다.")

    # 찾은 JSON 문자열을 파이썬 딕셔너리로 변환합니다.
    return json.loads(match.group())

# ============================================================
# Verifier Node
# ============================================================

def verifier_node(state: dict) -> dict:
    """
    Generator가 만든 답변을 검증합니다.

    1단계에서는 규칙 기반 검사를 수행합니다.
    2단계에서는 LLM 심판에게 답변 평가를 요청합니다.
    """

    # 검색된 문서를 가져옵니다.
    documents = state.get("documents") or []

    # Generator가 만든 답변을 가져옵니다.
    answer = state.get("answer", "")

    # 사용자 질문을 가져옵니다.
    question = state.get("question", "")

    # --------------------------------------------------------
    # 1차 검사: 비용이 발생하지 않는 규칙 기반 검사
    # --------------------------------------------------------

    # 답변 생성 중 오류가 발생했으면 중단합니다.
    if state.get("gen_error"):
        return {
            "grade": "giveup",
            "reason": "답변 생성 중 오류가 발생했습니다.",
            "log": ["검증: 생성 오류로 중단"]
        }

    # Generator가 근거 부족을 신고했으면 다시 검색합니다.
    if state.get("insufficient"):
        return {
            "grade": "research",
            "reason": "Generator가 근거 부족을 신고했습니다.",
            "log": ["검증(규칙): 근거 부족"]
        }

    # 답변에 인용 번호가 없으면 다시 생성합니다.
    if not state.get("has_citation"):
        return {
            "grade": "retry",
            "reason": "출처 번호가 없거나 유효하지 않습니다.",
            "log": ["검증(규칙): 인용 불량"]
        }

    # 답변이 너무 짧으면 다시 생성합니다.
    if len(answer.strip()) < 10:
        return {
            "grade": "retry",
            "reason": "답변이 너무 짧습니다.",
            "log": ["검증(규칙): 답변 길이 부족"]
        }

    # --------------------------------------------------------
    # 2차 검사: LLM 심판을 이용한 의미 기반 검사
    # --------------------------------------------------------

    try:
        # 검색 문서를 하나의 문자열로 합칩니다.
        context = build_context(documents)

        # 심판 LLM에 근거, 질문, 답변을 전달합니다.
        raw_result = judge_chain.invoke({
            "context": context,
            "question": question,
            "answer": answer
        })

        # LLM 응답에서 JSON 판정 결과를 추출합니다.
        judge_result = parse_json(raw_result)

    except Exception as error:
        # 검증 오류 때문에 전체 서비스가 중단되지 않도록 통과시킵니다.
        error_name = type(error).__name__

        return {
            "grade": "pass",
            "reason": f"검증 불가({error_name}) - 통과 처리",
            "log": [f"검증 오류: {error_name}"]
        }

    # --------------------------------------------------------
    # LLM 심판 결과를 읽습니다.
    # --------------------------------------------------------

    # 답변이 근거 자료에 포함되어 있는지 확인합니다.
    grounded = bool(judge_result.get("grounded"))

    # 답변이 질문에 적절히 답했는지 확인합니다.
    relevant = bool(judge_result.get("relevant"))

    # 심판 이유를 최대 200자까지만 저장합니다.
    reason = str(judge_result.get("reason", ""))[:200]

    # --------------------------------------------------------
    # 검증 결과에 따라 다음 작업을 결정합니다.
    # --------------------------------------------------------

    # 근거에도 맞고 질문에도 맞으면 통과시킵니다.
    if grounded and relevant:
        grade = "pass"

    # 근거에 없는 내용이 있으면 답변을 다시 생성합니다.
    elif not grounded:
        grade = "retry"

    # 근거는 있지만 질문에 맞지 않으면 다시 검색합니다.
    else:
        grade = "research"

    # --------------------------------------------------------
    # 검증 결과를 State에 기록합니다.
    # --------------------------------------------------------

    return {
        "grade": grade,
        "reason": reason,
        "log": [
            f"검증(LLM): grounded={grounded}, "
            f"relevant={relevant} → {grade}"
        ]
    }

# ============================================================
# 단독 테스트
# ============================================================

if __name__ == "__main__":

    # Retriever Node를 가져옵니다.
    from retriever import retriever_node

    # 테스트에 사용할 질문입니다.
    question = "환불은 며칠 이내인가요?"

    # 검색에 필요한 초기 State입니다.
    state = {
        "question": question,
        "query": question
    }

    # 질문에 대한 근거 문서를 검색합니다.
    retrieval_result = retriever_node(state)

    # 검색 결과를 기존 State에 합칩니다.
    state.update(retrieval_result)

    # Verifier가 제대로 오류를 잡는지 확인하기 위한 테스트입니다.
    test_cases = [
        {
            "name": "정상 답변",
            "answer": "환불은 상품 수령 후 7일 이내에 신청하실 수 있습니다[1].",
            "insufficient": False,
            "has_citation": True
        },
        {
            "name": "환각 포함",
            "answer": "환불은 7일 이내입니다[1]. 수수료는 상품가의 10%입니다[1].",
            "insufficient": False,
            "has_citation": True
        },
        {
            "name": "동문서답",
            "answer": "고객센터는 평일 09시부터 18시까지 운영합니다[1].",
            "insufficient": False,
            "has_citation": True
        },
        {
            "name": "인용 없음",
            "answer": "환불은 7일 이내에 신청하시면 됩니다.",
            "insufficient": False,
            "has_citation": False
        },
        {
            "name": "근거 부족 신고",
            "answer": "자료에서 확인할 수 없습니다.",
            "insufficient": True,
            "has_citation": False
        }
    ]

    # 각각의 테스트 케이스를 실행합니다.
    for test_case in test_cases:

        # 공통 State를 복사합니다.
        test_state = dict(state)

        # 테스트할 답변 정보를 추가합니다.
        test_state["answer"] = test_case["answer"]
        test_state["insufficient"] = test_case["insufficient"]
        test_state["has_citation"] = test_case["has_citation"]

        # Verifier Node를 실행합니다.
        result = verifier_node(test_state)

        # 테스트 결과를 출력합니다.
        print()
        print(f"[{test_case['name']}]")
        print(f"  판정: {result['grade']}")
        print(f"  이유: {result['reason'][:70]}")

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
예시 출력 (API 키 필요, 실행하지 않음)
__main__은 검색 뒤 정상·환각·동문서답·인용 없음·근거 부족을 넣어 판정을 찍도록 되어 있습니다.
그 다섯 케이스의 grade 원문은 기록에 없습니다.
전체 그래프에서 확인된 판정만 적습니다.
환불은 며칠 이내에 신청해야 하나요? grade=pass retries=0 rewrites=0 elapsed=6.69
  다른 실행 elapsed=2.39 n_docs=2 top_score=0.056
대표이사가 누구인가요? grade=giveup rewrites=2 elapsed=3.41 fail_reason=low_score top_score=-0.219
대표이사 건의 fail_reason=low_score는 Retriever가 적은 값입니다. 검색이 탈락하면 Verifier까지 가지 않을 수 있습니다.

코드 해설

Verifier Node. 규칙으로 먼저 거르고, 남은 답만 LLM 심판에게 넘겨 grade를 정합니다.

코드 앞에서 고정해 두는 말입니다.

  • 읽는 키: documents, answer, question, gen_error, insufficient, has_citation
  • 쓰는 키: grade, reason, log
  • 그래프:
  • gen_error 있음 ---------> grade=giveup
  • insufficient 참 -------> grade=research
  • has_citation 거짓 -----> grade=retry
  • 답 길이 10자 미만 -----> grade=retry
  • LLM: grounded·relevant -> pass

함수와 클래스로 나누어 읽습니다.

  • parse_json: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
  • verifier_node: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

← 이전 절26차시 · 6. 판정 결과를 세 가지로 나눈 이유다음 절 →26차시 · 8. 과도한 재시도 막기