Sustainability Report Q&A with RAG

Build a RAG pipeline that answers questions from real sustainability PDFs.

Introduction

30 Second Summary

Corporate sustainability reports are often 100+ pages of dense data, targets, and methodology. Finding a specific emissions number or policy commitment means scrolling through PDFs for minutes.

In this project, you will build a RAG-powered web application that lets anyone upload a sustainability report PDF and ask questions in plain English. The app retrieves the relevant sections from the document and generates accurate, grounded answers.

What You'll Build

Picture sharing a link with a hiring manager where they upload any sustainability PDF, type "What are the company's Scope 1 emissions targets?", and get a grounded answer with the exact source text displayed right alongside it for verification.

By the end of this project, you'll have:

  • A working RAG pipeline that extracts text from PDFs, creates vector embeddings, and retrieves relevant context to ground LLM answers in real document content.
  • A live web app on Hugging Face Spaces where anyone with the link can upload a sustainability report and ask questions at zero cost.
  • Firsthand experience with why RAG exists by watching an LLM fail without document context, then fixing the problem with retrieval-augmented generation.
  • Secret Mission: Add source citations with page numbers and a confidence score so users can audit the AI's answers against the original document.

Are there any prerequisites?

You should have Python, Ollama, and VS Code installed on your machine. Basic familiarity with Python (variables, functions, running scripts) is all you need.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Environment

Your RAG assistant needs several Python libraries working together: one to read PDFs, one to create vector embeddings, one to store and search those embeddings, one to build the web interface, and one for LLM inference.

In this step, you will create an isolated Python environment and install everything up front so there are no interruptions when you start building.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install all required packages for the RAG pipeline.
  • Verify all imports work correctly.
Create your project folder and virtual environment

A virtual environment keeps your project's packages separate from your system Python. This prevents version conflicts between projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the built-in terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir sustainability-qa
cd sustainability-qa
  • Confirm the folder exists by running:
ls

You should see the sustainability-qa folder listed on your Desktop.

  • Create a virtual environment by running this command:
python -m venv venv

What does this command do?

python -m venv venv creates a new virtual environment inside a folder called venv. This folder contains its own copy of the Python interpreter and a place to install packages without affecting your system.

  • Activate the virtual environment so packages install into it instead of your global Python.

macOS

  • Activate the environment by running:
source venv/bin/activate

You should see (venv) appear at the start of your terminal prompt.

Windows

  • Activate the environment by running:
venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt.

Don't see (venv) in your prompt?

  • On Windows PowerShell, you may need to run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser first, then try the activate command again.
  • Make sure you are inside the sustainability-qa folder. Run pwd to check your location.
  • Still stuck? Why can't I activate my Python virtual environment?
Install the required packages

Your RAG pipeline uses five packages. Each handles a different piece of the puzzle.

  • Install all five packages by running this command:
pip install chromadb sentence-transformers pymupdf gradio groq

What are these packages?

  • chromadb is a vector database that stores and searches your document embeddings.
  • sentence-transformers creates vector embeddings from text, turning words into numbers a computer can compare.
  • pymupdf reads PDF files and extracts their text content page by page.
  • gradio builds web interfaces with Python. It handles file uploads, text inputs, and output displays.
  • groq is the Python client for Groq's fast LLM inference API.

This installation downloads several hundred megabytes of model files and dependencies. It may take 2-5 minutes depending on your internet speed.

When it finishes, you should see a final line that says Successfully installed followed by a list of packages.

Seeing a permission error or install failure?

  • Make sure (venv) appears in your terminal prompt. If it does not, the virtual environment is not active. Re-run the activate command from the previous substep.
  • If you see a network timeout, check your internet connection and try the install command again.
  • Help me fix my pip install error.
Verify all imports work

Before you go any further, confirm that every package installed correctly and Python can import them.

What do you think will happen when you run a script that tries to import all five packages? If everything installed correctly, you should see a success message.

  • Test all imports by running this command:
python -c "import chromadb; import sentence_transformers; import pymupdf; import gradio; print('All imports OK')"

You should see All imports OK printed in your terminal.

Seeing a ModuleNotFoundError?

  • Check that (venv) still appears in your prompt. If you closed and reopened the terminal, you need to activate the environment again.
  • If a specific package failed to install, re-run the install for just that package. For example: pip install chromadb.
  • Help me fix my import errors.

Your environment is locked and loaded. Next up, you will ask an LLM a question about a sustainability report and watch it fail without any document context.

Ask the LLM Without RAG

In the last step, you set up your Python environment with all the libraries you need. Now it is time to see why you need RAG in the first place.

In this step, you will ask your local LLM a specific factual question about a real sustainability report. The LLM will either make up numbers or admit it has no idea. This firsthand experience of failure is what motivates the entire RAG pipeline you build next.

In this step, get ready to:
  • Download a real sustainability report PDF into your project folder.
  • Write a script that asks Ollama a factual question about the report.
  • Run the script and observe the LLM failing without document context.
Download a sustainability report PDF

