Skip to main content

Building a Local PDF AI Assistant with Mistral, Chroma and Streamlit

· 20 min read
Reda Jaifar
Lead Developer

September 21, 2026 · 10 min read

Working with large PDF documents can quickly become frustrating. Whether it is a technical handbook, a company specification, a legal document, or a 300-page report, finding one specific piece of information often means scrolling, searching, and trying to understand the context around the result.

What if you could simply ask the document a question?

"What are the rules regarding player registration?"

or:

"What does the document say about match postponements?"

And get an answer based only on the content of your PDF?

In this tutorial, we are going to build exactly that.

We will create a local PDF question-answering bot using:

  • Mistral 7B running locally
  • LangChain to connect the different components
  • Hugging Face embeddings to transform text into vectors
  • Chroma as our local vector database
  • Streamlit to build the user interface
  • PyPDF to extract the content of our PDF

The interesting part is that the entire application can run locally.

No external LLM API is required.

Let's build it.


What Are We Building?

The final application will allow us to load a PDF document and ask questions about its content.

The architecture looks like this:

                  ┌─────────────────┐
│ PDF │
│ Document │
└────────┬────────┘


┌─────────────────┐
│ PyPDFLoader │
│ Extract text │
└────────┬────────┘


┌─────────────────────────┐
│ Recursive Text Splitter │
│ │
│ PDF → smaller chunks │
└────────────┬────────────┘


┌─────────────────────────┐
│ Hugging Face Embeddings │
│ │
│ Text → vectors │
└────────────┬────────────┘


┌─────────────────┐
│ Chroma │
│ Vector Database │
└────────┬────────┘

User question


┌─────────────────┐
│ Similarity │
│ Search │
└────────┬────────┘

Relevant chunks


┌─────────────────┐
│ Mistral 7B │
│ Local LLM │
└────────┬────────┘


┌─────────────────┐
│ Streamlit │
│ Answer │
└─────────────────┘

This is a classic Retrieval-Augmented Generation (RAG) architecture.

Instead of asking the LLM to know everything about the PDF, we first retrieve the relevant pieces of the document and then give those pieces to Mistral as context.


Why Use RAG for a PDF?

A large language model by itself is not a PDF search engine.

Suppose our PDF contains 200 pages.

Sending the entire document to the model for every question would be inefficient and, depending on the model's context window, may not even be possible.

RAG solves this problem by splitting the document into smaller pieces.

When the user asks a question, the application:

  1. Converts the question into a vector.
  2. Searches the vector database for similar pieces of text.
  3. Retrieves the most relevant chunks.
  4. Adds those chunks to the prompt.
  5. Sends the prompt to Mistral.
  6. Displays the generated answer.

The model therefore receives something like:

Context:
----------------
Relevant paragraph from page 42...

Relevant paragraph from page 87...

Relevant paragraph from page 91...
----------------

Question:
What are the requirements for player registration?

Answer:

The LLM doesn't need to search the entire document itself.

The vector database has already done the retrieval work.


The Technology Stack

Our application is intentionally simple.

Mistral

Mistral is our local language model.

We will use a quantized GGUF version of Mistral 7B and load it with LlamaCpp.

This means the model runs directly on our machine instead of calling a hosted LLM API.

LangChain

LangChain provides the glue between our different components.

It handles things such as:

  • PDF loading
  • document splitting
  • embeddings
  • vector retrieval
  • LLM integration

Hugging Face Embeddings

Before we can search our document semantically, we need to transform the text into numerical vectors.

We will use:

sentence-transformers/all-mpnet-base-v2

Each chunk of our PDF becomes an embedding.

Questions can then be compared against those embeddings to find semantically similar text.

Chroma

Chroma is our vector database.

Instead of searching the PDF with traditional keyword matching, we search the numerical representations of the document.

This allows questions such as:

"What happens when a game is postponed?"

to potentially retrieve a passage containing different wording such as:

"Postponed fixtures shall be rescheduled..."

Streamlit

Finally, Streamlit gives us a simple web interface.

We don't need to build a React frontend or a REST API.

A few Python statements are enough to create the application.


Step 1: Create the Project

Let's start with a simple project structure:

pdf-bot/

├── .venv/

├── data/
│ └── premier-league-handbook-2026-27.pdf

├── chroma_db/

├── mistral-7b-instruct-v0.2.Q4_K_M.gguf

└── app.py

The data directory contains the PDF.

The GGUF file is our local Mistral model.

The chroma_db directory will contain the persistent Chroma database.

And app.py will contain our Streamlit application.


Step 2: Create the Python Environment

On macOS, we can create a virtual environment with:

