Spaces:
Runtime error
Runtime error
| """ | |
| 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() | |