Your LLM needs a document to be tested against. You will download a real, publicly available corporate sustainability report so you can ask the LLM factual questions about it.

  • Open your browser and search for a publicly available corporate sustainability or ESG report in PDF format. Many companies publish these on their investor relations pages (for example, search for "Microsoft 2023 Environmental Sustainability Report PDF").
  • Download the PDF and save it into your project folder (the same folder where your virtual environment lives).
  • Rename the file to report.pdf so it is easy to reference in your code.
  • Confirm the file is in your project folder by running:
ls

You should see report.pdf listed alongside your virtual environment folder.

Pick a report with numbers

Choose a report that contains specific data like emissions figures, energy consumption, or waste reduction targets. The more specific the data, the more obvious the LLM's failure will be when it cannot access it.

Write a script that asks Ollama without any document context

Now you will write a short Python script that sends a specific factual question about your sustainability report to Ollama. The key is to ask something that requires real data from the document.

  • Install the Ollama Python client by running:
pip install ollama

What is this package?

The ollama Python package is the official client library for communicating with a running Ollama server. It lets you send chat messages and receive responses from any model you have pulled locally.

  • Create a new file called no_rag.py in your project folder by running:
touch no_rag.py
  • Confirm the file exists by running:
ls

You should see no_rag.py listed in the output.

  • Open no_rag.py in VS Code and add the following code:
from ollama import chat

# Ask Ollama a specific factual question about the sustainability report
response = chat(
    model='llama3.1',
    messages=[
        {
            'role': 'user',
            'content': 'What were the total Scope 1 greenhouse gas emissions reported in the 2023 Microsoft Environmental Sustainability Report? Give me the exact number.'
        }
    ]
)

# Print the question and the LLM's response
print("Question: What were the total Scope 1 emissions in the 2023 report?")
print("\nLLM Response:")
print(response.message.content)

What does this code do?

The chat function sends a message to your local Ollama instance using whichever model you specify. It returns the model's response as an object you can read from.

The question is deliberately specific. You are asking for an exact number from a real document. The LLM has never seen your PDF, so it either has to guess, fabricate a plausible-sounding number, or admit it does not know.

💡 Use your own report's details

If you downloaded a different company's report, change the question in the content field to match. Ask about a specific number, target, or commitment that you can verify by reading the PDF yourself.

  • If you are using a different model than llama3.1, change the model value to match a model you have pulled (for example, mistral or gemma3).
  • Save no_rag.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before continuing.

ⓧ I'd like to double check the full code

Here is the full no_rag.py file:

from ollama import chat

# Ask Ollama a specific factual question about the sustainability report
response = chat(
    model='llama3.1',
    messages=[
        {
            'role': 'user',
            'content': 'What were the total Scope 1 greenhouse gas emissions reported in the 2023 Microsoft Environmental Sustainability Report? Give me the exact number.'
        }
    ]
)

# Print the question and the LLM's response
print("Question: What were the total Scope 1 emissions in the 2023 report?")
print("\nLLM Response:")
print(response.message.content)

Seeing a connection error?

Make sure Ollama is running on your machine. Open a new terminal and run ollama list to see your available models.

If you do not have a model pulled yet, run ollama pull llama3.1 to download one before trying again.

Help me troubleshoot my Ollama connection.

Run the script and observe the failure

This is the moment of truth. You are about to see exactly why RAG exists.

Before you run this, think about what you expect. Do you think Ollama will give you the correct emissions number from the report, or will it struggle?

  • Run your script by executing:
python no_rag.py

Read the response carefully. You will see one of two things: the LLM confidently states a number that is wrong (you can verify by checking your PDF), or it admits it does not have access to that information.

This failure is intentional

Whether the LLM hallucinated a fake number or said it does not know, both outcomes prove the same point. Without access to the actual document, the model cannot give you a trustworthy answer.

This is the exact gap that RAG fills. In the next step, you will build a pipeline that extracts text from the PDF, stores it in a vector database, and feeds the relevant chunks to the LLM as context. The same question will then produce an accurate, grounded answer.

🙋‍♀️ Script hanging or taking too long?

Local LLMs can take 10-30 seconds to respond depending on your hardware. If it takes longer than a minute, your model may be too large for your machine. Try a smaller model like gemma3:2b.

Help me troubleshoot why my Ollama script is slow or hanging.

You have now seen the problem firsthand. The LLM is powerful but blind to documents it has never been trained on. In the next step, you will solve this by building a full RAG pipeline that gives the LLM access to your sustainability report.

Build the RAG Pipeline

In the previous step, you asked Ollama a factual question about your sustainability report and it failed. The LLM either hallucinated numbers or admitted it had no idea.

That failure happened because the model has no access to your document. Now you fix that by building a RAG pipeline: extract the text from your PDF, split it into searchable chunks, convert those chunks into vector embeddings, store them in ChromaDB, and retrieve the most relevant pieces to feed as context to the LLM.

In this step, get ready to:
  • Extract text from the sustainability PDF page by page and split it into overlapping chunks.
  • Embed those chunks and store them in a ChromaDB vector store.
  • Retrieve the top 3 most relevant chunks for a question and verify the pipeline returns accurate content.
Write the PDF extraction logic

