"""Build FAISS, BM25, corpus CDFs, corpus LM, and doc lookup artifacts. By default this indexes BEIR SciFact when ``data/scifact`` is present. Use ``--dataset sample`` to rebuild the original toy CS corpus. Usage: python scripts/build_index.py python scripts/build_index.py --dataset sample """ from __future__ import annotations import argparse import json import random import sys from pathlib import Path from typing import Any sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.calibration import ( # noqa: E402 build_corpus_cdfs, build_corpus_language_model, save_corpus_cdfs, save_corpus_lm, ) from app.datasets import ( # noqa: E402 SCIFACT_DIR, load_scifact_corpus, load_scifact_golden_set, scifact_available, ) from app.retriever import INDEX_DIR, DocumentStore, chunk_text # noqa: E402 SUPPORTED = {".txt", ".md", ".pdf"} GOLDEN_SET_PATH = Path("eval/golden_set.json") INDEX_METADATA_PATH = INDEX_DIR / "index_metadata.json" MIN_CDF_QUERIES = 100 def choose_dataset(requested: str) -> str: if requested != "auto": return requested return "scifact" if scifact_available(SCIFACT_DIR) else "sample" def load_sample_golden_queries() -> list[str]: if not GOLDEN_SET_PATH.exists(): return [] with open(GOLDEN_SET_PATH, encoding="utf-8") as f: golden = json.load(f) return [item["query"] for item in golden] def load_dataset_queries(dataset: str) -> list[str]: if dataset == "scifact": return [item["query"] for item in load_scifact_golden_set(SCIFACT_DIR)] return load_sample_golden_queries() def generate_pseudo_queries(store: DocumentStore, count: int) -> list[str]: """Generate pseudo-queries from random indexed chunks.""" if not store.bm25_index.doc_metadata: return [] all_texts = [m["text"] for m in store.bm25_index.doc_metadata] random.seed(42) selected = random.sample(all_texts, min(count, len(all_texts))) pseudo_queries = [] for text in selected: words = text.split() if len(words) >= 5: start = random.randint(0, max(0, len(words) - 8)) span = " ".join(words[start : start + random.randint(5, 8)]) pseudo_queries.append(span) return pseudo_queries def index_sample_documents(store: DocumentStore) -> dict[str, Any]: docs_dir = Path("data/sample_docs") if not docs_dir.exists(): raise FileNotFoundError(f"{docs_dir} not found") files = sorted(f for f in docs_dir.iterdir() if f.suffix.lower() in SUPPORTED) print(f"Step 1: Indexing {len(files)} sample documents...\n") total_chunks = 0 total_characters = 0 for file_path in files: stats = store.add_file(file_path) total_chunks += stats["chunks"] total_characters += stats["characters"] print( f" * {stats['filename']:40s} " f"{stats['chunks']:3d} chunks {stats['characters']:6d} chars" ) return { "dataset": "sample", "documents": len(files), "chunks": total_chunks, "characters": total_characters, } def index_scifact_documents(store: DocumentStore) -> dict[str, Any]: records = load_scifact_corpus(SCIFACT_DIR) if not records: raise FileNotFoundError(f"No SciFact documents found under {SCIFACT_DIR}") print(f"Step 1: Indexing {len(records)} SciFact corpus documents...\n") documents = [] total_characters = 0 for record in records: total_characters += len(record["text"]) documents.extend(chunk_text(record["text"], record["doc_id"])) stats = store.add_documents(documents) print( f" Indexed {len(records)} source documents as " f"{stats['chunks']} chunks ({total_characters} chars)." ) return { "dataset": "scifact", "documents": len(records), "chunks": stats["chunks"], "characters": total_characters, } def build_calibration_artifacts( store: DocumentStore, dataset: str, total_chunks: int ) -> dict[str, Any]: print("\nStep 2: Building corpus-level CDFs...") golden_queries = load_dataset_queries(dataset) random.seed(42) cdf_golden_queries = golden_queries[: min(50, len(golden_queries))] n_pseudo = max(0, MIN_CDF_QUERIES - len(cdf_golden_queries)) pseudo_queries = generate_pseudo_queries(store, n_pseudo) sample_queries = cdf_golden_queries + pseudo_queries print( f" Sample queries: {len(cdf_golden_queries)} labeled + " f"{len(pseudo_queries)} pseudo = {len(sample_queries)} total" ) cdf_bm25, cdf_dense = build_corpus_cdfs( bm25_score_fn=store.bm25_index.score_all, dense_score_fn=store._dense_score_all, sample_queries=sample_queries, ) save_corpus_cdfs(cdf_bm25, cdf_dense, INDEX_DIR) print(f" BM25 CDF: {len(cdf_bm25)} scores (shape: {cdf_bm25.shape})") print(f" Dense CDF: {len(cdf_dense)} scores (shape: {cdf_dense.shape})") print("\nStep 3: Building corpus language model...") all_texts = [m["text"] for m in store.bm25_index.doc_metadata] term_freqs, total_terms = build_corpus_language_model(all_texts) save_corpus_lm(term_freqs, total_terms, INDEX_DIR / "corpus_lm.pkl") print(f" Vocabulary size: {len(term_freqs)} unique terms") print(f" Total terms: {total_terms}") return { "cdf_sample_queries": len(sample_queries), "cdf_scores_per_retriever": len(sample_queries) * total_chunks, "vocabulary_terms": len(term_freqs), "total_terms": total_terms, } def write_index_metadata(metadata: dict[str, Any]) -> None: INDEX_METADATA_PATH.parent.mkdir(parents=True, exist_ok=True) with open(INDEX_METADATA_PATH, "w", encoding="utf-8") as f: json.dump(metadata, f, indent=2) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build retrieval artifacts.") parser.add_argument( "--dataset", choices=("auto", "sample", "scifact"), default="auto", help="Dataset to index. auto prefers SciFact when data/scifact exists.", ) return parser.parse_args() def main() -> None: args = parse_args() dataset = choose_dataset(args.dataset) store = DocumentStore() if dataset == "scifact": corpus_stats = index_scifact_documents(store) else: corpus_stats = index_sample_documents(store) print(f"\n FAISS index: {store.vector_store.index.ntotal} vectors") print(f" BM25 index: {len(store.bm25_index.tokenized_corpus)} documents") artifact_stats = build_calibration_artifacts(store, dataset, corpus_stats["chunks"]) metadata = {**corpus_stats, **artifact_stats} write_index_metadata(metadata) print(f"\n{'=' * 60}") print(" BUILD COMPLETE") print(f"{'=' * 60}") print(f" Dataset: {metadata['dataset']}") print(f" Documents: {metadata['documents']}") print(f" Chunks: {metadata['chunks']}") print(f" FAISS vectors: {store.vector_store.index.ntotal}") print(f" BM25 documents: {len(store.bm25_index.tokenized_corpus)}") print( f" CDF samples: {metadata['cdf_sample_queries']} queries x {metadata['chunks']} docs" ) print(f" Vocabulary: {metadata['vocabulary_terms']} terms") print(f" Doc lookup: {len(store.doc_lookup)} entries") print(f" Metadata: {INDEX_METADATA_PATH}") print(f"{'=' * 60}") if __name__ == "__main__": main()