Software Journal
Software Architecture Updated Sep 2, 2026 13 min read

School Handbook RAG: Flask + Gemini + FAISS Explained

Deep dive into the School Handbook RAG system: how Flask, Gemini embeddings, FAISS search and retrieval-augmented generation power an AI handbook chatbot.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
School Handbook RAG: Flask + Gemini + FAISS Explained

School Handbook RAG: How Flask, Gemini and FAISS Build an AI Handbook Assistant

A verified technical deep dive into azario0/school-handbook-rag — a Python Flask application that answers natural-language questions about the Pathways Academy student handbook using Retrieval-Augmented Generation (RAG).

If you are searching for a Python RAG example with Flask, Google Gemini and FAISS, or want to understand how retrieval-augmented generation works end-to-end without LangChain, this project is a clean, minimal reference implementation. Every claim below is verified against the actual repository code.

What Is This Project?

The School Handbook RAG system is an AI-powered Q&A chatbot for the fictional Pathways Academy. Instead of fine-tuning an LLM on the handbook, it keeps the handbook as an external knowledge base, retrieves the most relevant passages for each user question, and feeds them to Google’s Gemini model to generate a grounded answer. This avoids hallucinations and keeps answers traceable to source text.

The core idea is classic RAG: chunk → embed → index → retrieve → generate. The repo implements that pipeline in two phases:

  1. Offline indexing in Jupyter notebooks (notebook.ipynb and notebook2.ipynb)
  2. Online serving via a Flask web app (school-handbook-rag/app.py) with a chat UI (templates/index.html)

Features listed in the README.md — AI-powered Q&A, FAISS vector search, real-time chat, quick-question buttons, session history, and mobile-responsive design — are all implemented directly in these files.

Programming Languages Used

This is primarily a Python project, with a frontend layer in HTML, CSS and vanilla JavaScript:

  • Python (≥3.8): All RAG logic, embeddings, vector search and the web server. Verified in requirements.txt and app.py.
  • HTML5 / CSS3 / JavaScript (Vanilla): The chat interface is not a React or Vue app. It is a single HTML file with embedded CSS and JS, generated by the create_templates() function in app.py#L186-L735 and also checked in as templates/index.html. No build step or frontend framework is required.
  • Jupyter Notebook (IPython): Data preparation and prototyping live in notebook.ipynb (8 cells) and the demo/prototype notebook2.ipynb.

Technology Stack at a Glance

LayerTechnologyPurposeWhere Configured
Web FrameworkFlask 2.3.3HTTP server, routing, session managementapp.py#L11, requirements.txt#L1
LLM APIgoogle-generativeai 0.3.2Embeddings + generationapp.py#L3
Embedding Modelmodels/text-embedding-004Turns text chunks and queries into 768-dim vectorsapp.py#L16, notebook2.ipynb
Generation Modelgemini-2.0-flash-expGenerates natural-language answers from retrieved contextapp.py#L17
Vector DatabaseFAISS-CPU 1.7.4 (IndexFlatL2)Exact nearest-neighbor search with L2 distanceapp.py#L4, notebook.ipynb
Data SerializationNumPy 1.24.3 + pickleStores vectors and text chunksapp.py#L5-L6
Configpython-dotenv 1.0.0Environment variables (intended)README.md

Project Structure Explained

The repository has a slightly nested layout — the runnable app lives in a subfolder with the same name as the repo:

school-handbook-rag/                  # repo root (see https://github.com/azario0/school-handbook-rag)
├── README.md                         # docs and setup guide
├── LICENSE                           # MIT License
├── notebook.ipynb                    # offline: handbook string → chunks → embeddings → FAISS
├── notebook2.ipynb                   # offline + online prototype: retrieval + generation demo
└── school-handbook-rag/              # <-- actual Flask app
    ├── app.py                        # main Flask application (753 lines)
    ├── requirements.txt              # 5 pinned dependencies
    ├── .env                          # empty placeholder (you must create)
    ├── school_handbook.faiss         # FAISS index with 70 vectors (211 KB)
    ├── school_handbook_chunks.pkl    # 70 text chunks (64 KB)
    └── templates/
        └── index.html                # chat UI (535 lines, also generated by app.py)

This is verified by inspecting the file tree and by loading the pickle file, which contains exactly 70 chunks, matching the notebook log: FAISS index loaded... Total vectors: 70 and Chunks data loaded... Total chunks: 70.

Architecture: How Retrieval-Augmented Generation Works Here

[ Handbook Text ] 
      ↓
[ Chunking (regex by headers) ]  → notebook.ipynb cell 1
      ↓
[ Gemini Embedding (RETRIEVAL_DOCUMENT) ] → IndexFlatL2
      ↓