The first piece of the RAG pipeline reads your PDF and pulls out the raw text. You need to know which page each piece of text came from so you can reference it later.

  • Create a new file called rag_pipeline.py in your project folder.
  • Add the imports, model initialization, and the PDF extraction function by pasting this code:
import pymupdf
import chromadb
from sentence_transformers import SentenceTransformer

# Load the embedding model for converting text to vectors
embedding_model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
# Create an in-memory ChromaDB client for storing vectors
chroma_client = chromadb.Client()

def extract_text_from_pdf(pdf_path):
    """Extract text from PDF page by page."""
    doc = pymupdf.open(pdf_path)
    pages = []
    for page_num in range(len(doc)):
        page = doc[page_num]
        text = page.get_text()
        # Only keep pages that have actual content
        if text.strip():
            pages.append({'text': text, 'page': page_num + 1})
    doc.close()
    return pages

What does this code do?

The SentenceTransformer model (all-MiniLM-L6-v2) converts text into 384-dimensional vectors that capture meaning. Two sentences about the same topic produce similar vectors, even if they use different words.

The extract_text_from_pdf function opens the PDF with PyMuPDF, iterates through every page, and collects the text along with the page number. Blank pages are skipped.

  • Save rag_pipeline.py.
  • Test the extraction by running this command:
python -c "from rag_pipeline import extract_text_from_pdf; pages = extract_text_from_pdf('[[PDF_FILE="your-report.pdf"]]'); print(f'Extracted {len(pages)} pages')"

You should see a line like Extracted 42 pages (the exact number depends on your PDF).

Seeing a FileNotFoundError?

  • Make sure you replaced the filename in the command with the exact name of your sustainability report PDF.
  • Confirm the PDF is in the same folder as rag_pipeline.py.
  • help me fix a FileNotFoundError when loading my PDF
Split pages into overlapping chunks

A full page of text is too long to search effectively. Splitting it into smaller overlapping chunks means each chunk is focused on one topic, and the overlap prevents important sentences from being cut in half.

  • Add the chunk_text function below extract_text_from_pdf in rag_pipeline.py:
def chunk_text(pages, chunk_size=2000, overlap=400):
    """Split pages into overlapping chunks."""
    chunks = []
    for page_data in pages:
        text = page_data['text']
        page_num = page_data['page']
        # Slide a window across the text
        start = 0
        while start < len(text):
            end = start + chunk_size
            chunk = text[start:end]
            if chunk.strip():
                chunks.append({'text': chunk, 'page': page_num})
            # Move forward by (chunk_size - overlap) to create overlap
            start += chunk_size - overlap
    return chunks

What does this code do?

  • Each chunk is roughly 2000 characters (~500 tokens) long.
  • The window advances by 1600 characters (2000 minus 400 overlap), so 400 characters from the end of one chunk appear at the start of the next.
  • That 400-character overlap (~100 tokens) prevents important sentences from being sliced at the boundary and losing context.
  • Each chunk remembers which page it came from, so you can trace answers back to their source.
  • Save rag_pipeline.py and test the chunking by running:
python -c "from rag_pipeline import extract_text_from_pdf, chunk_text; pages = extract_text_from_pdf('[[PDF_FILE="your-report.pdf"]]'); chunks = chunk_text(pages); print(f'Created {len(chunks)} chunks from {len(pages)} pages')"

You should see something like Created 187 chunks from 42 pages. The exact numbers depend on your PDF's length.

Seeing 0 chunks?

  • If your PDF uses scanned images instead of selectable text, PyMuPDF cannot extract characters from it. Try a different sustainability report that has selectable text.
  • help me figure out why my PDF extraction returns no text
Embed chunks and store them in ChromaDB

Now you convert each text chunk into a vector and store it in a vector store. This is what makes semantic search possible: instead of matching exact keywords, you find chunks whose meaning is closest to the question.

  • Add the build_vector_store function below chunk_text in rag_pipeline.py:
def build_vector_store(chunks):
    """Embed chunks and store in ChromaDB."""
    collection_name = 'sustainability_report'
    # Delete existing collection if it exists to start fresh
    try:
        chroma_client.delete_collection(collection_name)
    except Exception:
        pass
    collection = chroma_client.create_collection(name=collection_name)
    # Prepare data for batch insertion
    texts = [chunk['text'] for chunk in chunks]
    metadatas = [{'page': chunk['page']} for chunk in chunks]
    ids = [f'chunk_{i}' for i in range(len(chunks))]
    # Encode all chunks into vector embeddings
    embeddings = embedding_model.encode(texts).tolist()
    collection.add(documents=texts, embeddings=embeddings, metadatas=metadatas, ids=ids)
    return collection

What does this code do?

  • It deletes any existing collection with the same name to avoid duplicates on re-runs.
  • It creates a fresh ChromaDB collection called sustainability_report.
  • The embedding_model.encode(texts) call converts every chunk into a 384-dimensional vector in one batch.
  • All the vectors, original text, page metadata, and unique IDs are stored together in the collection.
  • Save rag_pipeline.py and test the embedding step by running:
python -c "from rag_pipeline import extract_text_from_pdf, chunk_text, build_vector_store; pages = extract_text_from_pdf('[[PDF_FILE="your-report.pdf"]]'); chunks = chunk_text(pages); col = build_vector_store(chunks); print(f'Stored {col.count()} chunks in ChromaDB')"

You should see Stored 187 chunks in ChromaDB (matching your chunk count from earlier). The first run takes a few seconds while the embedding model loads.

Taking a very long time?

  • The first time sentence-transformers runs, it downloads the model weights (~80MB). Subsequent runs load from cache and are much faster.
  • If it seems stuck, check your internet connection. The model downloads from Hugging Face.
  • help me debug slow embedding or download issues
Write the retrieval and generation logic

The final piece queries your vector store with a question and retrieves the chunks whose meaning is closest to what you asked. You also add a scaffold for the generation function that constructs the prompt with retrieved context.

  • Add the retrieve_context and generate_answer functions below build_vector_store in rag_pipeline.py:
def retrieve_context(collection, question, n_results=3):
    """Retrieve the most relevant chunks for a question."""
    question_embedding = embedding_model.encode([question]).tolist()
    results = collection.query(query_embeddings=question_embedding, n_results=n_results)
    return results

def generate_answer(question, context_chunks):
    """Generate answer using retrieved context."""
    context = '\n\n'.join(context_chunks)
    prompt = f'Answer based only on context:\n{context}\n\nQuestion: {question}\nAnswer:'
    # Send prompt to Ollama and return response
    pass

What does this code do?

  • The retrieve_context function embeds your question into a vector and queries ChromaDB for the 3 chunks with the closest meaning.
  • The generate_answer function assembles a prompt that includes the retrieved context and the question. The comment marks where you would send this to Ollama (you already did this in no_rag.py).
  • The key insight: by including the retrieved text in the prompt, you give the LLM access to the actual document content it was missing before.
  • Save rag_pipeline.py.

Before you run the retrieval test, think about this: you are about to ask the same question that failed in Step 2. Do you think the retrieved chunks will contain the actual data from the report?

  • Test the full retrieval pipeline by running:
python -c "
from rag_pipeline import extract_text_from_pdf, chunk_text, build_vector_store, retrieve_context
pages = extract_text_from_pdf('[[PDF_FILE="your-report.pdf"]]')
chunks = chunk_text(pages)
collection = build_vector_store(chunks)
results = retrieve_context(collection, 'What were the total Scope 1 emissions?')
for i, (doc, meta) in enumerate(zip(results['documents'][0], results['metadatas'][0])):
    print(f'--- Chunk {i+1} (Page {meta[chr(39)+chr(39)]}page{chr(39)+chr(39)}) ---')
    print(doc[:200])
"

You should see 3 chunks printed, each from a relevant page of your report. The chunks contain actual emissions data and numbers from the PDF. This is the content the LLM was missing in Step 2.

Why is this the breakthrough?

In Step 2, the LLM had to guess because it had never seen this document. Now your retrieval pipeline finds the exact paragraphs that answer the question. When you feed these chunks as context to the LLM (which you will do in the next step with a proper UI), it can answer accurately by reading rather than guessing.

🙋‍♀️ Retrieved chunks seem irrelevant?

  • Try a question that matches language in your specific PDF. Different reports use different terminology.
  • If you see only headers or table-of-contents text, your PDF may have unusual formatting. Try a different sustainability report with more body text.
  • help me improve my RAG retrieval results

✔️ Awesome, I've got everything!

Great. Double check you have saved rag_pipeline.py and that your retrieval test printed relevant chunks.

ⓧ I'd like to double check the full code

Scroll through your rag_pipeline.py and confirm you have these sections in order:

  • Imports (pymupdf, chromadb, SentenceTransformer) and model/client initialization.
  • The extract_text_from_pdf function that opens the PDF and returns a list of page dicts.
  • The chunk_text function with a sliding window of 2000 characters (~500 tokens) and 400 character (~100 token) overlap.
  • The build_vector_store function that embeds and stores chunks in a collection named sustainability_report.
  • The retrieve_context function that queries for the top 3 chunks.
  • The generate_answer scaffold that builds the prompt string.

Your RAG retrieval pipeline is working. Next up, you will wrap this in a web interface so anyone can upload a PDF and ask questions through a browser.

Add a Gradio Web Interface

Your RAG pipeline works. It pulls the right chunks from the PDF and generates accurate answers. But right now, the only way to use it is by editing a Python script and running it in the terminal.

In this step, you will wrap your pipeline in a Gradio web interface. This gives you a file upload, a question box, and formatted output that anyone can use in a browser.

In this step, get ready to:
  • Create app.py with all the RAG pipeline functions consolidated into one file.
  • Wire up Gradio components to let users upload a PDF and ask questions through the browser.
  • Run the app locally and test end-to-end at http://localhost:7860.
Create app.py with a Gradio Blocks interface

Instead of modifying your existing rag_pipeline.py, you will create a brand new file that combines the RAG logic with a web UI. This file also swaps out Ollama for Groq as the inference backend so your app can work anywhere, not just on your local machine.