python3 -m venv .venv

Activate it:

source .venv/bin/activate

Once activated, your terminal should show something similar to:

(.venv) $

Now install the required dependencies:

pip install streamlit
pip install langchain
pip install langchain-community
pip install langchain-text-splitters
pip install langchain-huggingface
pip install langchain-chroma
pip install chromadb
pip install pypdf
pip install sentence-transformers
pip install llama-cpp-python

At this point, the Python environment contains everything we need to build the application.


Step 3: Get a Local Mistral Model

Instead of calling an API, we are going to run Mistral locally.

For this example, we use a GGUF model:

mistral-7b-instruct-v0.2.Q4_K_M.gguf

Place the model at the root of the project:

pdf-bot/
├── mistral-7b-instruct-v0.2.Q4_K_M.gguf
├── app.py
└── data/

The important thing here is that LlamaCpp needs the path to the actual GGUF model file.

We configure that path in Python:

MODEL_PATH = "./mistral-7b-instruct-v0.2.Q4_K_M.gguf"

Step 4: Load the PDF

LangChain provides PyPDFLoader to extract text from PDF documents.

The basic operation is:

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader(PDF_PATH)
pages = loader.load()

The PDF is converted into a collection of LangChain documents.

Each page contains both the extracted text and metadata.

For example, the metadata can contain the page number.

This becomes useful later when we want to show the user where an answer came from.


Step 5: Split the PDF Into Chunks

We don't want to store entire pages as single documents.

Instead, we divide the content into smaller chunks.

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
)

documents = text_splitter.split_documents(pages)

Here we use a chunk size of approximately 1,000 characters.

We also use an overlap of 200 characters.

Why?

Imagine that an important sentence starts near the end of one chunk.

Without overlap, the next chunk could lose some of the surrounding context.

With overlap:

Chunk 1
████████████████████████████
████████

████████
Chunk 2
███████████████████████

The overlapping area gives the retrieval system a better chance of preserving context.

The exact values are not universal.

For a different document, you may want to experiment with:

chunk_size=500
chunk_overlap=100

or:

chunk_size=1500
chunk_overlap=300

The best values depend on the structure and density of your documents.


Step 6: Convert Text Into Embeddings

Now we need to transform each text chunk into a vector.

We can use Hugging Face embeddings:

from langchain_huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(
model_name="sentence-transformers/all-mpnet-base-v2"
)

Conceptually, the process looks like this:

"Players must be registered before..."


Embedding model


[0.013, -0.281, 0.721, ...]

The important idea is that semantically similar pieces of text tend to have vectors that are close to one another in vector space.

This is what allows us to perform semantic search.


Step 7: Store the Embeddings in Chroma

Now we can create our vector database.

from langchain_chroma import Chroma

db = Chroma.from_documents(
documents=documents,
embedding=embeddings,
persist_directory=CHROMA_PATH,
collection_name="pdf_documents",
)

Our document pipeline now looks like:

PDF


Extract text


Split into chunks


Generate embeddings


Store vectors


Chroma

The database is persisted locally in:

./chroma_db

This is useful because we don't want to lose our vector database every time the application stops.


Step 8: Retrieve Relevant Information

Now comes the most important part of the RAG pipeline.

When the user asks:

What are the requirements for player registration?

we don't immediately send that question to Mistral.

First, we search Chroma.

retriever = db.as_retriever(
search_kwargs={"k": 4}
)

source_documents = retriever.invoke(question)

The:

k=4

means that we ask the retriever for the four most relevant chunks.

We can then combine those chunks:

context = "\n\n".join(
doc.page_content
for doc in source_documents
)

Our question has now been transformed into a small, focused context extracted from the PDF.


Step 9: Build the Prompt

We now need to give Mistral both the retrieved context and the original question.

Our prompt is intentionally restrictive:

def build_prompt(context, question):
return f"""
You are a helpful assistant answering questions about a PDF document.

Use ONLY the information contained in the provided context.

If the answer cannot be found in the context, say:
"I couldn't find the answer in the PDF."

Do not make up information.

Context:
----------------
{context}
----------------

Question:
{question}

Answer:
"""

This is an important part of the application.

We don't want Mistral to answer from its general knowledge.

We want it to behave as a question-answering interface for our document.

The instruction:

Use ONLY the information contained in the provided context.

helps constrain the model.

And:

Do not make up information.

makes our intention explicit.

It is still important to remember that prompt instructions are not a mathematical guarantee that an LLM will never hallucinate. Retrieval quality and model behavior both matter.


Step 10: Load Mistral Locally

Now we can load the GGUF model using LlamaCpp.

