PyDA Course
Intermediate Half-scaffolded ⚡ +100 XP +15 XP per step

Chat with Your PDFs

RAGPDFsEmbeddings

The RAG App project chats with a folder of plain-text notes. This project takes the same idea somewhere more useful: a folder of real PDFs, reports, guides, handbooks, papers, with answers that cite exactly which document and which page a fact came from, the way a research assistant would. This assumes Python 101; it also helps a lot to have already built the RAG App project, since this one reuses its whole architecture and only changes how the source documents are read and cited, but it isn’t a strict requirement if you’re comfortable with the concepts.

This is optional and ungraded. See Real-World Projects for the full, growing list.

🎯 What you’ll do

  1. Extract text from a folder of PDFs, page by page, and split it into small chunks, keeping each chunk’s source filename and page number attached.
  2. Turn each chunk into a vector, entirely locally, with no API key and no cost, using sentence-transformers.
  3. Retrieve the chunks most relevant to a question across all the PDFs at once, then ask a free-tier LLM to answer using only that context, with a (source, page N) citation required for every fact.
  4. Wrap it all in a small interactive loop so you can keep asking questions without re-running a script each time.

Where to run this

Locally with uv is the path this lesson’s steps follow, and the recommended one, it’s real Python running on your own machine, the same “graduate to real Python” move as every other project in this section. The Setup section below walks through installing it.

GitHub Codespaces is a zero-setup alternative if you’d rather not install anything locally yet: open the whole course repo in a free Codespace (Node, Python, and uv are already installed, per the repo’s .devcontainer/devcontainer.json) and run the exact same uv commands from a terminal in your browser tab.

Google Colab, Kaggle Notebooks, or Binder also work, since this project needs no GPU, a real, runnable notebook version of this project’s pipeline (the same PDF chunking, local embedding, and cited-answer generation as the steps below) lives at examples/chat-with-pdfs/notebook.ipynb. Click a badge to launch it directly, no local install at all:

Open In Colab Open In Kaggle Binder

Be honest with yourself about the tradeoff, though: this is a lower-fidelity way to experience the project than a real local uv project, no separate files, no real project structure, just cells in a notebook. Treat it as a quick way to experiment, not the primary path.

Setup

Install uv

uv is a single tool that replaces the usual “install Python, then install pip, then install a virtual environment tool, then install packages” chain, it can install and manage Python versions itself, alongside your project’s dependencies.

macOS / Linux (terminal):

curl -LsSf https://astral.sh/uv/install.sh | sh

Windows (PowerShell):

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Close and reopen your terminal, then confirm it installed:

uv --version

Then set up a project:

uv init chat-with-pdfs
cd chat-with-pdfs
uv add pypdf sentence-transformers numpy openai python-dotenv

pypdf reads text out of PDF files. sentence-transformers is the library that turns text into vectors locally, on your own CPU, no API call, no key. numpy does the actual math for comparing vectors. python-dotenv lets you keep your LLM API key in a local .env file.

Get a free LLM API key

Generation (the last part of Step 3) needs a free-tier LLM API, extraction, chunking, embedding, and retrieval are all fully local and need no key at all, but it’s simplest to get this set up now, before you start building, rather than pausing partway through.

Pick whichever provider you like, none of them require a credit card at the time of writing, and this course doesn’t favor one over another.

ProviderWhere to get a keyWhy you might pick it
GitHub Models (suggested default)github.com/settings/tokens, a personal access token with the models: read scopeNo separate signup, you already have a GitHub account. More generous free-tier limits than Gemini’s.
GeminiGoogle AI StudioThe most commonly referenced option.
Groqconsole.groq.com/keysFast inference, generous free tier, no card.
Mistralconsole.mistral.ai/api-keysOne of the more generous permanent free quotas.
Cerebrascloud.cerebras.aiHigh daily token volume, no card.
OpenRouteropenrouter.ai/keysOne API, many free models, good for comparing providers.

Whichever you pick, the process is the same:

  1. Sign in and generate an API key on that provider’s site.
  2. Never paste this key directly into code or commit it to a repository. Put it in a .env file instead (already gitignored):
# .env
GITHUB_TOKEN=your-key-here

python-dotenv (installed above) reads this file into os.environ automatically, the same pattern used in the RAG App project and the AI Agent project if you’ve done either of those, GitHub Models happens to expose an OpenAI-compatible API, so the plain openai client library works for it without any extra package:

uv add openai

