DEV Community

MongoDB Guests for MongoDB

Posted on

How to Build an AI PDF Analyzer with MongoDB, RAG, and Ollama

This tutorial was written by Damilola Oladele.


This tutorial shows you how to build a command-line tool that reads a PDF, writes an outline and summary, and answers questions about the document. The language model runs on your local machine through Ollama.

The tool has two halves.

The first half analyzes the document, and it doesn’t use Retrieval-Augmented Generation (RAG). It reads the PDF and pulls the outline from the document’s own table of contents. The first half also reads the title and author from the PDF’s metadata and asks the local model for a section-by-section summary.

The second half answers questions, and this is the RAG pipeline. It splits the document into small overlapping passages called chunks and turns each chunk into a vector embedding. The second half then indexes those vectors in MongoDB Atlas, retrieves the chunks closest in meaning to your question, and asks the model to answer using only the retrieved text. The page and section travel with each chunk, so every answer can show its source.

The finished tool has two commands:

python cli.py analyze sample.pdf
python cli.py ask sample.pdf
Enter fullscreen mode Exit fullscreen mode

You can find all the code samples for this tutorial in the GitHub repository.

Prerequisites

Make sure you have the following before you start:

Add your current IP address to the MongoDB Atlas access list before you run any script. Go to Security > Network Access in the MongoDB Atlas UI and add it. Your scripts won't connect to your MongoDB cluster without this configuration.

Set Up the Project and Connect to MongoDB Atlas

This section prepares your machine, project, and MongoDB Atlas connection.

Start by creating a folder for the project and move into it in your terminal. Every file in this tutorial goes into this folder:

mkdir pdf-doc-analyzer
cd pdf-doc-analyzer
Enter fullscreen mode Exit fullscreen mode

Pull the language model

Ollama runs the language model on your own machine, so no request ever leaves your computer.

Confirm that Ollama is installed by running the following command:

ollama --version
Enter fullscreen mode Exit fullscreen mode

If you use the Ollama desktop application, open it before continuing. The desktop application can run Ollama for you.

If you use Ollama from the command line without the desktop application, start the Ollama server:

ollama serve
Enter fullscreen mode Exit fullscreen mode

Leave that terminal running, then open a second terminal and pull the model:

cd pdf-doc-analyzer
ollama pull llama3.2:3b
Enter fullscreen mode Exit fullscreen mode
The `llama3.2:3b` download is about 2 GB, so pulling the model can take a few minutes.
Enter fullscreen mode Exit fullscreen mode

Once the pull is successful, confirm the model is installed from that second terminal:

ollama list
Enter fullscreen mode Exit fullscreen mode

You should see llama3.2:3b in the list.

Create and activate a virtual environment

Create and activate a virtual environment to keep your project's Python packages separate from the rest of your system:

python -m venv venv
source venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

On Windows, activate the virtual environment with:

venv\Scripts\activate
Enter fullscreen mode Exit fullscreen mode

Install the packages

Create a file named requirements.txt in your pdf-doc-analyzer folder. This file lists every package the tool needs:

pymongo==4.17.0
pymupdf==1.28.2
ollama==0.6.2
sentence-transformers==6.0.0
einops==0.8.2
typer==0.27.1
rich==15.0.0
python-dotenv==1.2.3
Enter fullscreen mode Exit fullscreen mode

The following are the purposes of each package:

  • pymongo is the Python driver for MongoDB.
  • pymupdf reads PDF text, metadata, and bookmarks.
  • ollama calls the local language model.
  • sentence-transformers and einops run the nomic embedding model.
  • typer and rich build the command-line interface and format its output.
  • python-dotenv loads your connection string from a .env file.

Install the packages:

python -m pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Add your MongoDB Atlas connection string to an environment file

Keep your connection string out of your code, so you never commit credentials by accident. Create a file named .env in your pdf-doc-analyzer folder, and add your connection string:

MONGODB_URI="mongodb+srv://<user>:<pass>@<host>/"
Enter fullscreen mode Exit fullscreen mode

Replace <user>, <pass>, and <host> with the values from your MongoDB Atlas connection string. The python-dotenv package loads this value at runtime.

Create the configuration file

Create a file named config.py in your pdf-doc-analyzer folder. This file loads your connection string from .env and defines the constants that every other module imports:

import os
from dotenv import load_dotenv

# Load MONGODB_URI and any other variables from the .env file:
load_dotenv()

MONGODB_URI = os.getenv("MONGODB_URI")

DB_NAME = "doc_analyzer"
DOCUMENTS_COLLECTION = "documents"
CHUNKS_COLLECTION = "chunks"
LLM_MODEL = "llama3.2:3b"
EMBEDDING_MODEL = "nomic-ai/nomic-embed-text-v1"
EMBEDDING_DIMENSIONS = 768
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
VECTOR_INDEX_NAME = "vector_index"
APP_NAME = "devrel-tutorial-python-pdf-rag"
Enter fullscreen mode Exit fullscreen mode

Create the database access file

Create a file named db.py in your pdf-doc-analyzer folder. This file owns every call to MongoDB, so the rest of the code never touches the driver directly.

import datetime
from pymongo import MongoClient
import config


def get_client() -> MongoClient:
    """Open a MongoDB client using the connection string and appName from config."""
    # Connect to MongoDB and identify the application with the configured appName.
    return MongoClient(config.MONGODB_URI, appname=config.APP_NAME)


def get_documents_collection(client: MongoClient):
    """Return the collection that stores one record per analyzed PDF."""
    return client[config.DB_NAME][config.DOCUMENTS_COLLECTION]


def get_chunks_collection(client: MongoClient):
    """Return the collection that stores one record per chunk."""
    return client[config.DB_NAME][config.CHUNKS_COLLECTION]


def save_document(client: MongoClient, record: dict) -> str:
    """Insert or replace the stored record for one PDF, keyed on its filename."""
    # Stamp the record with the time of this write:
    record["updated_at"] = datetime.datetime.now(datetime.timezone.utc)
    # Upsert by filename so re-analysis replaces the existing document record.
    get_documents_collection(client).replace_one(
        {"filename": record["filename"]}, record, upsert=True
    )
    return record["filename"]


