eurlex-chat-api / scripts /build_index.py
nedaktov-ops
feat: improve CELEX citation compliance and fix build_index bugs
659c97c
Raw
History Blame Contribute Delete
27.8 kB
#!/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 = <http://publications.europa.eu/resource/authority/resource-type/{t}>"
for t in DOC_TYPES
]
type_filter = " ||\n ".join(type_filters_list)
prefixes = "PREFIX cdm: <http://publications.europa.eu/ontology/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)