If you picked a different provider, swap in that provider’s own client when you get to the generation step below (see the tip there).

Get some PDFs

Put a handful of real PDFs, reports, guides, papers, anything with actual text in it (not scanned images), into a pdfs/ folder inside your project. If you don’t have any handy, copy the three short sample PDFs from examples/chat-with-pdfs/pdfs/, or generate your own with the example’s generate_sample_pdfs.py script.

Step 1: Load and chunk your PDFs

pypdf extracts text from a PDF one page at a time, which is exactly the granularity this project needs, it’s what makes it possible to say which page an answer came from later. As with the RAG App project, a whole page is usually still too big and too unfocused to embed well, so each page gets split into smaller chunks, but unlike that project, every chunk here must also remember which file and which page it came from. Two sub-steps: the loader, then a run to check what it produced.

1.1 Write the PDF loader

👟 Starter hint: Create load_pdfs.py: for every .pdf in pdfs/, extract each page’s text, split it into paragraphs, greedily merge short paragraphs up to TARGET_CHUNK_SIZE, and attach source (filename) and page (1-indexed) to every chunk:

# load_pdfs.py
"""Loads every PDF in pdfs/, extracts text page by page, and splits each
page into small chunks -- keeping the source filename and page number
attached to every chunk, so later answers can cite exactly where a fact
came from.

Run with: uv run python load_pdfs.py

This only prints a summary -- build_index.py (Step 2) imports load_chunks()
from this file and does the actual embedding.
"""

from pathlib import Path

from pypdf import PdfReader

PDFS_DIR = Path("pdfs")
TARGET_CHUNK_SIZE = 500  # characters -- small enough to stay focused,
                         # large enough to hold a full thought

def split_into_paragraphs(text: str) -> list[str]:
    """Splits on blank lines, dropping empty paragraphs. Falls back to
    splitting on single newlines if a page has no blank-line breaks at
    all, which is common in PDFs extracted from single-column layouts."""
    paragraphs = [p.strip() for p in text.split("\n\n")]
    paragraphs = [p for p in paragraphs if p]
    if len(paragraphs) <= 1:
        paragraphs = [p.strip() for p in text.split("\n")]
        paragraphs = [p for p in paragraphs if p]
    return paragraphs

def merge_short_paragraphs(paragraphs: list[str], target_size: int) -> list[str]:
    """Greedily merges consecutive short paragraphs up to target_size
    characters, so a chunk isn't just one short line with barely any
    context in it."""
    chunks = []
    current = ""
    for paragraph in paragraphs:
        if current and len(current) + len(paragraph) > target_size:
            chunks.append(current)
            current = paragraph
        else:
            current = f"{current}\n\n{paragraph}" if current else paragraph
    if current:
        chunks.append(current)
    return chunks

def load_chunks() -> list[dict]:
    """Returns a list of {"text", "source", "page"} dicts, one per chunk,
    across every PDF in PDFS_DIR. `page` is 1-indexed, matching what a
    human reading the PDF would call "page N" -- pypdf's own page indices
    are 0-based, so every page number here has +1 applied."""
    chunks = []
    for path in sorted(PDFS_DIR.glob("*.pdf")):
        reader = PdfReader(str(path))
        for page_index, page in enumerate(reader.pages):
            text = page.extract_text() or ""
            paragraphs = split_into_paragraphs(text)
            for chunk_text in merge_short_paragraphs(paragraphs, TARGET_CHUNK_SIZE):
                chunks.append({
                    "text": chunk_text,
                    "source": path.name,
                    "page": page_index + 1,
                })
    return chunks

if __name__ == "__main__":
    chunks = load_chunks()
    print(f"Loaded {len(chunks)} chunks from {PDFS_DIR}/")
    for chunk in chunks[:3]:
        preview = chunk["text"][:80].replace("\n", " ")
        print(f"  [{chunk['source']} p{chunk['page']}] {preview}...")

🎯 Expected output: import load_pdfs succeeds, and load_chunks() returns a list of {"text", "source", "page"} dicts for the PDFs actually in pdfs/, one list across all of them, not per-file.

🩹 If it’s off: A FileNotFoundError means PDFS_DIR doesn’t exist or you’re running from a different folder than the project root. If load_chunks() returns [], every PDF in the folder is scanned/image-only (see “Common pitfalls”), extract_text() returns an empty string for those, so the loop produces zero chunks silently.

1.2 Run it and confirm sources and pages