def get_document(client: MongoClient, filename: str):
    """Return the saved record for one filename, or None when it isn't stored yet."""
    return get_documents_collection(client).find_one({"filename": filename})


def list_documents(client: MongoClient):
    """Return every saved document record, without the heavy full_text field."""
    return list(get_documents_collection(client).find({}, {"full_text": 0}))
Enter fullscreen mode Exit fullscreen mode

In MongoDB, a collection is the equivalent of a table in a relational database. It's a named group of records, and each record is a document. This tool uses two collections. The documents collection holds one record per analyzed PDF, and the chunks collection holds one record per chunk. The get_documents_collection() and get_chunks_collection() helpers hand back those two collections, so the rest of the code asks for a collection by name rather than repeating the database path.

The save_document() function writes one PDF's record, and it does three things:

  • It stamps the record with an updated_at timestamp.
  • It calls replace_one() with a filter on filename and upsert=True.
  • It returns the filename as the record's key.

The upsert=True flag inserts a new record when no document matches the filename, and it replaces the matching record when one already exists, so a second run on the same file never creates a duplicate.

Keep in mind what replace_one() does. It swaps the entire matching record for the new one rather than merging fields, so any field left out of record disappears after the write.

You'll add two more helpers to this file, save_chunks() and get_chunks(), once chunk storage is needed.

Define the Data Structures

The tool defines its data once as a set of Python data classes, and it stores two record shapes in MongoDB.

The documents collection keeps one record per analyzed PDF. Each record uses the PDF's filename as its identifier. The record shape is as follows:

{
  "filename": "report.pdf",
  "title": "…",
  "author": "…",
  "page_count": 12,
  "full_text": "…",
  "outline": [{"title": "Authentication", "level": 1, "page": 4}, "…"],
  "summary": "…",
  "updated_at": "ISODate"
}
Enter fullscreen mode Exit fullscreen mode

The chunks collection keeps one record per chunk. Each chunk is a small, overlapping slice of the document's text, sized so the language model can read it and so search can match it precisely. The record shape is as follows:

{
  "document_id": "report.pdf",
  "chunk_index": 0,
  "text": "…",
  "page_start": 4,
  "page_end": 5,
  "section": "Authentication",
  "embedding": [0.0, "…"]
}
Enter fullscreen mode Exit fullscreen mode

The embedding field holds the vector for that chunk's text. $vectorSearch, the MongoDB aggregation stage you'll use later, compares your question's vector against these stored vectors and returns the closest matches by meaning. The document_id field reuses the same filename key, so chunks link back to their document, and retrieval can filter to one PDF at a time.

Now, create a file named models.py in your pdf-doc-analyzer folder, and add the following code:

from __future__ import annotations
from dataclasses import dataclass, field


@dataclass
class Line:
    """One line of text and the page it sits on."""
    text: str
    page: int


@dataclass
class Page:
    """One page: its number and the lines found on it."""
    number: int
    # default_factory gives each Page its own list, rather than one list shared
    # across every instance. Without it, all Page objects would share the same
    # list, and appending a line to one page would add it to every page:
    lines: list[Line] = field(default_factory=list)


@dataclass
class OutlineEntry:
    """One heading from the document outline: its title, nesting level, and page."""
    title: str
    level: int                  # 1 = top level, 2 = subsection, and so on
    page: int


@dataclass
class Chunk:
    """One passage of text with its page span, section, and embedding."""
    chunk_index: int
    text: str
    page_start: int
    page_end: int
    section: str | None         # nearest preceding outline heading, or None
    embedding: list[float] | None = None


@dataclass
class Document:
    """The whole analyzed PDF: its text, pages, outline, summary, and metadata."""
    filename: str
    page_count: int
    pages: list[Page] = field(default_factory=list)
    full_text: str = ""
    embedded_toc: list = field(default_factory=list)   # PyMuPDF get_toc() output
    outline: list[OutlineEntry] = field(default_factory=list)
    summary: str = ""
    title: str = ""            # from the PDF's embedded metadata
    author: str = ""           # from the PDF's embedded metadata
Enter fullscreen mode Exit fullscreen mode

The preceding code defines the data structures used throughout the PDF analysis pipeline. A dataclass is a good fit here because it stores related data rather than performing complex behavior. The classes connect in one direction. PDF extraction fills a Document with Page and Line objects. The Generate the Outline and Summary section of this tutorial fills Document.outline with OutlineEntry objects. The Split the Document Into Chunks section produces Chunk objects, which receive embeddings when the code in the Generate Embeddings section runs. A later file, analyzer.py, converts a Document to and from the record stored in MongoDB.

Extract the PDF Text and Metadata

This section reads a PDF and fills in a Document object.

PyMuPDF, imported under the name pymupdf, reads a page as a nested structure. A page holds blocks, and a block holds lines. A line holds spans, where a span is a run of characters that share one font and style. You walk down to the spans to read each line's text.

The extraction pulls three things from the PDF:

  • The full text and the page count, which the summary and the chunk_document() function (a function defined later in the tutorial) both use.
  • The PDF's embedded outline through doc.get_toc(), which becomes the tool's outline.
This call reads the PDF's own bookmarks, not a printed table of contents page. A PDF can have a visible contents page without an embedded outline, and the reverse, so the two don't always match.
Enter fullscreen mode Exit fullscreen mode
  • The title and author through doc.metadata, which make document-level questions answerable later. For example, a reader who asks "who wrote this?" gets an answer from the metadata.

Now create a file named pdf_utils.py in your pdf-doc-analyzer folder, and add the following:

import os

import pymupdf  # PyMuPDF

from models import Document, Page, Line, OutlineEntry


