#!/usr/bin/env python3 """ build_index.py — Full build of the EUR-Lex vector index. Pipeline: 1. SPARQL query -> DIR + REG docs (non-corrigenda) from 2004-01-01 onward 2. Fetch XHTML from Cellar CELEX endpoint (parallel, 20 workers) 3. Parse XHTML via BeautifulSoup into chunks (articles, preamble, annex) 4. Embed with sentence-transformers (all-MiniLM-L6-v2, 384-dim, normalized) 5. Build FAISS IVFPQ index (~28MB for 500K vectors, vs 768MB float32) 6. Build SQLite DB of chunks (~35MB, on-disk, zero RAM at query time) 7. Upload index.faiss + chunks.db + last_updated.txt to HuggingFace Hub 8. Delete old vectors.npy + chunks.json from Hub Memory optimizations: - ThreadPoolExecutor batches with immediate future.pop() per future (CPython bpo-27144: as_completed no longer keeps references to yielded objects) - Corrigenda filtered at SPARQL level (~15-20% fewer downloads) - Explicit del + gc.collect() after embedding and chunk building - FAISS IVFPQ index: ~28MB vs 768MB for raw float32 (PQ48x8 = 48 bytes/vector) - use_precomputed_table=-1 saves ~127MB of precomputed distance tables - SQLite chunks: on-disk, only loads matched rows (top-10 per query) - Total RAM at query time: ~363MB (well under 512MB HF Space limit) Usage: source ~/Desktop/EUProjects/.venv/bin/activate HF_TOKEN=hf_yourtoken python3 scripts/build_index.py Output: data/index.faiss - FAISS IVFPQ index (~28MB) data/chunks.db - SQLite database of chunks (~35MB) data/last_updated.txt - ISO timestamp of build References: - CPython bpo-27144: concurrent.futures memory leak fix - FAISS guidelines: nlist ≈ 4*sqrt(N) for IVF (issue #2692) - EUR-Lex corrigendum pattern: R(xx) suffix on CELEX number """ import gc import json import logging import os import sqlite3 import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import UTC, datetime from urllib.parse import quote import numpy as np import requests from tqdm import tqdm try: import faiss except ImportError: faiss = None # will be checked before use logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger(__name__) DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data") os.makedirs(DATA_DIR, exist_ok=True) # Capture git revision for traceability GIT_REV = "" try: GIT_REV = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5, cwd=os.path.dirname(os.path.dirname(__file__)), ).stdout.strip() except Exception: pass # Configuration EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "all-MiniLM-L6-v2") INDEX_SUFFIX = os.environ.get("INDEX_SUFFIX", "") # e.g., "_eurlex" for EURLEX-BERT # Model-dependent defaults _IS_EURLEX = "eurlex" in EMBEDDING_MODEL.lower() EMBED_DIM = 768 if _IS_EURLEX else 384 # PQ config: 384/48 = 8 sub-quantizers, 768/48 = 16 sub-quantizers PQ_BITS = 48 FAISS_INDEX_FACTORY = f"IVF{{nlist}},PQ{PQ_BITS}x{{dim//PQ_BITS}}" SPARQL_ENDPOINT = "https://publications.europa.eu/webapi/rdf/sparql" DOC_TYPES = [ "REG", "DIR", ] FROM_DATE = os.environ.get("FROM_DATE", "2004-01-01") MAX_CHUNKS = os.environ.get("MAX_CHUNKS") if MAX_CHUNKS is not None: MAX_CHUNKS = int(MAX_CHUNKS) DOWNLOAD_WORKERS = 20 HF_DATASET_NAME = "eurlex-chat-data" HF_BACKUP_DATASET = "NedAktovOps/eurlex-chat-backups" def _cap_chunks(all_chunks, success_count): """Truncate chunks to MAX_CHUNKS limit if set.""" if MAX_CHUNKS and len(all_chunks) > MAX_CHUNKS: logger.warning("Capping chunks from %d to %d (MAX_CHUNKS=%d)", len(all_chunks), MAX_CHUNKS, MAX_CHUNKS) # Keep only first MAX_CHUNKS chunks truncated = all_chunks[:MAX_CHUNKS] # Recompute success_count from unique CELEXes in remaining chunks success_count = len(set(c["celex"] for c in truncated)) return truncated, success_count return all_chunks, success_count def query_all_documents(): """Query documents via SPARQL with date filter. Returns DIR + REG documents with dates from FROM_DATE onward. Uses FILTER on xsd:dateTime so the SPARQL endpoint does the date filtering server-side, not in Python. """ type_filters_list = [ f"?type = " for t in DOC_TYPES ] type_filter = " ||\n ".join(type_filters_list) prefixes = "PREFIX cdm: " query = f"""{prefixes} SELECT DISTINCT ?doc ?type ?celex ?date WHERE {{ ?doc cdm:work_has_resource-type ?type . FILTER( {type_filter} ) ?doc cdm:resource_legal_id_celex ?celex . OPTIONAL {{ ?doc cdm:work_date_document ?date . }} FILTER(?date >= "{FROM_DATE}T00:00:00"^^xsd:dateTime) # Exclude corrigenda — EUR-Lex docs confirm R(xx) suffix pattern FILTER(!CONTAINS(?celex, "R(")) }} """ logger.info(f"SPARQL query for types: {DOC_TYPES}, from: {FROM_DATE}") r = requests.get( SPARQL_ENDPOINT, params={"query": query, "format": "json"}, timeout=120, ) r.raise_for_status() data = r.json() bindings = data["results"]["bindings"] logger.info(f" Raw results: {len(bindings)} documents") all_docs = [] for b in bindings: all_docs.append({ "celex": b["celex"]["value"], "title": "", "date": b.get("date", {}).get("value", ""), "type": b["type"]["value"].split("/")[-1], "cellar_url": b["doc"]["value"], }) logger.info(f"Total documents: {len(all_docs)}") return all_docs def fetch_document_xhtml(doc): """Fetch XHTML from Cellar CELEX endpoint. Uses publications.europa.eu (no WAF) with proper content negotiation. """ celex = doc["celex"] try: url = ( f"https://publications.europa.eu/resource/celex/" f"{quote(celex, safe='')}.ENG.xhtml" ) r = requests.get(url, timeout=15, headers={ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", "Accept": "application/xhtml+xml", }) r.raise_for_status() if len(r.content) < 500: logger.warning(f" Empty content for {celex}") return None return r.text except requests.RequestException as e: logger.debug(f" HTTP error for {celex}: {e}") return None except Exception as e: logger.warning(f" Unexpected error for {celex}: {e}") return None def parse_html_to_chunks(html, celex_id, title): """Parse EUR-Lex HTML into chunks. Strategy: 1. Try .eli-container → .eli-subdivision (structured articles) 2. Try #text or #document1 (tab content) 3. Try #documentView (fallback container) 4. Last resort: extract meaningful paragraphs """ from bs4 import BeautifulSoup soup = BeautifulSoup(html, "html.parser") if not title: title_el = soup.select_one(".eli-main-title .oj-doc-ti") if title_el: title = title_el.get_text(strip=True) elif soup.title: title = soup.title.string or "" # Strategy 1: ELI subdivisions (modern documents) container = soup.select_one(".eli-container") if container: subdivisions = container.select(".eli-subdivision") if subdivisions: chunks = [] for sub in subdivisions: sub_id = sub.get("id", "") text = sub.get_text(separator=" ", strip=True) if not text or len(text) < 50: continue if sub_id.startswith("art_"): chunk_type = "article" elif sub_id.startswith("enc_"): chunk_type = "enacting" elif sub_id.startswith("pbl_"): chunk_type = "preamble" elif sub_id.startswith("ann_"): chunk_type = "annex" else: chunk_type = "section" chunks.append({ "text": text, "celex": celex_id, "title": title, "article": sub_id, "type": chunk_type, }) if chunks: return chunks # Strategy 2: Tab content (corrigenda, some older docs) for tab_sel in ["#text", "#document1", "#PP4Contents"]: tab = soup.select_one(tab_sel) if tab: text = tab.get_text(separator="\n", strip=True) paragraphs = extract_meaningful_paragraphs(text) if paragraphs: return [{"text": p, "celex": celex_id, "title": title, "article": None, "type": "paragraph"} for p in paragraphs] # Strategy 3: Document view (any page) doc_view = soup.select_one("#documentView") if doc_view: text = doc_view.get_text(separator="\n", strip=True) paragraphs = extract_meaningful_paragraphs(text) if paragraphs: return [{"text": p, "celex": celex_id, "title": title, "article": None, "type": "paragraph"} for p in paragraphs] # Strategy 4: Full page text text = soup.get_text(separator="\n", strip=True) paragraphs = extract_meaningful_paragraphs(text) if paragraphs: return [{"text": p, "celex": celex_id, "title": title, "article": None, "type": "paragraph"} for p in paragraphs] logger.warning(f" No content could be extracted for {celex_id}") return [] def extract_meaningful_paragraphs(text): """Extract meaningful paragraphs, filtering out navigation and feature garbage.""" lines = [p.strip() for p in text.split("\n") if p.strip()] meaningful = [] for line in lines: line = line.strip() if len(line) < 40: continue if any(skip in line.lower() for skip in [ "experimental feature", "deep linking", "visualisation of document", "replacement of celex identifiers", "do you want to help improving", "skip to main content", "you are here", "multilingual display", "toggle dropdown", "select your language", "more about this", "choose the experimental features", "official eu languages", "europa eur-lex home", "help print text", "document information", "up-to-date link", "permanent link", "download notice", "save to my items", "create an email alert", "create an rss", "log in", "sign in", "register", "my recent searches", "my eur-lex", "ecl-site-header", ]): continue if line.startswith("http://") or line.startswith("https://") or line.startswith("<"): continue meaningful.append(line) return meaningful def embed_chunks(all_chunks, batch_size=None): """Embed all chunks using sentence-transformers or EURLEX-BERT. Uses EMBEDDING_MODEL from environment (default: all-MiniLM-L6-v2). Memory-efficient batching: embed BATCH_SIZE chunks at a time. """ if batch_size is None: batch_size = 64 if _IS_EURLEX else 1024 if _IS_EURLEX: logger.info(f"Loading EURLEX-BERT model: {EMBEDDING_MODEL} (768-dim)") import torch from transformers import AutoModel, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained(EMBEDDING_MODEL) model = AutoModel.from_pretrained(EMBEDDING_MODEL) model.eval() else: logger.info(f"Loading sentence-transformers model: {EMBEDDING_MODEL} (384-dim)") from sentence_transformers import SentenceTransformer model = SentenceTransformer(EMBEDDING_MODEL) texts = [c["text"] for c in all_chunks] all_embeddings = [] logger.info(f"Embedding {len(texts)} chunks in batches of {batch_size}...") total_batches = (len(texts) + batch_size - 1) // batch_size for i in range(0, len(texts), batch_size): batch = texts[i : i + batch_size] batch_num = i // batch_size + 1 if _IS_EURLEX: import torch inputs = tokenizer(batch, padding=True, truncation=True, max_length=512, return_tensors="pt") with torch.no_grad(): outputs = model(**inputs) attention_mask = inputs["attention_mask"] token_embeddings = outputs.last_hidden_state input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() embeddings = (token_embeddings * input_mask_expanded).sum(1) / input_mask_expanded.sum(1).clamp(min=1e-9) embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1) all_embeddings.append(embeddings.cpu().numpy()) else: embeddings = model.encode(batch, show_progress_bar=False, normalize_embeddings=True) all_embeddings.append(embeddings) if batch_num % 5 == 0 or batch_num == total_batches: mem_mb = _get_memory_mb() logger.info(f" Embedded {min(i + batch_size, len(texts))}/{len(texts)} " f"(batch {batch_num}/{total_batches}) [mem: {mem_mb:.0f}MB]") return np.vstack(all_embeddings).astype(np.float32) def _get_memory_mb(): """Get current process memory usage in MB (best effort).""" try: import psutil proc = psutil.Process() return proc.memory_info().rss / 1e6 except ImportError: try: with open(f"/proc/{os.getpid()}/status") as f: for line in f: if line.startswith("VmRSS:"): # /proc reports in kB return float(line.split()[1]) / 1024 except Exception: pass return 0.0 def build_faiss_index(vectors): """Build FAISS IVFPQ index and return the path. Uses IVF(nlist) + PQ48x8 for memory-efficient approximate search. Evidence: - nlist = 4*sqrt(N) per FAISS issue #2692 - PQ48x8 = 48 bytes/vector (384/48 = 8 dims per sub-quantizer) - use_precomputed_table=-1 to save ~127MB of precomputed tables - nprobe ~ sqrt(nlist) for optimal recall/speed tradeoff """ if faiss is None: raise RuntimeError("faiss not installed. Run: pip install faiss-cpu") n_vectors, dim = vectors.shape # nlist = 4*sqrt(N) as recommended by FAISS guidelines (issue #2692) n_centroids = min(int(4 * np.sqrt(n_vectors)), max(n_vectors // 40, 1)) n_centroids = max(n_centroids, 1) index = faiss.index_factory( dim, f"IVF{n_centroids},PQ48x8", faiss.METRIC_INNER_PRODUCT ) logger.info(f"Training FAISS index: {n_centroids} centroids on {n_vectors} vectors...") index.train(vectors.astype(np.float32)) logger.info("Adding vectors to index...") index.add(vectors.astype(np.float32)) # Disable precomputed table to save ~127MB (nlist × M × ksub × 4 bytes). # Must be set AFTER add() because add() internally resets it. # FAISS IndexIVFPQ.h: precomputed_table size = nlist * pq.M * pq.ksub index.use_precomputed_table = -1 logger.info("Precomputed distance table disabled (saves ~127MB)") # nprobe ~ sqrt(nlist) per FAISS wiki: recall knee near sqrt(nlist) nprobe = min(50, n_centroids) index.nprobe = nprobe logger.info(f"nprobe set to {nprobe}") index_path = os.path.join(DATA_DIR, "index.faiss") faiss.write_index(index, index_path) index_size = os.path.getsize(index_path) / 1e6 logger.info(f"FAISS index saved: {index_size:.1f} MB") return index_path def build_chunks_db(all_chunks): """Build SQLite database of chunks and return the path.""" db_path = os.path.join(DATA_DIR, "chunks.db") # Ensure fresh start: delete existing DB if present if os.path.exists(db_path): os.remove(db_path) logger.debug(f"Removed existing chunks.db at {db_path}") conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, celex TEXT NOT NULL, title TEXT DEFAULT '', article TEXT DEFAULT NULL, type TEXT DEFAULT 'section', text TEXT NOT NULL ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_celex ON chunks(celex)") rows = [ (i, c["celex"], c.get("title", ""), c.get("article"), c.get("type", "section"), c["text"]) for i, c in enumerate(all_chunks) ] conn.executemany("INSERT INTO chunks VALUES (?, ?, ?, ?, ?, ?)", rows) conn.commit() conn.execute("PRAGMA optimize") conn.close() db_size = os.path.getsize(db_path) / 1e6 logger.info(f"SQLite DB saved: {len(rows)} chunks ({db_size:.1f} MB)") return db_path def upload_to_hub(index_path, db_path, dataset_name, token, success_count=0, chunk_count=0): from huggingface_hub import HfApi, create_repo api = HfApi() who = api.whoami()["name"] repo_id = f"{who}/{dataset_name}" try: create_repo(repo_id, repo_type="dataset", exist_ok=True, token=token) logger.info(f"HF dataset repo: {repo_id}") except Exception as e: logger.warning(f"Repo creation warning (may already exist): {e}") ts_path = os.path.join(DATA_DIR, "last_updated.txt") ts = datetime.now(UTC).isoformat() build_meta = { "timestamp": ts, "git_rev": GIT_REV, "n_documents": success_count, "n_chunks": chunk_count, } with open(ts_path, "w") as f: f.write(ts) # Also write structured metadata meta_path = os.path.join(DATA_DIR, "build_meta.json") with open(meta_path, "w") as f: json.dump(build_meta, f, indent=2) api.upload_file( repo_id=repo_id, path_in_repo="index.faiss", path_or_fileobj=index_path, repo_type="dataset", token=token, ) api.upload_file( repo_id=repo_id, path_in_repo="chunks.db", path_or_fileobj=db_path, repo_type="dataset", token=token, ) api.upload_file( repo_id=repo_id, path_in_repo="last_updated.txt", path_or_fileobj=ts_path, repo_type="dataset", token=token, ) api.upload_file( repo_id=repo_id, path_in_repo="build_meta.json", path_or_fileobj=meta_path, repo_type="dataset", token=token, ) for old_file in ["vectors.npy", "chunks.json", "vectors.npy", "test_vectors.npy", "test_chunks.json"]: try: api.delete_file( repo_id=repo_id, path_in_repo=old_file, repo_type="dataset", token=token, ) logger.info(f"Deleted old file: {old_file}") except Exception: pass logger.info(f"Uploaded to HF Hub: {repo_id}") return repo_id def _load_chunks_from_db(db_path): """Load chunks from a local chunks.db SQLite file. Returns list of chunk dicts or empty list if file missing. """ if not os.path.exists(db_path): logger.warning("chunks.db not found at %s", db_path) return [] conn = sqlite3.connect(db_path) rows = conn.execute("SELECT celex, title, article, type, text FROM chunks ORDER BY id").fetchall() conn.close() chunks = [ {"celex": r[0], "title": r[1] or "", "article": r[2], "type": r[3], "text": r[4]} for r in rows ] logger.info("Loaded %d chunks from %s", len(chunks), db_path) return chunks def load_chunks_from_backup(token): """Load chunks from the latest HF backup dataset, skipping SPARQL crawl. Returns list of chunk dicts with keys: celex, title, article, type, text. Returns empty list if no backup found. """ import shutil import tempfile from huggingface_hub import HfApi api = HfApi(token=token) try: refs = api.list_repo_refs(HF_BACKUP_DATASET, repo_type="dataset") backup_branches = [b for b in (refs.branches or []) if b.name.startswith("backup-")] if not backup_branches: logger.warning("No backup branches found in %s", HF_BACKUP_DATASET) return [] latest = sorted(backup_branches, key=lambda b: b.name, reverse=True)[0] logger.info("Loading chunks from backup: %s@%s", HF_BACKUP_DATASET, latest.name) except Exception: logger.warning("Could not list backup branches (dataset may not exist yet)") return [] restore_dir = tempfile.mkdtemp(prefix="backup-restore-") try: api.snapshot_download( repo_id=HF_BACKUP_DATASET, repo_type="dataset", revision=latest.name, local_dir=restore_dir, local_dir_use_symlinks=False, ) db_path = os.path.join(restore_dir, "chunks.db") return _load_chunks_from_db(db_path) finally: shutil.rmtree(restore_dir, ignore_errors=True) def _download_and_chunk(docs): """Download documents from SPARQL, parse XHTML, return (chunks, success_count).""" logger.info(f"Downloading and parsing documents ({DOWNLOAD_WORKERS} workers)...") all_chunks = [] success_count = 0 with ThreadPoolExecutor(max_workers=DOWNLOAD_WORKERS) as executor: batch_size = DOWNLOAD_WORKERS * 3 # 60 for batch_start in range(0, len(docs), batch_size): batch = docs[batch_start:batch_start + batch_size] future_map = { executor.submit(fetch_document_xhtml, doc): doc for doc in batch } for future in tqdm(as_completed(future_map), total=len(future_map), desc=f"Fetching {batch_start}-{batch_start + len(batch)}", leave=False): doc = future_map.pop(future) try: html = future.result() if html: chunks = parse_html_to_chunks(html, doc["celex"], "") all_chunks.extend(chunks) success_count += 1 except Exception as e: logger.debug(f"Failed {doc['celex']}: {e}") logger.info(f" Batch done: {success_count}/{batch_start + len(batch)} docs, " f"{len(all_chunks)} chunks so far") logger.info(f"Downloaded {success_count}/{len(docs)} documents successfully") logger.info(f"Total chunks: {len(all_chunks)}") return all_chunks, success_count def main(): import argparse parser = argparse.ArgumentParser(description="Build EUR-Lex vector index") parser.add_argument("--from-backup", action="store_true", help="Load chunks from HF backup instead of SPARQL crawl") parser.add_argument("--from-artifact", action="store_true", help="Load chunks from local data/chunks.db (GitHub artifact)") parser.add_argument("--download-only", action="store_true", help="Only download + chunk, save data/chunks.db, then exit") args = parser.parse_args() hf_token = os.environ.get("HF_TOKEN") if not hf_token: logger.error("HF_TOKEN environment variable required") logger.error("Usage: HF_TOKEN=hf_yourtoken python3 scripts/build_index.py") return total_start = time.time() # Mode 1: --download-only (SPARQL crawl → chunks.db, for Job 1) if args.download_only: if faiss is None: logger.warning("faiss not installed (not needed for download-only)") docs = query_all_documents() if not docs: logger.error("No documents found") return logger.info(f"Documents to process: {len(docs)}") all_chunks, success_count = _download_and_chunk(docs) if not all_chunks: logger.error("No chunks produced") return build_chunks_db(all_chunks) logger.info("Download phase complete. data/chunks.db ready for artifact.") total_time = time.time() - total_start logger.info(f" Duration: {total_time / 60:.1f} minutes") logger.info(f" Documents: {success_count}") logger.info(f" Chunks: {len(all_chunks)}") return # Mode 2: --from-artifact (load chunks from local file, for Job 2) if args.from_artifact: db_path = os.path.join(DATA_DIR, "chunks.db") all_chunks = _load_chunks_from_db(db_path) if not all_chunks: logger.error("No chunks found in data/chunks.db") return success_count = len(set(c["celex"] for c in all_chunks)) logger.info(f"Loaded {len(all_chunks)} chunks from artifact " f"({success_count} documents)") _build_from_chunks(all_chunks, success_count, hf_token, total_start) return if faiss is None: logger.error("faiss not installed. Run: pip install faiss-cpu") return # Mode 3: --from-backup (load from HF backup, existing behavior) if args.from_backup: all_chunks = load_chunks_from_backup(hf_token) if not all_chunks: logger.error("No backup chunks available") return success_count = len(set(c["celex"] for c in all_chunks)) logger.info(f"Loaded {len(all_chunks)} chunks from backup " f"({success_count} documents)") _build_from_chunks(all_chunks, success_count, hf_token, total_start) return # Mode 4: Full pipeline (SPARQL → download → embed → FAISS → upload) docs = query_all_documents() if not docs: logger.error("No documents found - SPARQL query returned empty") return logger.info(f"Documents to process: {len(docs)}") all_chunks, success_count = _download_and_chunk(docs) if not all_chunks: logger.error("No chunks produced - check Cellar XHTML endpoint") return _build_from_chunks(all_chunks, success_count, hf_token, total_start) def _build_from_chunks(all_chunks, success_count, hf_token, total_start): """Embed chunks, build FAISS index + SQLite DB, and upload to HF Hub.""" vectors = embed_chunks(all_chunks) dim = vectors.shape[1] logger.info(f"Embedding complete: {vectors.shape}") index_path = build_faiss_index(vectors) # vectors no longer needed — free ~700MB del vectors gc.collect() logger.info("Memory freed: embeddings (~700MB)") db_path = build_chunks_db(all_chunks) # all_chunks no longer needed — free ~456MB chunk_count = len(all_chunks) del all_chunks gc.collect() logger.info("Memory freed: chunks (~456MB)") repo_id = upload_to_hub(index_path, db_path, HF_DATASET_NAME, hf_token, success_count=success_count, chunk_count=chunk_count) total_time = time.time() - total_start logger.info("=" * 60) logger.info("BUILD COMPLETE") logger.info("=" * 60) logger.info(f" Git revision: {GIT_REV}") logger.info(f" Duration: {total_time / 60:.1f} minutes") logger.info(f" Documents: {success_count}") logger.info(f" Chunks: {chunk_count}") logger.info(f" Dimensions: {dim}") logger.info(f" Dataset: {repo_id}") logger.info(" Index: data/index.faiss") logger.info(" Database: data/chunks.db") logger.info("=" * 60) if __name__ == "__main__": try: main() except KeyboardInterrupt: logger.warning("Build interrupted by user") sys.exit(130) except Exception as e: logger.exception(f"Build failed: {e}") sys.exit(1)