👟 Starter hint: Run the script and spot-check that the printed metadata matches what opening each PDF yourself would show:

uv run python load_pdfs.py

🎯 Expected output: The run prints Loaded N chunks from pdfs/ with a nonzero N, and three previews each carrying a [filename pN] prefix plus a fragment of real text from your PDFs.

🩹 If it’s off: A page number that’s consistently one less than expected means the +1 (converting pypdf’s 0-based index to a human 1-indexed page) got dropped in load_chunks. Garbled or empty previews mean the source PDF has no clean embedded text, that’s a data problem, not a chunking bug; swap in a text-based PDF before going further.

Multiple documents, one pipeline

Nothing downstream of load_chunks() needs to know or care how many PDFs there are, or which one a chunk came from, every chunk carries its own source and page, so retrieval naturally searches across all your PDFs at once, and the eventual answer can mix facts from several different documents in one response, each correctly attributed.

1.3 Verify loading and chunking

✅ Checklist

  • uv run python load_pdfs.py runs without errors and prints a nonzero chunk count.
  • ✅ The printed previews look like real fragments of your PDFs’ text, not empty strings or garbled characters.
  • ✅ Each printed chunk shows both a filename and a page number that match what you’d see opening the PDF yourself.

🤔 Socratic Question(s)

  • Why extract text per page rather than reading the whole PDF into one big string and chunking that? What information would you lose?
  • A scanned PDF (a photo of a paper document, with no real embedded text) would make page.extract_text() return an empty string for every page. How would you notice this had happened, and what would you need to add to handle it (hint: look up “OCR”)?

Step 2: Embed your chunks locally

This step is identical in spirit to the RAG App project’s embedding step, the same model, the same reasoning, just embedding PDF-derived chunks instead of notes chunks. all-MiniLM-L6-v2 maps each chunk to a point in 384-dimensional space, trained so that chunks with similar meaning end up close together. It’s small (about 80MB), runs entirely on your CPU in about a second per chunk on a typical laptop, needs no API key, and costs nothing. Two sub-steps: the index builder, then a run to confirm the saved files.

2.1 Write the index builder

👟 Starter hint: Create build_index.py that loads the chunks, embeds their text with SentenceTransformer, and saves both the vectors (index.npy) and the chunk metadata (chunks.json) so Step 3’s retrieval never re-embeds anything at query time:

# build_index.py
"""Embeds every chunk from load_pdfs.py and saves the vectors + text
(including source filename and page number) locally, so retrieve() (Step 3)
doesn't need to re-embed anything at query time.

Run with: uv run python build_index.py
Re-run this any time you add, remove, or edit files in pdfs/ -- the saved
index doesn't update itself.
"""

import json

import numpy as np
from sentence_transformers import SentenceTransformer

from load_pdfs import load_chunks

MODEL_NAME = "all-MiniLM-L6-v2"
INDEX_PATH = "index.npy"
CHUNKS_PATH = "chunks.json"

def main() -> None:
    chunks = load_chunks()
    if not chunks:
        print("No chunks found -- add some .pdf files to pdfs/ first.")
        return

    print(f"Embedding {len(chunks)} chunks with {MODEL_NAME}...")
    model = SentenceTransformer(MODEL_NAME)
    texts = [chunk["text"] for chunk in chunks]
    embeddings = model.encode(texts, normalize_embeddings=True)

    np.save(INDEX_PATH, embeddings)
    with open(CHUNKS_PATH, "w", encoding="utf-8") as f:
        json.dump(chunks, f, ensure_ascii=False, indent=2)

    print(f"Saved {embeddings.shape[0]} vectors ({embeddings.shape[1]}-dim) to {INDEX_PATH}")
    print(f"Saved chunk text/metadata (source + page) to {CHUNKS_PATH}")

if __name__ == "__main__":
    main()

🎯 Expected output: SentenceTransformer(MODEL_NAME) loads (the first run downloads the ~80MB model, needs internet and takes a moment), and encode plus np.save/json.dump complete without errors when main() runs.

🩹 If it’s off: If it prints No chunks found -- add some .pdf files to pdfs/ first., the loader found nothing, check Step 1’s output before blaming Step 2, since indexing an empty corpus would otherwise “succeed” silently. A hung-looking first run is usually the model downloading, not a freeze.

2.2 Run it and confirm the saved files

👟 Starter hint: Run the builder, then open chunks.json to confirm the metadata survived the trip to disk:

uv run python build_index.py