from langchain_community.llms import LlamaCpp

llm = LlamaCpp(
model_path=MODEL_PATH,
n_ctx=4096,
max_tokens=512,
temperature=0.1,
verbose=False,
)

There are several parameters worth understanding.

model_path

This tells LlamaCpp where the GGUF model is located.

model_path=MODEL_PATH

n_ctx

This controls the model context window used by the application.

n_ctx=4096

Our prompt contains both the retrieved context and the question, so the context window needs to be large enough to accommodate them.

max_tokens

This controls the maximum amount of generated output.

max_tokens=512

For a document Q&A application, a few hundred tokens are often sufficient for a concise answer.

temperature

We use a low temperature:

temperature=0.1

For factual document Q&A, we generally want predictable answers rather than highly creative responses.


Step 11: Connect Everything Together

At this point, our RAG pipeline looks like this:

                         USER


Ask a question


┌──────────────────┐
│ Embedding/Search │
└────────┬─────────┘


Chroma DB


Top 4 PDF chunks


Build prompt


Local Mistral


Answer

This separation between retrieval and generation is what makes the application useful.

Chroma is responsible for finding information.

Mistral is responsible for generating the response.


Step 12: Build the Streamlit Interface

Now let's add a user interface.

First:

import streamlit as st

Then configure the page:

st.set_page_config(
page_title="Mistral PDF Q&A",
page_icon="📄",
layout="wide",
)

And create the title:

st.title("📄 Mistral PDF Q&A")

For the question itself, we can use:

question = st.text_input(
"Ask a question about the PDF:",
placeholder="What is this document about?",
)

When the user enters a question, we execute the RAG pipeline.


Step 13: Put the Application Together

Here is the complete Streamlit application:

import os
import streamlit as st

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


# --------------------------------------------------
# Streamlit UI
# --------------------------------------------------

st.set_page_config(
page_title="Mistral PDF Q&A",
page_icon="📄",
layout="wide",
)

st.title("📄 Mistral PDF Q&A")


# --------------------------------------------------
# Configuration
# --------------------------------------------------

PDF_PATH = "./data/premier-league-handbook-2026-27.pdf"
MODEL_PATH = "./mistral-7b-instruct-v0.2.Q4_K_M.gguf"
CHROMA_PATH = "./chroma_db"


# --------------------------------------------------
# Load Mistral GGUF model
# --------------------------------------------------

@st.cache_resource
def load_llm():

if not os.path.exists(MODEL_PATH):
raise FileNotFoundError(
f"Mistral model not found: {MODEL_PATH}"
)

llm = LlamaCpp(
model_path=MODEL_PATH,
n_ctx=4096,
max_tokens=512,
temperature=0.1,
verbose=False,
)

return llm


# --------------------------------------------------
# Load embedding model
# --------------------------------------------------

@st.cache_resource
def load_embeddings():

return HuggingFaceEmbeddings(
model_name="sentence-transformers/all-mpnet-base-v2"
)


# --------------------------------------------------
# Create / load Chroma vector database
# --------------------------------------------------

@st.cache_resource
def process_pdf(pdf_path):

loader = PyPDFLoader(pdf_path)
pages = loader.load()

text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
)

documents = text_splitter.split_documents(pages)

embeddings = load_embeddings()

db = Chroma.from_documents(
documents=documents,
embedding=embeddings,
persist_directory=CHROMA_PATH,
collection_name="pdf_documents",
)

return db


# --------------------------------------------------
# Build prompt
# --------------------------------------------------

def build_prompt(context, question):

return f"""
You are a helpful assistant answering questions about a PDF document.

Use ONLY the information contained in the provided context.

If the answer cannot be found in the context, say:
"I couldn't find the answer in the PDF."

Do not make up information.

Context:
----------------
{context}
----------------

Question:
{question}

Answer:
"""


# --------------------------------------------------
# Check PDF
# --------------------------------------------------

if not os.path.exists(PDF_PATH):

st.error(
f"PDF not found at `{PDF_PATH}`. "
"Please put your PDF inside the `data/` folder."
)

st.stop()


# --------------------------------------------------
# Load vector database
# --------------------------------------------------

with st.spinner("Loading PDF and building vector database..."):

try:
db = process_pdf(PDF_PATH)

except Exception as e:

st.error(
f"Error processing PDF: {e}"
)

st.stop()


st.success("PDF loaded successfully!")


# --------------------------------------------------
# Load LLM
# --------------------------------------------------

with st.spinner("Loading Mistral model..."):

try:
llm = load_llm()

except Exception as e:

st.error(
f"Error loading Mistral model: {e}"
)

