"""Run offline evaluation (Precision@K / Recall / F1 / latency) across methods. Uses the prebuilt index at ``artifacts/index.pkl`` if present, otherwise builds one from the committed sample. Relevance is judged with pseudo-qrels derived from the dataset's category labels (see ``news_search.evaluate``). Usage ----- python scripts/evaluate.py python scripts/evaluate.py --index artifacts/index.pkl --top-k 10 """ from __future__ import annotations import argparse import pickle import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) from news_search import SearchEngine, build_index, load_corpus # noqa: E402 from news_search.index import InvertedIndex # noqa: E402 from news_search.evaluate import evaluate # noqa: E402 ALL_METHODS = ["bm25", "tfidf", "prf", "wordnet", "bert", "hybrid"] BASE_METHODS = ["bm25", "tfidf", "prf", "wordnet"] def _load_dense(index_path: Path): """Load the dense (BERT) retriever sitting next to the index, if present.""" dense_path = Path(index_path).with_name("dense.pkl") if not dense_path.exists(): return None try: with dense_path.open("rb") as fh: return pickle.load(fh) except Exception as exc: # pragma: no cover print(f"[warn] could not load {dense_path}: {exc}") return None DEFAULT_QUERIES = [ "covid vaccine health", "election president vote", "movie film", "stock market money", "game sport", "travel food recipe", ] def main() -> int: ap = argparse.ArgumentParser(description="Offline IR evaluation across methods.") ap.add_argument("--index", default=str(ROOT / "artifacts" / "index.pkl"), help="Prebuilt index to evaluate (falls back to the sample).") ap.add_argument("--data", default=str(ROOT / "data" / "sample_news.jsonl"), help="Dataset used when no prebuilt index exists.") ap.add_argument("--top-k", type=int, default=10) ap.add_argument("--methods", nargs="*", default=None, help="Subset of methods to evaluate (default: bm25 tfidf prf wordnet).") args = ap.parse_args() if Path(args.index).exists(): print(f"Loading index from {args.index}") engine = SearchEngine(InvertedIndex.load(args.index), dense=_load_dense(Path(args.index))) else: print(f"No index at {args.index}; building from {args.data}") engine = SearchEngine(build_index(load_corpus(args.data), verbose=False)) # Evaluate BERT methods too when embeddings are available. methods = args.methods or (ALL_METHODS if engine.dense is not None else BASE_METHODS) print(f"Index: {engine.index.num_docs:,} docs | " f"BERT {'enabled' if engine.dense is not None else 'disabled'} | methods: {methods}") # Warm up the BERT model so its one-time load isn't charged to the first # timed query (gives a fair steady-state latency). if engine.dense is not None: engine.search("warmup query", method="bert", top_k=1) rows = evaluate(engine, DEFAULT_QUERIES, methods=methods, top_k=args.top_k) print(f"\n{'method':<12}{'P@K':>8}{'Recall':>9}{'F1':>8}{'avg ms':>9}") print("-" * 46) for r in rows: print(f"{r.method:<12}{r.precision:>8.3f}{r.recall:>9.3f}{r.f1:>8.3f}{r.avg_ms:>9.1f}") return 0 if __name__ == "__main__": sys.exit(main())