Before writing the code, you need a Groq API key. Groq provides free API access to Llama models with no credit card required.

  • Open your browser and go to console.groq.com.
  • Sign up for a free account (no credit card required).
  • Once signed in, navigate to console.groq.com/keys.
  • Click Create API Key, give it a name, and copy the key that appears.

Save your API key now

Groq only shows the full key once at creation time. Copy it immediately and keep it somewhere safe. You will use it in the next command.

  • Set the GROQ_API_KEY environment variable in your terminal by running this command:

macOS

export GROQ_API_KEY=your-api-key-here

Windows

$env:GROQ_API_KEY="your-api-key-here"
  • Replace your-api-key-here with the actual key you copied from Groq.

Why Groq instead of Ollama?

Ollama requires your local machine to load the model into RAM. That works for development, but when you deploy to Hugging Face Spaces later, there is no local GPU available.

Groq provides the same Llama models via a fast cloud API at zero cost. Swapping now means the same code works both locally and in production.

Now create the new file. Your app.py starts with imports and model initialization.

  • Create a new file called app.py in your project folder and add the imports and initialization code:
import os
import pymupdf
import chromadb
from sentence_transformers import SentenceTransformer
from groq import Groq
import gradio as gr

# Initialize the embedding model, Groq client, and ChromaDB
embedding_model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
groq_client = Groq(api_key=os.environ.get('GROQ_API_KEY'))
chroma_client = chromadb.Client()

What does this code do?

These imports bring in all the libraries your app needs: pymupdf for PDF reading, chromadb for the vector store, sentence_transformers for embeddings, groq for LLM generation, and gradio for the web UI.

The three client objects are created once at startup so they are reused across requests. The Groq client reads your API key from the GROQ_API_KEY environment variable you just set.

  • Save app.py and verify the imports load without errors by running:
python -c "import app; print('Imports OK')"

You should see Imports OK printed. The embedding model may take a few seconds to load the first time.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is activated. If you see an error about groq not being found, install it with pip install groq.

Help me figure out which package is missing.

Next, add the PDF extraction function. This is the same logic from your earlier pipeline.

  • Below the initialization code in app.py, add the extract_text_from_pdf function:
def extract_text_from_pdf(pdf_path):
    """Extract text from PDF page by page."""
    doc = pymupdf.open(pdf_path)
    pages = []
    for page_num in range(len(doc)):
        page = doc[page_num]
        text = page.get_text()
        if text.strip():
            pages.append({'text': text, 'page': page_num + 1})
    doc.close()
    return pages

What does this function do?

This opens a PDF file, loops through each page, and extracts the text content. It stores each page's text along with its page number so you can trace answers back to their source later.

  • Continue building app.py by adding the chunking function below extract_text_from_pdf:
def chunk_text(pages, chunk_size=500, overlap=50):
    """Split pages into overlapping chunks."""
    chunks = []
    for page_data in pages:
        text = page_data['text']
        page_num = page_data['page']
        start = 0
        while start < len(text):
            end = start + chunk_size
            chunk = text[start:end]
            if chunk.strip():
                chunks.append({'text': chunk, 'page': page_num})
            start += chunk_size - overlap
    return chunks

What does chunk_text do?

This splits extracted pages into overlapping 500-character chunks. The 50-character overlap ensures important sentences that straddle chunk boundaries are not lost.

  • Add the build_vector_store function below chunk_text:
def build_vector_store(chunks):
    """Embed chunks and store in ChromaDB."""
    collection_name = 'sustainability_report'
    try:
        chroma_client.delete_collection(collection_name)
    except Exception:
        pass
    collection = chroma_client.create_collection(name=collection_name)
    texts = [chunk['text'] for chunk in chunks]
    metadatas = [{'page': chunk['page']} for chunk in chunks]
    ids = [f'chunk_{i}' for i in range(len(chunks))]
    embeddings = embedding_model.encode(texts).tolist()
    collection.add(documents=texts, embeddings=embeddings, metadatas=metadatas, ids=ids)
    return collection

What does build_vector_store do?

This embeds each chunk using the sentence-transformers model and stores them in a ChromaDB collection. It deletes any existing collection first so re-uploads start fresh.

  • Save app.py and confirm there are no syntax errors by running:
python -c "from app import build_vector_store; print('Pipeline functions OK')"

You should see Pipeline functions OK printed.

Wire the RAG pipeline into the Gradio interface

You have the extraction and storage functions. Now add the retrieval and generation logic, followed by the handler functions that connect everything to the UI.

  • Add the retrieval and generation functions below build_vector_store in app.py:
def retrieve_context(collection, question, n_results=3):
    """Retrieve most relevant chunks for a question."""
    question_embedding = embedding_model.encode([question]).tolist()
    results = collection.query(query_embeddings=question_embedding, n_results=n_results)
    return results

def generate_answer(question, context_chunks):
    """Generate answer using Groq with retrieved context."""
    context = '\n\n'.join(context_chunks)
    prompt = f'You are a sustainability report analyst. Answer the question based ONLY on the provided context. If the context does not contain enough information to answer, say "I cannot find this information in the provided report."\n\nContext from the sustainability report:\n{context}\n\nQuestion: {question}\n\nAnswer:'
    chat_completion = groq_client.chat.completions.create(
        messages=[{'role': 'user', 'content': prompt}],
        model='llama-3.3-70b-versatile',
        temperature=0.2,
    )
    return chat_completion.choices[0].message.content

