| """ |
| ingest.py β Build a FAISS vector index from a folder of PDFs. |
| |
| Usage: |
| python ingest.py [--pdf-dir pdfs] [--index-dir faiss_index] [--chunk-size 800] [--chunk-overlap 100] |
| |
| Environment variables: |
| EMBED_MODEL β Embedding model (default: BAAI/bge-small-en-v1.5) |
| PDF_DIR β Folder containing PDF files (default: pdfs) |
| INDEX_DIR β Where to save the FAISS index (default: faiss_index) |
| """ |
|
|
| import argparse |
| import json |
| import os |
| from pathlib import Path |
|
|
| import fitz |
| from langchain_community.vectorstores import FAISS |
| from langchain_huggingface import HuggingFaceEmbeddings |
| from langchain_text_splitters import RecursiveCharacterTextSplitter |
| from langchain_core.documents import Document |
| from tqdm import tqdm |
|
|
| EMBED_MODEL = os.getenv("EMBED_MODEL", "BAAI/bge-small-en-v1.5") |
| PDF_DIR = Path(os.getenv("PDF_DIR", "pdfs")) |
| INDEX_DIR = Path(os.getenv("INDEX_DIR", "faiss_index")) |
| META_FILE = Path("metadata.json") |
|
|
|
|
| def extract_text(pdf_path: Path) -> str: |
| doc = fitz.open(str(pdf_path)) |
| return "\n".join(page.get_text() for page in doc) |
|
|
|
|
| def load_pdfs(pdf_dir: Path) -> list[Document]: |
| pdfs = sorted(pdf_dir.glob("**/*.pdf")) |
| if not pdfs: |
| raise FileNotFoundError(f"No PDF files found in {pdf_dir}") |
| print(f"Found {len(pdfs)} PDF(s) in {pdf_dir}") |
|
|
| docs = [] |
| metadata_records = [] |
| for pdf_path in tqdm(pdfs, desc="Reading PDFs"): |
| text = extract_text(pdf_path) |
| if not text.strip(): |
| print(f" [WARN] No text extracted from {pdf_path.name}, skipping") |
| continue |
| doc_id = pdf_path.stem |
| docs.append(Document( |
| page_content=text, |
| metadata={"doc_id": doc_id, "filename": pdf_path.name, "source": str(pdf_path)}, |
| )) |
| metadata_records.append({"doc_id": doc_id, "filename": pdf_path.name}) |
|
|
| with open(META_FILE, "w", encoding="utf-8") as f: |
| json.dump(metadata_records, f, ensure_ascii=False, indent=2) |
| print(f"Saved metadata for {len(metadata_records)} documents β {META_FILE}") |
| return docs |
|
|
|
|
| def chunk_documents(docs: list[Document], chunk_size: int, chunk_overlap: int) -> list[Document]: |
| splitter = RecursiveCharacterTextSplitter( |
| chunk_size=chunk_size, |
| chunk_overlap=chunk_overlap, |
| separators=["\n\n", "\n", ". ", " ", ""], |
| ) |
| chunks = splitter.split_documents(docs) |
| print(f"Split into {len(chunks)} chunks (chunk_size={chunk_size}, overlap={chunk_overlap})") |
| return chunks |
|
|
|
|
| def build_index(chunks: list[Document], embeddings: HuggingFaceEmbeddings, index_dir: Path) -> None: |
| print(f"Building FAISS index with {EMBED_MODEL}...") |
| vectorstore = FAISS.from_documents(chunks, embeddings) |
| index_dir.mkdir(parents=True, exist_ok=True) |
| vectorstore.save_local(str(index_dir)) |
| print(f"FAISS index saved β {index_dir}/") |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Ingest PDFs into a FAISS vector index") |
| parser.add_argument("--pdf-dir", type=Path, default=PDF_DIR) |
| parser.add_argument("--index-dir", type=Path, default=INDEX_DIR) |
| parser.add_argument("--chunk-size", type=int, default=1500) |
| parser.add_argument("--chunk-overlap",type=int, default=200) |
| args = parser.parse_args() |
|
|
| print(f"Loading embedding model: {EMBED_MODEL}") |
| embeddings = HuggingFaceEmbeddings( |
| model_name=EMBED_MODEL, |
| model_kwargs={"device": "cpu"}, |
| encode_kwargs={"normalize_embeddings": True}, |
| ) |
|
|
| docs = load_pdfs(args.pdf_dir) |
| chunks = chunk_documents(docs, args.chunk_size, args.chunk_overlap) |
| build_index(chunks, embeddings, args.index_dir) |
| print("\nDone! You can now run: python app.py") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|