Build a RAG-Powered Knowledge Base API

Build a FastAPI backend that answers student questions from uploaded course photos.

Introduction

30 Second Summary

Every university student has stacks of course photos sitting in their camera roll, full of valuable notes they can never find when they need them. Imagine turning all those photos into a personal study assistant that actually answers your questions.

In this project, you will build the backend for E-hanatra, a personal knowledge base that extracts text from course photos and lets students chat with their own notes using AI.

What You'll Build

A FastAPI server that accepts photos of course notes, extracts text using OCR, stores vector embeddings in ChromaDB, and exposes a chat endpoint powered by RAG so students can ask questions grounded in their own materials.

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

  • A FastAPI application with four REST endpoints for uploading photos, chatting, listing documents, and deleting documents.
  • An OCR ingestion pipeline that preprocesses course photos and extracts text using Tesseract.
  • A RAG chat engine powered by Groq's Llama 3.1 8B Instant model that answers questions using only the student's uploaded materials.
  • Secret Mission: Add audio transcription support so students can upload lecture recordings alongside their photos.

Are there any prerequisites?

You should have basic Python knowledge and some familiarity with REST APIs. You will also need a free Groq account, which you will create during setup.

Set Up Your Development Environment

You've got your tools installed, your Python packages ready, and your Groq API key saved. The next step is to give your project a clean structure so each piece of the RAG pipeline lives in its own module.

A well-organized project makes it easier to build, debug, and extend your API. In this step, you'll create the folder structure and write the configuration file that the rest of the application depends on.

In this step, get ready to:
  • Create the app/ package and services/ sub-package for the project.
  • Write config.py to centralize all project settings.
Open the project in VS Code
  • Press the Windows key (Windows) or Cmd+Space (macOS) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to and select the e-hanatra-backend folder you created earlier, then click Select Folder.