Just like the RAG App project, this deliberately avoids a vector database, for a personal folder of PDFs (dozens to low hundreds of documents, not millions), a plain NumPy array is simpler, has no extra service to install or run, and is fully transparent. normalize_embeddings=True scales every vector to length 1, which is what makes Step 3’s cosine similarity reduce to a single dot product.

🎯 Expected output: index.npy and chunks.json now exist in the project folder, and every entry in chunks.json has exactly text, source, and page fields whose values match what Step 1 printed.

🩹 If it’s off: If an entry in chunks.json is missing source or page, the loader returned dicts without those keys, fix load_chunks() rather than patching the output. And a stale index is a silent trap: re-run build_index.py any time you add, remove, or edit PDFs, because the saved vectors don’t update themselves.

2.3 Verify embedding

✅ Checklist

  • uv run python build_index.py completed without errors.
  • ✅ An index.npy file and a chunks.json file now exist in your project folder.
  • ✅ Opening chunks.json, each entry has a text, source, and page field.

🤔 Socratic Question(s)

  • If two different PDFs happen to contain nearly identical sentences (say, both quote the same regulation), what would you expect their embedding vectors to look like relative to each other?
  • Why re-embed the chunks here but not the PDFs themselves? What would embedding a whole PDF as a single vector lose, compared to embedding each of its chunks separately?

Step 3: Retrieve and generate a cited answer

Retrieval works exactly like the RAG App project, embed the question, rank every chunk by cosine similarity, take the top few, except now the ranking runs across every chunk from every PDF at once, so the most relevant result for a question might come from any of your documents. Three sub-steps: retrieve, generate with citations, then verify.

3.1 Retrieve the most relevant chunks

👟 Starter hint: Write retrieve.py, load the saved vectors and chunk data, embed the question, and rank every chunk by dot-product similarity (the chunks were normalized in Step 2, so that dot product is the cosine similarity). Return the top chunks with their scores, sources, and pages:

# retrieve.py
"""Given a question, finds the PDF chunks most relevant to it, across every
document in pdfs/ -- each result carries its source filename and page number.

Imported by ask.py -- not meant to be run directly, though the __main__
block below lets you try it standalone.
"""

import json

import numpy as np
from sentence_transformers import SentenceTransformer

MODEL_NAME = "all-MiniLM-L6-v2"
INDEX_PATH = "index.npy"
CHUNKS_PATH = "chunks.json"

_model = None  # loaded lazily so importing this module doesn't load the model

def get_model() -> SentenceTransformer:
    global _model
    if _model is None:
        _model = SentenceTransformer(MODEL_NAME)
    return _model

def retrieve(question: str, top_k: int = 4) -> list[dict]:
    """Returns the top_k chunks most similar to `question`, each with its
    similarity score, source document, and page number, ranked highest
    first -- possibly drawn from several different PDFs at once."""
    embeddings = np.load(INDEX_PATH)
    with open(CHUNKS_PATH, encoding="utf-8") as f:
        chunks = json.load(f)

    question_vector = get_model().encode([question], normalize_embeddings=True)[0]

    # Every row of `embeddings` is already unit-length (Step 2), and so is
    # question_vector, so this dot product *is* the cosine similarity.
    similarities = embeddings @ question_vector

    top_indices = np.argsort(similarities)[::-1][:top_k]
    return [
        {**chunks[i], "score": float(similarities[i])}
        for i in top_indices
    ]

if __name__ == "__main__":
    results = retrieve("How many days of paid time off do employees get?")
    for r in results:
        print(f"{r['score']:.3f}  [{r['source']} p{r['page']}]  {r['text'][:80]}...")
uv run python retrieve.py

🎯 Expected output: uv run python retrieve.py prints the top chunks for the sample question with score [source pN] prefixes, and they’re genuinely relevant fragments of your PDFs, drawn from whichever document best matches.

🩹 If it’s off: A FileNotFoundError for index.npy/chunks.json means Step 2’s build never ran for this folder (or you’re running from a different directory), re-run build_index.py. If every score is near zero and results look random, the question vector wasn’t normalized, check normalize_embeddings=True on the encode call, not just on the saved chunks.

3.2 Generate a cited answer

👟 Starter hint: Write ask.py, retrieve the top chunks, then ask a free-tier LLM to answer using only that context, with a citation rule enforced in the prompt.

The prompt is the whole idea of RAG-with-citations in one place: it hands the model the retrieved chunks labeled with their source and page, and requires every fact in the answer to be followed by a (source, page N) citation copied from that label, the model isn’t inventing citations, it’s echoing back the ones already attached to the text it was given.

