File size: 2,925 Bytes
b1ec669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
"""Step 3 — Ingestion (the "retrieval" half of RAG).

Pipeline:
    1. Load every PDF in data/
    2. Split the text into overlapping chunks
    3. Embed each chunk into a vector (sentence-transformers)
    4. Store the vectors in a persistent ChromaDB database

Run once after adding/changing documents:
    python -m src.ingest
"""
from __future__ import annotations

import shutil

from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma

from src.config import (
    DATA_DIR, CHROMA_DIR, CHUNK_SIZE, CHUNK_OVERLAP,
    EMBED_MODEL, COLLECTION_NAME,
)


def load_documents() -> list:
    """Load all PDFs from data/ into LangChain Document objects (one per page)."""
    pdf_paths = sorted(DATA_DIR.glob("*.pdf"))
    if not pdf_paths:
        raise FileNotFoundError(
            f"No PDFs found in {DATA_DIR}. "
            f"Run `python -m src.download_sources` first, or add your own PDFs."
        )
    docs = []
    for path in pdf_paths:
        print(f"  loading {path.name} ...")
        pages = PyPDFLoader(str(path)).load()
        # Keep a clean source label for citations later.
        for page in pages:
            page.metadata["source"] = path.name
        docs.extend(pages)
    print(f"Loaded {len(docs)} pages from {len(pdf_paths)} PDF(s).")
    return docs


def split_documents(docs: list) -> list:
    """Split pages into overlapping chunks for fine-grained retrieval."""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
        # Try to split on natural boundaries first (paragraphs, then lines).
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    chunks = splitter.split_documents(docs)
    print(f"Split into {len(chunks)} chunks "
          f"(size={CHUNK_SIZE}, overlap={CHUNK_OVERLAP} chars).")
    return chunks


def build_vector_store(chunks: list, reset: bool = True) -> Chroma:
    """Embed chunks and persist them in ChromaDB."""
    if reset and CHROMA_DIR.exists():
        print("Removing previous vector store ...")
        shutil.rmtree(CHROMA_DIR)

    print(f"Loading embedding model '{EMBED_MODEL}' (first run downloads it) ...")
    embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL)

    print("Embedding chunks and writing to ChromaDB ...")
    store = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        collection_name=COLLECTION_NAME,
        persist_directory=str(CHROMA_DIR),
    )
    print(f"Vector store ready at {CHROMA_DIR}")
    return store


def main() -> None:
    print("=== Ingestion ===")
    docs = load_documents()
    chunks = split_documents(docs)
    build_vector_store(chunks)
    print("Done. You can now run the chatbot.")


if __name__ == "__main__":
    main()