Build a RAG Academic AI Assistant
Build a RAG API that ingests PDFs and answers questions about them.
Introduction
30 Second Summary
Ever highlighted a textbook chapter, closed it, and immediately forgotten everything? Imagine if you could just ask your notes a question and get a clear answer backed by the exact page it came from.
In this project, you will build an AI-powered study assistant API that lets you upload academic PDFs, then ask questions about their content and get summaries on demand.
What You'll Build
A FastAPI application that extracts text from PDFs, stores it as vector embeddings in ChromaDB, and uses the Groq API to answer questions using a RAG pipeline.
By the end of this project, you'll have:
- A working RAG pipeline that ingests PDFs, chunks text, and retrieves relevant passages to answer your questions.
- A local ChromaDB vector database storing your document embeddings with zero external infrastructure.
- A REST API with endpoints for uploading PDFs, asking questions, and generating summaries.
- Secret Mission: Add a quiz generation endpoint that creates multiple-choice questions from your uploaded documents.
Are there any prerequisites?
You should have basic Python knowledge (variables, functions, and imports). You will also need a free Groq API key, which you can get at console.groq.com.
Before We Start
Before you can build your RAG-powered study assistant, you need the right tools installed. This step gets your development environment ready with Python, a virtual environment, and all the libraries your project depends on.
You will install six Python packages that handle different parts of the pipeline. FastAPI serves your API, PyMuPDF reads PDFs, ChromaDB stores vector embeddings, and the Groq SDK connects to the AI model.
In this step, get ready to:
- Install Python 3.10+ and create a virtual environment.
- Install all project dependencies from a requirements.txt file.
- Verify your Groq API key works by running a test script.
Install Python and set up your project folder
This project requires Python 3.10 or higher because both PyMuPDF and the Groq SDK depend on features introduced in that version.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
python3 --version
✔️ I see version 3.10 or higher
If you see something like Python 3.10.x or higher (e.g. Python 3.12.4), you are good to go. Skip ahead to the next bullet list below.
ⓧ I see an older version
If your output shows a version lower than 3.10 (for example Python 3.9.7), you need to install a newer version. macOS often ships with an older Python that shadows newer installs.
- Visit python.org/downloads and download the latest Python 3.10+ installer for your operating system.
- Run the installer. On Windows, check the Add Python to PATH box on the first screen before clicking Install Now.
- After installing, close and reopen your terminal, then run python3 --version again to confirm the new version appears.
macOS still showing the old version?
If python3 --version still returns the old version after installing, try running the full path: /usr/local/bin/python3 --version. If that shows 3.10+, use /usr/local/bin/python3 wherever this project says python3.
ⓧ Command not found
Python is not installed on your machine yet.
- Visit python.org/downloads and download the latest Python 3.10+ installer for your operating system.
- Run the installer. On Windows, check the Add Python to PATH box on the first screen before clicking Install Now.
- Close and reopen your terminal after installation completes.
- Run python3 --version again. You should now see a version number like Python 3.12.4.
Windows users
On Windows, the command may be python --version instead of python3 --version. Use whichever one returns a 3.10+ version number.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir smart-academic-assistant
cd smart-academic-assistant
- Create a virtual environment by running this command:
python3 -m venv venv
What is a virtual environment?
A virtual environment is an isolated space for your project's Python packages. It keeps this project's dependencies separate from other Python projects on your machine.
The venv folder that just appeared inside your project holds a local copy of Python and pip. Any packages you install while the environment is active go here instead of your global Python installation.
- Activate the virtual environment:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activate command again.
On macOS, make sure you are in the smart-academic-assistant folder (run pwd to check). The venv folder must be inside your current directory.
Help me activate my virtual environment.
- Open your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor if you use Cursor) and press Enter to open it.
- In your editor, click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop, select the smart-academic-assistant folder, and click Open.
Install the project dependencies
Your project needs six packages. You will list them in a requirements.txt file so anyone can recreate the same environment with one command.
- In your editor, create a new file called requirements.txt in the root of your smart-academic-assistant folder.
- Paste the following into requirements.txt:
fastapi[standard]
groq
pymupdf
chromadb
python-multipart
python-dotenv
What does each package do?
- fastapi[standard] provides the web framework and includes uvicorn for running the server.
- groq is the official Python SDK for calling the Groq inference API.
- pymupdf extracts text from PDF files at high speed with zero external dependencies.
- chromadb is a vector database that stores and searches text embeddings locally.
- python-multipart enables FastAPI to accept file uploads.
- python-dotenv loads environment variables from a .env file so your API key stays out of code.
- Save requirements.txt.
- Back in your terminal (make sure you still see (venv) in the prompt), install all the packages by running:
pip install -r requirements.txt
This will download and install all six packages plus their dependencies. It may take a minute or two.
What should I see?
You will see progress bars as each package downloads. When it finishes, the last line should say Successfully installed followed by a list of package names and versions.
Seeing an error during install?
If you see a permissions error, make sure your virtual environment is active ((venv) in your prompt). Never run pip install with sudo inside a virtual environment.
If a package fails to build, confirm your Python version is 3.10+ by running python3 --version again. PyMuPDF and the Groq SDK both require 3.10 as a minimum.
Help me fix my pip install error.
✔️ Awesome, I've got everything!
Great. Double check you have saved requirements.txt and the install finished successfully.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file:
fastapi[standard]
groq
pymupdf
chromadb
python-multipart
python-dotenv
Create your API key file and verify access
Your project needs a Groq API key to call the AI model. You will store it in a .env file so it never ends up in your code.
- In your editor, create a new file called .env in the root of your smart-academic-assistant folder.
- Paste the following into .env:
GROQ_API_KEY=your-api-key-here
- Replace your-api-key-here with your actual Groq API key from console.groq.com.
- Save .env.
Why a .env file?
Storing secrets in a .env file keeps them out of your source code. The python-dotenv package loads these values into your app at runtime without ever hard-coding them.
If you ever push this project to GitHub, add .env to your .gitignore file so your key stays private.
Now let's verify that your API key works by creating a quick test script.
- In your editor, create a new file called test_groq.py in the root of your smart-academic-assistant folder.
- Paste the following code into test_groq.py:
import os
from dotenv import load_dotenv
from groq import Groq
# Load the API key from .env
load_dotenv()
# Create the Groq client
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
# Make a test API call
chat_completion = client.chat.completions.create(
messages=[{"role": "user", "content": "Say hello in one sentence."}],
model="openai/gpt-oss-20b",
)
# Print the response
print(chat_completion.choices[0].message.content)
What does this script do?
- load_dotenv() reads the GROQ_API_KEY value from your .env file and makes it available as an environment variable.
- Groq(api_key=...) creates a client that can talk to the Groq inference API.
- client.chat.completions.create(...) sends a message to the openai/gpt-oss-20b model and gets a response.
- The final print outputs the model's reply to your terminal so you can confirm it worked.
- Save test_groq.py.
- In your terminal, run the test script:
python3 test_groq.py
You should see a short greeting printed in your terminal. This confirms your API key is valid and the Groq SDK is working.
Seeing an authentication or API error?
If you see an authentication error, open your .env file and check that GROQ_API_KEY is set to your full key with no spaces or quotes around the value.
If you see a module not found error for groq or dotenv, make sure your virtual environment is still active ((venv) in the prompt) and that pip install -r requirements.txt completed successfully.
Help me fix my Groq API test script error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both .env and test_groq.py.
ⓧ I'd like to double check the full code
Here is the complete .env file:
GROQ_API_KEY=your-api-key-here
Your environment is ready and your API key is confirmed working. Next up, you will build the PDF processing pipeline that extracts and chunks text from academic papers.
Build the PDF Processing Pipeline
Your environment is ready and your Groq API connection is confirmed. Now it's time to build the first real piece of your study assistant.
Before the AI can answer questions about a document, it needs to read the document first. In this step, you'll build a processing pipeline that extracts text from PDFs and splits it into bite-sized chunks that a language model can work with.
In this step, get ready to:
- Create a PDF text extraction function using PyMuPDF.
- Implement a text chunking function with overlap.
- Test the full pipeline with a sample PDF.
Create the PDF text extraction function
Your API will receive PDF files as raw bytes from the user's upload. You need a function that takes those bytes, opens the PDF, and pulls out the text from every page.
- Create a new file called pdf_processor.py in your project folder.
- Add the text extraction function by pasting the following code:
import pymupdf
def extract_text_from_pdf(pdf_bytes: bytes) -> list[dict]:
"""Extract text from PDF bytes, returning a list of page dicts."""
# Open the PDF from raw bytes in memory
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
pages = []
for page_num in range(len(doc)):
page = doc[page_num]
# Extract all text content from this page
text = page.get_text()
# Only include pages that have actual text
if text.strip():
pages.append({"page": page_num + 1, "text": text})
doc.close()
return pages
What does this code do?
This function receives the raw bytes of a PDF file (the way FastAPI will hand it over after an upload).
- pymupdf.open(stream=pdf_bytes, filetype="pdf") opens the PDF directly from memory without saving it to disk first.
- The loop walks through every page, calls page.get_text() to pull the text content, and stores each page's text with its page number.
- Pages with no text (like cover images) are skipped.
- Save pdf_processor.py.
- You won't see any output yet. The next chunk adds the chunking function, and then you'll test both together.
Seeing an import error?
- Make sure your virtual environment is active. You should see the environment name in parentheses at the start of your terminal prompt.
- If pymupdf is not found, reinstall it by running pip install pymupdf with your virtual environment active.
Help me fix my PyMuPDF import error.
Add the text chunking function
A full page of text is often too long for a language model to process effectively. Chunking splits the text into smaller, overlapping pieces so the AI can focus on the most relevant passage when answering a question.
- In pdf_processor.py, add the following function below extract_text_from_pdf:
def chunk_text(pages: list[dict], chunk_size: int = 500, overlap: int = 50) -> list[dict]:
"""Split page texts 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]
# Only keep chunks that have real content
if chunk.strip():
chunks.append({
"text": chunk.strip(),
"page": page_num,
"chunk_index": len(chunks),
})
# Move forward by chunk_size minus overlap
start += chunk_size - overlap
return chunks
What does this code do?
This function takes the page list from extract_text_from_pdf and slices each page's text into smaller pieces.
- Each chunk is ~500 characters long. The 50-character overlap means consecutive chunks share some text at their boundaries, so no sentence gets cut off without context in the next chunk.
- chunk_index gives each chunk a unique position number across the entire document.
- The page number travels with each chunk so you can cite sources later.
Why overlapping chunks?
Imagine a sentence that lands right at the boundary between two chunks. Without overlap, half the sentence would be in chunk A and half in chunk B. Neither chunk alone has the complete thought.
The 50-character overlap repeats a small window of text across adjacent chunks. This ensures that boundary sentences appear in full in at least one chunk, giving the AI complete context to work with.
- Save pdf_processor.py.
Seeing a syntax error on save?
- Check that the chunk_text function starts at the same indentation level as extract_text_from_pdf (no extra indentation).
- Verify you have a blank line between the end of extract_text_from_pdf and the start of chunk_text.
Help me fix a syntax error in my pdf_processor.py file.
✔️ Awesome, I've got everything!
Great. Double check you've saved pdf_processor.py with both functions.
ⓧ I'd like to double check the full code
Here is the complete pdf_processor.py file:
import pymupdf
def extract_text_from_pdf(pdf_bytes: bytes) -> list[dict]:
"""Extract text from PDF bytes, returning a list of page dicts."""
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
pages = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
if text.strip():
pages.append({"page": page_num + 1, "text": text})
doc.close()
return pages
def chunk_text(pages: list[dict], chunk_size: int = 500, overlap: int = 50) -> list[dict]:
"""Split page texts 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.strip(),
"page": page_num,
"chunk_index": len(chunks),
})
start += chunk_size - overlap
return chunks
Test the pipeline with a sample PDF
Both functions are written. Now you'll run them against a real PDF to confirm they extract and chunk text correctly.
- Find any PDF file on your computer (a research paper, textbook chapter, or any document with text). Copy or move it into your project folder and rename it to sample.pdf.
- Create a file called test_pipeline.py in your project folder and paste the following code:
from pdf_processor import extract_text_from_pdf, chunk_text
# Read the sample PDF as bytes
with open("sample.pdf", "rb") as f:
pdf_bytes = f.read()
# Extract text page by page
pages = extract_text_from_pdf(pdf_bytes)
print(f"Extracted {len(pages)} pages")
# Chunk the text into overlapping pieces
chunks = chunk_text(pages)
print(f"Created {len(chunks)} chunks")
# Preview the first chunk
if chunks:
print(f"\nFirst chunk (page {chunks[0]['page']}):")
print(chunks[0]["text"][:200])
- Run the test script by executing this command:
python test_pipeline.py
- You should see output showing the number of pages extracted, the number of chunks created, and a preview of the first chunk's text content.
What should I see?
Your exact numbers will vary depending on your PDF. A typical 10-page academic paper might produce 8-10 pages of text and 50-100 chunks. The first chunk preview should show recognizable text from the beginning of your document.
Seeing a FileNotFoundError?
- Make sure your PDF file is named exactly sample.pdf and is in the same folder as test_pipeline.py.
- If your PDF has a different name, update the filename in test_pipeline.py to match.
- Check that you're running the command from your project folder (where both files live).
Help me fix a FileNotFoundError when running my test_pipeline.py script.
Getting 0 pages extracted?
- Your PDF might be a scanned image rather than digital text. PyMuPDF extracts embedded text only, not text from images. Try a different PDF that contains selectable text.
- Open the PDF in a reader and try selecting text with your cursor. If you can highlight words, the PDF contains extractable text.
Help me figure out why my PDF extraction returned 0 pages.
Your PDF pipeline is ready to extract and chunk any academic document. Next, you'll store these chunks in a vector database so the AI can search through them instantly.
Set Up ChromaDB Vector Store
In the last step, you built a PDF processing pipeline that extracts text and splits it into manageable chunks. Now you need a place to store those chunks so your AI can find the most relevant ones when answering a question.
This is where a vector database comes in. Instead of searching by exact keywords, a vector database converts text into numerical representations (embeddings) and finds passages with similar meaning. In this step, you will set up ChromaDB as your local vector store and write the functions that power storage and retrieval.
In this step, get ready to:
- Initialize a ChromaDB client and create a document collection.
- Write functions to store document chunks with metadata.
- Write functions to query the store for relevant passages by meaning.
Create the vector store module
ChromaDB handles embedding your text automatically using a local model. You do not need a separate API key for embeddings.
- Create a new file called vector_store.py in your project folder.
- Add the following code to initialize the ChromaDB client and collection:
import chromadb
client = chromadb.Client()
collection = client.get_or_create_collection(name="academic_docs")
What does this code do?
chromadb.Client() creates an in-memory ChromaDB instance. All your data lives in memory for this session (perfect for development).
get_or_create_collection either retrieves an existing collection named academic_docs or creates a new one if it does not exist. This prevents errors if you restart your script.
- Save vector_store.py.
- Verify the module loads correctly by running this command in your terminal:
python -c "import vector_store; print(vector_store.collection.name)"
You should see academic_docs printed in the terminal.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated. You should see the environment name in parentheses in your terminal prompt.
- Confirm you are running the command from your project folder (the one containing vector_store.py).
- If you see an error about chromadb not being found, reinstall it with pip install chromadb.
Still stuck? Help me fix my ChromaDB import error in vector_store.py
What about the embedding model?
ChromaDB uses a local Sentence Transformers model called all-MiniLM-L6-v2 to create embeddings. The first time you add or query documents, ChromaDB will automatically download this model (about 90 MB).
This means you do not need to pay for or configure a separate embedding API. Everything runs locally on your machine.
Add the document storage function
When the learner uploads a PDF, the chunks need to be stored in ChromaDB along with metadata about which file and page they came from.
- In vector_store.py, add the following function below the collection line:
def add_document(chunks: list[dict], filename: str) -> int:
"""Add document chunks to the vector store."""
documents = [chunk["text"] for chunk in chunks]
ids = [f"{filename}_chunk_{chunk['chunk_index']}" for chunk in chunks]
metadatas = [
{"filename": filename, "page": chunk["page"]}
for chunk in chunks
]
collection.add(documents=documents, ids=ids, metadatas=metadatas)
return len(documents)
What does this function do?
- documents extracts just the text from each chunk dict. ChromaDB will embed these strings automatically.
- ids creates a unique identifier for each chunk by combining the filename and chunk index. ChromaDB requires every document to have a unique ID.
- metadatas stores the source filename and page number alongside each chunk so you can trace answers back to their source.
- collection.add() inserts everything into the vector store in one batch call.
- Save vector_store.py.
You will not see a change yet. The next function (query_documents) will let you test the full storage and retrieval cycle.
Add the query and listing functions
The retrieval side of your vector store is where the magic happens. When a user asks a question, ChromaDB finds the chunks whose meaning is closest to the question.
- In vector_store.py, add the query_documents function below add_document:
def query_documents(question: str, n_results: int = 3) -> list[dict]:
"""Query the vector store for relevant chunks."""
results = collection.query(query_texts=[question], n_results=n_results)
documents = results["documents"][0] if results["documents"] else []
metadatas = results["metadatas"][0] if results["metadatas"] else []
return [
{"text": doc, "metadata": meta}
for doc, meta in zip(documents, metadatas)
]
What does this function do?
- collection.query() takes your question string, embeds it, and performs a similarity search against all stored chunks.
- n_results=3 limits the response to the top 3 most relevant chunks. This keeps the context concise for the LLM.
- The function reshapes ChromaDB's nested response format into a clean list of dicts with text and metadata keys.
- Now add the list_documents function below query_documents:
def list_documents() -> list[str]:
"""List all unique document filenames in the store."""
all_metadatas = collection.get()["metadatas"]
filenames = list(set(meta["filename"] for meta in all_metadatas if meta))
return filenames
What does this function do?
collection.get() retrieves all metadata from the collection. The function extracts every unique filename and returns them as a list. This powers the /documents endpoint you will build later.
- Save vector_store.py.
- Confirm the complete module loads without errors by running:
python -c "from vector_store import add_document, query_documents, list_documents; print('Vector store ready')"
You should see Vector store ready printed in the terminal.
Seeing a SyntaxError or ImportError?
- Check for missing commas or mismatched quotes in the list comprehensions. Python f-strings with dictionary access (like chunk['chunk_index']) use single quotes inside double-quoted f-strings.
- Make sure your indentation is consistent (4 spaces per level, no tabs).
- Verify that collection is defined at the module level (above the function definitions), not inside a function.
Help me debug my vector_store.py import error
✔️ Awesome, I've got everything!
Great. Double check you have saved vector_store.py before moving on.
ⓧ I'd like to double check the full code
import chromadb
client = chromadb.Client()
collection = client.get_or_create_collection(name="academic_docs")
def add_document(chunks: list[dict], filename: str) -> int:
"""Add document chunks to the vector store."""
documents = [chunk["text"] for chunk in chunks]
ids = [f"{filename}_chunk_{chunk['chunk_index']}" for chunk in chunks]
metadatas = [
{"filename": filename, "page": chunk["page"]}
for chunk in chunks
]
collection.add(documents=documents, ids=ids, metadatas=metadatas)
return len(documents)
def query_documents(question: str, n_results: int = 3) -> list[dict]:
"""Query the vector store for relevant chunks."""
results = collection.query(query_texts=[question], n_results=n_results)
documents = results["documents"][0] if results["documents"] else []
metadatas = results["metadatas"][0] if results["metadatas"] else []
return [
{"text": doc, "metadata": meta}
for doc, meta in zip(documents, metadatas)
]
def list_documents() -> list[str]:
"""List all unique document filenames in the store."""
all_metadatas = collection.get()["metadatas"]
filenames = list(set(meta["filename"] for meta in all_metadatas if meta))
return filenames
Your vector store is ready to receive documents and answer queries. Next up, you will build the RAG engine that connects ChromaDB's retrieved context to the Groq LLM for intelligent answers.
Build the RAG Engine with Groq
Your vector store can find relevant chunks from a question. Now you need something that takes those chunks and generates a helpful answer from them.
This is where the RAG pattern comes together. You'll build a module that feeds retrieved context into Groq's ultra-fast inference API, which generates answers grounded in your actual documents instead of making things up.
In this step, get ready to:
- Initialize the Groq client with your API key.
- Build a question-answering function that uses retrieved context.
- Build a summarization function for document overviews.
Initialize the Groq client
The RAG engine is the brain of your assistant. It receives retrieved context and uses a language model to generate an answer that stays grounded in your documents.
- Create a new file called rag_engine.py in your project folder.
- Add the following code to set up the Groq client and model constant:
import os
from groq import Groq
# Initialize the Groq client with your API key from the environment
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
# Model ID for Groq's fast inference engine
MODEL = "openai/gpt-oss-20b"
What does this code do?
os.environ.get("GROQ_API_KEY") reads your API key from the environment variable you set in your .env file.
The MODEL constant stores the model ID openai/gpt-oss-20b. This is Groq's recommended production model with a 131K context window and inference speeds of 1000 tokens per second.
You won't see a change yet. The next chunk builds the function that uses this client.
Implement the answer_question function
When a learner asks a question, your assistant needs to combine the question with relevant context from ChromaDB, then send both to the LLM with instructions to answer using only that context.
- In rag_engine.py, add the answer_question function below the MODEL line:
def answer_question(question: str, context_chunks: list[dict]) -> str:
"""Answer a question using retrieved context chunks."""
# Format each chunk with its source info for the LLM
context = "\n\n".join(
f"[Source: {chunk['metadata']['filename']}, Page {chunk['metadata']['page']}]\n{chunk['text']}"
for chunk in context_chunks
)
# Send the question and context to Groq with a system prompt
chat_completion = client.chat.completions.create(
messages=[
{
"role": "system",
"content": (
"You are an academic study assistant. Answer the question "
"based ONLY on the provided context. If the context does not "
"contain enough information, say so. Cite the source page numbers."
),
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}",
},
],
model=MODEL,
)
return chat_completion.choices[0].message.content
What does this code do?
- The function joins all retrieved chunks into a single string, tagging each with its source filename and page number.
- The system prompt tells the model to answer ONLY from the provided context and to cite page numbers. This prevents hallucination.
- client.chat.completions.create sends the messages to Groq and returns the model's response.
Why restrict the model to the provided context?
Without this constraint, a language model will happily answer questions using its training data. That defeats the purpose of a RAG system. The system prompt forces the model to ground its answers in your actual documents.
You won't see a change yet. The next chunk completes this module with a summarization function.
Implement the summarize_document function
Summaries help learners get a quick overview of a long document without reading everything. This function takes chunks from your vector store and asks the model to produce a structured breakdown.
- Below the answer_question function in rag_engine.py, add the summarize_document function:
def summarize_document(chunks: list[dict]) -> str:
"""Generate a structured summary from document chunks."""
# Use the first 10 chunks to stay within context limits
text = "\n\n".join(chunk["text"] for chunk in chunks[:10])
chat_completion = client.chat.completions.create(
messages=[
{
"role": "system",
"content": (
"You are an academic study assistant. Summarize the following "
"document content into: 1) A brief overview (2-3 sentences), "
"2) Key points (bullet list), 3) Important terms defined."
),
},
{
"role": "user",
"content": f"Document content:\n{text}",
},
],
model=MODEL,
)
return chat_completion.choices[0].message.content
What does this code do?
- chunks[:10] limits input to the first 10 chunks. This keeps the request within reasonable token limits and avoids overwhelming the model.
- The system prompt asks for a structured format: an overview, key points, and defined terms. This gives learners a consistent study-friendly output.
- Save rag_engine.py.
- Confirm VS Code does not show any red underlines or error indicators in the file. If you see squiggly lines, check for typos in your function definitions.
Seeing red underlines in VS Code?
- If from groq import Groq is underlined, make sure your virtual environment is activated and that you installed dependencies with pip install groq.
- If VS Code is using the wrong Python interpreter, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and select Python: Select Interpreter. Choose the interpreter inside your virtual environment.
- Check that your f-strings use matching curly braces and that all parentheses are closed.
Why is my Groq import not recognized in VS Code?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete rag_engine.py file:
import os
from groq import Groq
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
MODEL = "openai/gpt-oss-20b"
def answer_question(question: str, context_chunks: list[dict]) -> str:
"""Answer a question using retrieved context chunks."""
context = "\n\n".join(
f"[Source: {chunk['metadata']['filename']}, Page {chunk['metadata']['page']}]\n{chunk['text']}"
for chunk in context_chunks
)
chat_completion = client.chat.completions.create(
messages=[
{
"role": "system",
"content": (
"You are an academic study assistant. Answer the question "
"based ONLY on the provided context. If the context does not "
"contain enough information, say so. Cite the source page numbers."
),
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}",
},
],
model=MODEL,
)
return chat_completion.choices[0].message.content
def summarize_document(chunks: list[dict]) -> str:
"""Generate a structured summary from document chunks."""
text = "\n\n".join(chunk["text"] for chunk in chunks[:10])
chat_completion = client.chat.completions.create(
messages=[
{
"role": "system",
"content": (
"You are an academic study assistant. Summarize the following "
"document content into: 1) A brief overview (2-3 sentences), "
"2) Key points (bullet list), 3) Important terms defined."
),
},
{
"role": "user",
"content": f"Document content:\n{text}",
},
],
model=MODEL,
)
return chat_completion.choices[0].message.content
Your RAG engine is ready. It knows how to answer questions and summarize documents using only the context you provide. Next up, you'll wire everything together with FastAPI endpoints so you can upload PDFs, ask questions, and get summaries through a real API.
Wire Up FastAPI Endpoints
You have a PDF processor, a vector store, and a RAG engine. All the pieces of your study assistant are ready. The problem is that none of them are accessible from the outside world.
In this step, you will create a FastAPI application that exposes your pipeline as a set of REST API endpoints. Anyone (including you) can upload a PDF, ask questions, get summaries, and list documents through simple HTTP requests.
In this step, get ready to:
- Create a FastAPI app with Pydantic request and response models.
- Build endpoints for PDF upload, question-answering, summarization, and document listing.
- Run the dev server and test every endpoint using FastAPI's interactive docs.
Create the FastAPI app and data models
Before defining endpoints, you need the app instance itself and the data shapes that describe incoming requests and outgoing responses. FastAPI uses Pydantic models to validate request bodies automatically.
- Create a new file called main.py in your project folder.
- Add the following imports, app initialization, and data models:
import os
from dotenv import load_dotenv
# Load environment variables before importing modules that need them
load_dotenv()
from fastapi import FastAPI, File, UploadFile
from pydantic import BaseModel
# Import functions from our pipeline modules
from pdf_processor import extract_text_from_pdf, chunk_text
from vector_store import add_document, query_documents, list_documents
from rag_engine import answer_question, summarize_document
# Create the FastAPI app instance
app = FastAPI(title="Smart Academic AI Assistant")
# Define the shape of incoming question requests
class QuestionRequest(BaseModel):
question: str
# Define the shape of answer responses
class AnswerResponse(BaseModel):
answer: str
sources: list[dict]
What does this code do?
load_dotenv() reads your .env file and loads GROQ_API_KEY into the environment. It runs before the module imports because rag_engine.py reads the key at import time.
QuestionRequest and AnswerResponse are Pydantic models. FastAPI uses them to validate incoming JSON and serialize outgoing responses automatically.
- Save main.py.
Import errors when you save?
Make sure pdf_processor.py, vector_store.py, and rag_engine.py are all in the same folder as main.py. Python resolves imports from the current directory.
Help me debug an import error in my FastAPI app
Build the PDF upload endpoint
The upload endpoint is the entry point for your entire pipeline. It accepts a PDF file, extracts text, chunks it, and stores the chunks in ChromaDB.
- In main.py, add the upload endpoint below your AnswerResponse class:
@app.post("/upload")
async def upload_pdf(file: UploadFile):
"""Upload a PDF and store its chunks in the vector database."""
# Read the uploaded file as raw bytes
contents = await file.read()
# Extract text from each page of the PDF
pages = extract_text_from_pdf(contents)
# Split the text into overlapping chunks
chunks = chunk_text(pages)
# Store chunks in ChromaDB with the filename as metadata
num_stored = add_document(chunks, file.filename)
return {
"message": f"Successfully processed {file.filename}",
"pages_extracted": len(pages),
"chunks_stored": num_stored,
}
What does this endpoint do?
UploadFile is FastAPI's built-in type for handling file uploads. It reads the file into memory as bytes.
The endpoint chains together your three pipeline modules: PDF extraction, chunking, and vector storage. The response tells the caller how many pages and chunks were processed.
- Save main.py.
Seeing a python-multipart error?
File uploads require the python-multipart package. Confirm it is installed by checking your virtual environment is active (you should see the environment name in your terminal prompt). If needed, run pip install python-multipart again.
Help me fix a multipart error in my FastAPI upload endpoint
Add the question, summary, and documents endpoints
Your remaining endpoints let users query their uploaded documents, generate summaries, and see what has been uploaded.
- In main.py, add the /ask endpoint below your upload function:
@app.post("/ask", response_model=AnswerResponse)
async def ask_question(request: QuestionRequest):
"""Ask a question about your uploaded documents."""
# Find the most relevant chunks for this question
relevant_chunks = query_documents(request.question)
# Pass the question and context to the RAG engine
answer = answer_question(request.question, relevant_chunks)
return AnswerResponse(answer=answer, sources=relevant_chunks)
What does this endpoint do?
response_model=AnswerResponse tells FastAPI to validate and serialize the response using your Pydantic model. The endpoint retrieves relevant chunks from ChromaDB, passes them as context to the Groq-powered RAG engine, and returns both the answer and the source chunks used.
- Now add the /summarize and /documents endpoints below the /ask function:
@app.post("/summarize")
async def summarize(request: QuestionRequest):
"""Summarize content related to a topic from your documents."""
# Retrieve more chunks (10) for a broader summary
relevant_chunks = query_documents(request.question, n_results=10)
summary = summarize_document(relevant_chunks)
return {"summary": summary}
@app.get("/documents")
async def get_documents():
"""List all uploaded documents."""
filenames = list_documents()
return {"documents": filenames}
Why does /summarize fetch 10 chunks?
Summaries need a broader view of the document than a single focused question. Retrieving 10 chunks gives the LLM more material to produce a comprehensive overview with key points and definitions.
The /documents endpoint is a simple utility that returns all unique filenames stored in ChromaDB. It helps you confirm which PDFs you have uploaded.
- Save main.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py with all four endpoints.
ⓧ I'd like to double check the full code
import os
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, File, UploadFile
from pydantic import BaseModel
from pdf_processor import extract_text_from_pdf, chunk_text
from vector_store import add_document, query_documents, list_documents
from rag_engine import answer_question, summarize_document
app = FastAPI(title="Smart Academic AI Assistant")
class QuestionRequest(BaseModel):
question: str
class AnswerResponse(BaseModel):
answer: str
sources: list[dict]
@app.post("/upload")
async def upload_pdf(file: UploadFile):
"""Upload a PDF and store its chunks in the vector database."""
contents = await file.read()
pages = extract_text_from_pdf(contents)
chunks = chunk_text(pages)
num_stored = add_document(chunks, file.filename)
return {
"message": f"Successfully processed {file.filename}",
"pages_extracted": len(pages),
"chunks_stored": num_stored,
}
@app.post("/ask", response_model=AnswerResponse)
async def ask_question(request: QuestionRequest):
"""Ask a question about your uploaded documents."""
relevant_chunks = query_documents(request.question)
answer = answer_question(request.question, relevant_chunks)
return AnswerResponse(answer=answer, sources=relevant_chunks)
@app.post("/summarize")
async def summarize(request: QuestionRequest):
"""Summarize content related to a topic from your documents."""
relevant_chunks = query_documents(request.question, n_results=10)
summary = summarize_document(relevant_chunks)
return {"summary": summary}
@app.get("/documents")
async def get_documents():
"""List all uploaded documents."""
filenames = list_documents()
return {"documents": filenames}
Now start the FastAPI development server to test your endpoints.
- Run the dev server by entering this command in your terminal:
fastapi dev main.py
- Open your browser and go to http://127.0.0.1:8000/docs.
You should see an interactive Swagger UI page listing all four endpoints: /upload, /ask, /summarize, and /documents.
- Click the POST /upload section to expand it.
- Click Try it out.
- Click Choose File and select any PDF from your computer (a research paper, textbook chapter, or any multi-page document).
- Click Execute.
You should see a JSON response showing the number of pages extracted and chunks stored.
- Now expand the POST /ask section and click Try it out.
- In the request body, replace the placeholder with a question about your uploaded PDF. For example:
{
"question": "What is the main topic of this document?"
}
- Click Execute.
You should see a JSON response with an answer field and a sources array showing which chunks were used.
Getting a connection error or 500 response?
Check your terminal for the error details. If you see a message about GROQ_API_KEY, make sure your .env file is in the same folder as main.py and contains your actual key.
If you see a rate limit error (429), wait a minute and try again. The Groq free tier has request limits.
Help me debug my FastAPI endpoint error
Your academic AI assistant is live. You have a complete RAG pipeline exposed through a clean REST API. Next, you will tackle the Secret Mission and add quiz generation to turn your documents into study tools.
Secret mission
Add a Quiz Generation Endpoint
Your study assistant can answer questions and summarize documents. But what if it could also quiz you on what you've read? In this mission, you'll add a new endpoint that generates structured multiple-choice questions from your uploaded PDFs.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Groq's free tier, so there are no ongoing costs.
Resources you used:
- Your project folder on the Desktop (Python files, virtual environment, .env).
- Groq API usage on the free tier (no charges incurred).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the assistant.
- Your project files remain on your Desktop ready to use anytime.
- ChromaDB stores data in memory while the server runs. If you stopped the server, you will need to re-upload your PDFs next time you start it.
- Your Groq API key stays in your .env file for future use.
Pause - I'll come back to this later
Shut down the running server to free up your terminal. Your project files stay on disk for next time.
- If your FastAPI server is still running, press Ctrl+C in the terminal to stop it.
- Deactivate your virtual environment by running:
deactivate
- When you come back, navigate to your project folder, reactivate the virtual environment, and run fastapi dev main.py to start the server again.
- You will need to re-upload your PDFs since ChromaDB stores data in memory only while the server is running.
Delete - I don't want to use this again
Remove all project files from your machine and start fresh.
- If your FastAPI server is still running, press Ctrl+C in the terminal to stop it.
- Deactivate your virtual environment by running:
deactivate
- Navigate to your Desktop and delete your project folder by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- Confirm the folder is gone by running:
ls
- You should no longer see your project folder in the output.
- Your Groq API key remains in your Groq account. If you want to revoke it, visit console.groq.com and delete the key from your API keys page.
Nice Work!
Nice Work!
You just built a fully functional RAG-powered academic assistant from scratch. Upload any research paper or textbook chapter, and your API turns it into an interactive study tool you can query, summarize, and quiz yourself on.
You've learned how to:
- Build a complete RAG pipeline that parses PDFs with PyMuPDF, chunks text into overlapping passages, and stores vector embeddings in ChromaDB.
- Connect Groq's ultra-fast inference to answer questions using only retrieved context, citing source page numbers.
- Create a FastAPI REST API with endpoints for PDF upload, Q&A, and document summarization.
- Secret Mission: Added a quiz generation endpoint that creates structured multiple-choice questions from your uploaded documents.
Ready to quiz yourself?