[.faiss + .pkl files]  ← served by Flask
      ↓
User Question → Gemini Embedding (RETRIEVAL_QUERY) → FAISS search (top_k=3)
      ↓
[ Top 3 chunks as context ] + Prompt Template → Gemini 2.0 Flash
      ↓
[ JSON response + source previews ] → Vanilla JS chat UI

There is no LangChain, no database, and no authentication — the entire RAG loop is ~120 lines of Python plus prompt templating.

Step 1: Data Preparation — The Handbook Source

The source of truth is a single Python string variable school_handbook_string defined in the first cell of notebook.ipynb. It contains the full Pathways Academy Student & Parent Handbook 2024-2025 in Markdown, starting with # Pathways Academy Student & Parent Handbook and including sections from 1. WELCOME through 10. TRANSPORTATION and a disclaimer. The table of contents explicitly lists numbered sections like 1.2 Our Mission, 3.4.1 Absences, 4.4 Dress Code, and 7.1 Emergency Procedures — the exact topics the quick-question buttons later target.

Step 2: Intelligent Chunking Strategy

Raw LLM context windows are limited and semantic search works best on focused passages. The function get_school_handbook_chunks() in notebook.ipynb cell 1 implements a rule-based chunker rather than naive character splitting:

Key method: get_school_handbook_chunks(school_handbook_string: str) -> list[str]

How it works:

  1. Split by major separator "\n---\n" — handbook sections separated by horizontal rules become major blocks.
  2. Keep Table of Contents intact — if a block contains **TABLE OF CONTENTS**, it is kept as one chunk.
  3. Sub-chunk by headers — otherwise, it scans line-by-line with two regexes:
    • numbered_header_pattern_str = r"^\s*\*\*(\d+(\.\d+){0,2})\.\s+(.+?)(?:\*\*|$)" — matches **1. TITLE**, **1.1. TITLE**, **3.4.1. TITLE** etc.
    • disclaimer_pattern_str = r"^\s*\*\(Disclaimer:.*?\)\*$" — matches the final *(Disclaimer: ...)* line.
  4. When a header line is found, the previous buffer is flushed as a complete chunk and a new chunk starts with that header. Residual lines are flushed at the end.

This produces 70 semantically coherent chunks — verified by get_school_handbook_chunks() output and the saved school_handbook_chunks.pkl. Each chunk retains its markdown header, so retrieved context is human-readable and LLM-friendly. The strategy preserves section boundaries instead of cutting mid-sentence, which improves retrieval precision for queries like “What is the dress code?”.

Step 3: Embedding and Indexing with Gemini and FAISS

Once chunked, each piece of text must be turned into a searchable vector.

In notebook.ipynb cells 4-7:

# Cell 4 — configuration
genai.configure(api_key=GEMINI_API_KEY)
EMBEDDING_MODEL_NAME = "models/text-embedding-004"
FAISS_INDEX_PATH = "school_handbook.faiss"
CHUNKS_DATA_PATH = "school_handbook_chunks.pkl"
token_counting_model = genai.GenerativeModel('gemini-2.0-flash-exp')
# Cell 6 — embedding (simplified)
response = genai.embed_content(
    model=EMBEDDING_MODEL_NAME,
    content=chunk,
    task_type="RETRIEVAL_DOCUMENT"  # important: document indexing mode
)
embeddings_list.append(response['embedding'])
# Cell 7 — FAISS indexing
dimension = embeddings_np.shape[1] # 768 for text-embedding-004
index = faiss.IndexFlatL2(dimension) # exact L2 search, no approximation
index.add(embeddings_np)
faiss.write_index(index, FAISS_INDEX_PATH)
with open(CHUNKS_DATA_PATH, "wb") as f:
    pickle.dump(handbook_chunks, f)

Key details verified in code:

  • Task types matter: It uses RETRIEVAL_DOCUMENT for indexing and RETRIEVAL_QUERY at search time — Gemini’s recommended way to optimize embedding space for asymmetric search.
  • Token counting: Cell 5 uses token_counting_model.count_tokens(chunk) to log tokens per chunk for debugging.
  • Index type: IndexFlatL2 is brute-force Euclidean search. With only 70 vectors it is instantaneous and 100% accurate; no need for IVF or HNSW.
  • Artifacts: The resulting school_handbook.faiss (211 KB) and school_handbook_chunks.pkl (64 KB) are committed to the repo, so the Flask app can run without re-embedding.

Step 4: Runtime Retrieval — Turning Questions into Vectors

At query time, the same embedding model converts the user’s question into a vector and finds the closest handbook chunks.

Key method 1 — Prototype in notebook2.ipynb cell retrieve_relevant_chunks:

