""" indexer.py — Local FAISS RAG index for Paper2Lab. Purpose ------- Build a retrieval index from section-aware PDF extraction output. Default mode is local and cheap: sentence-transformers + FAISS Optional mode supports NVIDIA/Nemotron-style embedding endpoints through langchain_nvidia_ai_endpoints when NVIDIA_API_KEY is available. The public functions are intentionally simple: build_rag_index(extracted) save_rag_index(index, path) load_rag_index(path, embedder_backend="local") No LLM generation happens here. qa.py handles answer synthesis. """ from __future__ import annotations import json import pickle import re from dataclasses import dataclass, asdict from pathlib import Path from typing import Any, Dict, Iterable, List, Literal, Optional import numpy as np try: import faiss # type: ignore except ImportError as exc: # pragma: no cover raise ImportError( "faiss-cpu is required for Paper2Lab RAG. Install with: pip install faiss-cpu" ) from exc EmbedderBackend = Literal["local", "nvidia"] @dataclass class RagChunk: chunk_id: str text: str source_type: str # section | caption | table | metadata title: str role: str = "other" page_start: Optional[int] = None page_end: Optional[int] = None label: Optional[str] = None score: Optional[float] = None def to_evidence(self) -> Dict[str, Any]: return { "chunk_id": self.chunk_id, "source_type": self.source_type, "title": self.title, "role": self.role, "page_start": self.page_start, "page_end": self.page_end, "label": self.label, "score": self.score, "text": self.text, } @dataclass class RagIndex: index: Any chunks: List[RagChunk] embedder_backend: EmbedderBackend embedder_model: str normalize_embeddings: bool = True # --------------------------------------------------------------------------- # Text utilities # --------------------------------------------------------------------------- def _clean(text: str) -> str: text = text or "" text = text.replace("\x00", " ").replace("\u00a0", " ") text = re.sub(r"\s+", " ", text) text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text) return text.strip(" .;:\n\t") def _bad_chunk(text: str) -> bool: low = text.lower() bad = [ "corresponding author", "how to cite", "access this article online", "copyright", "all rights reserved", "gmail.com", "@", "provided proper attribution", "permission to reproduce", ] if any(x in low for x in bad): return True if len(text.split()) < 8: return True if text.count("|") >= 2: return True return False def _split_into_windows(text: str, max_words: int = 170, overlap_words: int = 35) -> List[str]: """Chunk text by word windows. Simple and robust for noisy PDF text.""" text = _clean(text) if not text: return [] words = text.split() if len(words) <= max_words: return [] if _bad_chunk(text) else [text] chunks: List[str] = [] start = 0 while start < len(words): end = min(len(words), start + max_words) chunk = _clean(" ".join(words[start:end])) if chunk and not _bad_chunk(chunk): chunks.append(chunk) if end == len(words): break start = max(0, end - overlap_words) return chunks def _table_to_text(table: Dict[str, Any]) -> str: data = table.get("data") caption = _clean(table.get("caption") or "") if not isinstance(data, list): return caption rows: List[str] = [] for row in data[:12]: if isinstance(row, list): cells = [_clean(str(c)) for c in row if c is not None and _clean(str(c))] if cells: rows.append(" | ".join(cells[:8])) body = "\n".join(rows) return _clean(f"{caption}\n{body}") # --------------------------------------------------------------------------- # Chunk extraction # --------------------------------------------------------------------------- def build_chunks(extracted: Dict[str, Any], include_tables: bool = True, include_captions: bool = True) -> List[RagChunk]: chunks: List[RagChunk] = [] counter = 0 blocked_roles = {"references", "appendix", "boilerplate"} blocked_titles = {"front matter", "keywords", "table of contents"} for sec_idx, sec in enumerate(extracted.get("sections", []) or []): role = sec.get("role", "other") title = _clean(sec.get("title") or "Untitled section") if role in blocked_roles or title.lower() in blocked_titles: continue text = sec.get("text") or "" for window in _split_into_windows(text): counter += 1 chunks.append( RagChunk( chunk_id=f"section-{sec_idx}-{counter}", text=window, source_type="section", title=title, role=role, page_start=sec.get("page_start"), page_end=sec.get("page_end"), ) ) if include_captions: for cap_idx, cap in enumerate(extracted.get("captions", []) or []): label = _clean(cap.get("label") or f"caption-{cap_idx}") caption = _clean(cap.get("caption") or "") if caption and not _bad_chunk(caption): chunks.append( RagChunk( chunk_id=f"caption-{cap_idx}", text=caption, source_type="caption", title=label, role="caption", page_start=cap.get("page_number"), page_end=cap.get("page_number"), label=label, ) ) if include_tables: for table_idx, table in enumerate(extracted.get("tables", []) or []): text = _table_to_text(table) if text and not _bad_chunk(text): label = f"Table {table_idx + 1}" chunks.append( RagChunk( chunk_id=f"table-{table_idx}", text=text[:2500], source_type="table", title=label, role="table", page_start=table.get("page_number"), page_end=table.get("page_number"), label=label, ) ) return chunks # --------------------------------------------------------------------------- # Embedding backends # --------------------------------------------------------------------------- class BaseEmbedder: def encode_documents(self, texts: List[str]) -> np.ndarray: raise NotImplementedError def encode_query(self, text: str) -> np.ndarray: raise NotImplementedError class LocalSentenceTransformerEmbedder(BaseEmbedder): def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5") -> None: try: from sentence_transformers import SentenceTransformer except ImportError as exc: # pragma: no cover raise ImportError( "sentence-transformers is required. Install with: pip install sentence-transformers" ) from exc self.model_name = model_name self.model = SentenceTransformer(model_name) def encode_documents(self, texts: List[str]) -> np.ndarray: # BGE-style instruction prefix helps retrieval quality. docs = [f"passage: {t}" for t in texts] arr = self.model.encode(docs, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False) return arr.astype("float32") def encode_query(self, text: str) -> np.ndarray: arr = self.model.encode([f"query: {text}"], convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False) return arr.astype("float32") class NvidiaEndpointEmbedder(BaseEmbedder): """NVIDIA API/NIM embedding backend. Requires: pip install langchain-nvidia-ai-endpoints set NVIDIA_API_KEY=... Default model uses NVIDIA's retrieval QA embedding endpoint. """ def __init__(self, model_name: str = "nvidia/nv-embedqa-e5-v5") -> None: try: from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings except ImportError as exc: # pragma: no cover raise ImportError( "Install NVIDIA embeddings support with: pip install langchain-nvidia-ai-endpoints" ) from exc self.model_name = model_name self.embedder = NVIDIAEmbeddings(model=model_name) def encode_documents(self, texts: List[str]) -> np.ndarray: # NVIDIA embedding endpoints support document embedding methods through LangChain. vecs = self.embedder.embed_documents(texts) arr = np.array(vecs, dtype="float32") return _l2_normalize(arr) def encode_query(self, text: str) -> np.ndarray: vec = self.embedder.embed_query(text) arr = np.array([vec], dtype="float32") return _l2_normalize(arr) def _l2_normalize(arr: np.ndarray) -> np.ndarray: norms = np.linalg.norm(arr, axis=1, keepdims=True) norms[norms == 0] = 1.0 return (arr / norms).astype("float32") def make_embedder(backend: EmbedderBackend = "local", model_name: Optional[str] = None) -> BaseEmbedder: if backend == "local": return LocalSentenceTransformerEmbedder(model_name or "BAAI/bge-small-en-v1.5") if backend == "nvidia": return NvidiaEndpointEmbedder(model_name or "nvidia/nv-embedqa-e5-v5") raise ValueError(f"Unknown embedder backend: {backend}") # --------------------------------------------------------------------------- # Index build/search/save/load # --------------------------------------------------------------------------- def build_rag_index( extracted: Dict[str, Any], embedder_backend: EmbedderBackend = "local", embedder_model: Optional[str] = None, include_tables: bool = True, include_captions: bool = True, ) -> RagIndex: chunks = build_chunks(extracted, include_tables=include_tables, include_captions=include_captions) if not chunks: raise ValueError("No usable chunks found for RAG indexing.") embedder = make_embedder(embedder_backend, embedder_model) texts = [c.text for c in chunks] embeddings = embedder.encode_documents(texts) embeddings = _l2_normalize(embeddings) dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) index.add(embeddings) return RagIndex( index=index, chunks=chunks, embedder_backend=embedder_backend, embedder_model=embedder_model or ("BAAI/bge-small-en-v1.5" if embedder_backend == "local" else "nvidia/nv-embedqa-e5-v5"), normalize_embeddings=True, ) def search_rag_index(rag_index: RagIndex, query: str, top_k: int = 5) -> List[RagChunk]: embedder = make_embedder(rag_index.embedder_backend, rag_index.embedder_model) q = embedder.encode_query(query) q = _l2_normalize(q) scores, ids = rag_index.index.search(q, top_k) hits: List[RagChunk] = [] for score, idx in zip(scores[0].tolist(), ids[0].tolist()): if idx < 0 or idx >= len(rag_index.chunks): continue chunk = rag_index.chunks[idx] hits.append( RagChunk( chunk_id=chunk.chunk_id, text=chunk.text, source_type=chunk.source_type, title=chunk.title, role=chunk.role, page_start=chunk.page_start, page_end=chunk.page_end, label=chunk.label, score=round(float(score), 4), ) ) return hits def save_rag_index(rag_index: RagIndex, path: str | Path) -> None: path = Path(path) path.mkdir(parents=True, exist_ok=True) faiss.write_index(rag_index.index, str(path / "index.faiss")) metadata = { "embedder_backend": rag_index.embedder_backend, "embedder_model": rag_index.embedder_model, "normalize_embeddings": rag_index.normalize_embeddings, "chunks": [asdict(c) for c in rag_index.chunks], } (path / "metadata.json").write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8") def load_rag_index(path: str | Path) -> RagIndex: path = Path(path) index_path = path / "index.faiss" meta_path = path / "metadata.json" if not index_path.exists() or not meta_path.exists(): raise FileNotFoundError(f"Missing FAISS index files in {path}") index = faiss.read_index(str(index_path)) metadata = json.loads(meta_path.read_text(encoding="utf-8")) chunks = [RagChunk(**c) for c in metadata.get("chunks", [])] return RagIndex( index=index, chunks=chunks, embedder_backend=metadata.get("embedder_backend", "local"), embedder_model=metadata.get("embedder_model", "BAAI/bge-small-en-v1.5"), normalize_embeddings=bool(metadata.get("normalize_embeddings", True)), )