What does generate_answer do?

This constructs a prompt that includes the retrieved context chunks and sends it to Groq's llama-3.3-70b-versatile model. The prompt instructs the model to answer ONLY from the provided context, preventing hallucination.

The temperature=0.2 keeps responses factual and consistent rather than creative.

Now add the two handler functions that orchestrate the full workflow when a user interacts with the UI.

  • Add the handler functions below generate_answer in app.py:
# Global variable to store the current collection
current_collection = None

def process_pdf(pdf_file):
    """Process uploaded PDF and build vector store."""
    global current_collection
    if pdf_file is None:
        return 'Please upload a PDF file.'
    pages = extract_text_from_pdf(pdf_file)
    if not pages:
        return 'Could not extract text from PDF.'
    chunks = chunk_text(pages)
    current_collection = build_vector_store(chunks)
    return f'PDF processed successfully. Extracted {len(chunks)} chunks from {len(pages)} pages.'

What does process_pdf do?

When the user clicks the Process PDF button, this function runs the full pipeline: extract text, chunk it, embed it, and store it in ChromaDB. It saves the collection in a global variable so the question handler can access it later.

The status string it returns tells the user how many chunks were created, confirming the PDF was processed.

  • Add the ask_question handler directly below process_pdf:
def ask_question(question):
    """Answer a question using RAG."""
    global current_collection
    if current_collection is None:
        return 'Please upload and process a PDF first.', ''
    if not question.strip():
        return 'Please enter a question.', ''
    results = retrieve_context(current_collection, question)
    context_chunks = results['documents'][0]
    metadatas = results['metadatas'][0]
    answer = generate_answer(question, context_chunks)
    # Format the source chunks for display
    sources = ''
    for i, (chunk, meta) in enumerate(zip(context_chunks, metadatas)):
        sources += f'**Source {i+1}** (Page {meta["page"]}):\n{chunk[:200]}...\n\n'
    return answer, sources

What does ask_question do?

This retrieves the top 3 matching chunks from ChromaDB, sends them to Groq for generation, and formats the source chunks as markdown. The user sees both the AI's answer and the evidence it used.

  • Save app.py and verify the handlers import cleanly:
python -c "from app import ask_question; print('Handlers OK')"

You should see Handlers OK printed.

The final piece is the Gradio UI that ties all the components together.

  • Add the Gradio Blocks interface at the bottom of app.py:
# Build Gradio interface
with gr.Blocks(title='Sustainability Report Q&A') as demo:
    gr.Markdown('# Sustainability Report Q&A Assistant')
    gr.Markdown('Upload a sustainability/ESG report PDF and ask questions about it. Powered by RAG with local embeddings and Groq inference.')
    with gr.Row():
        with gr.Column():
            pdf_input = gr.File(label='Upload Sustainability Report (PDF)', file_types=['.pdf'])
            process_btn = gr.Button('Process PDF')
            status_output = gr.Textbox(label='Status', interactive=False)
        with gr.Column():
            question_input = gr.Textbox(label='Ask a question about the report', placeholder='What are the company\'s Scope 1 emissions?')
            ask_btn = gr.Button('Ask')
            answer_output = gr.Textbox(label='Answer', interactive=False)
            sources_output = gr.Markdown(label='Retrieved Sources')
    process_btn.click(fn=process_pdf, inputs=[pdf_input], outputs=[status_output])
    ask_btn.click(fn=ask_question, inputs=[question_input], outputs=[answer_output, sources_output])

if __name__ == '__main__':
    demo.launch()

What does this Gradio code do?

gr.Blocks creates a structured layout. The gr.Row() arranges two columns side by side: the left column handles PDF upload and processing, the right column handles questions and answers.

The .click() lines are event listeners. When the user clicks Process PDF, it calls process_pdf with the uploaded file. When they click Ask, it calls ask_question with their question text.

  • Save app.py.
Run the app locally and test

Everything is wired up. Before you run the app, what do you think will happen when you visit the URL in your browser?

  • Launch the Gradio app by running:
python app.py

You should see output that includes a local URL like http://localhost:7860. Gradio starts a local web server on port 7860 by default.

  • Open http://localhost:7860 in your browser.
  • Click the file upload area on the left and select the sustainability report PDF you downloaded earlier.
  • Click Process PDF.

The Status field should display a message like PDF processed successfully. Extracted 42 chunks from 15 pages. (your numbers will vary depending on the PDF).

  • In the right column, type a question about the report into the text field. Try the same question you used in Step 2.
  • Click Ask.

You should see a detailed answer in the Answer box, plus formatted source chunks below showing which parts of the PDF were used.

Getting a Groq authentication error?

If you see an error about an invalid API key, make sure you set the GROQ_API_KEY environment variable in the same terminal session where you ran python app.py. Environment variables do not persist across terminal windows.