def retrieve_relevant_chunks(user_prompt: str, faiss_index, text_chunks: list, top_k: int = 3) -> list[str]:
    query_embedding_response = genai.embed_content(
        model=EMBEDDING_MODEL_NAME,
        content=user_prompt,
        task_type="RETRIEVAL_QUERY"
    )
    query_vector_np = np.array([query_embedding_response['embedding']]).astype('float32')
    distances, indices = faiss_index.search(query_vector_np, top_k)
    # collect text_chunks[idx] for each returned index

Key method 2 — Production in app.py#L41-L78:

def retrieve_relevant_chunks(user_prompt: str, top_k: int = 3):
    if not loaded_index or not loaded_chunks:
        return [], []
    query_embedding_response = genai.embed_content(
        model=EMBEDDING_MODEL_NAME,
        content=user_prompt,
        task_type="RETRIEVAL_QUERY"
    )
    query_vector_np = np.array([query_embedding]).astype('float32')
    distances, indices = loaded_index.search(query_vector_np, top_k)
    # returns tuple: (relevant_chunks, chunk_info with distance + 200-char preview)

Differences: The Flask version uses global loaded_index / loaded_chunks, returns both chunks and a chunk_info dict for UI previews, and is called with top_k=3 by default in app.py#L132. Notebook logs show distances like 0.6486 for “What is the school’s mission?” matching **1.2. Our Mission** — proving semantic search works.

Loading: app.py#L28-L39 defines load_rag_data() which runs faiss.read_index(FAISS_INDEX_PATH) and pickle.load() at startup. Failure blocks the server from starting (see app.py#L745-L752).

Step 5: Response Generation with Gemini 2.0 Flash

Retrieved chunks alone are not user-friendly. The generation step synthesizes a helpful answer.

Key method: generate_response(user_question: str, relevant_chunks: list) in both notebook2.ipynb cell generate_response and app.py#L80-L111:

def generate_response(user_question: str, relevant_chunks: list):
    if not relevant_chunks:
        return "I couldn't find relevant information..."
    context = "\n\n".join([f"Source {i+1}:\n{chunk}" for i, chunk in enumerate(relevant_chunks)])
    prompt = f"""You are a helpful assistant for Pathways Academy. Use the following information...
CONTEXT FROM SCHOOL HANDBOOK:
{context}
STUDENT QUESTION: {user_question}
INSTRUCTIONS:
- Answer based primarily on the provided context
- Be clear, helpful, and student-friendly
- If context doesn't fully answer, suggest contacting administration
- Use warm, supportive tone
- Structure with headings or bullet points when appropriate
ANSWER:"""
    model = genai.GenerativeModel(GENERATION_MODEL_NAME) # gemini-2.0-flash-exp
    response = model.generate_content(prompt)
    return response.text

This is prompt-engineered RAG: The LLM is explicitly instructed to stay grounded in Source 1, Source 2 etc., fallback gracefully, and be student-friendly. The notebook demo shows a generated answer for “What is the school’s mission?” that correctly lists academic excellence, critical thinking, and responsible global citizens — directly from chunk **1.2. Our Mission**.

Step 6: Flask Backend — The Application Glue

school-handbook-rag/app.py is 753 lines and does four jobs: load data, retrieve, generate, and serve HTTP.

Configuration (Lines 11-22)

app = Flask(__name__)
app.secret_key = 'your-secret-key-change-this'
GEMINI_API_KEY = 'YOUR_API_KEY'
EMBEDDING_MODEL_NAME = "models/text-embedding-004"
GENERATION_MODEL_NAME = "gemini-2.0-flash-exp"
FAISS_INDEX_PATH = "school_handbook.faiss"

Note: Keys are hardcoded placeholders. In production, you must move them to environment variables (the empty .env and README.md suggest using python-dotenv).

API Endpoints

EndpointMethodHandlerPurpose
/GETindex() L113-L119Renders chat UI, initializes session['session_id'] + chat_history
/askPOSTask_question() L121-L159Receives {"question": "..."}, retrieves top 3 chunks, generates answer, appends to session['chat_history'], returns JSON with response, sources_info, timestamp
/historyGETget_history() L161-L165Returns session chat history
/clear_historyPOSTclear_history() L167-L172Clears session['chat_history']
/healthGEThealth_check() L174-L183Returns {"status": "healthy", "faiss_loaded": bool, "chunks_loaded": bool, "total_chunks": int}

The /ask handler is the heart of the app: it validates input, calls retrieve_relevant_chunks(question, top_k=3) and generate_response(question, relevant_chunks), stores an entry with timestamp, question, response, sources_used, and returns timestamp formatted as %I:%M %p.

Startup (Lines 737-752)

