Download src/ingest.py from kalspi/findocrag: direct link, hf CLI and curl.
- Browser
- Download file 5.02 kB
-
https://huggingface.co/spaces/kalspi/findocrag/resolve/main/src/ingest.py
- Command line
-
hf download hf://spaces/kalspi/findocrag/src/ingest.py
-
curl -L -o ingest.py https://huggingface.co/spaces/kalspi/findocrag/resolve/main/src/ingest.py
5.02 kB
| """Ingestion pipeline: parse PDFs -> chunk -> embed -> build & persist FAISS index. | |
| Run with: python -m src.ingest | |
| Chunks are built within page boundaries so every chunk maps to exactly one | |
| page. This keeps citations exact and makes the eval's citation-validity | |
| check programmatic. Pages longer than CHUNK_SIZE_TOKENS are split with a | |
| sliding window of CHUNK_OVERLAP_TOKENS overlap. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from dataclasses import asdict, dataclass | |
| from pathlib import Path | |
| import faiss | |
| import numpy as np | |
| from pypdf import PdfReader | |
| import config | |
| # Pages with less extracted text than this are retried with pdfplumber, | |
| # then skipped if still empty (covers image-only or decorative pages). | |
| MIN_PAGE_CHARS = 25 | |
| class Chunk: | |
| """One retrievable unit of text, tied to a single page of one report.""" | |
| id: int | |
| company: str | |
| doc: str | |
| page: int # 1-based page number, as printed by PDF readers | |
| chunk_idx: int # position of this chunk within its page | |
| text: str | |
| def _extract_pages(pdf_path: Path) -> list[tuple[int, str]]: | |
| """Extract (page_number, text) for every page; pdfplumber as fallback.""" | |
| reader = PdfReader(pdf_path) | |
| pages: list[tuple[int, str]] = [] | |
| fallback_pages: list[int] = [] | |
| for i, page in enumerate(reader.pages): | |
| text = (page.extract_text() or "").strip() | |
| if len(text) < MIN_PAGE_CHARS: | |
| fallback_pages.append(i) | |
| pages.append((i + 1, text)) | |
| if fallback_pages: | |
| import pdfplumber | |
| with pdfplumber.open(pdf_path) as pdf: | |
| for i in fallback_pages: | |
| text = (pdf.pages[i].extract_text() or "").strip() | |
| if len(text) >= MIN_PAGE_CHARS: | |
| pages[i] = (i + 1, text) | |
| return [(n, t) for n, t in pages if len(t) >= MIN_PAGE_CHARS] | |
| def chunk_text(text: str, tokenizer, chunk_size: int, overlap: int) -> list[str]: | |
| """Split text into chunks of ~chunk_size tokens with ~overlap token overlap. | |
| Token boundaries come from the embedding model's tokenizer so chunk sizes | |
| match what the embedder actually sees. Chunks are sliced from the original | |
| string via offset mappings (never decoded from token ids), preserving the | |
| source casing and number formatting exactly. | |
| """ | |
| encoding = tokenizer(text, add_special_tokens=False, return_offsets_mapping=True) | |
| offsets: list[tuple[int, int]] = encoding["offset_mapping"] | |
| if len(offsets) <= chunk_size: | |
| return [text] | |
| chunks: list[str] = [] | |
| step = chunk_size - overlap | |
| start = 0 | |
| while start < len(offsets): | |
| window = offsets[start : start + chunk_size] | |
| chunks.append(text[window[0][0] : window[-1][1]]) | |
| if start + chunk_size >= len(offsets): | |
| break | |
| start += step | |
| return chunks | |
| def build_chunks(tokenizer) -> list[Chunk]: | |
| """Parse every configured report into page-bound chunks.""" | |
| chunks: list[Chunk] = [] | |
| for stem, company in config.COMPANIES.items(): | |
| pdf_path = config.REPORTS_DIR / f"{stem}.pdf" | |
| if not pdf_path.exists(): | |
| raise FileNotFoundError(f"Missing report: {pdf_path}") | |
| pages = _extract_pages(pdf_path) | |
| n_before = len(chunks) | |
| for page_no, text in pages: | |
| for idx, piece in enumerate( | |
| chunk_text(text, tokenizer, config.CHUNK_SIZE_TOKENS, config.CHUNK_OVERLAP_TOKENS) | |
| ): | |
| chunks.append( | |
| Chunk( | |
| id=len(chunks), | |
| company=company, | |
| doc=stem, | |
| page=page_no, | |
| chunk_idx=idx, | |
| text=piece, | |
| ) | |
| ) | |
| print(f"{company}: {len(pages)} pages with text -> {len(chunks) - n_before} chunks") | |
| return chunks | |
| def main() -> None: | |
| from sentence_transformers import SentenceTransformer | |
| print(f"Loading embedding model {config.EMBEDDING_MODEL} ...") | |
| model = SentenceTransformer(config.EMBEDDING_MODEL) | |
| tokenizer = model.tokenizer | |
| print("Parsing and chunking reports ...") | |
| chunks = build_chunks(tokenizer) | |
| print(f"Total: {len(chunks)} chunks") | |
| print("Embedding chunks (normalized, cosine via inner product) ...") | |
| embeddings = model.encode( | |
| [c.text for c in chunks], | |
| batch_size=64, | |
| show_progress_bar=True, | |
| normalize_embeddings=True, | |
| ).astype(np.float32) | |
| index = faiss.IndexFlatIP(embeddings.shape[1]) | |
| index.add(embeddings) | |
| config.INDEX_DIR.mkdir(parents=True, exist_ok=True) | |
| faiss.write_index(index, str(config.INDEX_DIR / "index.faiss")) | |
| with open(config.INDEX_DIR / "chunks.jsonl", "w", encoding="utf-8") as f: | |
| for c in chunks: | |
| f.write(json.dumps(asdict(c), ensure_ascii=False) + "\n") | |
| print(f"Wrote {config.INDEX_DIR / 'index.faiss'} ({index.ntotal} vectors) " | |
| f"and chunks.jsonl") | |
| if __name__ == "__main__": | |
| main() | |