st.stop()


# --------------------------------------------------
# Question input
# --------------------------------------------------

question = st.text_input(
"Ask a question about the PDF:",
placeholder="What is this document about?",
)


# --------------------------------------------------
# RAG pipeline
# --------------------------------------------------

if question:

with st.spinner("Searching the PDF..."):

retriever = db.as_retriever(
search_kwargs={"k": 4}
)

source_documents = retriever.invoke(
question
)

if not source_documents:

st.warning(
"No relevant information was found in the PDF."
)

st.stop()

context = "\n\n".join(
doc.page_content
for doc in source_documents
)

prompt = build_prompt(
context=context,
question=question,
)

with st.spinner("Mistral is thinking..."):

try:

answer = llm.invoke(prompt)

except Exception as e:

st.error(
f"Error generating answer: {e}"
)

st.stop()

st.markdown("### Answer")

st.write(answer)

st.markdown("### Sources")

for i, doc in enumerate(
source_documents,
start=1
):

page_number = doc.metadata.get(
"page",
"unknown"
)

display_page = (
page_number + 1
if isinstance(page_number, int)
else page_number
)

with st.expander(
f"Source {i} — Page {display_page}"
):

st.write(
doc.page_content
)

Step 14: Run the Application

On macOS, activate the virtual environment:

source .venv/bin/activate

Then start Streamlit:

streamlit run app.py

Streamlit will start a local development server.

You can then open:

http://localhost:8501

The application should display:

📄 Mistral PDF Q&A

PDF loaded successfully!

Ask a question about the PDF:
[_______________________________]

### Answer

...

### Sources

Source 1 — Page 42
Source 2 — Page 87
Source 3 — Page 91
Source 4 — Page 103

And that's it.

We have a local document chatbot.


What Actually Happens When We Ask a Question?

Let's walk through a real request.

Suppose we ask:

What happens if a fixture is postponed?

The first step is retrieval.

Chroma searches the document for chunks that are semantically related to the question.

It might return four pieces of text:

Page 42
"Postponed fixtures shall be rearranged..."

Page 43
"Clubs must agree on an alternative date..."

Page 44
"The competition authority may determine..."

Page 78
"Fixtures affected by exceptional circumstances..."

These pieces are then combined into our context.

Mistral receives the prompt and generates an answer based on that retrieved information.

Finally, Streamlit displays both the answer and the source passages.

This last part is particularly useful.

The user doesn't have to blindly trust the generated answer.

They can open the sources and inspect the original text.


Why Show the Sources?

One of the most useful features of this small application is the source display.

We already have access to metadata from the PDF:

page_number = doc.metadata.get(
"page",
"unknown"
)

We can therefore show:

Source 1 — Page 42

and expose the actual retrieved text:

with st.expander(
f"Source {i} — Page {display_page}"
):
st.write(doc.page_content)

This creates a much more transparent experience.

Instead of:

Question → AI → Answer

we have:

Question

Retrieved evidence

AI-generated answer

Original sources

That distinction becomes increasingly important when building AI applications around business or technical documents.


Keeping Everything Local

One of the interesting aspects of this architecture is that the main components can run locally.

The flow is:

                 Your Computer
┌──────────────────────────────────────────────┐
│ │
│ PDF │
│ │ │
│ ▼ │
│ Embeddings ───────► Chroma │
│ │ │
│ ▼ │
│ Retrieval │
│ │ │
│ ▼ │
│ Mistral 7B │
│ │ │
│ ▼ │
│ Streamlit │
│ │
└──────────────────────────────────────────────┘

There is no need to send the document to an external LLM API for this architecture.

This can be particularly attractive for documents that you don't want to upload to a third-party service.

Of course, "local" does not automatically mean "secure". Your machine, dependencies, model files, logs, and application still need to be managed appropriately.


A Few Things to Watch Out For

Our application is intentionally simple, but there are several areas worth improving.

1. Rebuilding the Vector Database

The current implementation calls:

Chroma.from_documents(...)

when process_pdf() runs.

For a prototype this is perfectly fine.

For a larger application, however, you don't want to recreate all embeddings every time Streamlit starts.

A production-oriented implementation would:

  1. Calculate whether the document has changed.
  2. Check whether an existing Chroma collection exists.
  3. Reuse the existing embeddings.
  4. Only process new documents when necessary.

That can make startup considerably faster.

2. Chunk Size Matters

There is no perfect value for:

chunk_size=1000

A technical document may benefit from larger chunks.

A legal document might have meaningful sections that should be preserved.

A document containing tables can require a completely different strategy.

The quality of retrieval is strongly influenced by how we divide the document.

