import argparse import os import sqlite3 import sys from pathlib import Path from huggingface_hub import HfApi, HfFileSystem import pyarrow.parquet as pq from qdrant_client import QdrantClient from qdrant_client.models import Distance, PointStruct, VectorParams from sentence_transformers import SentenceTransformer from tqdm import tqdm # Add parent dir to sys.path to allow app imports sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.chunking import make_chunks from app.config import settings from app.dataset_loader import row_to_docs DATASET = "ai4bharat/MSMARCO-XI" # ai4bharat/MSMARCO-XI ships per-language parquet files under train/ and validation/ # (e.g. train/hintrain.parquet, validation/hinval.parquet) instead of a working # HF `datasets` loading script/config split. We resolve and stream those parquet # files directly with datasets' generic "parquet" builder, which bypasses the # broken legacy script entirely. DEFAULT_LANGUAGES = ["hin", "ben", "tam", "urd", "mar"] # Only pull the columns row_to_docs_msmarco_xi actually uses. These files store # every column of one split's ~98K-778K rows in a SINGLE parquet row group, so # pyarrow must materialize the full column chunk for any requested column before # yielding even one row - there's no way to cheaply read "just the first N rows". # Column projection still helps by skipping 'meta'/'Answer'/'Eng_Answer', which are # unused. Once that one-time read completes, pulling more rows from the same open # file is fast, so --max-rows only controls how many are kept, not how long the # initial read takes. NEEDED_COLUMNS = ["query", "Eng_Query", "query_id", "query_type", "target_lang", "source_lang", "passages"] def stream_parquet_rows(hf_path: str, max_rows: int, hf_token: str = None): """Yield up to max_rows dict rows from a hf:// parquet path, columns-projected.""" fs = HfFileSystem(token=hf_token) rel_path = hf_path.replace("hf://", "") count = 0 with fs.open(rel_path, "rb") as f: pf = pq.ParquetFile(f) for batch in pf.iter_batches(batch_size=64, columns=NEEDED_COLUMNS): for row in batch.to_pylist(): yield row count += 1 if count >= max_rows: return def resolve_parquet_urls(split: str, languages): api = HfApi() files = api.list_repo_files(repo_id=DATASET, repo_type="dataset") split_files = [f for f in files if f.startswith(f"{split}/") and f.endswith(".parquet")] if languages: wanted = set(languages) split_files = [f for f in split_files if Path(f).name[:3] in wanted] if not split_files: raise RuntimeError( f"No parquet files found for split='{split}' languages={languages}. " f"Available files: {files}" ) return [f"hf://datasets/{DATASET}/{f}" for f in sorted(split_files)] def ensure_sqlite(path: str, wipe: bool = True): Path(path).parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) cur = conn.cursor() if wipe: # Drop any previous build's rows (e.g. earlier placeholder/demo data) so a # fresh run produces a clean index. With --append, skip this so multiple # per-language runs (each bounded by this tool's ~10min timeout) accumulate # into one index instead of each wiping the last. cur.execute("DROP TABLE IF EXISTS chunks_fts") cur.execute("DROP TABLE IF EXISTS chunks_meta") cur.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( chunk_id UNINDEXED, text, title, language, strategy, tokenize='unicode61' ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS chunks_meta ( chunk_id TEXT PRIMARY KEY, text TEXT, title TEXT, language TEXT, strategy TEXT, parent_doc_id TEXT ) """) conn.commit() return conn def insert_sqlite(conn, chunk): cur = conn.cursor() p = chunk.payload cur.execute( "INSERT OR REPLACE INTO chunks_meta VALUES (?, ?, ?, ?, ?, ?)", ( chunk.chunk_id, chunk.text, p.get("title", ""), p.get("language", ""), p.get("chunk_strategy", ""), p.get("parent_doc_id", ""), ), ) cur.execute( "INSERT INTO chunks_fts(chunk_id, text, title, language, strategy) VALUES (?, ?, ?, ?, ?)", ( chunk.chunk_id, chunk.text, p.get("title", ""), p.get("language", ""), p.get("chunk_strategy", ""), ), ) def batched(items, batch_size): batch = [] for x in items: batch.append(x) if len(batch) >= batch_size: yield batch batch = [] if batch: yield batch def flush_chunks(chunk_buffer, model, client, conn, batch_size): total = 0 for batch in batched(chunk_buffer, batch_size): texts = [f"passage: {c.text}" for c in batch] vectors = model.encode(texts, normalize_embeddings=True, batch_size=batch_size) points = [] for c, v in zip(batch, vectors): payload = dict(c.payload) payload["text"] = c.text points.append( PointStruct( id=c.chunk_id, vector=v.tolist(), payload=payload, ) ) insert_sqlite(conn, c) client.upsert(collection_name=settings.qdrant_collection, points=points) total += len(points) conn.commit() return total def main(): parser = argparse.ArgumentParser(description="Index MSMARCO-XI into Qdrant & SQLite FTS5") parser.add_argument( "--languages", nargs="*", default=DEFAULT_LANGUAGES, help="3-letter language codes (e.g. hin ben tam). Pass 'all' for every available language.", ) parser.add_argument("--split", default="validation", choices=["train", "validation"], help="Dataset split. 'validation' files (~460MB/lang) are far smaller than " "'train' files (~3.7GB/lang) and are still real MSMARCO-XI data.") parser.add_argument("--max-rows", type=int, default=500, help="Max query rows per language to ingest") parser.add_argument("--batch-size", type=int, default=64, help="Embedding batch size") parser.add_argument("--append", action="store_true", help="Add to the existing index instead of wiping it first. Use this when " "indexing languages one at a time across multiple runs (each language's " "first read is slow - see README) so earlier languages aren't lost.") args = parser.parse_args() os.makedirs("storage", exist_ok=True) languages = None if args.languages == ["all"] else args.languages print(f"Resolving parquet files for split='{args.split}' languages={languages or 'all'}...") parquet_urls = resolve_parquet_urls(args.split, languages) print(f"Found {len(parquet_urls)} file(s):") for u in parquet_urls: print(" -", u) print(f"Loading embedding model: {settings.embed_model}...") model = SentenceTransformer(settings.embed_model) test_vec = model.encode(["passage: test"], normalize_embeddings=True)[0] dim = len(test_vec) print(f"Embedding dimension: {dim}") print("Connecting to Qdrant...") try: client = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key or None, timeout=2.0) client.get_collections() print(f"Connected to Qdrant server at {settings.qdrant_url}") except Exception: print(f"Qdrant server unreachable at {settings.qdrant_url}. Using local embedded database at '{settings.qdrant_path}'") client = QdrantClient(path=settings.qdrant_path) collection_exists = client.collection_exists(settings.qdrant_collection) if args.append and collection_exists: print(f"--append: keeping existing Qdrant collection '{settings.qdrant_collection}'") else: # recreate_collection alone doesn't reliably purge old on-disk segments in # Qdrant's local/embedded mode - explicitly delete first so stale points # from a previous build (e.g. earlier placeholder/demo data) can't survive. if collection_exists: client.delete_collection(settings.qdrant_collection) client.create_collection( collection_name=settings.qdrant_collection, vectors_config=VectorParams(size=dim, distance=Distance.COSINE), ) print(f"Created clean Qdrant collection: '{settings.qdrant_collection}'") conn = ensure_sqlite(settings.sqlite_fts_path, wipe=not args.append) total_chunks = 0 hf_token = os.environ.get("HF_TOKEN") for url in parquet_urls: lang_code = Path(url).stem.replace(args.split[:3], "").replace("val", "").replace("train", "") or Path(url).stem print(f"\nStreaming '{url}' (max {args.max_rows} rows)... this file's column data is read in one " f"shot regardless of --max-rows, so this may take several minutes before the first row appears.") chunk_buffer = [] row_count = 0 for row_index, row in enumerate(tqdm(stream_parquet_rows(url, args.max_rows, hf_token), total=args.max_rows)): docs = row_to_docs(row, lang_code, args.split, row_index) for doc in docs: chunk_buffer.extend(make_chunks(doc)) row_count += 1 if row_count >= args.max_rows: break if chunk_buffer: n = flush_chunks(chunk_buffer, model, client, conn, args.batch_size) total_chunks += n print(f"Indexed {n} chunks from {row_count} rows for language '{lang_code}'.") else: print(f"WARNING: no chunks extracted for language '{lang_code}' from {row_count} rows.") conn.close() print(f"\nIndexing complete! Total chunks indexed across Qdrant & SQLite FTS5: {total_chunks}") if total_chunks == 0: print("ERROR: zero chunks were indexed. Check dataset schema / network access before using this index.") sys.exit(1) if __name__ == "__main__": main()