Cybersecurity

Build a Private Course Assistant with RAG

Python Docker LLMs RAG Authentication OAuth Data Science Transformers Embeddings Vector Search Local AI Ollama LLaMA
1,968 words Includes Code

Build a Private Course Assistant with RAG

RAG-powered assistant for lecture notes, assignments, FAQs, and policies — with access control and citations

🎯 Key Takeaway: A course assistant needs more than just RAG — it needs access control (students see only their course), citation tracking (answers linked to sources), and privacy (student data stays local).

A student asks: "When is homework 3 due?"

A good course assistant answers:

Homework 3 is due Friday, October 18th at 11:59 PM.

Sources:
[1] CS101 Syllabus, Section 4.2 "Assignment Deadlines"
[2] CS101 FAQ, "Homework Submission Policy"

A great course assistant also:

  • ✅ Only answers questions about the student's enrolled course
  • ✅ Never reveals other students' grades or submissions
  • ✅ Cites the exact source for every answer
  • ✅ Runs locally — student data never leaves the institution

This article shows you how to build this system.

Architecture Overview

Private course assistant architecture with document sources, ingestion pipeline, vector store with access control, and query pipeline with citations
Figure 1: Private Course Assistant Architecture

Course Materials Structure

A course assistant needs four types of content:

Content Type Examples Update Frequency Access
Lecture Notes Slides, transcripts, summaries Weekly All students
Assignments HW descriptions, rubrics, examples Per assignment Students (after release)
FAQs Common questions, answers As needed All students
Policies Syllabus, grading, late policy Per semester All students

Directory Structure

courses/
├── cs101/
│   ├── lectures/
│   │   ├── week01_intro.md
│   │   ├── week02_variables.md
│   │   └── week03_loops.md
│   ├── assignments/
│   │   ├── hw01_description.md
│   │   ├── hw01_rubric.md
│   │   └── hw02_description.md
│   ├── faqs/
│   │   ├── submission_policy.md
│   │   ├── grading_questions.md
│   │   └── office_hours.md
│   └── policies/
│       ├── syllabus.md
│       ├── late_policy.md
│       └── academic_integrity.md
├── cs102/
│   └── ...

1. Document Ingestion with Metadata

from dataclasses import dataclass
from typing import List, Optional
import os

@dataclass
class CourseDocument:
    content: str
    metadata: dict

def ingest_course_materials(
    course_dir: str,
    course_id: str,
    access_level: str = "student"
) -> List[CourseDocument]:
    """
    Ingest all course materials with access metadata.
    
    Args:
        course_dir: Path to course directory
        course_id: Course identifier (e.g., "cs101")
        access_level: Default access level
    """
    documents = []
    
    for root, dirs, files in os.walk(course_dir):
        for file in files:
            if file.endswith((".md", ".txt")):
                filepath = os.path.join(root, file)
                
                # Determine content type from path
                content_type = "general"
                if "lectures" in filepath:
                    content_type = "lecture"
                elif "assignments" in filepath:
                    content_type = "assignment"
                elif "faqs" in filepath:
                    content_type = "faq"
                elif "policies" in filepath:
                    content_type = "policy"
                
                # Read content
                with open(filepath, "r") as f:
                    content = f.read()
                
                # Add metadata
                metadata = {
                    "course_id": course_id,
                    "content_type": content_type,
                    "filename": file,
                    "filepath": filepath,
                    "access_level": access_level,
                    "semester": "Fall 2026"
                }
                
                documents.append(CourseDocument(
                    content=content,
                    metadata=metadata
                ))
    
    return documents

# Ingest CS101 materials
docs = ingest_course_materials(
    course_dir="./courses/cs101",
    course_id="cs101"
)
print(f"Ingested {len(docs)} documents")

2. Section-Aware Chunking

from langchain.text_splitter import RecursiveCharacterTextSplitter

def chunk_course_document(
    doc: CourseDocument,
    chunk_size: int = 1000,
    chunk_overlap: int = 200
) -> List[dict]:
    """Chunk a course document while preserving metadata."""
    
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", ". ", " "]
    )
    
    chunks = text_splitter.split_text(doc.content)
    
    return [
        {
            "content": chunk,
            "metadata": {
                **doc.metadata,
                "chunk_id": i,
                "total_chunks": len(chunks)
            }
        }
        for i, chunk in enumerate(chunks)
    ]