Help me debug my API key setup.

  • Stop the app by pressing Ctrl+C in your terminal when you are finished testing.

✔️ Awesome, I've got everything!

Great. Make sure app.py is saved. You have a working web app that takes a PDF and answers questions about it.

ⓧ I'd like to double check the full code

Your app.py should contain the following sections in order. Scroll up and confirm each one is present:

  • Imports and initialization (import os through chroma_client = chromadb.Client()).
  • The extract_text_from_pdf function.
  • The chunk_text function.
  • The build_vector_store function.
  • The retrieve_context function.
  • The generate_answer function.
  • The current_collection = None global variable.
  • The process_pdf handler function.
  • The ask_question handler function.
  • The gr.Blocks interface with process_btn.click and ask_btn.click event handlers.
  • The if __name__ == '__main__': demo.launch() block at the very bottom.

Your RAG pipeline is now a real web application. Anyone with the URL can upload a PDF and get AI-powered answers. Next up, you will deploy this to the internet so the world can access it.

Deploy to Hugging Face Spaces with Groq

Your app works locally, but nobody else can access it. To turn it into a portfolio piece, you need it hosted publicly where anyone with the link can try it.

Hugging Face Spaces provides free hosting for Gradio apps. Swapping your local Ollama inference for Groq's free API (same Llama models, no credit card required) means the app runs on Hugging Face's servers without needing a GPU.

In this step, get ready to:
  • Set up a Groq account and API key for cloud inference.
  • Create a Hugging Face Space and configure your deployment files.
  • Deploy your app and test it live with a shareable URL.
Set up your Groq account and API key

Your app.py already references the Groq client and reads an API key from your environment. You need to create a Groq account and generate that key so the generation step actually works.

  • Open your browser and navigate to console.groq.com.
  • Click Sign Up and create a free account (no credit card required).
  • Once signed in, click API Keys in the left navigation menu.
  • Click Create API Key.
  • Give the key a name (e.g. sustainability-qa) and copy the generated key. Store it somewhere safe. You will not see it again.

Why Groq instead of Ollama for deployment?

Ollama requires the host machine to have enough RAM to load the model. Hugging Face Spaces free tier has no GPU and loading a model in CPU mode would be too slow for a demo.

Groq provides fast inference at zero cost through their free API tier. Your app gets the same Llama model quality without needing local hardware.

Now set the GROQ_API_KEY environment variable in your terminal so the app can read it.

macOS

  • Set the environment variable by running this command (replace your-api-key-here with the key you just copied):
export GROQ_API_KEY=your-api-key-here

Windows

  • Set the environment variable by running this command in PowerShell (replace your-api-key-here with the key you just copied):
$env:GROQ_API_KEY="your-api-key-here"
  • Test your app locally with Groq by running:
python app.py
  • Open http://localhost:7860 in your browser.
  • Upload your sustainability report PDF and click Process PDF.
  • Ask a question (e.g. "What are the company's Scope 1 emissions?") and click Ask.

You should see a factual answer drawn from the report text, along with the retrieved source chunks displayed below it.

Getting a connection or authentication error?

  • Confirm your GROQ_API_KEY is set in the same terminal session you ran python app.py from. If you opened a new terminal, re-run the export command.
  • Check that you copied the full key with no extra spaces.
  • Help me debug this

💡 What about cost?

Groq's free tier is rate-limited to 30 requests per minute. No credit card is on file, so you can never be charged. If you exceed the rate limit, requests return a 429 error instead of incurring cost.

Create your Hugging Face Space and prepare deployment files

Hugging Face Spaces hosts your Gradio app for free. You need an account, a new Space, and a requirements.txt file that tells the platform which packages to install.

  • Open your browser and go to huggingface.co.
  • Click Sign Up and create a free account.
  • Once signed in, navigate to the Spaces page and click Create new Space.
  • Enter sustainability-qa as the Space name.
  • Select Gradio as the SDK.
  • Leave visibility as Public and click Create Space.

Now add your Groq API key as a secret so it is available to the app at runtime.

  • In your new Space, click the Settings tab.
  • Scroll to the Secrets section.
  • Add a new secret with the name GROQ_API_KEY and paste your Groq API key as the value.

How do Spaces secrets work?

Secrets are exposed to your app as environment variables at runtime. Your code already reads os.environ.get("GROQ_API_KEY"), so it picks up the secret automatically.

Secrets are never visible to visitors or included if someone duplicates your Space.

Next, create the requirements.txt file that Hugging Face Spaces uses to install your dependencies.

  • In VS Code, create a new file called requirements.txt in your project folder and add the following:
chromadb==1.5.9
sentence-transformers==5.6.0
pymupdf==1.28.0
gradio==6.20.0
groq==1.5.0

What does this file do?

When Hugging Face Spaces builds your app, it runs pip install against this file. Pinning exact versions ensures the deployed app installs the same packages you tested locally.

  • Save requirements.txt.
  • Confirm both app.py and requirements.txt are saved and ready to upload.
Push your code and test the live app

