sangsangfinder / scripts /generate_reranker_eval_dataset_gemini.py
cksleigen's picture
Initial clean deploy
54656fc
Raw
History Blame Contribute Delete
20.3 kB
#!/usr/bin/env python3
"""Generate a robust reranker evaluation dataset from Supabase notices.
This script samples representative notices from the Supabase `notices` table,
uses Gemini to create realistic search-query variants for each sampled notice,
and attaches heuristic hard-negative qrels from nearby/confusable notices.
It intentionally does not evaluate search quality. Its output is an offline
dataset that can be consumed by a separate retrieval/reranker evaluation script.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import random
import re
import sys
import time
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
from dotenv import load_dotenv
load_dotenv(ROOT / ".env")
except ImportError:
pass
from api.core.utils import clean_title, tokenize_ko # noqa: E402
from crawling.supabase_store import _connect # noqa: E402
DATA_DIR = ROOT / "data"
DEFAULT_OUTPUT_PATH = DATA_DIR / "reranker_eval_dataset_gemini.json"
DEFAULT_CACHE_PATH = DATA_DIR / "reranker_eval_dataset_gemini_cache.json"
MIN_BODY_CHARS = 80
MAX_PROMPT_BODY_CHARS = 1800
DEFAULT_SAMPLE_SIZE = 150
DEFAULT_QUERIES_PER_NOTICE = 5
DEFAULT_HARD_NEGATIVES = 6
DEFAULT_MAX_RETRIES = 3
ACTION_KEYWORDS = {
"apply": [
"신청", "지원", "접수", "모집", "참여", "선발", "장학생", "장학금",
"근로장학생", "교환학생", "현장실습", "인턴십", "프로그램", "캠프",
],
"schedule": ["일정", "기간", "언제", "마감", "납부기간", "정정기간", "발표일"],
"result": ["합격자", "선정자", "결과", "발표", "선발 결과"],
"guide": ["방법", "안내", "절차", "기준", "요건", "조건", "수수료", "비용", "발급"],
"download": ["양식", "서류", "파일", "제출서류", "신청서"],
"contact": ["문의", "전화", "연락처", "담당자", "위치"],
}
FAMILY_HINTS = [
"해외봉사", "월드프렌즈", "WFK", "KOICA", "청년봉사단", "봉사단",
"국가장학금", "근로장학", "근로장학생", "장학금", "장학생",
"기숙사", "생활관", "상상빌리지", "우촌학사",
"수강신청", "수강정정", "계절학기", "강의평가",
"등록금", "분할납부", "휴학", "복학", "졸업", "조기졸업",
"복수전공", "부전공", "전과", "교환학생", "어학연수",
"현장실습", "인턴십", "취업", "특강", "상담", "증명서",
]
@dataclass(frozen=True)
class Notice:
id: int
notice_id: str | None
title: str
url: str
posted_at: str | None
category: str
body: str
category_type: list[str]
job_types: list[str]
@property
def year(self) -> int | None:
if not self.posted_at:
return None
match = re.search(r"(20\d{2})", self.posted_at)
return int(match.group(1)) if match else None
@property
def text_for_matching(self) -> str:
return f"{self.title}\n{self.body[:1200]}"
def normalize_space(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def infer_action(text: str) -> str:
counts = {
action: sum(1 for keyword in keywords if keyword in text)
for action, keywords in ACTION_KEYWORDS.items()
}
action, count = max(counts.items(), key=lambda item: item[1])
return action if count > 0 else "unknown"
def infer_family(title: str, body: str) -> str:
text = f"{clean_title(title)} {body[:400]}".upper()
hints = [hint for hint in FAMILY_HINTS if hint.upper() in text]
if hints:
return " / ".join(hints[:3])
title_text = re.sub(r"\[[^\]]+\]", " ", clean_title(title))
title_text = re.sub(r"20\d{2}[\w\s.월년학년도-]*", " ", title_text)
title_text = re.sub(r"\d+\s*(기|차|회)", " ", title_text)
title_text = re.sub(r"(모집|안내|신청|접수|발표|결과|선발|공고|공지|새글)", " ", title_text)
tokens = [token for token in tokenize_ko(title_text) if len(token) >= 2]
return " ".join(tokens[:4]) or clean_title(title)[:30]
def notice_signature(notice: Notice) -> str:
raw = f"{notice.id}|{notice.url}|{notice.title}|{notice.posted_at}|{notice.body[:500]}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def load_supabase_notices() -> list[Notice]:
with _connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
select
id,
notice_id,
title,
url,
posted_at::text,
coalesce(category, ''),
coalesce(body, ''),
coalesce(category_type, '{}'),
coalesce(job_types, '{}')
from notices
where source = 'hansung'
and title is not null
and url is not null
and length(coalesce(body, '')) >= %s
order by posted_at desc nulls last, id desc
""",
(MIN_BODY_CHARS,),
)
rows = cur.fetchall()
notices = []
for row in rows:
notices.append(
Notice(
id=row[0],
notice_id=row[1],
title=normalize_space(row[2]),
url=row[3],
posted_at=row[4],
category=row[5] or "기타",
body=normalize_space(row[6]),
category_type=list(row[7] or []),
job_types=list(row[8] or []),
)
)
return notices
def stratify(notice: Notice) -> tuple[str, str, str, str]:
year = str(notice.year or "unknown")
action = infer_action(notice.text_for_matching)
family = infer_family(notice.title, notice.body)
return (notice.category or "기타", year, action, family)
def sample_representative_notices(
notices: list[Notice],
sample_size: int,
seed: int,
) -> list[Notice]:
rng = random.Random(seed)
strata: dict[tuple[str, str, str, str], list[Notice]] = defaultdict(list)
for notice in notices:
strata[stratify(notice)].append(notice)
for bucket in strata.values():
rng.shuffle(bucket)
selected: list[Notice] = []
seen_urls: set[str] = set()
# First pass: take one from as many category/year/action/family buckets as possible.
for key in sorted(strata, key=lambda item: (item[0], item[1], item[2], item[3])):
if len(selected) >= sample_size:
break
for notice in strata[key]:
if notice.url not in seen_urls:
selected.append(notice)
seen_urls.add(notice.url)
break
# Second pass: fill remaining slots while preserving deterministic randomness.
remaining = [notice for notice in notices if notice.url not in seen_urls]
rng.shuffle(remaining)
for notice in remaining:
if len(selected) >= sample_size:
break
selected.append(notice)
seen_urls.add(notice.url)
return selected
def token_overlap(a: str, b: str) -> float:
left = {token for token in tokenize_ko(a) if len(token) >= 2}
right = {token for token in tokenize_ko(b) if len(token) >= 2}
if not left or not right:
return 0.0
return len(left & right) / len(left | right)
def find_hard_negatives(
target: Notice,
all_notices: list[Notice],
limit: int,
) -> list[dict[str, Any]]:
target_action = infer_action(target.text_for_matching)
target_family = infer_family(target.title, target.body)
scored = []
for other in all_notices:
if other.url == target.url:
continue
other_action = infer_action(other.text_for_matching)
other_family = infer_family(other.title, other.body)
same_category = other.category == target.category
same_family = other_family == target_family
same_year = other.year is not None and other.year == target.year
overlap = token_overlap(target.title, other.title)
score = overlap
score += 0.35 if same_category else 0.0
score += 0.45 if same_family else 0.0
score += 0.15 if same_year else 0.0
score += 0.25 if other_action != target_action and other_action != "unknown" else 0.0
if score <= 0:
continue
reason_bits = []
if same_family:
reason_bits.append("same_family")
if same_category:
reason_bits.append("same_category")
if same_year:
reason_bits.append("same_year")
if other_action != target_action:
reason_bits.append(f"action_mismatch:{target_action}->{other_action}")
if overlap:
reason_bits.append(f"title_overlap:{overlap:.2f}")
scored.append(
{
"notice_id": other.id,
"url": other.url,
"title": other.title,
"category": other.category,
"posted_at": other.posted_at,
"relevance": 1 if same_family or same_category else 0,
"reason": ", ".join(reason_bits) or "lexically similar",
"_score": score,
}
)
scored.sort(key=lambda row: row["_score"], reverse=True)
negatives = []
seen_urls = set()
for row in scored:
if row["url"] in seen_urls:
continue
seen_urls.add(row["url"])
row.pop("_score", None)
negatives.append(row)
if len(negatives) >= limit:
break
return negatives
def load_cache(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
with path.open(encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
def save_cache(path: Path, cache: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
with tmp_path.open("w", encoding="utf-8") as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
f.write("\n")
tmp_path.replace(path)
def parse_json_response(text: str) -> dict[str, Any]:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
start = cleaned.find("{")
end = cleaned.rfind("}")
if start >= 0 and end > start:
return json.loads(cleaned[start : end + 1])
raise
def build_generation_prompt(notice: Notice, queries_per_notice: int) -> str:
return f"""
너는 한성대학교 공지 검색 시스템의 평가셋을 만드는 검색 품질 엔지니어다.
아래 공지를 정확히 찾을 수 있는 한국어 검색 질의 {queries_per_notice}개를 만들어라.
요구사항:
- 실제 학생이 검색창에 입력할 법한 질의로 작성한다.
- 서로 다른 유형을 섞는다: 짧은 키워드형, 자연어형, 조건 포함형, 약어/영문 포함형, 일정/마감/대상자 질문형.
- 정답 공지를 과하게 그대로 복사하지 말고, 그래도 이 공지가 1순위로 나와야 한다.
- 다른 유사 공지와 헷갈릴 수 있는 표현도 일부 포함한다.
- 각 질의는 4~45자 정도로 작성한다.
- JSON만 출력한다. 설명 문장은 쓰지 않는다.
출력 형식:
{{
"queries": [
{{
"query": "검색 질의",
"query_type": "keyword|natural|condition|abbreviation|schedule|audience",
"intent": "사용자 의도 한 문장",
"must_match": ["랭킹에서 중요하게 봐야 할 조건"]
}}
]
}}
[공지]
제목: {notice.title}
카테고리: {notice.category}
게시일: {notice.posted_at or ""}
본문: {notice.body[:MAX_PROMPT_BODY_CHARS]}
""".strip()
def generate_queries_with_gemini(
notice: Notice,
queries_per_notice: int,
model_name: str,
cache: dict[str, Any],
max_retries: int,
) -> list[dict[str, Any]]:
cache_key = f"{notice.id}:{notice_signature(notice)}:{queries_per_notice}:{model_name}"
if cache_key in cache:
return cache[cache_key]["queries"]
api_key = os.getenv("GEMINI_API_KEY_PAID_TIER")
if not api_key:
raise RuntimeError("GEMINI_API_KEY_PAID_TIER is required in .env")
import google.generativeai as genai
genai.configure(api_key=api_key)
model = genai.GenerativeModel(model_name)
last_error: Exception | None = None
parsed: dict[str, Any] | None = None
prompt = build_generation_prompt(notice, queries_per_notice)
for attempt in range(1, max_retries + 1):
try:
response = model.generate_content(
prompt,
generation_config=genai.types.GenerationConfig(
temperature=0.45 if attempt == 1 else 0.25,
top_p=0.9,
max_output_tokens=2400,
response_mime_type="application/json",
),
)
parsed = parse_json_response(response.text)
break
except Exception as exc:
last_error = exc
if attempt >= max_retries:
raise
time.sleep(min(2 * attempt, 8))
if parsed is None:
raise RuntimeError(f"Gemini response parse failed for notice id={notice.id}") from last_error
queries = parsed.get("queries", [])
if not isinstance(queries, list) or not queries:
raise ValueError(f"Gemini returned no queries for notice id={notice.id}")
cleaned = []
seen = set()
for item in queries:
if not isinstance(item, dict):
continue
query = normalize_space(item.get("query"))
if not query or query in seen:
continue
seen.add(query)
cleaned.append(
{
"query": query,
"query_type": normalize_space(item.get("query_type")) or "unknown",
"intent": normalize_space(item.get("intent")),
"must_match": [
normalize_space(value)
for value in item.get("must_match", [])
if normalize_space(value)
],
}
)
if len(cleaned) >= queries_per_notice:
break
if not cleaned:
raise ValueError(f"Gemini queries were invalid for notice id={notice.id}")
cache[cache_key] = {
"notice_id": notice.id,
"notice_url": notice.url,
"generated_at": datetime.now().isoformat(timespec="seconds"),
"queries": cleaned,
}
return cleaned
def build_dataset(
notices: list[Notice],
sampled: list[Notice],
queries_per_notice: int,
hard_negative_count: int,
model_name: str,
cache_path: Path,
sleep_seconds: float,
save_every: int,
max_retries: int,
) -> dict[str, Any]:
cache = load_cache(cache_path)
query_rows = []
for index, notice in enumerate(sampled, start=1):
generated_queries = generate_queries_with_gemini(
notice=notice,
queries_per_notice=queries_per_notice,
model_name=model_name,
cache=cache,
max_retries=max_retries,
)
hard_negatives = find_hard_negatives(notice, notices, hard_negative_count)
for query_index, query_item in enumerate(generated_queries, start=1):
query_id = f"n{notice.id:06d}_q{query_index:02d}"
qrels = [
{
"notice_id": notice.id,
"url": notice.url,
"title": notice.title,
"relevance": 3,
"reason": "target_notice",
}
]
qrels.extend(hard_negatives)
query_rows.append(
{
"id": query_id,
"query": query_item["query"],
"query_type": query_item["query_type"],
"intent": query_item["intent"],
"must_match": query_item["must_match"],
"positive_notice": {
"notice_id": notice.id,
"source_notice_id": notice.notice_id,
"url": notice.url,
"title": notice.title,
"category": notice.category,
"posted_at": notice.posted_at,
"action": infer_action(notice.text_for_matching),
"family": infer_family(notice.title, notice.body),
},
"qrels": qrels,
}
)
if index % save_every == 0:
save_cache(cache_path, cache)
if sleep_seconds > 0 and index < len(sampled):
time.sleep(sleep_seconds)
save_cache(cache_path, cache)
return {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"source": {
"table": "notices",
"sampled_notice_count": len(sampled),
"loaded_notice_count": len(notices),
},
"generation": {
"model": model_name,
"queries_per_notice": queries_per_notice,
"hard_negatives_per_query": hard_negative_count,
"api_key_env": "GEMINI_API_KEY_PAID_TIER",
},
"labels": {
"3": "exact target notice",
"1": "hard negative: related/confusable but not the exact target",
"0": "hard negative: weakly related lexical/category confounder",
},
"queries": query_rows,
}
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate Gemini-based reranker evaluation queries from Supabase notices.",
)
parser.add_argument("--sample-size", type=int, default=DEFAULT_SAMPLE_SIZE)
parser.add_argument("--queries-per-notice", type=int, default=DEFAULT_QUERIES_PER_NOTICE)
parser.add_argument("--hard-negatives", type=int, default=DEFAULT_HARD_NEGATIVES)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--model", default="gemini-2.5-flash")
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH)
parser.add_argument("--cache", type=Path, default=DEFAULT_CACHE_PATH)
parser.add_argument("--sleep-seconds", type=float, default=4.0)
parser.add_argument("--save-every", type=int, default=10)
parser.add_argument("--max-retries", type=int, default=DEFAULT_MAX_RETRIES)
args = parser.parse_args()
if args.sample_size < 1:
raise ValueError("--sample-size must be at least 1")
if args.queries_per_notice < 1:
raise ValueError("--queries-per-notice must be at least 1")
if args.hard_negatives < 0:
raise ValueError("--hard-negatives must be non-negative")
notices = load_supabase_notices()
if not notices:
raise SystemExit("No Supabase notices loaded. Check SUPABASE_DB_URL and notices table contents.")
sampled = sample_representative_notices(
notices=notices,
sample_size=min(args.sample_size, len(notices)),
seed=args.seed,
)
dataset = build_dataset(
notices=notices,
sampled=sampled,
queries_per_notice=args.queries_per_notice,
hard_negative_count=args.hard_negatives,
model_name=args.model,
cache_path=args.cache,
sleep_seconds=args.sleep_seconds,
save_every=max(1, args.save_every),
max_retries=max(1, args.max_retries),
)
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", encoding="utf-8") as f:
json.dump(dataset, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"Loaded notices: {len(notices)}")
print(f"Sampled notices: {len(sampled)}")
print(f"Generated queries: {len(dataset['queries'])}")
print(f"Wrote: {args.output}")
if __name__ == "__main__":
main()