# Chunk all documents
all_chunks = []
for doc in docs:
    chunks = chunk_course_document(doc)
    all_chunks.extend(chunks)

print(f"Created {len(all_chunks)} chunks from {len(docs)} documents")

3. Access Control Implementation

Access control ensures students only see their own course materials.

from enum import Enum
from typing import Set

class Role(Enum):
    STUDENT = "student"
    TA = "ta"
    INSTRUCTOR = "instructor"
    ADMIN = "admin"

class AccessController:
    """Manages access control for course materials."""
    
    def __init__(self):
        self.user_courses = {}  # user_id -> set of course_ids
        self.user_roles = {}    # user_id -> role
    
    def register_user(
        self,
        user_id: str,
        role: Role,
        course_ids: Set[str]
    ):
        """Register a user with their role and courses."""
        self.user_courses[user_id] = course_ids
        self.user_roles[user_id] = role
    
    def get_accessible_courses(self, user_id: str) -> Set[str]:
        """Get courses a user can access."""
        role = self.user_roles.get(user_id, Role.STUDENT)
        courses = self.user_courses.get(user_id, set())
        
        if role == Role.ADMIN:
            # Admin can access all courses
            return {"cs101", "cs102", "cs103"}  # All courses
        
        return courses
    
    def filter_chunks(
        self,
        user_id: str,
        chunks: List[dict]
    ) -> List[dict]:
        """Filter chunks based on user access."""
        accessible_courses = self.get_accessible_courses(user_id)
        
        return [
            chunk for chunk in chunks
            if chunk["metadata"]["course_id"] in accessible_courses
        ]

# Example usage
ac = AccessController()

# Register users
ac.register_user("student_alice", Role.STUDENT, {"cs101"})
ac.register_user("student_bob", Role.STUDENT, {"cs102"})
ac.register_user("ta_charlie", Role.TA, {"cs101", "cs102"})
ac.register_user("instructor_diana", Role.INSTRUCTOR, {"cs101", "cs102", "cs103"})

# Filter chunks for Alice
alice_chunks = ac.filter_chunks("student_alice", all_chunks)
print(f"Alice can access {len(alice_chunks)} chunks")

# Filter chunks for Charlie (TA)
charlie_chunks = ac.filter_chunks("ta_charlie", all_chunks)
print(f"Charlie can access {len(charlie_chunks)} chunks")

Access Control Matrix

Role Own Course Other Courses Grades Admin
Student ✅ Read ❌ No access ✅ Own only ❌ No access
TA ✅ Read + Grade ❌ No access ✅ Assigned students ❌ No access
Instructor ✅ Full access ✅ Their courses ✅ All students ❌ No access
Admin ✅ Full access ✅ All courses ✅ All students ✅ Full access

4. Retrieval with Access Control

import chromadb
from sentence_transformers import SentenceTransformer

class CourseAssistant:
    def __init__(self):
        self.embedding_model = SentenceTransformer("nomic-embed-text")
        self.client = chromadb.Client()
        self.collection = self.client.create_collection("course_materials")
        self.access_controller = AccessController()
        self.llm = ollama
    
    def ingest_course(self, course_dir: str, course_id: str):
        """Ingest course materials."""
        docs = ingest_course_materials(course_dir, course_id)
        
        for doc in docs:
            chunks = chunk_course_document(doc)
            for chunk in chunks:
                embedding = self.embedding_model.encode(chunk["content"])
                self.collection.add(
                    documents=[chunk["content"]],
                    embeddings=[embedding.tolist()],
                    metadatas=[chunk["metadata"]],
                    ids=[f"{course_id}_{chunk['metadata']['chunk_id']}"]
                )
    
    def query(
        self,
        user_id: str,
        question: str,
        course_id: str,
        k: int = 5
    ) -> dict:
        """
        Query with access control.
        
        Args:
            user_id: User asking the question
            question: The question
            course_id: Course to search in
            k: Number of results
        """
        # Check access
        accessible = self.access_controller.get_accessible_courses(user_id)
        if course_id not in accessible:
            return {
                "answer": "You don't have access to this course.",
                "sources": [],
                "error": "Access denied"
            }
        
        # Search with course filter
        query_embedding = self.embedding_model.encode([question])
        
        results = self.collection.query(
            query_embeddings=query_embedding.tolist(),
            n_results=k,
            where={"course_id": course_id}  # Filter by course
        )
        
        # Build context with citations
        context_parts = []
        sources = []
        
        for i, (doc, meta) in enumerate(
            zip(results["documents"][0], results["metadatas"][0]),
            1
        ):
            citation = f"[{i}] {meta['filename']}, {meta['content_type']}"
            context_parts.append(f"Source {i} ({citation}):\n{doc}")
            sources.append({
                "filename": meta["filename"],
                "type": meta["content_type"],
                "citation": citation
            })
        
        context = "\n\n".join(context_parts)
        
        # Generate answer
        prompt = f"""Answer the question using ONLY the provided context.
Cite sources using [Source X] format.
If the context doesn't contain the answer, say "I don't have that information in the course materials."

Course: {course_id}
Context:
{context}

Question: {question}

Answer with citations:"""
        
        response = self.llm.chat(
            model="llama3.1:8b",
            messages=[{"role": "user", "content": prompt}]
        )
        
        return {
            "answer": response["message"]["content"],
            "sources": sources
        }