def extract(pdf_path: str) -> Document:
    """Read a PDF file and return a populated Document."""
    # A context manager closes the PDF automatically, even when an error is raised:
    with pymupdf.open(pdf_path) as doc:
        # 1. Things to note: the page count, and all the page text joined together:
        page_count = doc.page_count
        full_text = "\n".join(page.get_text() for page in doc)

        # 2. The PDF's embedded outline (its bookmarks), not a printed contents page.
        #    Each entry is [level, title, page]:
        embedded_toc = doc.get_toc()

        # 3. Read the PDF's embedded metadata, including its declared title and author:
        metadata = doc.metadata or {}

        # 4. Walk every page and collect each line's text, paired with its page number:
        pages: list[Page] = []
        for page_number, page in enumerate(doc, start=1):
            lines = []
            # get_text("dict") returns nested blocks, then lines, then spans:
            for block in page.get_text("dict")["blocks"]:
                for line in block.get("lines", []):
                    text = "".join(span["text"] for span in line["spans"]).strip()
                    if text:  # skip blank lines
                        lines.append(Line(text=text, page=page_number))
            pages.append(Page(number=page_number, lines=lines))

    # 5. Return the filled Document. The outline and summary stay empty until later sections:
    return Document(
        filename=os.path.basename(pdf_path),
        page_count=page_count,
        pages=pages,
        full_text=full_text,
        embedded_toc=embedded_toc,
        title=(metadata.get("title") or "").strip(),
        author=(metadata.get("author") or "").strip(),
    )


# Temporary check: Allows you to run this file directly to confirm the output:
if __name__ == "__main__":
    doc = extract("sample.pdf")
    print("pages:", doc.page_count)
    print("title:", doc.title)
    print("author(s):", doc.author)
    print("outline entries:", len(doc.embedded_toc))
    for level, title, page in doc.embedded_toc[:15]:
        print(f"  {'  ' * (level - 1)}p{page}: {title}")
Enter fullscreen mode Exit fullscreen mode

The extract() function opens the PDF inside a context manager, joins its text, reads the embedded outline and the title and author, collects every line paired with its page, then returns a filled Document. The context manager closes the PDF automatically, including when an error is raised.

The file ends with a temporary block that runs only when you run the file on its own. Place your sample PDF at the root of your pdf-doc-analyzer folder and rename it to sample.pdf, then run:

python pdf_utils.py
Enter fullscreen mode Exit fullscreen mode

You should see the page count, the title, the author, and the first several outline entries. The headings should appear in reading order. The extract() function that precedes it is what the rest of the tool imports.

Generate the Outline and Summary

This section turns the raw PDF into two readable results:

  • a nested outline
  • a section-by-section summary

The outline comes straight from the embedded bookmarks with no AI at all. The summary is the first model call in the tool, and it runs locally through Ollama.

Build the outline from the embedded bookmarks

The PDF's embedded outline is the author's own heading list, so it's the most reliable structure you can get. You can turn it into OutlineEntry objects directly with no model call. Open pdf_utils.py again and add this function after extract():

def outline_from_toc(doc: Document) -> list[OutlineEntry]:
    """Turn the PDF's embedded outline into a list of OutlineEntry objects."""
    return [
        OutlineEntry(title=title, level=level, page=page)
        for level, title, page in doc.embedded_toc
    ]
Enter fullscreen mode Exit fullscreen mode

Each embedded outline entry arrives as [level, title, page], and this function maps it onto the OutlineEntry fields. The outline you build here serves two purposes later:

  • The summary uses it to process each section.
  • The chunk_document() function (a function defined later in the tutorial) uses it to tag each chunk with its section.

Summarize the document section by section

A small local model can't read a whole book in one request, and asking for everything at once gives a thin summary. So the summary reuses the outline. It walks each top-level section, sends that one section's text to the model, and asks for two or three sentences. It then joins the section summaries, so each section gets its own paragraph.

A minimum-length check clears any thin section the outline lists, because a near-empty structural page would waste a model call. When a section exceeds about 9,000 characters, the tool splits it into parts, summarizes each part, and then combines them into a single section summary. This avoids silently losing content and reflects how to handle sections that exceed a model’s per-request limit.

Create a file named llm_utils.py in your pdf-doc-analyzer folder, and add the following:

import ollama

import config
from models import Document


# Skip a section shorter than this. It clears thin pages, the outline still lists:
MIN_SECTION_CHARS = 700
# The most text to send in one model call. Longer sections get split into parts:
MAX_SECTION_CHARS = 9000


def _section_text(doc: Document, start_page: int, end_page: int) -> str:
    """Join the text of every line on pages in the range [start_page, end_page)."""
    parts = []
    for page in doc.pages:
        if start_page <= page.number < end_page:
            parts.extend(line.text for line in page.lines)
    return "\n".join(parts)


def _summarize(text: str, instruction: str) -> str:
    """Send one block of text to the model and return its plain-text reply."""
    response = ollama.chat(
        model=config.LLM_MODEL,
        messages=[
            {"role": "system", "content": "You write clear, concise summaries."},
            {"role": "user", "content": instruction + "\n\n" + text},
        ],
    )
    return response["message"]["content"].strip()


def _summarize_section(title: str, text: str) -> str:
    """Summarize one section, splitting it into parts when it exceeds the per-call limit."""
    # Short enough for one call: summarize the section directly.
    if len(text) <= MAX_SECTION_CHARS:
        return _summarize(
            text,
            f'In two or three sentences, summarize this section titled "{title}":',
        )
    # Too long for one call: split into parts, summarize each part, then combine those
    # summaries. This avoids silently discarding content:
    pieces = [text[i:i + MAX_SECTION_CHARS] for i in range(0, len(text), MAX_SECTION_CHARS)]
    piece_summaries = [
        _summarize(piece, f'Summarize this part of the section titled "{title}":')
        for piece in pieces
    ]
    return _summarize(
        "\n\n".join(piece_summaries),
        f'In two or three sentences, combine these notes into one summary of the section titled "{title}":',
    )