# ask.py
"""Retrieves relevant chunks across every PDF in pdfs/, then asks a
free-tier LLM to answer using only that context -- citing which document
and page each part of the answer came from.

Run with: uv run python ask.py "your question here"
"""

import os
import sys

from dotenv import load_dotenv
from openai import OpenAI

from retrieve import retrieve

load_dotenv()

PROMPT_TEMPLATE = """Answer the question using ONLY the context below. If the
context doesn't contain the answer, say so -- do not make something up.

Every fact you use MUST be followed by a citation in the form
(source, page N), taken from the [source, page N] tag on the context chunk
it came from. If your answer draws on more than one chunk, cite each one.

Context:
{context}

Question: {question}

Answer:"""

def build_prompt(question: str, chunks: list[dict]) -> str:
    context = "\n\n".join(
        f"[{c['source']}, page {c['page']}] {c['text']}" for c in chunks
    )
    return PROMPT_TEMPLATE.format(context=context, question=question)

def ask(question: str, top_k: int = 4) -> str:
    chunks = retrieve(question, top_k=top_k)
    prompt = build_prompt(question, chunks)

    client = OpenAI(
        api_key=os.environ["GITHUB_TOKEN"],
        base_url="https://models.github.ai/inference",
    )
    response = client.chat.completions.create(
        model="gpt-4o-mini",  # confirm this still has a free tier before running
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content

if __name__ == "__main__":
    question = " ".join(sys.argv[1:]) or "How many days of paid time off do employees get?"
    print(ask(question))
uv run python ask.py "How many days of paid time off do employees get?"

Using a different provider?

Swap the OpenAI(...) block for your provider’s own client, following the same pattern as the RAG App project and the AI Agent project, e.g. Google’s google-genai package for Gemini, or groq’s own client for Groq. Cerebras and OpenRouter are also OpenAI-compatible, so the openai package works for them too, just with a different base_url.

🎯 Expected output: uv run python ask.py "How many days of paid time off do employees get?" prints an answer, not a traceback, and every factual claim in it carries a (source, page N) citation.

🩹 If it’s off: A KeyError: 'GITHUB_TOKEN' means the .env key isn’t being loaded, check load_dotenv() ran before first use, and that the variable name matches the provider table in Setup. A 429 is the provider’s free-tier rate limit, not a bug. If an answer contains no citations, the prompt requirement was likely edited out, or the retrieved chunks themselves had no tags, confirm retrieve() returns entries with source and page (see 3.1’s output) before blaming generation.

3.3 Verify retrieval and generation

✅ Checklist

  • uv run python retrieve.py prints results from your PDFs with plausible-looking source filenames and page numbers.
  • uv run python ask.py "a real question" prints an answer, not a traceback.
  • ✅ Every factual claim in the answer is followed by a (source, page N) citation, and each citation’s page actually contains that fact when you check the PDF.
  • ✅ Asking something your PDFs clearly don’t cover makes the model say so, rather than confidently making something up (including a fake citation).

🤔 Socratic Question(s)

  • The prompt requires a citation for every fact. What do you expect to happen if you remove that requirement, would the model still tend to answer accurately, or does asking for citations actually change how carefully it sticks to the context? Try both and compare.
  • If retrieve() pulls the top chunk from the right page but the wrong PDF (say, two different products both mention “warranty”), would you notice from reading the citation alone? What does that suggest about always checking citations rather than trusting an answer just because it has one?

Step 4: A small interactive loop

Re-running ask.py with a new command-line argument for every question works, but it’s slow to iterate with. Wrap it in a small loop instead, so you can keep chatting with your PDFs in one running session. Two sub-steps: the loop, then verify.

4.1 Build the chat loop

👟 Starter hint: Write chat.py, a while True loop around ask(): prompt for a question, print the cited answer, and break on quit/exit (an empty line keeps you in the loop):

# chat.py
"""A small interactive loop: keep asking questions about the PDFs in pdfs/
until you type "quit" or "exit".

Run with: uv run python chat.py
"""

from ask import ask

def main() -> None:
    print("Chat with your PDFs -- ask a question, or type 'quit' to stop.\n")
    while True:
        question = input("> ").strip()
        if not question:
            continue
        if question.lower() in {"quit", "exit"}:
            break
        answer = ask(question)
        print(f"\n{answer}\n")

if __name__ == "__main__":
    main()
uv run python chat.py

This is the whole app

There’s no server, no framework, no UI toolkit here, a while True loop around ask() is a legitimate chat app. Every “chat with your data” product you’ve seen is this same loop underneath, with a web frontend, streaming responses, and conversation history layered on top. None of those layers change what’s actually happening: retrieve, then generate, then print.

🎯 Expected output: uv run python chat.py starts with a Chat with your PDFs -- ... banner, answers a question with a cited response, and returns to a fresh > prompt; typing quit ends it cleanly.

🩹 If it’s off: If the loop exits on a normal question, check question.lower() in {"quit", "exit"}, the .strip()/.lower() normalization is what stops a capital or trailing space from being treated as a quit. If the first answer takes a while, that’s the one-time model-load + file-load cost described in the Socratic note below, not a freeze, later questions in the same session are the payoff.

4.2 Verify the interactive loop

✅ Checklist

  • uv run python chat.py starts, accepts a question, prints a cited answer, and loops back to a new > prompt.
  • ✅ Typing quit or exit ends the loop cleanly.
  • ✅ You can ask two different questions about two different PDFs in the same session without restarting anything.

🤔 Socratic Question(s)

  • Each call to ask() re-loads index.npy and chunks.json from disk and reloads the embedding model. For a single question that’s fine, what would you change in chat.py and retrieve.py if you wanted the loop to feel snappier after the first question?
  • This loop has no memory of earlier questions, each ask() call is independent. What would break if you asked a follow-up like “what about the second one?” right after another question? What would you need to add to support that?

⚠️ Common pitfalls

  • Scanned, image-only PDFs return no text. pypdf’s extract_text() only reads text that’s actually embedded in the PDF, a PDF made from photographed or scanned pages has no embedded text at all, so load_pdfs.py will silently produce zero chunks for that file. If a document you expect to see answers from never shows up, check whether you can select/copy its text in a normal PDF viewer first; if you can’t, it needs OCR (outside this project’s scope) before this pipeline can use it.
  • Chunks too large or too small. Same tradeoff as the RAG App project: too large and retrieval gets blurry, too small and a chunk loses the surrounding context the model needs to answer well. If answers feel off, try a different TARGET_CHUNK_SIZE and re-run build_index.py.
  • Forgetting to rebuild the index after changing pdfs/. build_index.py only runs when you run it, add, remove, or edit a PDF, and retrieve() won’t reflect the change until you re-run uv run python build_index.py.
  • Trusting a citation without checking it. The prompt asks the model to cite only what’s actually in the retrieved context, and in practice it does this reliably, but nothing here mathematically guarantees it. Spot-check a few citations against the actual PDF pages, especially before relying on this for anything that matters.
  • Rate limits on the free LLM tier. Extraction, chunking, embedding, and retrieval are all local and unlimited; only ask()’s LLM call counts against your provider’s free-tier quota. A 429 error there is the provider telling you to slow down, not a bug, see the AI Agent project for the same pattern and a retry approach you can copy.

What you just built

A multi-document RAG pipeline with citations: page-aware PDF extraction and chunking, local embedding, in-memory similarity search across an arbitrary number of documents, and a final generation step that’s required to point back at exactly where each fact came from, the same shape of system behind real “chat with your documents” products, minus the vector database and paid API standing in for a free one and a flat NumPy array.

Where to go from here

  • Once your PDF folder outgrows what comfortably fits in memory (tens of thousands of chunks), look at a real vector database like ChromaDB, same nearest-neighbor search as retrieve() above, indexed for speed at a much larger scale, with metadata filtering (e.g. “only search PDFs from 2024”) this flat-file version doesn’t have.
  • Add a source filter: let a question restrict retrieval to just one PDF (retrieve(question, source="warranty.pdf")), useful once your folder holds documents about very different topics that shouldn’t be blended together.
  • Try OCR with a library like pytesseract for scanned PDFs, so image-only documents can join the pipeline instead of silently contributing zero chunks.
  • Extend citations to include a snippet, not just a page number, return the exact sentence the fact came from alongside (source, page N), so you can verify an answer without opening the PDF yourself.

Share your project with the class

Built something you’re proud of? examples/student-projects/ is a gallery of projects other students have submitted, and its README has a full, beginner-friendly walkthrough for adding yours via a pull request, even if you’ve never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.

Welcome to writing Python outside the browser. 🎓

Finish every step, then mark this project complete to claim its XP.