sangsangfinder / test /eval_generated_qa_url.py
cksleigen's picture
Initial clean deploy
54656fc
Raw
History Blame Contribute Delete
6.83 kB
#!/usr/bin/env python3
"""Evaluate generated QA JSON using URL ground truth against Pinecone search."""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
from pathlib import Path
from typing import Any, Iterable
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
os.environ["VECTOR_DB"] = "pinecone"
from api.core.models import get_vector_collection # noqa: E402
from api.services.search_service import hybrid_search # noqa: E402
DEFAULT_QA_PATH = ROOT / "data" / "qa_supabase_2026_smoke.json"
def load_qa_rows(path: Path) -> list[dict[str, Any]]:
with path.open(encoding="utf-8") as f:
payload = json.load(f)
if isinstance(payload, list):
rows = payload
elif isinstance(payload, dict) and isinstance(payload.get("qas"), list):
rows = payload["qas"]
else:
raise ValueError("QA file must be a JSON list or an object with a 'qas' list.")
valid = []
skipped = []
for idx, row in enumerate(rows):
question = str(row.get("question") or "").strip()
gt_url = str(row.get("gt_url") or row.get("notice_url") or "").strip()
if not question or not gt_url:
skipped.append((idx, "missing question or gt_url"))
continue
valid.append(
{
**row,
"question": question,
"gt_url": gt_url,
"type": row.get("type") or "unknown",
}
)
if skipped:
print(f"[WARN] skipped invalid rows: {len(skipped)}")
for idx, reason in skipped[:5]:
print(f" - row {idx}: {reason}")
return valid
def recall_at_k(ranked_urls: list[str], gt_url: str, k: int) -> float:
return 1.0 if gt_url in ranked_urls[:k] else 0.0
def mrr_score(ranked_urls: list[str], gt_url: str) -> float:
for idx, url in enumerate(ranked_urls):
if url == gt_url:
return 1.0 / (idx + 1)
return 0.0
def ndcg_at_k(ranked_urls: list[str], gt_url: str, k: int) -> float:
for idx, url in enumerate(ranked_urls[:k]):
if url == gt_url:
return 1.0 / math.log2(idx + 2)
return 0.0
def compute_scores(rows: list[dict[str, Any]], k: int) -> dict[str, float | int]:
n = len(rows)
if n == 0:
return {f"Recall@{k}": 0.0, "MRR": 0.0, f"NDCG@{k}": 0.0, "n": 0}
return {
f"Recall@{k}": round(sum(recall_at_k(r["ranked_urls"], r["gt_url"], k) for r in rows) / n, 4),
"MRR": round(sum(mrr_score(r["ranked_urls"], r["gt_url"]) for r in rows) / n, 4),
f"NDCG@{k}": round(sum(ndcg_at_k(r["ranked_urls"], r["gt_url"], k) for r in rows) / n, 4),
"n": n,
}
def check_ground_truth_coverage(gt_urls: Iterable[str]) -> tuple[int, int]:
collection = get_vector_collection()
unique_urls = sorted(set(gt_urls))
present = 0
missing = []
for url in unique_urls:
found = collection.get(where={"url": url}, limit=1)
if found.get("ids"):
present += 1
else:
missing.append(url)
if missing:
print(f"[WARN] missing GT URLs in Pinecone: {len(missing)}")
for url in missing[:5]:
print(f" - {url}")
return present, len(unique_urls)
def alpha_values(step: float) -> list[float]:
if step <= 0 or step > 1:
raise ValueError("--step must be in (0, 1].")
count = int(round(1.0 / step))
return [round(i * step, 10) for i in range(count + 1)]
def format_ratio(alpha: float) -> str:
return f"{1 - alpha:.1f}:{alpha:.1f}"
def evaluate(rows: list[dict[str, Any]], alpha: float, k: int) -> list[dict[str, Any]]:
evaluated = []
for row in rows:
results = hybrid_search(row["question"], top_k=k, alpha=alpha)
ranked_urls = [result["url"] for result in results if result.get("url")]
top = results[0] if results else {}
evaluated.append(
{
**row,
"ranked_urls": ranked_urls,
"top_url": top.get("url", ""),
"top_title": top.get("title", ""),
"hit": row["gt_url"] in ranked_urls[:k],
}
)
return evaluated
def main() -> None:
parser = argparse.ArgumentParser(description="Evaluate generated QA JSON by URL ground truth.")
parser.add_argument("--qa", type=Path, default=DEFAULT_QA_PATH)
parser.add_argument("--type", default=None, help="Optional QA type filter.")
parser.add_argument("--k", type=int, default=5)
parser.add_argument("--step", type=float, default=0.1)
parser.add_argument("--allow-missing-gt", action="store_true")
args = parser.parse_args()
rows = load_qa_rows(args.qa)
if args.type:
rows = [row for row in rows if row.get("type") == args.type]
if not rows:
raise SystemExit("No valid QA rows to evaluate.")
present, total = check_ground_truth_coverage(row["gt_url"] for row in rows)
print(f"QA file : {args.qa}")
print(f"QA rows : {len(rows)}")
print(f"Unique GT URLs : {total}")
print(f"GT URL coverage in Pinecone: {present}/{total}")
if present < total and not args.allow_missing_gt:
raise SystemExit("Abort: pass --allow-missing-gt to evaluate with missing GT URLs.")
print(f"{'alpha(vector)':>13} {'keyword:vector':>16} {f'Recall@{args.k}':>10} {'MRR':>10} {f'NDCG@{args.k}':>10} {'n':>6}")
results_by_alpha = []
rows_by_alpha = {}
for alpha in alpha_values(args.step):
evaluated = evaluate(rows, alpha, args.k)
scores = compute_scores(evaluated, args.k)
results_by_alpha.append((alpha, scores))
rows_by_alpha[alpha] = evaluated
print(
f"{alpha:>13.1f} {format_ratio(alpha):>16} "
f"{scores[f'Recall@{args.k}']:>10.4f} {scores['MRR']:>10.4f} "
f"{scores[f'NDCG@{args.k}']:>10.4f} {scores['n']:>6}"
)
best_alpha, best_scores = max(
results_by_alpha,
key=lambda item: (item[1][f"NDCG@{args.k}"], item[1]["MRR"], item[1][f"Recall@{args.k}"]),
)
print(
f"BEST alpha={best_alpha:.1f} keyword:vector={format_ratio(best_alpha)} "
f"Recall@{args.k}={best_scores[f'Recall@{args.k}']:.4f} "
f"MRR={best_scores['MRR']:.4f} NDCG@{args.k}={best_scores[f'NDCG@{args.k}']:.4f}"
)
misses = [row for row in rows_by_alpha[best_alpha] if not row["hit"]]
if misses:
print(f"Misses at best alpha: {len(misses)}")
for row in misses[:5]:
print(f" - [{row.get('type')}] {row['question']} | GT={row['notice_title']} | Top={row['top_title']}")
if __name__ == "__main__":
main()