def generate_summary(doc: Document) -> str:
    """Summarize the document one top-level section at a time, then join the parts."""
    tops = [entry for entry in doc.outline if entry.level == 1]

    last_page = doc.page_count + 1
    parts = []

    for i, entry in enumerate(tops):
        start = entry.page
        end = tops[i + 1].page if i + 1 < len(tops) else last_page
        text = _section_text(doc, start, end)

        # Skip sections with too little text to summarize:
        if len(text) < MIN_SECTION_CHARS:
            continue

        paragraph = _summarize_section(entry.title, text)
        parts.append(f"{entry.title}\n{paragraph}")

    return "\n\n".join(parts)


# Temporary check: Allows you to run this file directly to confirm the output:
if __name__ == "__main__":
    import pdf_utils

    doc = pdf_utils.extract("sample.pdf")
    doc.outline = pdf_utils.outline_from_toc(doc)

    print("Outline:")
    for entry in doc.outline[:15]:
        indent = "  " * (entry.level - 1)
        print(f"{indent}p{entry.page}: {entry.title}")

    print("\nSummary:\n" + generate_summary(doc))
Enter fullscreen mode Exit fullscreen mode

A model call reads a list of messages, and each message has a role. The system message sets the model's job, such as "you write clear, concise summaries." The user message carries the actual task, which here is one section's text. The _summarize() helper wraps both into a single call. The _summarize_section() helper summarizes a whole section and splits any section longer than MAX_SECTION_CHARS into parts. It then summarizes each part and combines the results into one summary. The generate_summary() function runs this for every top-level section and joins the results.

The file ends with a temporary block that runs only when you run the file on its own.

Make sure Ollama is running, llama3.2:3b is pulled, and the sample.pdf file from the prerequisites is in the root of your project folder. Then run:

python llm_utils.py
Enter fullscreen mode Exit fullscreen mode

You should see a nested outline, then a summary with one paragraph per section.

The model runs on your machine and makes one call per section, so a document with many sections can take some minutes.
Enter fullscreen mode Exit fullscreen mode

Build the Analyze Command

This section wires the pieces together into the analyze command.

You create two files:

  • analyzer.py holds the logic that extracts, builds the outline, summarizes, and saves a PDF.
  • cli.py turns that logic into a command you type in the terminal.

Create a file named analyzer.py in your pdf-doc-analyzer folder and add the following code:

import os

from pymongo import MongoClient

import db
import llm_utils
import pdf_utils
from models import Document, OutlineEntry


def _record(doc: Document) -> dict:
    """Turn a Document into the dictionary saved in the documents collection."""
    return {
        "filename": doc.filename,
        "page_count": doc.page_count,
        "full_text": doc.full_text,
        "outline": [{"title": e.title, "level": e.level, "page": e.page} for e in doc.outline],
        "summary": doc.summary,
        "title": doc.title,
        "author": doc.author,
    }


def _document(record: dict) -> Document:
    """Rebuild a Document from a saved database record."""
    doc = Document(filename=record["filename"], page_count=record["page_count"])
    doc.full_text = record.get("full_text", "")
    doc.summary = record.get("summary", "")
    doc.title = record.get("title", "")
    doc.author = record.get("author", "")
    doc.outline = [
        OutlineEntry(title=e["title"], level=e["level"], page=e["page"])
        for e in record.get("outline", [])
    ]
    return doc


def analyze(
    client: MongoClient,
    pdf_path: str,
    force: bool = False,
) -> Document:
    """Extract, outline, and summarize a PDF, then save it. Reuse a saved result when present."""
    filename = os.path.basename(pdf_path)

    # Reuse a saved analysis so a second run doesn't call the model again:
    if not force:
        saved = db.get_document(client, filename)
        if saved:
            return _document(saved)

    doc = pdf_utils.extract(pdf_path)
    doc.outline = pdf_utils.outline_from_toc(doc)
    doc.summary = llm_utils.generate_summary(doc)  # reads doc.outline, so set it first
    db.save_document(client, _record(doc))
    return doc
Enter fullscreen mode Exit fullscreen mode

The analyze() function runs the analyze half of the tool in the following order:

  1. extract the PDF
  2. build the outline from the embedded bookmarks
  3. write the summary
  4. save the record

It checks for a saved result first, so the caching behavior is worth explaining:

  1. The first analyze extracts, calls the model for the summary, and then saves the result.
  2. The second analyze finds the saved document and skip the model.
  3. The forced analyze (--force) extracts, calls the model again, and then replaces the saved result.

The cache is stored using the PDF's filename as the identifier. This is a prototype simplification, not a real document identity. A different PDF saved under an existing name could return the earlier analysis, so pass --force when you swap in a different file under an old name.

Two small libraries build the command itself. Typer turns a plain Python function into a terminal command, so analyze(pdf_path) becomes python cli.py analyze sample.pdf. Rich prints readable output, using an indented tree for the outline and a bordered panel for the summary.

Create a file named cli.py in your pdf-doc-analyzer folder, and add the following:

import typer
from rich.console import Console
from rich.panel import Panel
from rich.tree import Tree

import analyzer
import db
from models import Document

app = typer.Typer(help="Analyze a PDF and ask questions about it.")
console = Console()


# Keep the CLI ready for the ask command you'll add later:
@app.callback()
def main():
    """Analyze a PDF and ask questions about it."""


def _render(doc: Document) -> None:
    """Print the outline in a titled panel and the summary in another."""
    tree = Tree(f"[bold]{doc.filename}[/bold]")
    parents = {0: tree}
    for entry in doc.outline:
        parent = parents.get(entry.level - 1, tree)
        node = parent.add(f"p{entry.page}: {entry.title}")
        parents[entry.level] = node
    console.print(Panel(tree, title="Outline"))
    if doc.summary:
        console.print(Panel(doc.summary, title="Summary"))


@app.command()
def analyze(
    pdf_path: str,
    force: bool = typer.Option(
        False,
        help="Re-run even if a saved result exists.",
    ),
):
    """Build the outline and summary for a PDF."""
    client = db.get_client()
    try:
        doc = analyzer.analyze(client, pdf_path, force=force)
        _render(doc)
    finally:
        client.close()


if __name__ == "__main__":
    app()
Enter fullscreen mode Exit fullscreen mode

The empty @app.callback() keeps your command-line interface (CLI) structured for more than one command. It holds the analyze command name in place now, and it stays in place once you add the ask command later.

