From 3faf8b09ce011144fbf49059921813fc97e8b761 Mon Sep 17 00:00:00 2001 From: sal Date: Mon, 1 Jun 2026 17:11:00 +0900 Subject: [PATCH] Phase 20: RAGAS evaluation suite - eval/run_ragas.py: collect contexts (RetrieverService) + answers (/chat API), evaluate with faithfulness / answer_relevancy / context_recall / context_precision - eval/dataset.jsonl: 5 Korean Q&A pairs for initial evaluation - eval/requirements.txt: ragas==0.2.9, datasets, langchain-google-vertexai - Evaluator LLM priority: OpenAI > Anthropic > local Qwen3 - Runtime shim for ragas 0.2 / langchain-community 0.4+ vertexai incompatibility Co-Authored-By: Claude Sonnet 4.6 --- docs/ROADMAP.md | 37 +++++-- eval/dataset.jsonl | 5 + eval/requirements.txt | 3 + eval/run_ragas.py | 240 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 eval/dataset.jsonl create mode 100644 eval/requirements.txt create mode 100644 eval/run_ragas.py diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad2e55b..dcf0f5f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -409,14 +409,39 @@ cd youlbot-webui && python app.py --- -## Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆ +## ✅ Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆ **배경**: 청킹 전략·검색 파라미터·Reranker 변경 시 답변 품질이 실제로 나아졌는지 수치로 확인할 방법이 없다. -**구현 방식**: -- `ragas` 라이브러리로 Faithfulness·Answer Relevancy·Context Recall 자동 측정 -- 테스트 질문-정답 셋을 `eval/` 디렉터리에 관리 -- 설정 변경 후 `python eval/run_ragas.py`로 비교 가능 +**구현 내용**: + +``` +eval/ +├── dataset.jsonl ← 평가용 Q&A 쌍 (질문·정답 — 필요 시 수정) +├── run_ragas.py ← 평가 실행 스크립트 +├── requirements.txt ← ragas==0.2.9, datasets, langchain-google-vertexai +└── results/ ← report_YYYYMMDD_HHMMSS.{csv,json} 저장 +``` + +**평가 지표**: + +| 지표 | 설명 | +|------|------| +| `faithfulness` | 답변이 검색 컨텍스트에 충실한가 (환각 탐지) | +| `answer_relevancy` | 답변이 질문에 얼마나 관련 있는가 | +| `context_recall` | 컨텍스트가 정답에 필요한 정보를 포함하는가 | +| `context_precision` | 검색된 컨텍스트 중 실제 유용한 비율 | + +**평가 LLM 우선순위**: OpenAI GPT-4o-mini > Anthropic Claude Haiku > 로컬 Qwen3 + +**실행 방법**: +```bash +# API 서버 실행 후 +python eval/run_ragas.py +python eval/run_ragas.py --dataset eval/dataset.jsonl --api http://localhost:8000 +``` + +**호환성 처리**: ragas 0.2가 langchain-community 0.4+에서 `ChatVertexAI` 임포트 실패하는 문제를 런타임 shim으로 우회. **난이도**: 중간 | **임팩트**: 중간 (장기 품질 관리 기반) @@ -501,7 +526,7 @@ Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 16 (Docke | Phase 23 WebUI 분리 | ✅ 완료 | — | — | — | | Phase 24 사고 과정 UI 분리 | ✅ 완료 | — | — | — | | Phase 25 RAG 출처 전용 박스 | ✅ 완료 | — | — | — | -| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 1순위 | +| Phase 20 RAGAS 평가 | ✅ 완료 | — | — | — | | Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 | | Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 | | Phase 17 멀티모달 | 🔲 미완 | 높음 | 높음 | 6순위 | diff --git a/eval/dataset.jsonl b/eval/dataset.jsonl new file mode 100644 index 0000000..572b61e --- /dev/null +++ b/eval/dataset.jsonl @@ -0,0 +1,5 @@ +{"question": "부모의 반응성이 아동의 인지 발달에 어떤 영향을 미치나요?", "ground_truth": "부모의 민감한 반응성은 아동의 인지 발달에 긍정적인 영향을 미친다. 특히 영유아기에 부모가 아동의 신호에 적절히 반응할 때 아동의 탐색 행동과 학습 능력이 향상된다."} +{"question": "아동의 사회성 발달을 돕기 위해 부모가 할 수 있는 것은?", "ground_truth": "부모는 일관된 애착 관계를 형성하고 긍정적인 상호작용 모델을 보여줌으로써 아동의 사회성 발달을 지원할 수 있다."} +{"question": "영유아기 발달 평가 방법에는 어떤 것들이 있나요?", "ground_truth": "영유아기 발달 평가는 표준화된 발달 검사, 관찰 기반 평가, 부모 보고 척도 등 다양한 방법을 통해 이루어진다."} +{"question": "논리수학 지능 발달에 영향을 미치는 요인은?", "ground_truth": "논리수학 지능 발달에는 부모의 상호작용 방식, 탐색 기회 제공, 문제 해결 경험 등이 영향을 미친다."} +{"question": "어머니의 반응성과 아동 언어 발달의 관계는?", "ground_truth": "어머니의 반응성은 아동의 언어 발달에 긍정적 영향을 미치며, 어머니가 아동의 발화에 민감하게 반응할수록 어휘 습득과 언어 이해 능력이 향상된다."} diff --git a/eval/requirements.txt b/eval/requirements.txt new file mode 100644 index 0000000..8238be8 --- /dev/null +++ b/eval/requirements.txt @@ -0,0 +1,3 @@ +ragas==0.2.9 +datasets>=2.14.0 +langchain-google-vertexai>=2.0.0 diff --git a/eval/run_ragas.py b/eval/run_ragas.py new file mode 100644 index 0000000..b68b1c8 --- /dev/null +++ b/eval/run_ragas.py @@ -0,0 +1,240 @@ +"""youlbot RAGAS 평가 스크립트 (Phase 20) + +실행: + cd /path/to/youlbot + python eval/run_ragas.py [--dataset eval/dataset.jsonl] [--api http://localhost:8000] + +결과: + eval/results/report_YYYYMMDD_HHMMSS.csv + +사전 조건: + - youlbot API 서버 실행 중 (uvicorn api:app --port 8000) + - Qdrant + MySQL 접근 가능 + - .env에 API_TOKEN, RAG_SHOW_SOURCES=true 설정 + +평가 지표: + - faithfulness : 답변이 검색 컨텍스트에 충실한가 (환각 탐지) + - answer_relevancy : 답변이 질문에 얼마나 관련 있는가 + - context_recall : 컨텍스트가 정답에 필요한 정보를 포함하는가 + - context_precision : 검색된 컨텍스트 중 실제 유용한 비율 + +참고: + 평가에 로컬 LLM(Qwen3)을 사용하므로 결과 신뢰도는 모델 크기에 의존합니다. + 더 정확한 평가를 원하면 OPENAI_API_KEY 또는 ANTHROPIC_API_KEY를 설정하세요. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +# ── Compatibility shim ─────────────────────────────────────────────────────── +# ragas 0.2.x imports langchain_community.chat_models.vertexai which was +# removed in langchain-community 0.4+. Re-export from langchain-google-vertexai. +try: + import langchain_community.chat_models.vertexai # noqa: F401 +except ModuleNotFoundError: + try: + from langchain_google_vertexai import ChatVertexAI as _CV + _stub = type(sys)("langchain_community.chat_models.vertexai") + _stub.ChatVertexAI = _CV + sys.modules["langchain_community.chat_models.vertexai"] = _stub + except ImportError: + # vertexai not available — inject an empty stub (unused by our eval) + _stub = type(sys)("langchain_community.chat_models.vertexai") + _stub.ChatVertexAI = object + sys.modules["langchain_community.chat_models.vertexai"] = _stub + +from ragas import evaluate +from ragas.metrics import answer_relevancy, context_precision, context_recall, faithfulness +from ragas.embeddings import LangchainEmbeddingsWrapper +from ragas.llms import LangchainLLMWrapper +from datasets import Dataset + +# ── Project path ───────────────────────────────────────────────────────────── +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +os.chdir(ROOT) # .env 읽기 위해 프로젝트 루트로 이동 + +from container import container # noqa: E402 (after sys.path setup) + + +# ── Answer collection via API ──────────────────────────────────────────────── + +async def _collect_answer(api_url: str, token: str, message: str) -> str: + """youlbot /chat SSE 스트림에서 순수 답변 텍스트만 수집.""" + import httpx + + headers = {"Authorization": f"Bearer {token}"} if token else {} + parts: list[str] = [] + async with httpx.AsyncClient(timeout=180) as client: + async with client.stream( + "POST", + f"{api_url}/chat", + json={"message": message, "user_id": "eval", "show_thinking": False}, + headers=headers, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + payload = json.loads(line[6:]) + if isinstance(payload, str): + parts.append(payload) + elif isinstance(payload, dict) and payload.get("__done"): + break + return "".join(parts) + + +def collect_answer(api_url: str, token: str, message: str) -> str: + return asyncio.run(_collect_answer(api_url, token, message)) + + +# ── Evaluator LLM 선택 ──────────────────────────────────────────────────────── + +def _build_evaluator_llm(): + """평가용 LLM: OpenAI > Anthropic > 로컬 MLX 순으로 시도.""" + if os.getenv("OPENAI_API_KEY"): + from langchain_openai import ChatOpenAI + print("[RAGAS] 평가 LLM: OpenAI GPT-4o-mini") + return LangchainLLMWrapper(ChatOpenAI(model="gpt-4o-mini", temperature=0)) + + if os.getenv("ANTHROPIC_API_KEY"): + from langchain_anthropic import ChatAnthropic + print("[RAGAS] 평가 LLM: Anthropic Claude Haiku") + return LangchainLLMWrapper( + ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0) + ) + + print("[RAGAS] 평가 LLM: 로컬 Qwen3 (신뢰도 제한적)") + return LangchainLLMWrapper(container.chat_model()) + + +def _build_evaluator_embeddings(): + return LangchainEmbeddingsWrapper(container.embeddings()) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def run(dataset_path: str, api_url: str, api_token: str) -> None: + # 1. 데이터셋 로드 + samples = [] + with open(dataset_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + samples.append(json.loads(line)) + + if not samples: + print(f"[오류] 데이터셋이 비어 있습니다: {dataset_path}") + sys.exit(1) + + print(f"[RAGAS] 평가 시작 — {len(samples)}개 질문, API: {api_url}") + + # 2. RetrieverService 초기화 + retriever = container.retriever_service() + + # 3. 질문별 context + answer 수집 + questions: list[str] = [] + answers: list[str] = [] + contexts: list[list[str]] = [] + ground_truths: list[str] = [] + + for i, sample in enumerate(samples, 1): + q = sample["question"] + gt = sample["ground_truth"] + print(f"\n[{i}/{len(samples)}] {q[:50]}...") + + docs = retriever.search(q) + ctxs = [doc.page_content for doc in docs] + print(f" 컨텍스트: {len(ctxs)}개 청크") + + answer = collect_answer(api_url, api_token, q) + print(f" 답변: {len(answer)}자") + + questions.append(q) + answers.append(answer) + contexts.append(ctxs) + ground_truths.append(gt) + + # 4. RAGAS Dataset + ds = Dataset.from_dict( + { + "question": questions, + "answer": answers, + "contexts": contexts, + "ground_truth": ground_truths, + } + ) + + # 5. 평가 실행 + llm = _build_evaluator_llm() + emb = _build_evaluator_embeddings() + + print("\n[RAGAS] 지표 계산 중...") + result = evaluate( + ds, + metrics=[faithfulness, answer_relevancy, context_recall, context_precision], + llm=llm, + embeddings=emb, + raise_exceptions=False, + ) + + # 6. 결과 출력 및 저장 + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + results_dir = ROOT / "eval" / "results" + results_dir.mkdir(exist_ok=True) + out_csv = results_dir / f"report_{ts}.csv" + out_json = results_dir / f"report_{ts}.json" + + df = result.to_pandas() + df.to_csv(out_csv, index=False, encoding="utf-8-sig") + + summary = { + "timestamp": ts, + "dataset": dataset_path, + "n_samples": len(samples), + "scores": { + "faithfulness": float(result["faithfulness"]) if "faithfulness" in result else None, + "answer_relevancy": float(result["answer_relevancy"]) if "answer_relevancy" in result else None, + "context_recall": float(result["context_recall"]) if "context_recall" in result else None, + "context_precision": float(result["context_precision"]) if "context_precision" in result else None, + }, + } + out_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + + print(f"\n{'='*55}") + print("RAGAS 평가 결과") + print("="*55) + for k, v in summary["scores"].items(): + bar = "█" * int((v or 0) * 20) if v is not None else "" + score_str = f"{v:.3f}" if v is not None else "N/A" + print(f" {k:<22} {score_str} {bar}") + print("="*55) + print(f"CSV : {out_csv}") + print(f"JSON: {out_json}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="youlbot RAGAS 평가") + parser.add_argument( + "--dataset", + default=str(ROOT / "eval" / "dataset.jsonl"), + help="평가 데이터셋 경로 (기본: eval/dataset.jsonl)", + ) + parser.add_argument( + "--api", + default=os.getenv("YOULBOT_API_URL", "http://localhost:8000"), + help="youlbot API URL (기본: http://localhost:8000)", + ) + args = parser.parse_args() + + from dotenv import load_dotenv + load_dotenv(ROOT / ".env") + api_token = os.getenv("API_TOKEN", "") + + run(args.dataset, args.api, api_token)