rajvivan's picture
sync: push iris-ir-platform to HuggingFace Space
2a5d15a
Raw
History Blame Contribute Delete
10.7 kB
#!/usr/bin/env python3
"""
FinBot Ingestion Script
========================
Runs the full ingestion pipeline on a PDF document:
1. Parse PDF β†’ text + tables (PyMuPDF + pdfplumber)
2. Chunk text β†’ semantic chunks
3. Embed chunks β†’ local BGE embeddings β†’ ChromaDB
4. Index tables β†’ JSON table store
5. Render pages β†’ high-quality slide PNG images for display + ColPali
6. ColPali indexing β†’ multi-vector patch embeddings per page
7. Save all metadata
Usage:
python ingest.py --pdf "../IR Chatbot/emiratesnbd_investor_presentation_2026_q1.pdf"
python ingest.py --pdf path/to/doc.pdf --doc-id my_doc_id
ColPali note:
First run downloads vidore/colpali-v1.2-merged (~7GB).
Subsequent runs are fast (model cached by HuggingFace).
Runs on Mac MPS (M-series) or CPU.
"""
import argparse
import asyncio
import logging
import json
import sys
import time
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("finbot.ingest")
BASE_DIR = Path(__file__).parent
DATA_DIR = BASE_DIR / "data"
PAGES_DIR = DATA_DIR / "pages"
COLPALI_DIR = DATA_DIR / "colpali_index"
CHROMA_DIR = DATA_DIR / "chroma"
TABLES_FILE = DATA_DIR / "tables.json"
def ingest_document_sync(doc_id: str, pdf_path: str):
"""
Synchronous ingestion pipeline.
Called from CLI or background task.
"""
pdf_path = Path(pdf_path)
if not pdf_path.exists():
raise FileNotFoundError(f"PDF not found: {pdf_path}")
doc_name = pdf_path.stem.replace("_", " ").replace("-", " ").title()
total_start = time.time()
logger.info("=" * 60)
logger.info(f"FinBot Ingestion Pipeline")
logger.info(f"Document : {pdf_path.name}")
logger.info(f"Doc ID : {doc_id}")
logger.info("=" * 60)
# ── Step 1: Parse PDF ────────────────────────────────────────────
logger.info("\n[1/6] Parsing PDF (text + tables)…")
from services.ingestion.pdf_parser import parse_pdf, save_extraction
t0 = time.time()
extracted = parse_pdf(pdf_path, doc_id, doc_name)
logger.info(
f" βœ“ {extracted.total_pages} pages parsed in {time.time()-t0:.1f}s"
f" | Tables on {sum(1 for p in extracted.pages if p.has_table)} pages"
)
# Save extraction JSON for debugging
save_extraction(extracted, DATA_DIR / "processed")
# ── Step 2: Chunk text ───────────────────────────────────────────
logger.info("\n[2/6] Chunking text…")
from services.ingestion.text_chunker import TextChunker
t0 = time.time()
chunker = TextChunker(chunk_size=300, chunk_overlap=60)
page_texts = [
{
"page_number": p.page_number,
"text": p.text,
"section_heading": getattr(p, "section_heading", ""),
"slide_number": getattr(p, "slide_number", p.page_number),
}
for p in extracted.pages if p.text.strip()
]
chunks = chunker.chunk_document(page_texts, doc_id)
logger.info(f" βœ“ {len(chunks)} chunks in {time.time()-t0:.1f}s")
# ── Step 3: Embed + index text in ChromaDB ───────────────────────
logger.info("\n[3/6] Embedding text chunks (local BGE model)…")
from services.retrieval.text_retriever import TextRetriever
t0 = time.time()
chunks = chunker.embed_chunks(chunks)
text_retriever = TextRetriever(persist_dir=CHROMA_DIR)
n_indexed = text_retriever.index_chunks(chunks)
logger.info(f" βœ“ {n_indexed} chunks indexed in ChromaDB | {time.time()-t0:.1f}s")
# ── Step 4: Index tables ─────────────────────────────────────────
logger.info("\n[4/6] Indexing tables…")
from services.retrieval.table_retriever import TableRetriever
t0 = time.time()
table_retriever = TableRetriever(TABLES_FILE)
n_tables = table_retriever.index_tables(extracted)
logger.info(f" βœ“ {n_tables} tables indexed in {time.time()-t0:.1f}s")
# ── Step 5: Render PDF pages to images ───────────────────────────
logger.info("\n[5/6] Rendering PDF pages as high-quality slide images…")
from services.ingestion.page_renderer import render_pdf_pages
t0 = time.time()
PAGES_DIR.mkdir(parents=True, exist_ok=True)
page_records = render_pdf_pages(
pdf_path=pdf_path,
output_dir=PAGES_DIR,
doc_id=doc_id,
)
logger.info(
f" βœ“ {len(page_records)} pages rendered in {time.time()-t0:.1f}s"
f" β†’ {PAGES_DIR / doc_id / 'pages'}"
)
# ── Step 6: ColPali indexing ──────────────────────────────────────────
logger.info("\n[6/6] Generating ColPali patch embeddings…")
logger.info(" (First run downloads ~7GB model β€” cached after that)")
logger.info(" Running on: Mac MPS or CPU (batch_size=1 to avoid OOM)")
import os, torch
if torch.backends.mps.is_available():
os.environ["PYTORCH_MPS_HIGH_WATERMARK_RATIO"] = "0.0"
logger.info(" MPS detected β€” disabled memory watermark limit")
from services.ingestion.colpali_indexer import ColPaliIndexer
t0 = time.time()
colpali_indexer = ColPaliIndexer(store_dir=COLPALI_DIR)
page_records = colpali_indexer.index_pages(
page_records=page_records,
doc_id=doc_id,
batch_size=1, # Always 1 on MPS β€” OOM otherwise
)
elapsed = time.time() - t0
logger.info(
f" βœ“ {len(page_records)} pages ColPali-indexed in {elapsed:.1f}s"
f" (~{elapsed/len(page_records):.1f}s/page)"
)
# ── Save document metadata ────────────────────────────────────────
docs_file = DATA_DIR / "documents.json"
if docs_file.exists():
with open(docs_file) as f:
docs = json.load(f)
else:
docs = []
# Remove old entry for this doc_id
docs = [d for d in docs if d["doc_id"] != doc_id]
docs.append({
"doc_id": doc_id,
"name": doc_name,
"doc_type": extracted.metadata.get("doc_type", "Financial Document"),
"period": extracted.metadata.get("period", "Unknown"),
"institution": extracted.metadata.get("institution", "Emirates NBD"),
"total_pages": extracted.total_pages,
"status": "indexed",
"filename": pdf_path.name,
"chunks_indexed": n_indexed,
"tables_indexed": n_tables,
"colpali_pages": len(page_records),
})
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(docs_file, "w") as f:
json.dump(docs, f, indent=2)
total_elapsed = time.time() - total_start
logger.info("\n" + "=" * 60)
logger.info(f"βœ… Ingestion complete in {total_elapsed:.1f}s")
logger.info(f" Text chunks : {n_indexed}")
logger.info(f" Tables : {n_tables}")
logger.info(f" Pages imaged : {len(page_records)}")
logger.info(f" ColPali pages: {len(page_records)}")
logger.info("=" * 60)
logger.info("\nStart the backend: uvicorn app.main:app --reload --port 8000")
logger.info("Start the frontend: npm run dev (in finbot-ir-platform/)")
async def ingest_document(doc_id: str, pdf_path: str):
"""Async wrapper for background task usage."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, ingest_document_sync, doc_id, pdf_path)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="IRIS PDF Ingestion β€” text + tables + ColPali visual indexing"
)
parser.add_argument(
"--pdf",
default=None,
help="Path to the PDF file to ingest (if omitted, scans the documents/ folder)",
)
parser.add_argument(
"--doc-id",
default=None,
help="Document ID (default: derived from filename)",
)
parser.add_argument(
"--skip-colpali",
action="store_true",
help="Skip ColPali indexing (faster, but no visual retrieval)",
)
args = parser.parse_args()
if args.pdf:
pdf = Path(args.pdf)
doc_id = args.doc_id or pdf.stem.lower().replace(" ", "_").replace("-", "_")
logger.info(f"Ingesting specific file: {pdf.name} with doc_id: {doc_id}")
ingest_document_sync(doc_id, str(pdf))
else:
# Scan the documents/ directory for PDF files
documents_dir = BASE_DIR.parent / "documents"
if not documents_dir.exists():
documents_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created documents/ folder. Please place PDF files in: {documents_dir}")
sys.exit(0)
pdf_files = list(documents_dir.glob("*.pdf"))
if not pdf_files:
logger.info(f"No PDF files found in documents/ folder: {documents_dir}")
sys.exit(0)
# Check existing metadata to skip already indexed files
docs_file = DATA_DIR / "documents.json"
indexed_filenames = set()
if docs_file.exists():
try:
with open(docs_file) as f:
docs = json.load(f)
for d in docs:
if d.get("status") == "indexed":
indexed_filenames.add(d.get("filename"))
except Exception as e:
logger.warning(f"Could not load documents.json: {e}")
logger.info(f"Scanning documents/ folder. Found {len(pdf_files)} PDF files.")
new_files = [f for f in pdf_files if f.name not in indexed_filenames]
if not new_files:
logger.info("All PDF files in documents/ are already indexed. Nothing to do!")
sys.exit(0)
logger.info(f"Found {len(new_files)} new files to ingest.")
for pdf in new_files:
doc_id = pdf.stem.lower().replace(" ", "_").replace("-", "_")
logger.info(f"\n>>> Starting ingestion for new file: {pdf.name} (ID: {doc_id})")
try:
ingest_document_sync(doc_id, str(pdf))
except Exception as e:
logger.error(f"❌ Failed to ingest {pdf.name}: {e}")