""" src/evaluation/eval_worker_b.py RAG 품질 평가 — 자체 구현 메트릭 (RAGAS 대신) 평가 메트릭: Hit Rate : top-k 안에 정답 source_chunk가 포함된 비율 (목표 ≥ 85%) NDCG@3 : 상위 3개 청크 순위 품질, 정답이 높을수록 높은 점수 (목표 ≥ 0.75) MRR : Mean Reciprocal Rank, 첫 정답 문서 역순위 평균 (목표 ≥ 0.70) Context Recall: top-k 컨텍스트가 정답 생성에 필요한 정보를 포함하는지 (목표 ≥ 0.90) Faithfulness : 생성 답변이 retrieved 컨텍스트에 근거하는지 LLM judge (목표 ≥ 0.85) 입력 데이터셋 형식 (router_test_cases_gen.json, QA_ONLY + BOTH 필터): { "id": "r_001", "user_input": "customer email text", "label": "QA_ONLY", "rag_evidence": "exact chunk text (str or list[str] for multi-chunk)", "qa_question": "extracted knowledge question" } CLI 사용법: python -m src.evaluation.eval_worker_b # 전체 평가 python -m src.evaluation.eval_worker_b --skip-llm-judge # retrieval 메트릭만 (빠름) python -m src.evaluation.eval_worker_b --report reports/worker_b_eval_result.json """ from __future__ import annotations import logging import math import os import time from dotenv import load_dotenv from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from src.config import get_config from src.rag.retriever import build_hybrid_retriever from src.rag.reranker import rerank def _serialize_docs(docs: list) -> list[dict]: return [ { "content": doc.page_content, "source": doc.metadata.get("source", ""), "chunk_id": doc.metadata.get("chunk_id", ""), "page": doc.metadata.get("page", -1), } for doc in docs ] load_dotenv() logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # LLM for judge (gemini-flash) # --------------------------------------------------------------------------- _judge_llm: ChatOpenAI | None = None def _get_judge_llm() -> ChatOpenAI: global _judge_llm if _judge_llm is not None: return _judge_llm cfg = get_config() api_key = os.getenv("OPENROUTER_API_KEY", "") judge_cfg = cfg.models.eval_judge _judge_llm = ChatOpenAI( model=judge_cfg.name, temperature=0.0, openai_api_key=api_key, openai_api_base=cfg.openrouter.base_url, default_headers={ "HTTP-Referer": "https://github.com/daisysooyeon/SAP-ERP-AI-Agent", "X-Title": "SAP-ERP-AI-Agent RAG-Eval", }, ) return _judge_llm # --------------------------------------------------------------------------- # 매칭 헬퍼 # --------------------------------------------------------------------------- def _chunk_in_docs(source_chunks: str | list[str], docs: list[dict], threshold: float = 0.5) -> int | None: """ source_chunks(문자열 또는 리스트) 중 하나라도 retrieved docs 중 어느 위치(0-indexed)에 있는지 반환. 정확한 substring이 없으면 50% 이상 토큰 겹침으로 판단. 여러 개 매칭될 경우 가장 높은 순위(작은 인덱스) 반환. 없으면 None 반환. """ if not source_chunks: return None if isinstance(source_chunks, str): chunks_to_check = [source_chunks] else: chunks_to_check = source_chunks best_pos = None for chunk in chunks_to_check: gt_tokens = set(chunk.lower().split()) for i, doc in enumerate(docs): content = doc["content"] # 1순위: substring if chunk[:80].strip() in content: if best_pos is None or i < best_pos: best_pos = i break # 2순위: 토큰 overlap doc_tokens = set(content.lower().split()) if gt_tokens and doc_tokens: overlap = len(gt_tokens & doc_tokens) / len(gt_tokens) if overlap >= threshold: if best_pos is None or i < best_pos: best_pos = i break return best_pos def _match_by_id(evidence_ids: str | list[str], docs: list[dict]) -> int | None: """retrieved docs 중 metadata chunk_id 가 정답 id 집합에 속한 첫 위치(0-indexed)를 반환.""" if not evidence_ids: return None id_set = {evidence_ids} if isinstance(evidence_ids, str) else set(evidence_ids) for i, doc in enumerate(docs): if doc.get("chunk_id") in id_set: return i return None def _locate_evidence(case: dict, docs: list[dict], threshold: float = 0.5) -> int | None: """ 정답 청크가 docs 중 어느 위치에 있는지 반환. case 에 정답 chunk_id(evidence_chunk_id)가 있으면 id 정확 매칭, 없으면 rag_evidence 텍스트 fuzzy 매칭으로 폴백. (build_eval_ids.py 로 id 부여 가능) """ evidence_ids = case.get("evidence_chunk_id") if evidence_ids: return _match_by_id(evidence_ids, docs) return _chunk_in_docs(case.get("rag_evidence"), docs, threshold) # --------------------------------------------------------------------------- # Retrieval 메트릭 (Hit Rate, NDCG@3, MRR) # --------------------------------------------------------------------------- def compute_hit_rate(cases: list[dict], all_retrieved: list[list[dict]]) -> float: """top-k 안에 정답 청크가 포함된 비율""" hits = sum( 1 for case, docs in zip(cases, all_retrieved) if _locate_evidence(case, docs) is not None ) return hits / len(cases) if cases else 0.0 def compute_ndcg_at_3(cases: list[dict], all_retrieved: list[list[dict]]) -> float: """ NDCG@3: 정답이 top-3 안에서 몇 번째에 있는지에 따라 점수 차등 IDCG = 1/log2(2) = 1.0 (정답이 1위인 이상적 케이스) """ ndcg_scores = [] for case, docs in zip(cases, all_retrieved): top3 = docs[:3] pos = _locate_evidence(case, top3) if pos is None: ndcg_scores.append(0.0) else: dcg = 1.0 / math.log2(pos + 2) # pos는 0-indexed, log2(rank+1) idcg = 1.0 / math.log2(2) # 이상적: rank=1 ndcg_scores.append(dcg / idcg) return sum(ndcg_scores) / len(ndcg_scores) if ndcg_scores else 0.0 def compute_mrr(cases: list[dict], all_retrieved: list[list[dict]]) -> float: """Mean Reciprocal Rank: 첫 번째 정답 문서의 역순위 평균""" rr_scores = [] for case, docs in zip(cases, all_retrieved): pos = _locate_evidence(case, docs) if pos is None: rr_scores.append(0.0) else: rr_scores.append(1.0 / (pos + 1)) # 0-indexed → 1-indexed return sum(rr_scores) / len(rr_scores) if rr_scores else 0.0 # --------------------------------------------------------------------------- # LLM Judge 메트릭 (Context Recall, Faithfulness) # --------------------------------------------------------------------------- _CONTEXT_RECALL_PROMPT = ChatPromptTemplate.from_messages([ ("system", ( "You are an evaluation assistant. " "Given a question and a context, " "determine whether the context contains enough information to answer the question correctly.\n" "Respond with ONLY '1' (yes, context is sufficient) or '0' (no, context is insufficient)." )), ("human", ( "Question: {question}\n\n" "Context:\n{context}\n\n" "Does the context contain sufficient information? (1 or 0):" )), ]) _FAITHFULNESS_PROMPT = ChatPromptTemplate.from_messages([ ("system", ( "You are an evaluation assistant. " "Given an answer and the context it was generated from, " "determine whether the answer is fully supported by the context (no hallucination).\n" "Respond with ONLY '1' (yes, answer is faithful) or '0' (no, answer contains hallucination)." )), ("human", ( "Context:\n{context}\n\n" "Answer: {rag_answer}\n\n" "Is the answer fully supported by the context? (1 or 0):" )), ]) def _llm_judge( prompt_template: ChatPromptTemplate, variables: dict, metric: str = "unknown", delay: float = 0.5, ) -> tuple[int, bool]: """LLM judge 호출 → (score, is_invalid) 반환. score는 0 또는 1.""" try: llm = _get_judge_llm() chain = prompt_template | llm result = chain.invoke(variables) # qwen3-8b는 ... 블록 또는 줄바꿈 후 숫자를 출력하는 경우가 있음 import re as _re raw_val = result.content.strip() # ... 제거 val = _re.sub(r".*?", "", raw_val, flags=_re.DOTALL).strip() # 첫 번째 등장하는 0 또는 1 추출 m = _re.search(r"[01]", val) val = m.group() if m else val time.sleep(delay) if val.startswith("1"): return 1, False elif val.startswith("0"): return 0, False else: logger.warning( "[eval_rag] LLM judge [%s] unexpected response: %r", metric, val[:80], ) return 0, True # conservative: unexpected 응답은 0점 처리 except Exception as e: logger.warning("[eval_rag] LLM judge [%s] failed: %s", metric, e) return 0, False def compute_context_recall( cases: list[dict], all_retrieved: list[list[dict]], ) -> tuple[float, int]: """Context Recall: retrieved context가 정답 생성에 충분한지 LLM judge (목표 >= 0.90)""" scores = [] invalid = 0 for case, docs in zip(cases, all_retrieved): context = "\n\n".join(d["content"] for d in docs) score, is_invalid = _llm_judge( _CONTEXT_RECALL_PROMPT, {"question": case.get("qa_question") or case.get("question"), "context": context}, metric="context_recall", ) if is_invalid: invalid += 1 scores.append(score) logger.debug("[eval_rag] Context Recall [%s]: %d", case.get("id"), score) return (sum(scores) / len(scores) if scores else 0.0), invalid def compute_faithfulness( cases: list[dict], all_retrieved: list[list[dict]], rag_answers: list[str], ) -> tuple[float, int]: """Faithfulness: 생성 답변이 컨텍스트에 근거하는지 LLM judge (목표 >= 0.85)""" scores = [] invalid = 0 for case, docs, answer in zip(cases, all_retrieved, rag_answers): if not answer or not docs: scores.append(0) continue context = "\n\n".join(d["content"] for d in docs) score, is_invalid = _llm_judge( _FAITHFULNESS_PROMPT, {"context": context, "rag_answer": answer}, metric="faithfulness", ) if is_invalid: invalid += 1 scores.append(score) logger.debug("[eval_rag] Faithfulness [%s]: %d", case.get("id"), score) return (sum(scores) / len(scores) if scores else 0.0), invalid # --------------------------------------------------------------------------- # 메인 평가 함수 # --------------------------------------------------------------------------- def run_rag_evaluation( test_cases: list[dict], rag_answers: list[str] | None = None, all_retrieved: list[list[dict]] | None = None, skip_llm_judge: bool = False, ) -> dict: """ 전체 RAG 평가 실행. Args: test_cases: rag_test_cases.json 로드 결과 rag_answers: 각 케이스에 대한 생성 답변 (없으면 retrieval 메트릭만 계산) all_retrieved: 각 케이스의 retrieved docs 목록 skip_llm_judge: True이면 Context Recall / Faithfulness 스킵 Returns: { "hit_rate": float, "ndcg_at_3": float, "mrr": float, "context_recall": float | None, "faithfulness": float | None, "n_cases": int, } """ n = len(test_cases) logger.info("[eval_rag] Evaluating %d cases ...", n) hit_rate = compute_hit_rate(test_cases, all_retrieved) ndcg_at_3 = compute_ndcg_at_3(test_cases, all_retrieved) mrr = compute_mrr(test_cases, all_retrieved) context_recall: float | None = None faithfulness: float | None = None invalid_cr: int = 0 invalid_fa: int = 0 if not skip_llm_judge and rag_answers: logger.info("[eval_rag] Running LLM judges (Context Recall + Faithfulness) ...") context_recall, invalid_cr = compute_context_recall(test_cases, all_retrieved) faithfulness, invalid_fa = compute_faithfulness(test_cases, all_retrieved, rag_answers) if invalid_cr > 0: logger.warning("[eval_rag] Context Recall judge invalid: %d / %d", invalid_cr, n) if invalid_fa > 0: logger.warning("[eval_rag] Faithfulness judge invalid: %d / %d", invalid_fa, n) return { "hit_rate": round(hit_rate, 4), "ndcg_at_3": round(ndcg_at_3, 4), "mrr": round(mrr, 4), "context_recall": round(context_recall, 4) if context_recall is not None else None, "faithfulness": round(faithfulness, 4) if faithfulness is not None else None, "n_cases": n, "judge_invalid_context_recall": invalid_cr, "judge_invalid_faithfulness": invalid_fa, } # --------------------------------------------------------------------------- # CLI 진입점 (python -m src.evaluation.eval_rag 또는 run_eval_rag.sh 에서 호출) # --------------------------------------------------------------------------- TARGETS = { "hit_rate": 0.85, "ndcg_at_3": 0.75, "mrr": 0.70, "context_recall": 0.90, "faithfulness": 0.85, } METRIC_LABELS = { "hit_rate": "Hit Rate ", "ndcg_at_3": "NDCG@3 ", "mrr": "MRR ", "context_recall": "Ctx Recall ", "faithfulness": "Faithfulness", } def _main(): import argparse import json import sys from pathlib import Path # Windows cp949 터미널 인코딩 대응 try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass from src.logging_config import setup_logging setup_logging() parser = argparse.ArgumentParser(description="RAG Quality Evaluation") parser.add_argument("--report", default="reports/worker_b_eval_result.json", help="출력 리포트 경로") parser.add_argument("--dataset", default="data/eval/router_test_cases_gen.json") parser.add_argument("--skip-llm-judge", action="store_true", help="LLM judge 메트릭 스킵") parser.add_argument( "--no-preprocess", action="store_true", help="Skip email preprocessing (cleaned_body extraction) and extract queries from raw user_input " "(default: use preprocessed cleaned_body, matching production worker_b_node)", ) args = parser.parse_args() # 데이터셋 로드 및 필터링 (QA_ONLY, BOTH) dataset_path = Path(args.dataset) all_cases: list[dict] = json.loads(dataset_path.read_text(encoding="utf-8")) test_cases = [c for c in all_cases if c.get("label") in ("QA_ONLY", "BOTH")] logger.info("Loaded %d test cases for Worker B from %s", len(test_cases), dataset_path) # 리트리버 / 리랭커 초기화 logger.info("Initializing retriever (bge-m3 loading may take a moment) ...") retriever = build_hybrid_retriever() cfg = get_config() top_n = cfg.rag.top_k_rerank # 이메일 전처리 — 프로덕션(worker_b_node)은 원본 대신 cleaned_body로 쿼리를 추출한다. # eval도 동일 조건을 맞추기 위해 케이스별로 전처리해 cleaned_body를 쿼리 추출 입력으로 쓴다. # (--no-preprocess 면 기존처럼 원본 user_input 사용) if not args.no_preprocess: from src.preprocess.email_preprocessor import preprocess_email logger.info("Email preprocessing ENABLED (using cleaned_body for query extraction, matching production)") else: logger.info("Email preprocessing DISABLED (using raw user_input)") # 각 케이스 실행 results = [] all_retrieved: list[list[dict]] = [] rag_answers: list[str] = [] print(f"\n{'='*60}") print(f" RAG Evaluation - {len(test_cases)} cases") print(f"{'='*60}\n") for i, case in enumerate(test_cases): cid = case.get("id", f"case_{i}") # router_test_cases_gen.json의 user_input (이메일 원문) 사용 raw_input = case.get("user_input") or case.get("qa_question") or case.get("question", "") t0 = time.perf_counter() query_extraction_input = raw_input if not args.no_preprocess: try: ctx = preprocess_email(raw_input) if ctx.preprocess_ok: query_extraction_input = ( ctx.question_summary or ctx.cleaned_body or raw_input ) except Exception as e: logger.warning("[%s] preprocess failed: %s — using raw user_input", cid, e) # 쿼리 추출 — Query Expansion (worker_b의 실제 로직과 동일하게) from src.graph.worker_b import _extract_rag_queries rag_queries = _extract_rag_queries(query_extraction_input) if not rag_queries: rag_queries = [case.get("qa_question") or raw_input] # qa_question이 이메일 전문보다 정확한 쿼리 rag_query = rag_queries[0] # 대표 쿼리 (reranking 기준) # 검색 — 쿼리별 검색 후 중복 제거 병합 try: seen_contents: set[str] = set() candidate_docs = [] for q in rag_queries: for doc in retriever.invoke(q): if doc.page_content not in seen_contents: seen_contents.add(doc.page_content) candidate_docs.append(doc) except Exception as e: logger.error("[%s] Retrieval failed: %s", cid, e) candidate_docs = [] # 리랭킹 (multi-query) try: top_docs = rerank(rag_query, candidate_docs, top_n=top_n, queries=rag_queries) except Exception as e: logger.warning("[%s] Rerank failed: %s. Using top-k.", cid, e) top_docs = candidate_docs[:top_n] # 직렬화 docs_serial = _serialize_docs(top_docs) # 답변 생성 (LLM judge 필요 시) answer = "" if not args.skip_llm_judge: from src.graph.worker_b import _generate_answer answer = _generate_answer(rag_query, top_docs) elapsed = (time.perf_counter() - t0) * 1000 # 정답 위치 (evidence_chunk_id 있으면 id 정확 매칭, 없으면 텍스트 폴백) hit_pos = _locate_evidence(case, docs_serial) result = { "id": cid, "question": rag_query, "raw_input": raw_input[:100], "label": case.get("label", ""), "hit": hit_pos is not None, "hit_position": hit_pos, "rag_answer": answer, "retrieved_docs": docs_serial, "elapsed_ms": round(elapsed, 1), } results.append(result) all_retrieved.append(docs_serial) rag_answers.append(answer) status = f"HIT@{hit_pos+1}" if hit_pos is not None else "MISS" print(f" [{cid}] {status} | {elapsed:.0f}ms | {rag_query[:60]!r}") # 집계 print(f"\n{'='*60}") print(" Computing metrics ...") summary = run_rag_evaluation( test_cases=test_cases, rag_answers=rag_answers if not args.skip_llm_judge else None, all_retrieved=all_retrieved, skip_llm_judge=args.skip_llm_judge, ) # 결과 출력 print(f"\n{'='*60}") print(" RAG Evaluation Summary") print(f"{'='*60}") for key, label in METRIC_LABELS.items(): val = summary.get(key) if val is None: print(f" {label}: (skipped)") continue target = TARGETS.get(key, 0) pct = f"{val*100:.1f}%" flag = "OK" if val >= target else "FAIL" print(f" {label}: {pct:>7} (target: {target*100:.0f}%) [{flag}]") print(f" {'Cases':12}: {summary['n_cases']}") if not args.skip_llm_judge: inv_cr = summary.get("judge_invalid_context_recall", 0) inv_fa = summary.get("judge_invalid_faithfulness", 0) print(f" {'Ctx Invalid':12}: {inv_cr:>3} [{'OK' if inv_cr == 0 else 'WARN'}]") print(f" {'Faith Invalid':12}: {inv_fa:>3} [{'OK' if inv_fa == 0 else 'WARN'}]") print(f"{'='*60}\n") # 리포트 저장 report_path = Path(args.report) report_path.parent.mkdir(parents=True, exist_ok=True) report = {"summary": summary, "targets": TARGETS, "results": results} report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8") print(f"Report saved to: {report_path}\n") if __name__ == "__main__": _main()