With your Space created and secrets configured, upload your code files to start the build.

  • Return to your Space page on Hugging Face and click the Files tab.
  • Click Add file and then select Upload files.
  • Upload both app.py and requirements.txt from your project folder.
  • Click Commit changes to main to trigger the build.

What happens during the build?

Hugging Face installs your packages from requirements.txt, downloads the embedding model, and starts your Gradio app. This can take 2-5 minutes the first time because sentence-transformers downloads model weights.

  • Watch the build logs in the Logs section of your Space. Wait until you see a message indicating the app is running.

Before you test, predict: do you think uploading a PDF and asking a question on the live Space will produce the same quality answer you saw locally?

  • Click the App tab on your Space page to view your live app.
  • Upload a sustainability report PDF and click Process PDF.
  • Ask a factual question about the report and click Ask.

You should see a correct, context-grounded answer with source chunks displayed below. The response comes from Groq's API running llama-3.3-70b-versatile on their infrastructure.

  • Copy your Space URL from the browser address bar. It follows the pattern https://huggingface.co/spaces/your-username/sustainability-qa.
  • Open the URL in a different browser or incognito window to confirm anyone can access it.

Space showing an error or stuck building?

  • Check the build logs for a missing dependency. Ensure requirements.txt lists all five packages with correct spelling and version pins.
  • Verify you added the GROQ_API_KEY secret in the Space Settings tab. If the secret is missing, the app will crash when it tries to call the Groq API.
  • If the build succeeds but the app shows a timeout, wait 1-2 minutes. The embedding model download can take time on the first request.
  • Help me debug my Space

💡 Free tier sleep behavior

Free CPU Basic Spaces sleep after 48 hours of inactivity. When someone visits, the Space wakes automatically in about 30 seconds. Your app stays available indefinitely at zero cost.

✔️ Awesome, I've got everything!

Great. Confirm you have both app.py and requirements.txt saved and uploaded to your Space.

ⓧ I'd like to double check the full code

Your app.py should match the version you built in the previous step. Scroll up and confirm it is unchanged. Your requirements.txt should contain:

chromadb==1.5.9
sentence-transformers==5.6.0
pymupdf==1.28.0
gradio==6.20.0
groq==1.5.0

You now have a publicly accessible AI application that anyone can try by visiting a single link. This is a real, deployable portfolio piece that proves you can build and ship RAG systems. Next up: the Secret Mission adds auditability features that make your app enterprise-ready.

Secret mission

Add Source Citations and Confidence Scoring

Your app answers questions, but can users trust the answers? In the sustainability sector, auditability is everything. Add numbered page citations and a confidence score so anyone can verify the AI's claims against the original document.

Clean Up Your Resources

Clean Up Your Resources

This project uses entirely free-tier services. Your Hugging Face Space sleeps after 48 hours of inactivity and wakes automatically when someone visits. Your Groq API key is rate-limited and cannot incur charges. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Hugging Face Space (free CPU Basic tier, auto-sleeps after 48 hours of inactivity).
  • Groq API key (free tier, rate-limited to 30 requests per minute, no credit card on file).
  • Local project folder with virtual environment, app.py, and requirements.txt.

Keep everything running

No action needed. Choose this if you plan to keep iterating on your sustainability Q&A app or want the live URL available for your portfolio.

  • Your Hugging Face Space will auto-sleep after 48 hours of inactivity and auto-wake when someone visits the link. No cost is incurred.
  • Your Groq API key stays active on the free tier. If rate limits are exceeded, requests return a 429 error. You are never charged.
  • Your local project folder remains on your Desktop for future edits.

Pause - I'll come back to this later

Shut down your live app to take it offline temporarily while keeping everything intact for later.

  • Open your Space page on Hugging Face.
  • Click the three-dot menu near the top of the page, then click Settings.
  • Click Pause Space to take your app offline. It will not wake automatically until you manually resume it.
  • Your Groq API key and local project files remain untouched.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete your Hugging Face Space:
  • Open your Space page on Hugging Face.
  • Click the three-dot menu near the top, then click Settings.
  • Scroll to the bottom of the page and click Delete this space.
  • Confirm by clicking I understand, delete this space.
  • Revoke your Groq API key:
  • Go to console.groq.com/keys and log in.
  • Find the API key you created for this project and delete it from the keys list.
  • Delete your local project folder by running this command in your terminal:
cd ~/Desktop
rm -rf sustainability-qa

On Windows?

Use this command instead in PowerShell:

cd ~\Desktop
Remove-Item -Recurse -Force sustainability-qa

Nice Work!

Nice Work!

You just built and deployed a complete RAG-powered Q&A assistant that answers questions about real sustainability reports. Anyone with the link can try it.

You've learned how to:

  • Build a complete RAG pipeline from scratch: PDF extraction, chunking, vector embeddings, ChromaDB storage, semantic retrieval, and LLM generation.
  • Experience firsthand why RAG exists by watching an LLM fail without document context, then fix that failure with retrieved evidence.
  • Deploy a publicly accessible AI application at zero cost using Hugging Face Spaces and Groq inference.
  • Secret Mission: Added auditability features (page citations and confidence scoring) that demonstrate you understand enterprise AI requirements for the sustainability sector.

Ready to quiz yourself?