Make sure Ollama is running, keep sample.pdf in your folder, then run the command:

python cli.py analyze sample.pdf
Enter fullscreen mode Exit fullscreen mode

You should see the outline in a titled panel, the summary in another, and the record in your MongoDB Atlas cluster under the doc_analyzer database. Run the same command again, and it returns the saved result right away.

To run the model again, add --force to the command:

python cli.py analyze sample.pdf --force
Enter fullscreen mode Exit fullscreen mode

Split the Document Into Chunks

This section starts the question-answering half of the tool. It splits the document into chunks, which are small overlapping slices of text. It also tags each chunk with the pages it covers and the section it belongs to. That tagging is what lets every answer show its source.

A model searches better over small passages than over a whole book, and small chunks make precise page and section references possible. The chunk_document() function (defined later in the tutorial) builds each piece from whole lines until it reaches about 1,000 characters. It then carries the trailing lines, up to about 150 characters, into the start of the next piece. Whole lines keep a word from being split across a boundary, and the small overlap repeats the trailing lines so text near a boundary appears in both chunks. A line boundary can still fall in the middle of a sentence, so the overlap, not the line split, is what keeps a boundary-spanning idea readable in the following chunk.

You also record the first and last page each chunk covers, and you look up its section by finding the nearest outline heading at or before the chunk's first page. These four fields, chunk_index, page_start, page_end, and section, travel with each chunk as retrieval and source metadata. The application stores and reads them directly, and the language model never produces them.

Create a file named qa.py in your pdf-doc-analyzer folder, and add the following:

from models import Chunk, Document


def chunk_document(doc: Document, target_chars: int = 1000, overlap: int = 150) -> list[Chunk]:
    """Split the document into overlapping chunks that carry their page span and section."""
    # Every line in reading order, paired with the page it sits on:
    lines = [(page.number, line.text) for page in doc.pages for line in page.lines]

    def section_for(page_number):
        # The title of the nearest outline heading at or before this page:
        current = None
        for entry in doc.outline:
            if entry.page <= page_number:
                current = entry.title
            else:
                break
        return current

    chunks = []
    window = []      # the (page, line) pairs in the current chunk, kept whole
    size = 0         # running character count for the current chunk
    index = 0

    def flush():
        nonlocal index
        text = "\n".join(line for _, line in window).strip()
        if not text:
            return
        # page_start and page_end come from the lines actually in this chunk:
        page_start = window[0][0]
        page_end = window[-1][0]
        chunks.append(Chunk(chunk_index=index, text=text,
                            page_start=page_start, page_end=page_end,
                            section=section_for(page_start)))
        index += 1

    for page_number, text in lines:
        window.append((page_number, text))
        size += len(text) + 1
        if size >= target_chars:
            flush()
            # Carry whole trailing lines into the next chunk, up to the overlap size.
            # Whole lines keep words intact, and the cap stops a long line from
            # repeating across many chunks:
            carry = []
            carried = 0
            for pair in reversed(window):
                if carried + len(pair[1]) + 1 > overlap:
                    break
                carry.insert(0, pair)
                carried += len(pair[1]) + 1
            window = carry
            size = carried
    if window:
        flush()
    return chunks


# Temporary check. Allows you to run this file directly:
if __name__ == "__main__":
    import pdf_utils

    doc = pdf_utils.extract("sample.pdf")
    doc.outline = pdf_utils.outline_from_toc(doc)

    chunks = chunk_document(doc)
    with_section = [c for c in chunks if c.section]
    print(f"chunks: {len(chunks)} ({len(with_section)} carry a section)")

    # Show the first chunk of each section, so you can see the mapping working:
    seen = set()
    for chunk in chunks:
        if chunk.section and chunk.section not in seen:
            seen.add(chunk.section)
            print(f"  p{chunk.page_start}-{chunk.page_end} | {chunk.section}")
Enter fullscreen mode Exit fullscreen mode

You also need a place to store chunks. Open db.py again and add two helpers after the ones you already have. save_chunks() clears the old chunks for a document first, then inserts the new set, so a re-analysis never leaves stale chunks behind. get_chunks() reads them back:

def save_chunks(client: MongoClient, document_id: str, chunks: list) -> None:
    """Replace the stored chunks for one document with a fresh set."""
    # Clear any existing chunks for this document, then insert the new set:
    collection = get_chunks_collection(client)
    collection.delete_many({"document_id": document_id})
    if chunks:
        collection.insert_many(chunks)


def get_chunks(client: MongoClient, document_id: str) -> list:
    """Return every stored chunk for one document."""
    return list(get_chunks_collection(client).find({"document_id": document_id}))
Enter fullscreen mode Exit fullscreen mode

Keep sample.pdf in your folder and run the temporary check:

python qa.py
Enter fullscreen mode Exit fullscreen mode

You should see the chunk count, how many chunks carry a section, and the first chunk of each section. The sections should track the outline as the page numbers climb. A few chunks at the very front of the document show no section, which is correct, because they sit before the first heading.

Generate Embeddings

An embedding is a list of numbers that captures the meaning of a piece of text. Two pieces of text with similar meanings produce vectors that sit close together. This lets the tool match a question to relevant text based on meaning rather than exact words. This section turns each chunk into a vector with the local nomic model.

The nomic model was trained to treat stored text and search queries differently, so it uses two prefixes. The search_document: prefix marks text you store, and the search_query: prefix marks a question you search with. These prefixes are a trained behavior, not formatting, and the results degrade when you mix them.

Create a file named embedding_utils.py in your pdf-doc-analyzer folder, and add the following:

import os
import warnings

# Quiet the noisy startup logs before the model loads. The deprecation notice about
# get_extended_attention_mask comes from the nomic model's own downloaded code, not from
# anything here, so quieting the logs is the practical way to keep the terminal clean:
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
warnings.filterwarnings("ignore")

from sentence_transformers import SentenceTransformer

import config

try:
    from transformers.utils import logging as hf_logging
    hf_logging.set_verbosity_error()
