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 <noreply@anthropic.com>
This commit is contained in:
sal
2026-06-01 17:11:00 +09:00
parent 589946ab36
commit 3faf8b09ce
4 changed files with 279 additions and 6 deletions
+31 -6
View File
@@ -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순위 |
+5
View File
@@ -0,0 +1,5 @@
{"question": "부모의 반응성이 아동의 인지 발달에 어떤 영향을 미치나요?", "ground_truth": "부모의 민감한 반응성은 아동의 인지 발달에 긍정적인 영향을 미친다. 특히 영유아기에 부모가 아동의 신호에 적절히 반응할 때 아동의 탐색 행동과 학습 능력이 향상된다."}
{"question": "아동의 사회성 발달을 돕기 위해 부모가 할 수 있는 것은?", "ground_truth": "부모는 일관된 애착 관계를 형성하고 긍정적인 상호작용 모델을 보여줌으로써 아동의 사회성 발달을 지원할 수 있다."}
{"question": "영유아기 발달 평가 방법에는 어떤 것들이 있나요?", "ground_truth": "영유아기 발달 평가는 표준화된 발달 검사, 관찰 기반 평가, 부모 보고 척도 등 다양한 방법을 통해 이루어진다."}
{"question": "논리수학 지능 발달에 영향을 미치는 요인은?", "ground_truth": "논리수학 지능 발달에는 부모의 상호작용 방식, 탐색 기회 제공, 문제 해결 경험 등이 영향을 미친다."}
{"question": "어머니의 반응성과 아동 언어 발달의 관계는?", "ground_truth": "어머니의 반응성은 아동의 언어 발달에 긍정적 영향을 미치며, 어머니가 아동의 발화에 민감하게 반응할수록 어휘 습득과 언어 이해 능력이 향상된다."}
+3
View File
@@ -0,0 +1,3 @@
ragas==0.2.9
datasets>=2.14.0
langchain-google-vertexai>=2.0.0
+240
View File
@@ -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)