sangsangfinder / test /eval_systems.py
cksleigen's picture
Initial clean deploy
54656fc
Raw
History Blame Contribute Delete
12.5 kB
"""
6개 검색 μ‹œμŠ€ν…œ 비ꡐ 평가 슀크립트 (ꡬ λͺ¨λΈ vs μ‹  λͺ¨λΈ)
System A: jhgan/ko-sroberta (OLD) + dense only
System B: jhgan/ko-sroberta (OLD) + BM25 hybrid (Ξ±=0.5)
System C: BM-K/KoSimCSE (NEW) + dense only
System D: BM-K/KoSimCSE (NEW) + BM25 hybrid (Ξ±=0.5)
System E: νŒŒμΈνŠœλ‹ μž„λ² λ”© (NEW) + BM25 hybrid (Ξ±=0.5) <- ν˜„μž¬ μ‹œμŠ€ν…œ
System F: System E + cross-encoder reranker
Metrics (TEST split): Recall@5, MRR, NDCG@5
Corpus : qa_dataset_generation/data/test_notices_2025.json (100 notices)
QA : qa_dataset_generation/data/qa_test_2025.jsonl (TEST split)
"""
import json
import math
import os
import re
import sys
from pathlib import Path
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder
# ── 경둜 ─────────────────────────────────────────────────────────────────
ROOT = Path(__file__).parent.parent # ν”„λ‘œμ νŠΈ 루트
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from api.core.models import SimCSEEmbedder # noqa: E402
QA_DATA_DIR = ROOT / "qa_dataset_generation" / "data"
CORPUS_PATH = QA_DATA_DIR / "test_notices_2025.json"
QA_PATH = QA_DATA_DIR / "qa_test_2025.jsonl"
OLD_BASE_MODEL = "jhgan/ko-sroberta-multitask" # ꡬ 베이슀 λͺ¨λΈ
NEW_BASE_MODEL = "BM-K/KoSimCSE-roberta-multitask" # μ‹  베이슀 λͺ¨λΈ
BASE_MODEL = NEW_BASE_MODEL # ν•˜μœ„ ν˜Έν™˜ alias
FINETUNED_MODEL = str(ROOT / "models" / "embed_finetuned")
# cross-encoder λͺ¨λΈ: ν•œκ΅­μ–΄ 지원 λͺ¨λΈλ‘œ ꡐ체 ꢌμž₯
# - BAAI/bge-reranker-v2-m3 (λ‹€κ΅­μ–΄, κ³ μ„±λŠ₯)
# - cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 (λ‹€κ΅­μ–΄, κ²½λŸ‰)
CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
K = 5 # Recall@K, NDCG@K
RERANK_TOPN = 20 # reranker 후보 수
ALPHA = 0.5 # hybrid: dense κ°€μ€‘μΉ˜
def load_embedder(model_path: str):
if model_path == NEW_BASE_MODEL or Path(model_path).name == "embed_finetuned":
print(" νŒŒμ΄ν”„λΌμΈ: SimCSE CLS pooling")
return SimCSEEmbedder(model_path, device="cpu")
from sentence_transformers import SentenceTransformer
print(" νŒŒμ΄ν”„λΌμΈ: sentence-transformers κΈ°λ³Έ encode")
return SentenceTransformer(model_path, device="cpu")
# ── ν…μŠ€νŠΈ 포맷 (app.py 의 index_notices 와 동일) ────────────────────────
def format_doc(notice: dict) -> str:
return f"제λͺ©: {notice['title']}\n\n{notice.get('body', '')}"
def tokenize_ko(text: str) -> list:
return re.findall(r"[\wκ°€-힣]+", text.lower())
# ── 평가 μ§€ν‘œ ────────────────────────────────────────────────────────────
def recall_at_k(ranked: list, gt: int, k: int) -> float:
return 1.0 if gt in ranked[:k] else 0.0
def mrr_score(ranked: list, gt: int) -> float:
for i, r in enumerate(ranked):
if r == gt:
return 1.0 / (i + 1)
return 0.0
def ndcg_at_k(ranked: list, gt: int, k: int) -> float:
# 단일 μ •λ‹΅ λ¬Έμ„œ: IDCG = 1/log2(2) = 1.0
for i, r in enumerate(ranked[:k]):
if r == gt:
return 1.0 / math.log2(i + 2)
return 0.0
def compute_scores(all_ranked: list[list[int]], all_gt: list[int]) -> dict:
# TEST split 좜처 (CLAUDE.md: 평가 μ‹œ split λͺ…μ‹œ)
n = len(all_ranked)
r5 = sum(recall_at_k(r, g, K) for r, g in zip(all_ranked, all_gt)) / n
mrr_ = sum(mrr_score(r, g) for r, g in zip(all_ranked, all_gt)) / n
ndcg = sum(ndcg_at_k(r, g, K) for r, g in zip(all_ranked, all_gt)) / n
return {
f"Recall@{K}": round(r5, 4),
"MRR": round(mrr_, 4),
f"NDCG@{K}": round(ndcg, 4),
}
# ── 검색 μ‹œμŠ€ν…œ ──────────────────────────────────────────────────────────
class DenseRetriever:
"""System A: 단일 bi-encoder, dense μœ μ‚¬λ„λ§Œ μ‚¬μš©"""
def __init__(self, model_path: str, docs: list[str]):
print(f" λͺ¨λΈ λ‘œλ”©: {model_path}")
self.model = load_embedder(model_path)
print(" λ¬Έμ„œ 인코딩 쀑...", flush=True)
embs = self.model.encode(docs, show_progress_bar=True)
norms = np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9
self.doc_embs = embs / norms
def search(self, query: str, k: int) -> list[int]:
q = self.model.encode([query])
q = q / (np.linalg.norm(q, axis=1, keepdims=True) + 1e-9)
sims = (q @ self.doc_embs.T)[0]
return np.argsort(-sims)[:k].tolist()
class HybridRetriever:
"""System B/C: dense(Ξ±) + BM25(1-Ξ±) ν•˜μ΄λΈŒλ¦¬λ“œ"""
def __init__(self, model_path: str, docs: list[str], alpha: float = ALPHA):
self.alpha = alpha
print(f" λͺ¨λΈ λ‘œλ”©: {model_path}")
self.model = load_embedder(model_path)
print(" λ¬Έμ„œ 인코딩 쀑...", flush=True)
embs = self.model.encode(docs, show_progress_bar=True)
norms = np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9
self.doc_embs = embs / norms
print(" BM25 인덱슀 ꡬ좕 쀑...", flush=True)
self.bm25 = BM25Okapi([tokenize_ko(d) for d in docs])
def _scores(self, query: str) -> np.ndarray:
q = self.model.encode([query])
q = q / (np.linalg.norm(q, axis=1, keepdims=True) + 1e-9)
dense = (q @ self.doc_embs.T)[0]
d_min, d_max = dense.min(), dense.max()
dense_n = (dense - d_min) / (d_max - d_min + 1e-9)
bm25 = np.array(self.bm25.get_scores(tokenize_ko(query)))
b_max = bm25.max()
bm25_n = bm25 / (b_max + 1e-9)
return self.alpha * dense_n + (1 - self.alpha) * bm25_n
def search(self, query: str, k: int) -> list[int]:
return np.argsort(-self._scores(query))[:k].tolist()
class RerankRetriever:
"""System D: HybridRetriever 후보λ₯Ό cross-encoder둜 μž¬μ •λ ¬"""
def __init__(self, hybrid: HybridRetriever, docs: list[str],
ce_model: str, rerank_topn: int = RERANK_TOPN):
self.hybrid = hybrid
self.docs = docs
self.rerank_topn = rerank_topn
print(f" Cross-encoder λ‘œλ”©: {ce_model}")
self.ce = CrossEncoder(ce_model)
def search(self, query: str, k: int) -> list[int]:
candidates = self.hybrid.search(query, self.rerank_topn)
pairs = [(query, self.docs[i]) for i in candidates]
scores = self.ce.predict(pairs)
reranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [idx for idx, _ in reranked[:k]]
# ── 메인 ─────────────────────────────────────────────────────────────────
def compare_systems():
corpus = json.load(open(CORPUS_PATH, encoding="utf-8"))
qa_list = [json.loads(l) for l in open(QA_PATH, encoding="utf-8") if l.strip()]
# TEST split 좜처 둜그 (CLAUDE.md κ·œμΉ™ μ€€μˆ˜)
print("=" * 65)
print(f"[TEST split] QA 파일 : {QA_PATH.name}")
print(f"[TEST split] μ½”νΌμŠ€ : {CORPUS_PATH.name} ({len(corpus)}개 곡지)")
print(f" OLD 베이슀 λͺ¨λΈ: {OLD_BASE_MODEL}")
print(f" NEW 베이슀 λͺ¨λΈ: {NEW_BASE_MODEL}")
print("=" * 65)
docs = [format_doc(n) for n in corpus]
title_to_idx = {n["title"]: i for i, n in enumerate(corpus)}
queries, gt_indices = [], []
skipped = 0
for qa in qa_list:
idx = title_to_idx.get(qa["notice_title"])
if idx is None:
skipped += 1
continue
queries.append(qa["question"])
gt_indices.append(idx)
if skipped:
print(f"⚠️ μ½”νΌμŠ€ λ―Έλ§€μΉ­ QA {skipped}개 μ œμ™Έ")
print(f"평가 QA: {len(queries)}개 (고유 곡지: {len(set(gt_indices))}개)\n")
results = {}
# ── System A : OLD 베이슀 + dense ────────────────────────────────────
print("─" * 65)
print(f"System A: {OLD_BASE_MODEL} (OLD) + dense only")
sys_a = DenseRetriever(OLD_BASE_MODEL, docs)
ranked_a = [sys_a.search(q, K) for q in queries]
results["A (old+dense)"] = compute_scores(ranked_a, gt_indices)
print(f" κ²°κ³Ό: {results['A (old+dense)']}\n")
# ── System B : OLD 베이슀 + hybrid ───────────────────────────────────
print("─" * 65)
print(f"System B: {OLD_BASE_MODEL} (OLD) + BM25 hybrid")
sys_b = HybridRetriever(OLD_BASE_MODEL, docs)
ranked_b = [sys_b.search(q, K) for q in queries]
results["B (old+hybrid)"] = compute_scores(ranked_b, gt_indices)
print(f" κ²°κ³Ό: {results['B (old+hybrid)']}\n")
# ── System C : NEW 베이슀 + dense ────────────────────────────────────
print("─" * 65)
print(f"System C: {NEW_BASE_MODEL} (NEW) + dense only")
sys_c = DenseRetriever(NEW_BASE_MODEL, docs)
ranked_c = [sys_c.search(q, K) for q in queries]
results["C (new+dense)"] = compute_scores(ranked_c, gt_indices)
print(f" κ²°κ³Ό: {results['C (new+dense)']}\n")
# ── System D : NEW 베이슀 + hybrid ───────────────────────────────────
print("─" * 65)
print(f"System D: {NEW_BASE_MODEL} (NEW) + BM25 hybrid")
sys_d = HybridRetriever(NEW_BASE_MODEL, docs)
ranked_d = [sys_d.search(q, K) for q in queries]
results["D (new+hybrid)"] = compute_scores(ranked_d, gt_indices)
print(f" κ²°κ³Ό: {results['D (new+hybrid)']}\n")
# ── System E : NEW νŒŒμΈνŠœλ‹ + hybrid ─────────────────────────────────
print("─" * 65)
print("System E: NEW νŒŒμΈνŠœλ‹ μž„λ² λ”© + BM25 hybrid ← ν˜„μž¬ μ‹œμŠ€ν…œ")
if os.path.exists(FINETUNED_MODEL):
sys_e = HybridRetriever(FINETUNED_MODEL, docs)
print(f" νŒŒμΈνŠœλ‹ λͺ¨λΈ μ‚¬μš©: {FINETUNED_MODEL}")
else:
print(f" ⚠️ νŒŒμΈνŠœλ‹ λͺ¨λΈ μ—†μŒ ({FINETUNED_MODEL})")
print(" β†’ NEW 베이슀 λͺ¨λΈλ‘œ λŒ€μ²΄ (System D 와 동일 κ²°κ³Ό μ˜ˆμƒ)")
sys_e = sys_d
ranked_e = [sys_e.search(q, K) for q in queries]
results["E (finetuned+hybrid)"] = compute_scores(ranked_e, gt_indices)
print(f" κ²°κ³Ό: {results['E (finetuned+hybrid)']}\n")
# ── System F : System E + cross-encoder reranker ──────────────────────
print("─" * 65)
print("System F: System E + cross-encoder reranker")
sys_f = RerankRetriever(sys_e, docs, CROSS_ENCODER_MODEL)
ranked_f = [sys_f.search(q, K) for q in queries]
results["F (E+reranker)"] = compute_scores(ranked_f, gt_indices)
print(f" κ²°κ³Ό: {results['F (E+reranker)']}\n")
# ── μ΅œμ’… 비ꡐ ν…Œμ΄λΈ” ─────────────────────────────────────────────────
print("\n" + "=" * 70)
print(f"πŸ“Š μ‹œμŠ€ν…œ 비ꡐ [TEST split β€” {QA_PATH.name}]")
print("=" * 70)
print(f"{'μ‹œμŠ€ν…œ':<35} {f'Recall@{K}':>10} {'MRR':>10} {f'NDCG@{K}':>10}")
print("-" * 70)
separators = {"C (new+dense)": "── NEW λͺ¨λΈ ─────────────────────────────────────────────────────"}
for name, m in results.items():
if name in separators:
print(separators[name])
print(f"{name:<35} {m[f'Recall@{K}']:>10.4f} {m['MRR']:>10.4f} {m[f'NDCG@{K}']:>10.4f}")
print("=" * 70)
print(f"평가 QA: {len(queries)}개 | μ½”νΌμŠ€: {len(corpus)}개 | K={K} | Ξ±={ALPHA}")
print(f"OLD: {OLD_BASE_MODEL}")
print(f"NEW: {NEW_BASE_MODEL}")
print(f"Corpus split: TEST (qa_test_2025.jsonl, 2025λ…„ 곡지 기반 독립 생성)")
if __name__ == "__main__":
compare_systems()