if __name__ == '__main__':
    create_templates() # ensures templates/index.html exists
    if load_rag_data():
        app.run(debug=True, host='0.0.0.0', port=5000)
    else:
        print("❌ Failed to load RAG data...")

Step 7: Frontend — Chat UI with Vanilla JavaScript

The UI is a single file templates/index.html (also embedded inside app.py#L196-L731 via create_templates()). Key sections:

  • Header (lines 510-514): Pathways Academy branding with gradient CSS (--primary-color: #2563eb, --gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%)).
  • Quick Questions (lines 516-538): 6 pre-set buttons — Mission & Vision, Dress Code, Attendance, Contact Information, Emergency Procedures, Academic Policies — each calls askQuickQuestion('...') which fills the input and triggers askQuestion().
  • Chat Container (lines 540-573): .chat-messages scrollable div with welcome message, .loading spinner, and .input-container with questionInput + sendBtn.
  • Controls (lines 575-582): Clear Chat and View History buttons.
  • JavaScript:
    • handleKeyPress(event) — sends on Enter
    • askQuickQuestion(question) — convenience wrapper
    • async askQuestion() — POSTs to /ask, disables button, shows spinner, calls addMessage(), handles errors
    • addMessage(text, sender, sources, timestamp) — creates .message.user or .message.assistant bubbles, renders sources_info with 200-char previews and source count
    • scrollToBottom(), clearHistory(), showHistory() — UX helpers for session management

The CSS is responsive (@media max-width: 768px) and uses CSS variables for theming — easy to customize per the README.md customization section.

How All Pieces Work Together: End-to-End Request Flow

  1. User opens http://localhost:5000 → Flask index() in app.py#L113 renders index.html and sets session_id.
  2. User types “Tell me about the dress code” → askQuestion() JS fetch('/ask') POSTs JSON.
  3. Flask ask_question() validates, calls retrieve_relevant_chunks() → Gemini embeds query with RETRIEVAL_QUERY, FAISS returns top 3 chunks (e.g., **4.4. Dress Code** at distance 0.6979).
  4. generate_response() builds prompt with Source 1:\n**4.4. Dress Code**... and calls gemini-2.0-flash-exp.
  5. Gemini returns student-friendly answer → Flask returns {response, sources_info, timestamp}.
  6. JS addMessage() renders assistant bubble + collapsible Sources Found (3) previews.
  7. Chat history persists in Flask session until /clear_history is called.

Running Locally and Customizing

Verified from README.md and requirements.txt:

git clone https://github.com/azario0/school-handbook-rag.git
cd school-handbook-rag/school-handbook-rag
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt  # Flask==2.3.3, faiss-cpu==1.7.4, etc.
echo "GEMINI_API_KEY=your_key" > .env
python app.py  # creates templates, loads 70 vectors, serves on 5000
curl http://localhost:5000/health
curl -X POST http://localhost:5000/ask -H "Content-Type: application/json" -d '{"question":"What is the school mission?"}'

To customize: change top_k in app.py#L132, edit the generation prompt in app.py#L89-L103, or replace CSS variables in index.html.

For production, the README.md recommends gunicorn -w 4 -b 0.0.0.0:8000 app:app, nginx reverse proxy, HTTPS, and rate limiting.

Verified Limitations and Improvement Opportunities

Checking actual code reveals honest trade-offs:

  • Hardcoded secrets: GEMINI_API_KEY = 'YOUR_API_KEY' and app.secret_key in app.py#L12-L15 should be os.getenv() — the .env file is committed empty.
  • Dual template generation: create_templates() in app.py#L186 writes templates/index.html at startup, but the file is also checked in. This is redundant but harmless.
  • No persistence beyond session: session['chat_history'] is in-memory Flask session, not a database — history clears on server restart.
  • No authentication or rate limiting: Open /ask endpoint could be abused.
  • Exact search only: IndexFlatL2 is fine for 70 docs but will not scale to thousands; would need IndexIVFFlat or HNSW.
  • Notebook duplication: notebook.ipynb and notebook2.ipynb share ~90% of code; could be refactored into a utils.py.

These are normal for an educational demo and are good first issues for contributors.

Conclusion: Why This RAG Example Stands Out

This project is valuable because it shows RAG without magic. In under 800 lines of Python and one HTML file, it demonstrates how to chunk documents intelligently, use Gemini’s text-embedding-004 with correct task_type flags, store vectors in FAISS, retrieve with L2 search, and prompt Gemini to stay grounded. If you are learning Retrieval-Augmented Generation with Python, building a Flask AI chatbot, or using FAISS for semantic search, this codebase is a verified, minimal template you can clone, run, and extend with your own handbook or docs.

MIT licensed — see LICENSE.


Verified against commit main on 2026-09-02. All file paths, function signatures, and counts (70 chunks/vectors) checked locally.

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.