5. Citation Formatting

Good citations help students verify answers and find more information.

def format_course_citation(chunk: dict) -> str:
    """Format a chunk as a course citation."""
    meta = chunk["metadata"]
    
    # Format based on content type
    if meta["content_type"] == "lecture":
        return f"[{meta['filename'].replace('.md', '')}]"
    elif meta["content_type"] == "assignment":
        return f"[{meta['filename'].replace('.md', '')}]"
    elif meta["content_type"] == "faq":
        return f"[FAQ: {meta['filename'].replace('.md', '')}]"
    elif meta["content_type"] == "policy":
        return f"[Policy: {meta['filename'].replace('.md', '')}]"
    else:
        return f"[{meta['filename']}]"

# Example citations
citations = [
    format_course_citation({"metadata": {"filename": "week03_loops.md", "content_type": "lecture"}}),
    format_course_citation({"metadata": {"filename": "hw01_description.md", "content_type": "assignment"}}),
    format_course_citation({"metadata": {"filename": "submission_policy.md", "content_type": "faq"}}),
    format_course_citation({"metadata": {"filename": "late_policy.md", "content_type": "policy"}}),
]

# Output:
# [week03_loops]
# [hw01_description]
# [FAQ: submission_policy]
# [Policy: late_policy]

Complete Working Example

#!/usr/bin/env python3
"""
Private Course Assistant with RAG
Complete working example with access control and citations.
"""

import os
import chromadb
from sentence_transformers import SentenceTransformer
from dataclasses import dataclass
from typing import List, Set
from enum import Enum

# Create synthetic course materials
def create_sample_course():
    """Create sample CS101 course materials."""
    os.makedirs("courses/cs101/lectures", exist_ok=True)
    os.makedirs("courses/cs101/assignments", exist_ok=True)
    os.makedirs("courses/cs101/faqs", exist_ok=True)
    os.makedirs("courses/cs101/policies", exist_ok=True)
    
    # Lecture notes
    with open("courses/cs101/lectures/week01_intro.md", "w") as f:
        f.write("""# Week 1: Introduction to Python

## Learning Objectives
- Understand Python basics
- Set up development environment
- Write your first program

## Key Concepts
Python is a high-level, interpreted programming language.
It emphasizes code readability with significant indentation.

## Hello World
```python
print("Hello, World!")
```

## Variables
```python
name = "Alice"
age = 20
gpa = 3.8
```
""")
    
    # Assignments
    with open("courses/cs101/assignments/hw01_description.md", "w") as f:
        f.write("""# Homework 1: Python Basics

## Due Date
Friday, September 6th at 11:59 PM

## Submission
Submit via Canvas. Late submissions lose 10% per day.

## Requirements
1. Write a program that calculates the area of a circle
2. Create a function that converts temperature
3. Build a simple calculator

## Grading
- Correctness: 60%
- Code style: 20%
- Documentation: 20%
""")
    
    # FAQs
    with open("courses/cs101/faqs/submission_policy.md", "w") as f:
        f.write="""# Submission Policy FAQ

## Q: How do I submit homework?
A: Submit via Canvas. Click "Submit Assignment" on the homework page.

## Q: Can I submit late?
A: Yes, but late submissions lose 10% per day. No submissions after 3 days.

## Q: Can I work with others?
A: You may discuss concepts, but code must be your own. See academic integrity policy.

## Q: What file format should I use?
A: Submit .py files. Name them: firstname_lastname_hw01.py
""")
    
    # Policies
    with open("courses/cs101/policies/late_policy.md", "w") as f:
        f.write="""# Late Submission Policy

## Standard Policy
- Assignments due at 11:59 PM on the due date
- 10% deduction per day late
- No submissions accepted after 3 days

## Extensions
Extensions granted only for:
- Medical emergencies (with documentation)
- University-approved absences
- Contact instructor at least 24 hours before deadline

## Grade Impact
Late submissions may affect participation grade.
""")