except Exception:
    pass

# Load the model once when this file is first imported:
model = SentenceTransformer(config.EMBEDDING_MODEL, trust_remote_code=True)


def embed_documents(texts: list[str]) -> list[list[float]]:
    """Embed many chunks in one batched call, far faster than one call per chunk."""
    # nomic wants a "search_document:" prefix for the text you store:
    prefixed = [f"search_document: {text}" for text in texts]
    vectors = model.encode(prefixed, batch_size=32, show_progress_bar=False)
    return vectors.tolist()


def embed_query(text: str) -> list[float]:
    """Embed a single question at search time."""
    # nomic wants a "search_query:" prefix for the question you search with:
    return model.encode(f"search_query: {text}").tolist()
Enter fullscreen mode Exit fullscreen mode

The embed_documents() function embeds a whole list of chunks in one batched call, which is faster than embedding them one at a time. The embed_query() function embeds a single question at search time. Both return plain Python lists that are ready to store in MongoDB or pass to $vectorSearch.

`trust_remote_code=True` lets the model run Python code it downloads from Hugging Face on your machine. If that source code is ever compromised, the code runs automatically in your environment. Pin the model version for any non-prototype use, and review the source before you rely on it in production.
Enter fullscreen mode Exit fullscreen mode
The first run downloads the model, about 500 MB, and caches it. Every later run loads it from that cache. You may see a one-time notice about `get_extended_attention_mask` or unauthenticated requests, both of which are harmless.
Enter fullscreen mode Exit fullscreen mode

You'll test embeddings as part of the question-and-answer flow. For a quick check now, an embedding from embed_query("test") has a length of 768, which matches config.EMBEDDING_DIMENSIONS.

Create the Vector Search Index

A vector search index tells MongoDB which field holds the vectors, how many numbers each vector has, and how to measure closeness between them. You have to create it before you can run any meaning-based search. This section builds an index on the chunks.embedding field, plus a filter field so a question can target a single document.

Two details matter:

  • The numDimensions value has to match your embedding model exactly, so it stays at 768 for nomic-embed-text-v1. A mismatch makes the index fail.
  • The filter field on document_id is what lets a question search only the chunks from one PDF.

Create a file named vector_index.py in your pdf-doc-analyzer folder, and add the following code:

import time

from pymongo import MongoClient
from pymongo.operations import SearchIndexModel

import config
import db


def _index_exists(collection) -> bool:
    """True when the vector search index already exists on this collection."""
    return any(index["name"] == config.VECTOR_INDEX_NAME
              for index in collection.list_search_indexes())


def create_vector_index(client: MongoClient):
    """Create the Atlas Vector Search index over the chunks' embeddings, and wait until it's ready."""
    database = client[config.DB_NAME]
    # The collection has to exist before MongoDB Atlas can index it:
    if config.CHUNKS_COLLECTION not in database.list_collection_names():
        database.create_collection(config.CHUNKS_COLLECTION)
    collection = db.get_chunks_collection(client)

    search_index_model = SearchIndexModel(
        definition={
            "fields": [
                {"type": "vector", "path": "embedding",
                 "numDimensions": config.EMBEDDING_DIMENSIONS, "similarity": "cosine"},
                {"type": "filter", "path": "document_id"},
            ]
        },
        name=config.VECTOR_INDEX_NAME,
        type="vectorSearch",
    )
    result = collection.create_search_index(model=search_index_model)

    # The index builds in the background, so wait until MongoDB Atlas reports it queryable:
    predicate = lambda index: index.get("queryable") is True
    while True:
        indices = list(collection.list_search_indexes(result))
        if len(indices) and predicate(indices[0]):
            break
        time.sleep(5)  # check every 5 seconds

    print(result + " is ready for querying.")
    return result


def ensure_vector_index(client: MongoClient):
    """Create the index only when it's missing, so the ask command can call it safely on every run."""
    database = client[config.DB_NAME]
    if config.CHUNKS_COLLECTION not in database.list_collection_names():
        database.create_collection(config.CHUNKS_COLLECTION)
    collection = db.get_chunks_collection(client)
    if _index_exists(collection):
        return config.VECTOR_INDEX_NAME
    return create_vector_index(client)


if __name__ == "__main__":
    client = db.get_client()
    try:
        create_vector_index(client)
    finally:
        client.close()
Enter fullscreen mode Exit fullscreen mode

Two things happen here, and they're separate:

  1. The code creates the index, which is quick.
  2. The code waits because the index becomes searchable only after MongoDB Atlas finishes building it in the background. The polling loop checks every 5 seconds until MongoDB Atlas reports the index as queryable.

The ensure_vector_index() function creates the index only when it's missing. The ask command calls it on startup, so the index gets built automatically the first time you ask a question. You can also run vector_index.py directly to build the index before asking any questions and wait for it to become ready:

python vector_index.py
Enter fullscreen mode Exit fullscreen mode
The index builds in the background and can take up to two minutes. It builds only once. You can run this before any chunks exist, because the code creates the collection first.
Enter fullscreen mode Exit fullscreen mode

Build the Interactive Q&A Command

The ask command starts an interactive question-and-answer session for the sample PDF.

The first question for a PDF runs the one-time preparation, then answers:

  1. Make sure the vector index exists.
  2. Extract, outline, and summarize the PDF if that hasn't happened yet.
  3. Chunk the document, embed every chunk in one batch, and store the chunks.
  4. Wait until at least one of those chunks is searchable in the index.
  5. Embed the question, then retrieve the closest chunks for that one PDF.
  6. Pass the retrieved chunks to the model and generate the answer.
  7. Display the sources from the retrieved chunks.

Every later question skips steps 2 through 4 and reuses the stored chunks and their embeddings. It still embeds the new question in step 5 for searching, so a later question doesn't re-embed the document. The stages stay separate, so a weak answer is easy to trace to the stage that produced it.

Add the answer generation

The model call that writes the answer belongs with the other model calls, so you add it to llm_utils.py. It labels each retrieved chunk with its page and section, then tells the model to answer using only that context. It also puts the document's own title and author at the top of the context.