VS Code opens with your project in the Explorer sidebar on the left. You should see your .env and .gitignore files listed.

  • Open the integrated terminal by pressing Ctrl+` (backtick, the key above Tab).

Activate your virtual environment

If your terminal prompt does not show (venv) at the beginning, activate your virtual environment by running venv\Scripts\activate in the terminal.

Create the project folder structure

Python uses __init__.py files to mark folders as packages. Without them, Python cannot import code from one folder into another.

  • Create the app/ and app/services/ directories by running these commands:
mkdir app\services
  • Create the empty __init__.py files to turn these folders into Python packages by running:
New-Item app\__init__.py
New-Item app\services\__init__.py

What are __init__.py files?

An __init__.py file tells Python that a folder is a package. This lets you write imports like from app.services.ocr_service import extract_text_from_image.

These files can be completely empty. Just their presence is enough for Python to recognize the folder as a package.

  • Confirm the structure by running:
dir app -Recurse

You should see __init__.py inside app/ and a services folder containing its own __init__.py.

Don't see the files listed?

Make sure you ran the commands from the e-hanatra-backend folder. Check that your terminal's current directory matches your project root by looking at the path in the prompt.

If mkdir says the directory already exists, that means the folder was already created. You can skip that command and move on to creating the __init__.py files.

Help me troubleshoot my project folder structure.

Add the configuration settings

Every service in the E-hanatra backend needs access to shared settings like the Groq API key, the ChromaDB storage path, and chunking parameters. A central configuration file keeps these values in one place instead of scattered across multiple files.

  • In the Explorer sidebar, right-click the app folder.
  • Click New File.
  • Name the file config.py and press Enter.
  • Paste the following code into config.py:
import os
from dotenv import load_dotenv

# Load variables from the .env file into the environment
load_dotenv()

# Groq API key for the LLM chat completions
GROQ_API_KEY = os.getenv("GROQ_API_KEY")

# Local path for ChromaDB's persistent storage
CHROMA_DB_PATH = "./chroma_data"

# Text chunking settings for the RAG pipeline
CHUNK_SIZE = 500
CHUNK_OVERLAP = 50

# Groq model for chat completions
LLM_MODEL = "llama-3.1-8b-instant"

# ChromaDB collection name for storing course note embeddings
EMBEDDING_COLLECTION = "student_notes"

# Path to the Tesseract OCR executable on Windows
TESSERACT_CMD = r"C:\Program Files\Tesseract-OCR\tesseract.exe"

What does this code do?

load_dotenv() reads the .env file you created earlier and loads its values into the environment. This is how your Groq API key becomes available to the rest of the app without being hardcoded.

CHUNK_SIZE and CHUNK_OVERLAP control how your course notes get split into smaller pieces for the vector database. A chunk size of 500 characters with 50-character overlap keeps enough context in each piece for accurate retrieval.

TESSERACT_CMD points to the Tesseract OCR executable on your machine. The r prefix before the string tells Python to treat backslashes as literal characters instead of escape sequences.

  • Save config.py by pressing Ctrl+S (Windows) or Cmd+S (macOS).
  • Verify the file loads without errors by running this in the terminal:
python -c "from app.config import GROQ_API_KEY; print('Config loaded successfully')"

You should see Config loaded successfully printed in the terminal.

Seeing a ModuleNotFoundError?

If you see ModuleNotFoundError: No module named 'app', make sure your terminal is in the e-hanatra-backend folder (the one that contains the app/ directory).

If you see ModuleNotFoundError: No module named 'dotenv', check that your virtual environment is activated. Your terminal prompt should start with (venv).

Help me fix my config.py import error.

✔️ Awesome, I've got everything!

Great. Double check you've saved config.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete config.py file. Make sure your code matches this exactly.

import os
from dotenv import load_dotenv

load_dotenv()

GROQ_API_KEY = os.getenv("GROQ_API_KEY")
CHROMA_DB_PATH = "./chroma_data"
CHUNK_SIZE = 500
CHUNK_OVERLAP = 50
LLM_MODEL = "llama-3.1-8b-instant"
EMBEDDING_COLLECTION = "student_notes"
TESSERACT_CMD = r"C:\Program Files\Tesseract-OCR\tesseract.exe"

Your project skeleton is in place and your configuration is loading cleanly. Next up, you'll build the OCR service that extracts text from photos of course notes.

Build the OCR Ingestion Service

Your development environment is set up and ready to go. Now it's time to build E-hanatra's first real feature: the ability to read text from photos of course notes.

A photo is just pixels to a computer. To turn those pixels into searchable, retrievable text, you need OCR (Optical Character Recognition). In this step, you'll build an OCR service using Tesseract and test it against a real course photo.

In this step, get ready to:
  • Build an OCR service that preprocesses and extracts text from course photos.
  • Test the OCR pipeline with a real photo to verify it works.
Build the OCR extraction function

Your project already has an app/services/ directory from the previous step. This is where all of E-hanatra's backend services will live.

The OCR service takes raw image bytes from an uploaded photo, preprocesses the image for better accuracy, and runs Tesseract to extract the text. This extracted text is what the rest of the RAG pipeline will chunk, embed, and search through.

  • Create a new file called ocr_service.py inside the app/services/ folder.
  • Add the following code to ocr_service.py:
import pytesseract
from PIL import Image, ImageOps
from io import BytesIO
from app.config import TESSERACT_CMD

pytesseract.pytesseract.tesseract_cmd = TESSERACT_CMD


def extract_text_from_image(image_bytes: bytes) -> str:
    """Extract text from an image using Tesseract OCR with preprocessing."""
    image = Image.open(BytesIO(image_bytes))

    # Preprocessing for better OCR quality
    grayscale = ImageOps.grayscale(image)
    enhanced = ImageOps.autocontrast(grayscale)

    text = pytesseract.image_to_string(enhanced)
    return text.strip()

What does this code do?

This function is the core of the ingestion pipeline. It receives raw image bytes (from an uploaded file) and returns clean, extracted text.

  • pytesseract.pytesseract.tesseract_cmd tells Python where the Tesseract executable lives on your machine. This path comes from config.py, which you set up in Step 1.
  • Image.open(BytesIO(image_bytes)) converts the raw bytes into a Pillow image object that can be processed.
  • ImageOps.grayscale() and ImageOps.autocontrast() are preprocessing steps that improve OCR accuracy on phone camera photos.
  • pytesseract.image_to_string() runs Tesseract on the preprocessed image and returns the extracted text.

Why preprocess images before OCR?

Photos taken with a phone camera often have uneven lighting, color casts, and low contrast. These reduce OCR accuracy significantly.

Converting to grayscale removes color noise. Autocontrast stretches the brightness range so text stands out sharply against the background. Together, these two steps give Tesseract a much cleaner image to work with.

  • Save ocr_service.py.

You won't see a visible change yet. The next substep uses a test script to verify everything works.

Seeing a red underline on the imports?

Make sure your virtual environment is activated. In VS Code, press Ctrl+Shift+P, type Python: Select Interpreter, and choose the interpreter inside your venv folder.

help me fix import errors in my OCR service file

✔️ Awesome, I've got everything!

Double check you've saved ocr_service.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete ocr_service.py file for comparison.

import pytesseract
from PIL import Image, ImageOps
from io import BytesIO
from app.config import TESSERACT_CMD

pytesseract.pytesseract.tesseract_cmd = TESSERACT_CMD


def extract_text_from_image(image_bytes: bytes) -> str:
    """Extract text from an image using Tesseract OCR with preprocessing."""
    image = Image.open(BytesIO(image_bytes))

    # Preprocessing for better OCR quality
    grayscale = ImageOps.grayscale(image)
    enhanced = ImageOps.autocontrast(grayscale)

    text = pytesseract.image_to_string(enhanced)
    return text.strip()
Test OCR with a sample photo

Before building more services on top of the OCR output, you need to confirm it actually works. A quick test script will let you feed in a real course photo and see the extracted text.

  • Create a new file called test_ocr.py in the root of your e-hanatra-backend folder (not inside app/).
  • Add the following code to test_ocr.py:
from app.services.ocr_service import extract_text_from_image
from pathlib import Path

# Replace with the path to a real course photo on your machine
test_image_path = Path("test_photo.jpg")

if not test_image_path.exists():
    print(f"Place a test course photo at: {test_image_path.absolute()}")
    print("Then run this script again.")
else:
    image_bytes = test_image_path.read_bytes()
    text = extract_text_from_image(image_bytes)
    print("=== Extracted Text ===")
    print(text)
    print(f"\n=== Length: {len(text)} characters ===")

What does this script do?

  • It looks for an image file called test_photo.jpg in your project root.
  • If the file does not exist, it prints the expected path so you know where to place it.
  • If the file exists, it reads the image bytes, passes them through your OCR service, and prints the extracted text along with its character count.
  • Save test_ocr.py.
  • Take a photo of a page from your course notes (or any printed text) using your phone.
  • Transfer the photo to your computer and place it in the e-hanatra-backend folder.
  • Rename the photo to test_photo.jpg.

No course photo handy?

You can use any image with clearly printed text. A screenshot of a textbook page, a photo of a whiteboard, or even a photo of a printed article will work.

  • Run the test script by entering this command in your terminal:
python test_ocr.py

You should see the extracted text printed between === Extracted Text === and === Length: ... characters === lines. The text might not be perfect, but it should capture most of the words from your photo.

Seeing a TesseractNotFoundError?

This means Python cannot find the Tesseract executable. Check these common causes:

  • Verify Tesseract is installed by opening a new terminal window and running tesseract --version. If that fails, Tesseract is not on your system PATH.
  • Make sure the TESSERACT_CMD path in config.py matches your actual Tesseract install location. The default is C:\Program Files\Tesseract-OCR\tesseract.exe.
  • If you changed the install path during setup, update the TESSERACT_CMD value in app/config.py to match.

help me fix my Tesseract OCR setup

Getting very little or garbled text?

  • Make sure the photo is well-lit and the text is in focus. Blurry or dimly lit photos produce poor OCR results.
  • Try a different image. Printed text works better than handwritten notes for Tesseract.
  • Confirm your image is a .jpg or .png file. Other formats like HEIC (common on iPhones) need to be converted first.

help me improve my OCR text extraction quality

✔️ Awesome, I've got everything!

Double check you've saved both ocr_service.py and test_ocr.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete ocr_service.py file:

import pytesseract
from PIL import Image, ImageOps
from io import BytesIO
from app.config import TESSERACT_CMD

pytesseract.pytesseract.tesseract_cmd = TESSERACT_CMD


def extract_text_from_image(image_bytes: bytes) -> str:
    """Extract text from an image using Tesseract OCR with preprocessing."""
    image = Image.open(BytesIO(image_bytes))

    # Preprocessing for better OCR quality
    grayscale = ImageOps.grayscale(image)
    enhanced = ImageOps.autocontrast(grayscale)

    text = pytesseract.image_to_string(enhanced)
    return text.strip()

And here is the complete test_ocr.py file:

from app.services.ocr_service import extract_text_from_image
from pathlib import Path

# Replace with the path to a real course photo on your machine
test_image_path = Path("test_photo.jpg")

if not test_image_path.exists():
    print(f"Place a test course photo at: {test_image_path.absolute()}")
    print("Then run this script again.")
else:
    image_bytes = test_image_path.read_bytes()
    text = extract_text_from_image(image_bytes)
    print("=== Extracted Text ===")
    print(text)
    print(f"\n=== Length: {len(text)} characters ===")

Your OCR service can now turn photos into text. Next up, you'll set up ChromaDB to chunk that text, generate vector embeddings, and store them for semantic search.

Set Up ChromaDB and Document Chunking

Your OCR pipeline can extract text from course photos. The next challenge is storing that text in a way that lets students search it by meaning, not just by keywords.

In this step, you will build a vector store service using ChromaDB. This service splits extracted text into smaller chunks, converts them into vector embeddings, and stores them locally so you can retrieve the most relevant notes when a student asks a question.

In this step, get ready to:
  • Create a VectorStoreService class backed by ChromaDB with persistent local storage.
  • Implement document chunking so long notes get split into searchable pieces.
  • Add query, list, and delete methods for full document management.
Create the vector store service class

A vector store converts text into numerical arrays (embeddings) and lets you search by meaning instead of exact word matches. ChromaDB is a lightweight vector database that runs entirely on your machine with no external API needed for embeddings.

What is a vector embedding?

An embedding is a list of numbers that captures the meaning of a piece of text. Two sentences about the same topic will have similar numbers, even if they use different words.

ChromaDB automatically converts your text into embeddings using a local model called all-MiniLM-L6-v2. No API key required for embeddings.

  • Create a new file called vector_store.py inside the app/services/ folder.
  • Add the imports and the VectorStoreService class with its __init__ method by pasting the following code:
import uuid
from datetime import datetime, timezone
import chromadb
from langchain_text_splitters import RecursiveCharacterTextSplitter
from app.config import CHROMA_DB_PATH, CHUNK_SIZE, CHUNK_OVERLAP, EMBEDDING_COLLECTION


class VectorStoreService:
    def __init__(self):
        # Connect to a local ChromaDB database that persists to disk
        self.client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
        # Get or create the collection where student notes will be stored
        self.collection = self.client.get_or_create_collection(
            name=EMBEDDING_COLLECTION
        )
        # Set up the text splitter for chunking documents
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=CHUNK_SIZE,
            chunk_overlap=CHUNK_OVERLAP,
        )

What does this code do?

  • PersistentClient creates a ChromaDB database that saves data to a chroma_data folder on disk. Your embeddings survive between server restarts.
  • get_or_create_collection either opens the existing student_notes collection or creates a new one. ChromaDB uses the all-MiniLM-L6-v2 embedding model automatically behind the scenes.
  • RecursiveCharacterTextSplitter breaks long text into smaller chunks of 500 characters with a 50-character overlap so no content falls through the cracks.
  • Save vector_store.py.
  • Test that the service initializes correctly by running this command in your terminal:
python -c "from app.services.vector_store import VectorStoreService; vs = VectorStoreService(); print('VectorStoreService ready')"
  • You should see VectorStoreService ready printed in your terminal.

Seeing a download progress bar?

The first time ChromaDB runs, it downloads the all-MiniLM-L6-v2 embedding model (about 80MB). This is a one-time download that happens automatically. Let it finish before continuing.

Seeing an import error?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Check that the file is saved at app/services/vector_store.py, not directly in the app/ folder.
  • Confirm that both app/__init__.py and app/services/__init__.py exist. Python needs these files to recognize the folders as packages.

Why is my VectorStoreService import failing?

Add the document chunking method

Extracted OCR text can be hundreds or thousands of characters long. Searching through all of that at once produces vague results. Document chunking splits the text into smaller, focused pieces that each capture one idea or paragraph.

  • In vector_store.py, add the add_document method inside the VectorStoreService class, right below the __init__ method. Copy the following code:
    def add_document(self, text: str, filename: str) -> dict:
        """Chunk text and add to ChromaDB with metadata."""
        doc_id = str(uuid.uuid4())
        chunks = self.splitter.split_text(text)

        if not chunks:
            raise ValueError("No text chunks were generated from the document.")

        timestamp = datetime.now(timezone.utc).isoformat()

        # Create a unique ID for each chunk so ChromaDB can track them
        ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
        # Attach metadata to each chunk for filtering and display later
        metadatas = [
            {
                "doc_id": doc_id,
                "filename": filename,
                "chunk_index": i,
                "uploaded_at": timestamp,
            }
            for i in range(len(chunks))
        ]

        # Store the chunks, IDs, and metadata in ChromaDB
        self.collection.add(
            documents=chunks,
            ids=ids,
            metadatas=metadatas,
        )

        return {
            "doc_id": doc_id,
            "filename": filename,
            "chunk_count": len(chunks),
            "text_preview": text[:200],
            "uploaded_at": timestamp,
        }

What does this method do?

  • split_text(text) breaks the full OCR text into chunks of 500 characters. Consecutive chunks overlap by 50 characters so context is not lost at chunk boundaries.
  • Each chunk gets a unique ID built from the document ID plus its index (e.g. abc123_chunk_0). ChromaDB uses these IDs to track individual chunks.
  • Metadata like doc_id and filename is attached to every chunk. This makes it possible to trace an answer back to its source document later.
  • Save vector_store.py.

You will not see a visible change yet. The next substep adds the query and management methods that complete the service.

Add query and document management methods

The vector store needs three more capabilities. It needs to search for relevant chunks when a student asks a question. It needs to list all uploaded documents. It needs to delete a document and all its chunks.

  • In vector_store.py, add the query method below the add_document method. Copy the following code:
    def query(self, question: str, n_results: int = 5) -> dict:
        """Query the vector store for relevant chunks."""
        results = self.collection.query(
            query_texts=[question],
            n_results=n_results,
        )

        # Extract the matched documents and their metadata from the response
        documents = results.get("documents", [[]])[0]
        metadatas = results.get("metadatas", [[]])[0]

        return {
            "chunks": documents,
            "metadatas": metadatas,
        }

What does the query method do?

This is where semantic search happens. ChromaDB converts the student's question into an embedding and compares it against every stored chunk. The 5 most similar chunks are returned, ranked by relevance.

The results.get("documents", [[]])[0] pattern safely unpacks ChromaDB's nested response format. The outer list wraps all queries, and [0] grabs results for our single query.

  • Save vector_store.py.

You will not see a change yet. The next chunk adds the final two methods that complete the service.

  • Still in vector_store.py, add the list_documents and delete_document methods below the query method. Copy the following code:
    def list_documents(self) -> list[dict]:
        """List all unique documents in the collection."""
        all_data = self.collection.get()
        metadatas = all_data.get("metadatas", [])

        # De-duplicate: multiple chunks share the same doc_id
        seen = {}
        for meta in metadatas:
            doc_id = meta["doc_id"]
            if doc_id not in seen:
                seen[doc_id] = {
                    "doc_id": doc_id,
                    "filename": meta["filename"],
                    "uploaded_at": meta["uploaded_at"],
                }

        return list(seen.values())

    def delete_document(self, doc_id: str) -> bool:
        """Delete all chunks belonging to a document."""
        self.collection.delete(where={"doc_id": doc_id})
        return True

What do these methods do?

  • list_documents pulls all metadata from the collection, then de-duplicates by doc_id. A single uploaded photo might produce 10 chunks, but the student should see it listed as one document.
  • delete_document uses ChromaDB's where filter to find and remove every chunk that belongs to a given doc_id in one call.
  • Save vector_store.py.
  • Verify the complete service loads correctly by running this command in your terminal:
python -c "from app.services.vector_store import VectorStoreService; vs = VectorStoreService(); print(f'Collection: {vs.collection.name}'); print('All methods ready')"
  • You should see Collection: student_notes followed by All methods ready printed in your terminal.

Seeing a NameError or AttributeError?

  • Make sure every method is indented inside the VectorStoreService class. Python uses indentation to determine class membership.
  • Check that your imports at the top of the file include uuid, datetime, chromadb, and RecursiveCharacterTextSplitter.
  • Verify the config imports match what is in app/config.py: CHROMA_DB_PATH, CHUNK_SIZE, CHUNK_OVERLAP, and EMBEDDING_COLLECTION.

Help me debug my VectorStoreService errors.

Double check your complete vector_store.py file matches the full code below.

✔️ 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 uuid
from datetime import datetime, timezone
import chromadb
from langchain_text_splitters import RecursiveCharacterTextSplitter
from app.config import CHROMA_DB_PATH, CHUNK_SIZE, CHUNK_OVERLAP, EMBEDDING_COLLECTION


class VectorStoreService:
    def __init__(self):
        self.client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
        self.collection = self.client.get_or_create_collection(
            name=EMBEDDING_COLLECTION
        )
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=CHUNK_SIZE,
            chunk_overlap=CHUNK_OVERLAP,
        )

    def add_document(self, text: str, filename: str) -> dict:
        """Chunk text and add to ChromaDB with metadata."""
        doc_id = str(uuid.uuid4())
        chunks = self.splitter.split_text(text)

        if not chunks:
            raise ValueError("No text chunks were generated from the document.")

        timestamp = datetime.now(timezone.utc).isoformat()

        ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
        metadatas = [
            {
                "doc_id": doc_id,
                "filename": filename,
                "chunk_index": i,
                "uploaded_at": timestamp,
            }
            for i in range(len(chunks))
        ]

        self.collection.add(
            documents=chunks,
            ids=ids,
            metadatas=metadatas,
        )

        return {
            "doc_id": doc_id,
            "filename": filename,
            "chunk_count": len(chunks),
            "text_preview": text[:200],
            "uploaded_at": timestamp,
        }

    def query(self, question: str, n_results: int = 5) -> dict:
        """Query the vector store for relevant chunks."""
        results = self.collection.query(
            query_texts=[question],
            n_results=n_results,
        )

        documents = results.get("documents", [[]])[0]
        metadatas = results.get("metadatas", [[]])[0]

        return {
            "chunks": documents,
            "metadatas": metadatas,
        }

    def list_documents(self) -> list[dict]:
        """List all unique documents in the collection."""
        all_data = self.collection.get()
        metadatas = all_data.get("metadatas", [])

        seen = {}
        for meta in metadatas:
            doc_id = meta["doc_id"]
            if doc_id not in seen:
                seen[doc_id] = {
                    "doc_id": doc_id,
                    "filename": meta["filename"],
                    "uploaded_at": meta["uploaded_at"],
                }

        return list(seen.values())

    def delete_document(self, doc_id: str) -> bool:
        """Delete all chunks belonging to a document."""
        self.collection.delete(where={"doc_id": doc_id})
        return True

Your vector store service is ready to store and search course notes. Next up, you will build the RAG chat engine that takes a student's question, retrieves the most relevant chunks, and sends them to Groq's LLM for a grounded answer.

Build the RAG Chat Engine

Your vector store can chunk, embed, and search course notes by meaning. The foundation for semantic search is in place.

Now you need the brain that ties it all together. In this step, you'll build a RAG engine that retrieves relevant chunks from ChromaDB and sends them to Groq's Llama 3.1 8B model to generate grounded answers.

In this step, get ready to:
  • Define a system prompt that keeps answers grounded in uploaded course materials.
  • Build a RAGEngine class that connects the vector store to Groq's LLM.
  • Return structured responses with source file references for the frontend.
Create the RAG engine file and system prompt

The RAG engine is the component that turns a student's question into a grounded answer. It works in three stages: retrieve relevant chunks from the vector store, inject them into a prompt, and send everything to an LLM for a response.

  • Create a new file called rag_engine.py inside the app/services/ folder.
  • Add the imports and system prompt constant by pasting this code:
from groq import Groq
from app.config import GROQ_API_KEY, LLM_MODEL
from app.services.vector_store import VectorStoreService

# The system prompt defines the AI's personality and rules
SYSTEM_PROMPT = """You are E-hanatra, a personal study assistant for a university student.
You answer questions ONLY based on the course materials provided below.

Rules:
- If the answer is in the provided context, give a clear, helpful explanation.
- If the answer is NOT in the context, say: "I don't have information about this in your uploaded course materials. Try uploading more notes on this topic."
- Always reference which part of the notes your answer comes from.
- Use simple, clear language appropriate for a student."""

What does this code do?

The imports pull in the Groq client, your config constants, and the VectorStoreService you built in the previous step.

SYSTEM_PROMPT is the instruction set the LLM follows for every question. It tells the model to answer only from the provided context and to admit when it does not have relevant information. This is the key to preventing hallucination in a RAG system.

Why is this prompt so strict?

Without these rules, the LLM would happily answer any question using its general training data. A student asking about their biology lecture could get a response that sounds confident but is completely unrelated to their actual notes.

The strict rules force the model to stay within the context you provide. If the answer is not in the notes, it tells the student to upload more material instead of guessing.

  • Save rag_engine.py.

You won't see any output yet. The next chunk adds the class that uses this prompt.

Build the RAGEngine class

The RAGEngine class needs two things: a Groq client to talk to the LLM, and a reference to the vector store to retrieve context. Its chat method handles the full RAG flow from question to answer.

  • In rag_engine.py, add the RAGEngine class below the SYSTEM_PROMPT constant by pasting this code:

class RAGEngine:
    def __init__(self, vector_store: VectorStoreService):
        self.client = Groq(api_key=GROQ_API_KEY)
        self.vector_store = vector_store

    def chat(self, question: str) -> dict:
        """Answer a question using RAG."""
        # Step 1: Retrieve relevant chunks from the vector store
        retrieval = self.vector_store.query(question, n_results=5)
        chunks = retrieval["chunks"]
        metadatas = retrieval["metadatas"]

        # Handle case where no documents have been uploaded yet
        if not chunks:
            return {
                "answer": "You haven't uploaded any course materials yet. Upload some photos of your notes first!",
                "sources": [],
            }

        # Step 2: Build the context string from retrieved chunks
        context = "\n\n---\n\n".join(chunks)

        # Step 3: Construct the user message with context injected
        user_message = f"""Context from the student's course materials:

{context}

---

Student's question: {question}"""

        # Step 4: Send the prompt to Groq's LLM
        response = self.client.chat.completions.create(
            model=LLM_MODEL,
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": user_message},
            ],
            temperature=0.3,
            max_tokens=1024,
        )

        # Step 5: Extract unique source filenames for the frontend
        source_filenames = list(set(m["filename"] for m in metadatas))

        return {
            "answer": response.choices[0].message.content,
            "sources": source_filenames,
        }

What does this code do?

  • __init__ creates a Groq client using your API key and stores a reference to the vector store service.
  • self.vector_store.query(question, n_results=5) searches ChromaDB for the 5 chunks most semantically similar to the student's question.
  • The retrieved chunks are joined into a single context string separated by --- dividers, then injected into the user message alongside the question.
  • temperature=0.3 keeps the model's responses factual and consistent. Lower temperature means less randomness.
  • source_filenames extracts the unique filenames from the chunk metadata so the frontend can show which documents the answer came from.
  • Save rag_engine.py.

There is no visible output to check right now. You will test this engine through the API endpoints in the next steps.

Seeing a red underline on the Groq import?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • If VS Code is not using the right Python interpreter, press Ctrl+Shift+P, type Python: Select Interpreter, and choose the one inside your venv folder.

Why is my Groq import showing an error in VS Code?

Review the response format

The chat method returns a dictionary with two keys. This structured format is designed for the frontend to consume easily.

What does the response contain?

  • answer is the LLM's response text, grounded only in the student's uploaded notes.
  • sources is a list of unique filenames that the retrieved chunks came from. The frontend can display these so students know which documents the answer references.

If no documents have been uploaded yet, the method returns a friendly message asking the student to upload notes first.

✔️ Awesome, I've got everything!

Double check you've saved rag_engine.py and let's keep moving.

ⓧ I'd like to double check the full code

Here is the complete rag_engine.py file. Compare your code against this and fix any differences.

from groq import Groq
from app.config import GROQ_API_KEY, LLM_MODEL
from app.services.vector_store import VectorStoreService

SYSTEM_PROMPT = """You are E-hanatra, a personal study assistant for a university student.
You answer questions ONLY based on the course materials provided below.

Rules:
- If the answer is in the provided context, give a clear, helpful explanation.
- If the answer is NOT in the context, say: "I don't have information about this in your uploaded course materials. Try uploading more notes on this topic."
- Always reference which part of the notes your answer comes from.
- Use simple, clear language appropriate for a student."""


class RAGEngine:
    def __init__(self, vector_store: VectorStoreService):
        self.client = Groq(api_key=GROQ_API_KEY)
        self.vector_store = vector_store

    def chat(self, question: str) -> dict:
        """Answer a question using RAG."""
        retrieval = self.vector_store.query(question, n_results=5)
        chunks = retrieval["chunks"]
        metadatas = retrieval["metadatas"]

        if not chunks:
            return {
                "answer": "You haven't uploaded any course materials yet. Upload some photos of your notes first!",
                "sources": [],
            }

        context = "\n\n---\n\n".join(chunks)

        user_message = f"""Context from the student's course materials:

{context}

---

Student's question: {question}"""

        response = self.client.chat.completions.create(
            model=LLM_MODEL,
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": user_message},
            ],
            temperature=0.3,
            max_tokens=1024,
        )

        source_filenames = list(set(m["filename"] for m in metadatas))

        return {
            "answer": response.choices[0].message.content,
            "sources": source_filenames,
        }

Your RAG engine is ready to answer student questions grounded in their own course materials. Next up, you'll wire everything together with FastAPI endpoints so this engine can be called over HTTP.

Wire Up the FastAPI Endpoints

Your OCR service, vector store, and RAG engine are all built and ready to go. The missing piece is a way for the outside world to actually use them.

In this step, you will create a FastAPI application that wraps your services in HTTP endpoints. These endpoints will let a frontend (or Swagger UI) upload course photos, ask questions, and manage documents through simple API calls.

In this step, get ready to:
  • Create a FastAPI application with CORS configured for a Next.js frontend.
  • Build REST endpoints for uploading, chatting, listing, and deleting documents.
  • Add input validation and error handling across every endpoint.
Create the FastAPI app with CORS

The E-hanatra backend will eventually be consumed by a Next.js frontend running on http://localhost:3000. Browsers block cross-origin requests by default, so the API needs CORS middleware configured from the start.

  • In the Explorer sidebar of VS Code, open app/main.py. This file should be empty.
  • Add the imports, app creation, and CORS middleware by pasting the following code:
from fastapi import FastAPI, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from app.services.ocr_service import extract_text_from_image
from app.services.vector_store import VectorStoreService
from app.services.rag_engine import RAGEngine

# Create the FastAPI application with metadata
app = FastAPI(
    title="E-hanatra API",
    description="A personal Second Brain RAG API for university students.",
    version="1.0.0",
)

# Allow the Next.js frontend to make cross-origin requests
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

What does this code do?

The first six lines import everything the API needs. FastAPI is the web framework, UploadFile handles file uploads, and HTTPException lets you return structured error responses.

CORSMiddleware tells the browser that requests from http://localhost:3000 are allowed. Without this, a Next.js frontend would get blocked by the browser's same-origin policy.

The app also needs singleton instances of the services you built in previous steps, plus a list of accepted image types.

  • Add the service instances, allowed types, and Pydantic response models below the CORS middleware by pasting this code:
# Create singleton service instances shared by all endpoints
vector_store = VectorStoreService()
rag_engine = RAGEngine(vector_store)

# Only accept image uploads in these formats
ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]


# Pydantic models define the shape of request and response bodies
class ChatRequest(BaseModel):
    question: str


class ChatResponse(BaseModel):
    answer: str
    sources: list[str]


class DocumentResponse(BaseModel):
    doc_id: str
    filename: str
    chunk_count: int
    text_preview: str
    uploaded_at: str

What does this code do?

  • vector_store and rag_engine are created once when the app starts. Every endpoint shares these same instances so they all read from and write to the same ChromaDB collection.
  • ALLOWED_IMAGE_TYPES restricts uploads to JPEG, PNG, and WebP. Any other file type gets rejected before OCR runs.
  • The three Pydantic models (ChatRequest, ChatResponse, DocumentResponse) enforce the exact shape of data going into and out of the API. FastAPI validates incoming requests against these models automatically.
  • Save app/main.py.

Seeing a red underline on an import?

Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt. If it is missing, run venv\Scripts\activate from the e-hanatra-backend folder.

Help me fix import errors in my FastAPI project.

Implement the API endpoints

With the app skeleton in place, it is time to add the endpoints that give the frontend something to call. You will build four endpoints: upload, chat, list, and delete.

You will also add a quick health check endpoint so you can verify the server is running before testing anything else.

  • In app/main.py, add the health check and upload endpoints below the DocumentResponse class by pasting this code:
# Quick health check to verify the server is running
@app.get("/api/health")
def health_check():
    return {"status": "healthy", "service": "e-hanatra-api"}


# Upload a course photo, run OCR, chunk the text, and store embeddings
@app.post("/api/upload", response_model=DocumentResponse)
async def upload_document(file: UploadFile):
    # Reject non-image files before doing any processing
    if file.content_type not in ALLOWED_IMAGE_TYPES:
        raise HTTPException(
            status_code=400,
            detail=f"File type {file.content_type} not supported. Upload JPEG, PNG, or WebP images.",
        )

    image_bytes = await file.read()

    # Run OCR on the uploaded image
    try:
        text = extract_text_from_image(image_bytes)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"OCR failed: {str(e)}")

    # Make sure OCR actually found something
    if not text:
        raise HTTPException(
            status_code=400,
            detail="No text could be extracted from this image. Try a clearer photo.",
        )

    # Chunk the text and store embeddings in ChromaDB
    try:
        result = vector_store.add_document(text, file.filename)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return DocumentResponse(**result)

What does this code do?

  • The /api/health endpoint returns a simple JSON object confirming the server is alive. This is useful for quick smoke tests.
  • The /api/upload endpoint is where the real work happens. It validates the file type, reads the bytes, runs OCR, checks that text was actually extracted, then chunks and stores the result in ChromaDB.
  • The async keyword on upload_document lets FastAPI handle the file read without blocking other requests.
  • Save app/main.py.

You will not see a change yet. The next chunk adds the chat endpoint that ties into your RAG engine.

  • Add the chat endpoint below the upload_document function by pasting this code:
# Accept a student question, retrieve relevant chunks, and return a grounded answer
@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest):
    # Reject empty questions before hitting the LLM
    if not request.question.strip():
        raise HTTPException(status_code=400, detail="Question cannot be empty.")

    try:
        result = rag_engine.chat(request.question)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}")

    return ChatResponse(**result)

What does this code do?

This endpoint receives a JSON body matching the ChatRequest model (a single question field). It validates the question is not blank, then passes it to the RAG engine you built in Step 4.

The try/except block catches Groq API errors (rate limits, timeouts) and returns a clear error message instead of crashing the server.

  • Save app/main.py.

Two more endpoints to go. These let the frontend list all uploaded documents and delete individual ones.

  • Add the list and delete endpoints below the chat function by pasting this code:
# Return all uploaded documents with their metadata
@app.get("/api/documents")
def list_documents():
    documents = vector_store.list_documents()
    return {"documents": documents}


# Delete a document and all its chunks from ChromaDB
@app.delete("/api/documents/{doc_id}")
def delete_document(doc_id: str):
    vector_store.delete_document(doc_id)
    return {"status": "deleted", "doc_id": doc_id}

What does this code do?

  • GET /api/documents calls the vector store's list_documents() method to return every unique document with its filename and upload date.
  • DELETE /api/documents/{doc_id} removes all chunks belonging to a specific document from ChromaDB. The doc_id comes from the URL path itself.
  • Save app/main.py.

Seeing a syntax error or red underline?

Check that each code chunk was pasted below the previous one, not inside an existing function. Every @app decorator should be at the top level of the file (no indentation).

Help me fix a syntax error in my main.py file.

Review the error handling and validation patterns

You have already woven validation and error handling into each endpoint. Take a moment to understand the three layers of protection you just built.

What validation patterns does this API use?

  • Pydantic models enforce the structure of request and response bodies. If someone sends a chat request without a question field, FastAPI rejects it automatically with a 422 error.
  • Content-type checks on the upload endpoint reject any file that is not JPEG, PNG, or WebP before OCR even runs. This saves processing time and avoids confusing Tesseract errors.
  • try/except blocks around OCR and Groq API calls catch runtime failures (network timeouts, rate limits, corrupted images) and return informative error messages instead of crashing the server.

✔️ Awesome, I've got everything!

Double check you have saved app/main.py. Your file should have 5 endpoints: health, upload, chat, list, and delete.

ⓧ I'd like to double check the full code

Here is the complete app/main.py file. Compare it with yours and fix any differences.

from fastapi import FastAPI, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from app.services.ocr_service import extract_text_from_image
from app.services.vector_store import VectorStoreService
from app.services.rag_engine import RAGEngine

app = FastAPI(
    title="E-hanatra API",
    description="A personal Second Brain RAG API for university students.",
    version="1.0.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

vector_store = VectorStoreService()
rag_engine = RAGEngine(vector_store)

ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]


class ChatRequest(BaseModel):
    question: str


class ChatResponse(BaseModel):
    answer: str
    sources: list[str]


class DocumentResponse(BaseModel):
    doc_id: str
    filename: str
    chunk_count: int
    text_preview: str
    uploaded_at: str


@app.get("/api/health")
def health_check():
    return {"status": "healthy", "service": "e-hanatra-api"}


@app.post("/api/upload", response_model=DocumentResponse)
async def upload_document(file: UploadFile):
    if file.content_type not in ALLOWED_IMAGE_TYPES:
        raise HTTPException(
            status_code=400,
            detail=f"File type {file.content_type} not supported. Upload JPEG, PNG, or WebP images.",
        )

    image_bytes = await file.read()

    try:
        text = extract_text_from_image(image_bytes)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"OCR failed: {str(e)}")

    if not text:
        raise HTTPException(
            status_code=400,
            detail="No text could be extracted from this image. Try a clearer photo.",
        )

    try:
        result = vector_store.add_document(text, file.filename)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return DocumentResponse(**result)


@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest):
    if not request.question.strip():
        raise HTTPException(status_code=400, detail="Question cannot be empty.")

    try:
        result = rag_engine.chat(request.question)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}")

    return ChatResponse(**result)


@app.get("/api/documents")
def list_documents():
    documents = vector_store.list_documents()
    return {"documents": documents}


@app.delete("/api/documents/{doc_id}")
def delete_document(doc_id: str):
    vector_store.delete_document(doc_id)
    return {"status": "deleted", "doc_id": doc_id}

Your entire API is wired up and ready to run. In the next step, you will start the server and test every endpoint using FastAPI's built-in Swagger UI.

Test the Full RAG Pipeline

You have built every layer of the E-hanatra backend. The OCR service extracts text from photos, the vector store chunks and embeds that text, and the RAG engine answers questions grounded in those embeddings.

Now it is time to prove it all works together. You will start the FastAPI dev server, open its built-in Swagger UI, and test every endpoint with real course photos.

In this step, get ready to:
  • Start the FastAPI dev server and open Swagger UI.
  • Test all four API endpoints with a real course photo.
  • Validate RAG quality with multiple documents.
Start the server and open Swagger UI

FastAPI comes with an auto-generated interactive API explorer called Swagger UI. It lets you test every endpoint directly in your browser without writing any frontend code or using tools like Postman.

  • Make sure your virtual environment is active (you should see (venv) in your terminal prompt).
  • Start the FastAPI development server by running this command:
fastapi dev app/main.py

What does this command do?

The fastapi dev command starts a development server with auto-reload enabled. Any time you save a file, the server restarts automatically.

  • You should see output that includes Uvicorn running on http://127.0.0.1:8000.

First-run model download

The first time the server starts, ChromaDB downloads its default embedding model (all-MiniLM-L6-v2, roughly 80MB). This happens automatically and may take a minute on your first run.

Server not starting?

  • If you see ModuleNotFoundError, make sure your virtual environment is active. Run venv\Scripts\activate and try again.
  • If you see GROQ_API_KEY errors, double-check your .env file has the correct key and is saved in the e-hanatra-backend folder.
  • If the port is already in use, close any other terminals that might be running a server on port 8000.

Help me debug why my FastAPI server is not starting.

  • Open your browser and go to http://localhost:8000/docs.

You should see the Swagger UI page titled E-hanatra API with all five of your endpoints listed.

Test the complete workflow

You will now walk through each endpoint in the order a real student would use them. Have a photo of course notes ready on your computer. If you still have the test_photo.jpg from earlier, you can use that.

Best photos for testing

Use a photo with printed or clearly handwritten text. A photo of a textbook page, a whiteboard, or typed lecture notes works well. Blurry or low-contrast images will produce poor OCR results.

  • In Swagger UI, click on the POST /api/upload bar to expand it.
  • Click Try it out in the top right corner of the expanded section.
  • Click Choose File and select your course photo.
  • Click Execute.

The response body should show a 200 status code with a JSON object containing doc_id, filename, chunk_count, and a text_preview of the extracted text.

What just happened behind the scenes?

Your photo went through the entire ingestion pipeline. The OCR service extracted text from the image. The vector store split that text into chunks and stored their embeddings in ChromaDB.

The chunk_count tells you how many pieces your document was split into. A typical page of notes produces 2 to 10 chunks depending on how much text is in the photo.

  • Copy the doc_id value from the response. You will need it later to test the delete endpoint.

Now test the chat endpoint with a question about your uploaded notes.

  • Click on the POST /api/chat bar to expand it.
  • Click Try it out.
  • In the request body, replace the example question with a question about something you saw in the text_preview from your upload response.
  • Click Execute.

The response should contain an answer that references specific content from your notes. You should also see a sources array listing the filename of your uploaded photo.

Next, test how the AI handles questions it cannot answer from your materials.

  • In the same POST /api/chat section, change the question to something completely unrelated to your notes, such as What is the population of Mars?.
  • Click Execute.

The response should say something like "I don't have information about this in your uploaded course materials." This proves the system prompt is working correctly to prevent hallucination.

Now verify the list and delete endpoints.

  • Click on GET /api/documents to expand it.
  • Click Try it out, then click Execute.

You should see a documents array containing your uploaded file with its doc_id, filename, and uploaded_at timestamp.

  • Click on DELETE /api/documents/{doc_id} to expand it.
  • Click Try it out.
  • Paste the doc_id you copied earlier into the doc_id field.
  • Click Execute.

You should see a response with "status": "deleted". Run the GET /api/documents endpoint again to confirm the document is gone.

Getting a 500 error on chat?

  • Check your terminal for the full error traceback. If you see AuthenticationError, your Groq API key in .env is incorrect or expired. Generate a new one at console.groq.com.
  • If you see RateLimitError, the Groq free tier allows 30 requests per minute. Wait a moment and try again.
  • If the upload worked but chat returns empty sources, make sure you uploaded a photo with readable text before asking a question.

Help me debug my E-hanatra chat endpoint error.

Validate RAG quality with multiple documents

A single document is a good start, but the real value of a knowledge base comes from searching across multiple sources. Upload a few more photos to see how the retrieval handles a larger collection.

  • Upload 2 to 3 different course photos using POST /api/upload. Choose photos on different topics so you can test cross-document retrieval.
  • After uploading, run GET /api/documents to confirm all your documents appear in the list.
  • Use POST /api/chat to ask a question that could span content from multiple photos. Check whether the sources array in the response includes filenames from more than one document.
  • Ask a detailed question about a specific topic from one of your photos. Verify the answer references the correct source document.

Improve OCR quality for tricky photos

If the extracted text is garbled for certain photos, try photos with higher contrast or printed text. Handwritten notes with clear handwriting work well too.

For advanced tuning, you could add Pillow preprocessing steps like sharpening or thresholding to ocr_service.py. That is beyond this project's scope, but worth exploring if you work with lower-quality images.

Where is your data stored?

ChromaDB persists all your embeddings in the chroma_data/ folder inside your project directory. You can see this folder in your VS Code file explorer. Your documents survive server restarts because of this persistent storage.

Secret mission

Add Audio Transcription with Groq Whisper

Students don't just take photos of their notes. They record lectures too. Add audio transcription support using Groq's Whisper Large v3 Turbo so students can upload lecture recordings alongside their course photos.

Before you go

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 a free Groq API tier, so there are no ongoing costs.

Resources you used:

  • e-hanatra-backend project folder on your Desktop.
  • Python virtual environment (venv/).
  • ChromaDB persistent data (chroma_data/).
  • Tesseract OCR installation on your system.
  • Groq API account and API key.
  • .env file containing your Groq API key.

Keep everything running

No action needed. Choose this if you are still actively building or plan to connect this backend to a Next.js frontend soon.

  • Your project files, virtual environment, and ChromaDB data stay on your machine ready to use.
  • Your Groq API key remains active on the free tier with no charges.
  • To start the server again later, activate your virtual environment and run fastapi dev app/main.py.

Pause - I'll come back to this later

Shut down the running FastAPI server and deactivate your virtual environment to free up system resources. Your files and data stay intact.

  • Press Ctrl+C in your terminal to stop the FastAPI development server.
  • Run deactivate to exit the Python virtual environment.
  • Your project folder, ChromaDB data, and Groq API key all remain on your machine for next time.
  • To resume, navigate back to the e-hanatra-backend folder, run venv\Scripts\activate, then start the server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your uploaded documents and vector data permanently.

  • Press Ctrl+C in your terminal to stop the FastAPI development server if it is still running.
  • Run deactivate to exit the virtual environment.
  • Delete the entire e-hanatra-backend folder from your Desktop. This removes all project files, the virtual environment, and the ChromaDB data.
  • To revoke your Groq API key, go to the Groq console, navigate to API Keys, and delete the key you created.
  • Optionally, uninstall Tesseract OCR from Settings, then Apps, then Apps & features on Windows.

Mission accomplished

That's a Wrap!

Nice work! You just built the backend engine for E-hanatra, a personal AI-powered knowledge base for university students.

You've learned how to:

  • Built a FastAPI REST API with OCR-powered document ingestion that extracts text from course photos using Tesseract and Pillow.
  • Set up a persistent ChromaDB vector store with automatic embeddings and document chunking for semantic search over student notes.
  • Built a RAG chat engine powered by Groq's Llama 3.1 8B Instant that answers questions grounded strictly in the student's own uploaded materials.
  • Secret Mission: Added audio transcription support using Groq's Whisper Large v3 Turbo so students can upload lecture recordings alongside their photos.

Ready to quiz yourself?