3. PDFs Are Not Always Simple Text

PDFs can contain:

  • tables
  • images
  • scanned pages
  • multiple columns
  • headers and footers
  • unusual layouts

A normal text extractor may not always preserve the original structure correctly.

For scanned PDFs, OCR may be necessary before embeddings can be generated.

4. Retrieval Quality Matters

The LLM is only as good as the information we retrieve.

If Chroma returns the wrong chunks, Mistral doesn't have the information required to answer the question correctly.

This is one of the most important lessons when building RAG systems:

Improving retrieval can be just as important as changing the LLM.

Increasing k is one possible experiment:

search_kwargs={"k": 6}

But more context is not automatically better.

Too many irrelevant chunks can make the prompt noisy.


Where Could We Take This Next?

The basic chatbot is only the beginning.

Once the architecture is working, there are several interesting improvements we can add.

Multiple PDFs

Instead of:

One PDF

One Chroma collection

we could support:

PDF 1 ─┐
PDF 2 ─┤
PDF 3 ─┼──► Chroma
PDF 4 ─┘

The user could then ask questions across an entire document library.

PDF Uploads

Instead of hard-coding:

PDF_PATH = "./data/premier-league-handbook-2026-27.pdf"

Streamlit can provide a file uploader:

uploaded_file = st.file_uploader(
"Upload a PDF",
type="pdf"
)

This would turn our application from a single-document demo into a reusable PDF assistant.

Conversation History

Currently every question is independent.

We could introduce conversation memory so that users can ask:

What does the document say about player registration?

followed by:

What about the deadline?

The application could use the previous question and answer to understand what "the deadline" refers to.

Better Source Citations

Instead of displaying raw chunks, we could format the sources more elegantly:

Answer

Players must be registered before the applicable deadline...

Sources

📄 Page 42
📄 Page 43
📄 Page 44

We could also add a button to open the PDF at the relevant page.

Streaming Mistral Responses

Rather than waiting for the entire answer to be generated, the UI could display tokens progressively.

This would make the application feel much more responsive, especially when running a local model on CPU.

A Better Retrieval Strategy

A more advanced implementation could introduce:

  • metadata filtering
  • hybrid search
  • reranking
  • parent/child document retrieval
  • query rewriting
  • multiple embedding models
  • retrieval evaluation

At that point, we are moving from a simple RAG prototype toward a more serious document intelligence platform.


The Architecture in One Picture

The entire application can be reduced to five major components:

┌──────────────┐
│ PDF │
└──────┬───────┘


┌──────────────┐
│ Chunking │
└──────┬───────┘


┌──────────────┐
│ Embeddings │
└──────┬───────┘


┌──────────────┐
│ Chroma │
│ Vector Store │
└──────┬───────┘

│ relevant chunks

┌──────────────┐
│ Mistral │
│ Local LLM │
└──────┬───────┘


┌──────────────┐
│ Streamlit │
│ UI │
└──────────────┘

Each component has a clear responsibility.

PyPDF extracts the document.

The text splitter creates manageable chunks.

The embedding model converts those chunks into vectors.

Chroma finds the relevant information.

Mistral turns that information into a natural-language answer.

Streamlit makes the whole thing accessible through a browser.


Final Thoughts

Building a PDF chatbot doesn't necessarily require a large cloud architecture or an external AI API.

With a local Mistral model, a vector database, and a lightweight Streamlit interface, we can build a surprisingly capable document assistant with just Python.

The important concept is not the chatbot interface itself.

It is the pipeline behind it:

Document

Chunk

Embed

Retrieve

Generate

Once this pattern becomes familiar, it can be applied to much more than PDFs.

The same architecture can power assistants for:

  • technical documentation
  • internal company knowledge
  • product manuals
  • research papers
  • compliance documents
  • meeting notes
  • contracts
  • knowledge bases

And because the model and vector database can run locally, it provides an interesting foundation for experimenting with private, self-hosted AI applications.

The next step is no longer simply asking an LLM questions.

It is giving an LLM the right information at the right time.

That's where RAG becomes particularly powerful.


Project Structure

For reference, the final project looks like this:

pdf-bot/

├── .venv/

├── data/
│ └── premier-league-handbook-2026-27.pdf

├── chroma_db/

├── mistral-7b-instruct-v0.2.Q4_K_M.gguf

└── app.py

Start the application with:

source .venv/bin/activate
streamlit run app.py

Then open:

http://localhost:8501

And start talking to your PDF.


Tags:

AI · Mistral · RAG · LLM · Python · Streamlit · Chroma · Vector Database · LangChain · Machine Learning