The prompt keeps the answer grounded. It tells the model to use only the supplied context, to say so plainly when the context falls short, and to keep the comparisons and relationships the context describes. It also tells the model not to invent page numbers, section names, chunk IDs, or source references, because the application builds the source list from the retrieved records. The title and author are metadata in the document rather than retrieved text, so the application places them directly into the answer context. The question still runs through the normal retrieval flow, and the model sees the retrieved chunks too, so the metadata sits alongside them and stays available even when vector search doesn't return the page that names the author.

Open llm_utils.py and add this function before the temporary check block:

def generate_answer(question: str, chunks: list[dict], title: str = "", author: str = "") -> str:
    """Answer a question using the document metadata and the retrieved chunks as context."""
    if not chunks and not (title or author):
        return "I couldn't find anything about that in the document."

    parts = []
    # Put the document's own title and author up front, so metadata questions are answerable:
    if title or author:
        meta = "Document metadata:"
        if title:
            meta += f"\nTitle: {title}"
        if author:
            meta += f"\nAuthor: {author}"
        parts.append(meta)
    # Label each chunk with its page and section so the model can ground its answer:
    for c in chunks:
        parts.append(f'[Page {c["page_start"]}, {c.get("section") or "no section"}]\n{c["text"]}')
    context = "\n\n".join(parts)
    response = ollama.chat(
        model=config.LLM_MODEL,
        messages=[
            {"role": "system", "content":
                "Answer the question using only the context provided. "
                "Do not add facts from outside the context, and do not fill gaps with general knowledge. "
                "When the context doesn't contain enough to answer, say so plainly rather than guessing. "
                "Explain terms and concepts according to how the document uses them, "
                "rather than giving generic definitions. "
                "Keep the comparisons and relationships the context describes. "
                "Do not invent page numbers, section names, chunk IDs, or source references. "
                "The application supplies the sources separately, so write your answer in prose "
                "without citing pages or sections yourself."},
            {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
        ],
    )
    return response["message"]["content"].strip()
Enter fullscreen mode Exit fullscreen mode

Add retrieval and the answer flow

Open qa.py and replace the import line at the top of the file with the following:

import os
import time

from pymongo import MongoClient
from pymongo.errors import OperationFailure

import analyzer
import config
import db
import embedding_utils
import llm_utils
import pdf_utils
from models import Chunk, Document, OutlineEntry
Enter fullscreen mode Exit fullscreen mode

Then add these four functions under chunk_document():

def embed_and_store(client: MongoClient, doc: Document) -> int:
    """Chunk the document, embed all chunks in one batch, and save them. Returns the chunk count."""
    chunks = chunk_document(doc)
    vectors = embedding_utils.embed_documents([chunk.text for chunk in chunks])
    records = []
    for chunk, vector in zip(chunks, vectors):
        records.append({
            "document_id": doc.filename,
            "chunk_index": chunk.chunk_index,
            "text": chunk.text,
            "page_start": chunk.page_start,
            "page_end": chunk.page_end,
            "section": chunk.section,
            "embedding": vector,
        })
    db.save_chunks(client, doc.filename, records)
    return len(records)


def retrieve(client: MongoClient, document_id: str, question: str, k: int = 5) -> list[dict]:
    """Find the k chunks whose meaning is closest to the question."""
    query_vector = embedding_utils.embed_query(question)
    pipeline = [
        {"$vectorSearch": {
            "index": config.VECTOR_INDEX_NAME,
            "path": "embedding",
            "queryVector": query_vector,
            "numCandidates": k * 20,     # start near 20x the limit, then tune
            "limit": k,
            "filter": {"document_id": document_id},
        }},
        {"$project": {"_id": 0, "text": 1, "page_start": 1, "page_end": 1,
                      "section": 1, "chunk_index": 1,
                      "score": {"$meta": "vectorSearchScore"}}},
    ]
    return list(db.get_chunks_collection(client).aggregate(pipeline))


def _wait_until_searchable(client: MongoClient, document_id: str, timeout: int = 90) -> None:
    """Newly inserted chunks take a moment to reach the vector index. Poll until one is searchable."""
    probe = embedding_utils.embed_query("test")
    collection = db.get_chunks_collection(client)
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            hits = list(collection.aggregate([
                {"$vectorSearch": {
                    "index": config.VECTOR_INDEX_NAME,
                    "path": "embedding",
                    "queryVector": probe,
                    "numCandidates": 10,
                    "limit": 1,
                    "filter": {"document_id": document_id},
                }},
            ]))
        except OperationFailure:
            # MongoDB Atlas can report the index as not ready right after the insert.
            # Retry only this expected condition, and let every other error propagate:
            hits = []
        if hits:
            return
        time.sleep(3)  # check every 3 seconds
    # The chunks never became searchable, so fail loudly rather than query an unready index:
    raise TimeoutError(
        f"Chunks for {document_id} were not searchable within {timeout} seconds."
    )


def answer_question(client: MongoClient, pdf_path: str, question: str, k: int = 5) -> dict:
    """Answer a question about a PDF, and return the answer with its sources."""
    filename = os.path.basename(pdf_path)

    # Analyze the PDF the first time we see it. This builds and saves the outline and summary:
    if db.get_document(client, filename) is None:
        analyzer.analyze(client, pdf_path)

    # Build and store chunks the first time we answer for this PDF:
    if not db.get_chunks(client, filename):
        doc = pdf_utils.extract(pdf_path)            # fresh pages for chunking
        record = db.get_document(client, filename)   # reuse the saved outline
        doc.outline = [OutlineEntry(title=e["title"], level=e["level"], page=e["page"])
                       for e in record["outline"]]
        embed_and_store(client, doc)
        _wait_until_searchable(client, filename)     # let the index catch up before we query

    record = db.get_document(client, filename)   # for the document title and author
    chunks = retrieve(client, filename, question, k)
    answer_text = llm_utils.generate_answer(question, chunks,
                                            title=record.get("title", ""),
                                            author=record.get("author", ""))
    sources = [{"chunk_index": c["chunk_index"], "page": c["page_start"], "section": c.get("section")}
               for c in chunks]
    return {"answer": answer_text, "sources": sources}
Enter fullscreen mode Exit fullscreen mode

The retrieve() function embeds the question, then runs $vectorSearch on the stored chunk vectors. The numCandidates value sets how many near neighbors the search considers before it returns the top limit results. A larger value can improve recall and costs some query time. It's recommended to start with at least 20 times the limit for approximate nearest-neighbor search. The code therefore, starts with numCandidates set to 20 times k and you can tune it based on your data and queries. The filter on document_id keeps the search within a single PDF, so answers stay grounded in the document you asked about.

Retrieval and answer generation are separate stages. MongoDB Vector Search selects the chunks and returns them with their stored page and section fields. The application passes those chunks to the model as context, and the model writes the answer from that context. The model doesn't run the search or decide which pages or sections count as sources. The application builds the Sources panel from the retrieved chunk records.

The answer_question() function handles the first-run setup for you. It analyzes the PDF when it's new, and it embeds and stores the chunks the first time you ask about it. Every later question reuses that stored work. A later question still embeds the one you typed, so it can search, and it reuses the stored chunk embeddings rather than re-embedding the document.

The _wait_until_searchable() poll matters. MongoDB Atlas indexes new chunks in the background, so a search that runs a split second after the insert can come back empty, which shows up as a "couldn't find anything" answer on the very first question. The poll sends a throwaway search every 3 seconds until a chunk from this document appears, so the subsequent real search returns results. If a chunk never becomes searchable within the timeout, the poll raises an error rather than letting retrieval run against an unready index. The index creation and the searchability wait are two different operations. ensure_vector_index() builds the index definition and waits for that definition to become queryable, which happens once. _wait_until_searchable() handles the separate, per-first-question delay while MongoDB Atlas adds the freshly inserted chunks to that existing index.

Your first question might take a minute to respond to, so give it up to a minute. It embeds every chunk in one batch, stores them, and waits for the MongoDB Atlas index to catch up. The spinner shows while it works. Every later question in the session reuses the stored chunks and answers quickly.
Enter fullscreen mode Exit fullscreen mode

Add the ask command

Open cli.py and replace the whole file with this version, which adds the ask command next to analyze:

import typer
from rich.console import Console
from rich.panel import Panel
from rich.tree import Tree

import analyzer
import db
import qa
import vector_index
from models import Document

app = typer.Typer(help="Analyze a PDF and ask questions about it.")
console = Console()


# Keep the CLI ready for the ask command you'll add later:
@app.callback()
def main():
    """Analyze a PDF and ask questions about it."""


def _render(doc: Document) -> None:
    """Print the outline in a titled panel and the summary in another."""
    tree = Tree(f"[bold]{doc.filename}[/bold]")
    parents = {0: tree}
    for entry in doc.outline:
        parent = parents.get(entry.level - 1, tree)
        node = parent.add(f"p{entry.page}: {entry.title}")
        parents[entry.level] = node
    console.print(Panel(tree, title="Outline"))
    if doc.summary:
        console.print(Panel(doc.summary, title="Summary"))


@app.command()
def analyze(
    pdf_path: str,
    force: bool = typer.Option(
        False,
        help="Re-run even if a saved result exists.",
    ),
):
    """Build the outline and summary for a PDF."""
    client = db.get_client()
    try:
        doc = analyzer.analyze(client, pdf_path, force=force)
        _render(doc)
    finally:
        client.close()


@app.command()
def ask(pdf_path: str):
    """Start an interactive question-and-answer session about a PDF, with sources."""
    client = db.get_client()
    try:
        # Make sure the vector index exists before the first question runs:
        with console.status("Preparing the document..."):
            vector_index.ensure_vector_index(client)
        console.print(f"Ask questions about [bold]{pdf_path}[/bold]. Press Enter on an empty line to quit.")
        while True:
            question = console.input("\n[bold cyan]Question:[/bold cyan] ").strip()
            if not question or question.lower() in {"exit", "quit", "q"}:
                console.print("Goodbye.")
                break
            with console.status("Thinking..."):
                result = qa.answer_question(client, pdf_path, question)
            console.print(Panel(result["answer"], title="Answer"))
            if result["sources"]:
                lines = [f"p{s['page']}: {s['section'] or '(no section)'}" for s in result["sources"]]
                console.print(Panel("\n".join(lines), title="Sources"))
    finally:
        client.close()


if __name__ == "__main__":
    app()
Enter fullscreen mode Exit fullscreen mode

Make sure Ollama is running, then start a session:

python cli.py ask sample.pdf
Enter fullscreen mode Exit fullscreen mode

Type a question at the prompt, such as "What do 'ecological fidelity' and 'reproducibility' mean in the context of Agent-Diff?", and press Enter. You should get an answer drawn from the document, along with a Sources panel that lists the page and section behind it, such as p1: 2 Related Work. Open the PDF to those pages and confirm the listed section really covers the answer. Ask as many questions as you like, then press Enter on an empty line to quit.

This is an interactive question-and-answer session, not a chat. The tool prompts for a question in a loop and answers each one on its own, so the second question doesn't inherit context from the first. A question like "What is the main argument?" followed by "Who wrote the document?" produces two independent answers. Press Enter on an empty line, or type `quit`, to leave.
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • The tool runs almost entirely on your machine. Ollama serves the model locally; embeddings are run locally via sentence-transformers; and only MongoDB Atlas is remote, on the free M0 tier.
  • The ask command starts an interactive session for a PDF. The first question does the one-time chunking, embedding, and storage, and every subsequent question reuses that work.
  • MongoDB Atlas holds the documents and the chunks together and runs the vector search behind retrieval, so the data and the vectors live in one place.
  • Extraction runs without the model, and the separate RAG stages make a weak answer easy to trace to the stage that failed.
  • Sources come from the page and section stored with each retrieved chunk, not from the model, so every answer points back to a real passage in the document.

You can find all the code samples for this tutorial in the GitHub repository.

Top comments (0)