# Run the example
if __name__ == "__main__":
    # Create sample course
    create_sample_course()
    
    # Initialize components
    embedding_model = SentenceTransformer("nomic-embed-text")
    client = chromadb.Client()
    collection = client.create_collection("course_materials")
    
    # Ingest course materials
    for root, dirs, files in os.walk("courses/cs101"):
        for file in files:
            if file.endswith(".md"):
                filepath = os.path.join(root, file)
                with open(filepath, "r") as f:
                    content = f.read()
                
                # Determine content type
                content_type = "general"
                if "lectures" in filepath:
                    content_type = "lecture"
                elif "assignments" in filepath:
                    content_type = "assignment"
                elif "faqs" in filepath:
                    content_type = "faq"
                elif "policies" in filepath:
                    content_type = "policy"
                
                embedding = embedding_model.encode(content)
                collection.add(
                    documents=[content],
                    embeddings=[embedding.tolist()],
                    ids=[filepath],
                    metadatas=[{
                        "course_id": "cs101",
                        "content_type": content_type,
                        "filename": file
                    }]
                )
                print(f"Ingested: {file}")
    
    # Query
    query = "When is homework 1 due?"
    query_embedding = embedding_model.encode([query])
    
    results = collection.query(
        query_embeddings=query_embedding.tolist(),
        n_results=3,
        where={"course_id": "cs101"}
    )
    
    print("\n=== Results ===")
    for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
        print(f"\n📄 {meta['filename']} ({meta['content_type']})")
        print(f"   {doc[:200]}...")

Run It

# Save as course_assistant.py
python course_assistant.py

# Output:
# Ingested: week01_intro.md
# Ingested: hw01_description.md
# Ingested: submission_policy.md
# Ingested: late_policy.md
#
# === Results ===
# 📄 hw01_description.md (assignment)
#    # Homework 1: Python Basics
#    ## Due Date
#    Friday, September 6th at 11:59 PM...
# 📄 late_policy.md (policy)
#    # Late Submission Policy
#    ## Standard Policy
#    - Assignments due at 11:59 PM...

Privacy Considerations

Concern Risk Mitigation
Student data Grades, submissions exposed Access control, local processing
Course materials Unauthorized access Role-based filtering
API calls Data sent to external services Use local LLM (Ollama)
Logs Query history exposed Minimal logging, no PII
⚠️ Important: Student data (grades, submissions) should NEVER be sent to external APIs. Use local LLMs for privacy.

Try It Yourself

Build your own course assistant with these BestWordz resources:

🤖 Local AI Assistant

Complete RAG project with Docker

Build It →

🏗️ RAG Architecture

Complete guide to every component

Learn More →

📚 Research Paper RAG

PDF processing with citations

Explore →

🔐 AI Privacy Guide

Privacy-by-design principles

Read Guide →

Conclusion

A private course assistant needs four key components:

  1. Course Materials: Lectures, assignments, FAQs, policies
  2. Access Control: Students see only their course
  3. Citations: Every answer linked to source
  4. Privacy: Local processing, no external APIs

The workflow is:

Course Materials → Ingest with Metadata → Vector Store
                                                    ↓
Student Query → Auth Check → Filter by Access → Search → Answer + Citations

For production systems, consider:

  • Web interface (FastAPI + frontend)
  • User authentication (OAuth, SSO)
  • Analytics (popular questions, gaps)
  • Feedback loop (correct/incorrect answers)
  • Multi-course support

Further Reading