Authored by Dr. Tiziana Ligorio for AI Agents - CSCI 395.32 taught at Hunter College of The City University of New York¶
In this demo, we build a simple AI workflow that demonstrates the Retrieval Augmented Generation (RAG) pattern — using a fully local stack with no API calls required.
We will create a FastHTML tutor that can answer questions about the FastHTML library by retrieving relevant information from its documentation.
Our workflow consists of the following stages:
Document Loading — Load the FastHTML documentation from a text file
Chunking — Split the documentation into manageable pieces
Embedding — Create vector embeddings for each chunk using Ollama
Storage — Store the embeddings in a local Postgres database with pgvector
Retrieval — Query the database for relevant chunks based on user questions
Generation — Use the retrieved context to generate accurate answers
This tutorial mirrors the hosted RAG tutorial (using OpenRouter and Supabase) but runs entirely on your local machine. This approach offers:
Full privacy — Your data never leaves your machine
No API costs — After initial setup, everything runs locally
Offline capability — Works without an internet connection
We use Ollama for local LLM and embeddings, and Postgres with pgvector for vector storage.
Prerequisites Setup¶
Before running this notebook, you need to install and set up two components:
Ollama — Local LLM server
Docker — To run Postgres with pgvector
Step 1 — Install and Set Up Ollama¶
Download and install Ollama from https://
ollama .com /download Start Ollama:
macOS: Open Ollama from Applications (or Spotlight: Cmd+Space, type “Ollama”). A llama icon will appear in your menu bar.
Windows: Open Ollama from the Start menu. A llama icon will appear in your system tray.
Linux: Run
ollama servein a terminal (keep it running, or run as a background service).
Open a terminal and pull the models we’ll use:
# Pull a chat model
ollama pull llama3.2
# Pull an embedding model
ollama pull mxbai-embed-largeVerify the models are available:
ollama listYou should see both models listed.
Step 2 — Install Docker and Start Postgres with pgvector¶
Download and install Docker from https://
docs .docker .com /get -docker/ Start Docker:
macOS: Open Docker from Applications (or Spotlight: Cmd+Space, type “Docker”). Wait for the whale icon in the menu bar to stop animating.
Windows: Open Docker Desktop from the Start menu. Wait for the whale icon in the system tray to show “Docker Desktop is running”.
Linux: Run
sudo systemctl start docker(orsudo service docker starton older systems).
Start a Postgres container with pgvector:
docker run -d \
--name pgvector \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=vectors \
-p 5432:5432 \
pgvector/pgvector:pg16Verify it’s running:
docker psYou should see the pgvector container running.
Note: To stop the container later: docker stop pgvector
To restart it: docker start pgvector
Installs and Imports¶
%%capture
!pip install ollama psycopg2-binary python-dotenv%%capture hides the pip install output to keep the notebook clean.
# Ollama: Python client for running local LLMs and embeddings
import ollama
# psycopg2: PostgreSQL adapter for Python
import psycopg2
# Standard library
import os
import hashlib # For generating content hashes to prevent duplicate document insertionsOllama is a tool for running large language models locally. It provides a simple API for chat completions and embeddings, similar to OpenAI’s API but running entirely on your machine.
psycopg2 is the most popular PostgreSQL adapter for Python. We use it to connect to our local Postgres database with pgvector.
Initialize the Clients¶
We need to set up:
Ollama — Already running as a service, we just use the
ollamamodulePostgres connection — Connect to our local database
We’ll also define the models we’ll use:
Chat model:
llama3.2for generating responsesEmbedding model:
mxbai-embed-largefor creating vector embeddings (1024 dimensions)
# Database connection parameters
DB_HOST = "localhost"
DB_PORT = "5432"
DB_NAME = "vectors"
DB_USER = "postgres"
DB_PASSWORD = "postgres"
# Models to use
CHAT_MODEL = "llama3.2"
EMBEDDING_MODEL = "mxbai-embed-large"
EMBEDDING_DIM = 1024 # mxbai-embed-large produces 1024-dimensional vectors
# Test Ollama connection
try:
response = ollama.list()
print(f"Ollama is running. Available models: {[m.model for m in response.models]}")
except Exception as e:
print(f"Error connecting to Ollama: {e}")
print("Make sure Ollama is running (try 'ollama serve' in terminal)")
# Test Postgres connection
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
conn.close()
print(f"Postgres connection successful to {DB_NAME}")
except Exception as e:
print(f"Error connecting to Postgres: {e}")
print("Make sure the Docker container is running")Ollama is running. Available models: ['nomic-embed-text:latest', 'llama3.2:latest']
Postgres connection successful to vectors
Set Up the Database¶
We need to enable the pgvector extension and create our documents table.
def get_db_connection():
"""Create and return a database connection."""
return psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
# Enable pgvector extension and create table
conn = get_db_connection()
cur = conn.cursor() # Create a cursor
# Enable the vector extension
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
# Create the documents table
cur.execute(f"""
CREATE TABLE IF NOT EXISTS documents (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector({EMBEDDING_DIM}),
content_hash TEXT UNIQUE
);
""")
conn.commit()
cur.close()
conn.close()
print("Database setup complete: pgvector enabled and documents table created")Database setup complete: pgvector enabled and documents table created
Step 1: Document Loading¶
The first step in building a RAG system is loading the source documents. In our case, we have a single text file containing the FastHTML documentation.
# Load the FastHTML documentation
with open("FastHTML.txt", "r", encoding="utf-8") as f:
document = f.read()
print(f"Loaded document with {len(document)} characters")
document[:200]Loaded document with 403766 characters
'### Minimal FastHTML Application Example\n\nSource: https://www.fastht.ml/docs/ref/concise_guide.html\n\nDemonstrates a basic FastHTML application setup. It includes defining an app instance, a route with'Step 2: Chunking¶
Large documents need to be split into smaller pieces (chunks) for two reasons:
Embedding models have token limits — Most embedding models can only process a limited amount of text at once
Retrieval precision — Smaller chunks allow us to retrieve more relevant, focused context
Chunking Strategy¶
We’ll implement a simple recursive character text splitter to demonstrate how chunking works:
Split the text into chunks of approximately 1000 characters
Include 200 characters of overlap between chunks to preserve context across boundaries
The overlap ensures that if important information spans a chunk boundary, it will appear in at least one complete chunk.
Note: Since our FastHTML documentation is in markdown format, a better choice might be to use a markdown-aware splitter such as MarkdownTextSplitter from the langchain-text-splitters package. These splitters respect markdown structure (headers, code blocks, etc.) and can preserve header hierarchy as metadata. Here, we implement a simple character-based splitter to illustrate the core concepts.
def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]:
"""
Split text into overlapping chunks.
Args:
text: The text to split
chunk_size: Target size for each chunk (in characters)
overlap: Number of characters to overlap between chunks
Returns:
List of text chunks
"""
chunks = []
start = 0
while start < len(text):
# Get the chunk
end = start + chunk_size
chunk = text[start:end]
# Try to break at a natural boundary (newline or period)
if end < len(text):
# Look for a good break point near the end
last_newline = chunk.rfind('\n')
last_period = chunk.rfind('. ')
# Use the later of the two break points, if found in the last 20% of the chunk
break_point = max(last_newline, last_period)
if break_point > chunk_size * 0.8:
chunk = text[start:start + break_point + 1]
end = start + break_point + 1
chunks.append(chunk.strip())
# Move start position, accounting for overlap
start = end - overlap
return chunks# Split the document into chunks
chunks = chunk_text(document, chunk_size=1000, overlap=200)
print(f"Created {len(chunks)} chunks")
print(f"\nExample chunk (chunk 0):\n{chunks[0][:500]}...")
print(f"\nChunk sizes: min={min(len(c) for c in chunks)}, max={max(len(c) for c in chunks)}, avg={sum(len(c) for c in chunks)//len(chunks)}")Created 527 chunks
Example chunk (chunk 0):
### Minimal FastHTML Application Example
Source: https://www.fastht.ml/docs/ref/concise_guide.html
Demonstrates a basic FastHTML application setup. It includes defining an app instance, a route with type-constrained parameters, and serving the application. The example highlights FastHTML's approach to routing, HTML generation using FastTags, and automatic server startup.
```python
# Meta-package with all key symbols from FastHTML and Starlette. Import it like this at the start of every FastHT...
Chunk sizes: min=619, max=1000, avg=964
Step 3: Create Embeddings¶
Embeddings are numerical representations of text that capture semantic meaning. Similar texts will have similar embeddings (vectors that are close together in high-dimensional space).
We use Ollama’s mxbai-embed-large model, which produces 1024-dimensional vectors. This model runs entirely locally and provides strong retrieval performance for technical content.
How Embeddings Work¶
Text goes into the embedding model
The model outputs a vector of 1024 floating-point numbers
These numbers encode the semantic meaning of the text
Similar meanings → similar vectors → can be found via similarity search
def get_embedding(text: str) -> list[float]:
"""
Get the embedding vector for a piece of text using Ollama.
Args:
text: The text to embed
Returns:
A list of floats representing the embedding vector
"""
response = ollama.embed(
model=EMBEDDING_MODEL,
input=text
)
return response.embeddings[0]Pedagoggical note: Always understand what you are working with! Explore response directly to understand its structure
response = ollama.embed(
model=EMBEDDING_MODEL,
input="What is FastHTML?"
)
print(f"Response fields: {response.model_dump().keys()}")
print(f"Number of embeddings: {len(response.embeddings)}")
print(f"Embedding dimension: {len(response.embeddings[0])}")Response fields: dict_keys(['model', 'created_at', 'done', 'done_reason', 'total_duration', 'load_duration', 'prompt_eval_count', 'prompt_eval_duration', 'eval_count', 'eval_duration', 'embeddings'])
Number of embeddings: 1
Embedding dimension: 1024
# Test the embedding function with a sample text
sample_embedding = get_embedding("What is FastHTML?")
print(f"Embedding dimension: {len(sample_embedding)}")
print(f"First 10 values: {sample_embedding[:10]}")Embedding dimension: 1024
First 10 values: [-0.055831574, -0.045276552, -0.03962449, 0.058835875, -0.017381666, -0.0074752322, -0.0030449165, -0.024085538, 0.060129706, 0.029415373]
# Generate embeddings for all chunks
# This may take a few minutes depending on the number of chunks
print(f"Generating embeddings for {len(chunks)} chunks...")
embeddings = []
for i, chunk in enumerate(chunks):
embedding = get_embedding(chunk)
embeddings.append(embedding)
if (i + 1) % 50 == 0:
print(f" Processed {i + 1}/{len(chunks)} chunks")
print(f"Done! Generated {len(embeddings)} embeddings")Generating embeddings for 527 chunks...
Processed 50/527 chunks
Processed 100/527 chunks
Processed 150/527 chunks
Processed 200/527 chunks
Processed 250/527 chunks
Processed 300/527 chunks
Processed 350/527 chunks
Processed 400/527 chunks
Processed 450/527 chunks
Processed 500/527 chunks
Done! Generated 527 embeddings
Step 4: Store in Postgres¶
Now we store our chunks and their embeddings in our local Postgres database.
We use ON CONFLICT to implement upsert behavior — if a document with the same content hash already exists, we update it rather than creating a duplicate.
def get_content_hash(text: str) -> str:
"""Generate a hash of the content to use as a unique identifier."""
return hashlib.md5(text.encode()).hexdigest() # creates a hash as a 32-character hex string
# Insert all chunks and embeddings into Postgres using upsert
# This prevents duplicates if you re-run the notebook
print(f"Upserting {len(chunks)} documents into Postgres...")
conn = get_db_connection()
cur = conn.cursor()
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
content_hash = get_content_hash(chunk)
cur.execute("""
INSERT INTO documents (content, embedding, content_hash)
VALUES (%s, %s, %s)
ON CONFLICT (content_hash)
DO UPDATE SET content = EXCLUDED.content, embedding = EXCLUDED.embedding;
""", (chunk, embedding, content_hash))
if (i + 1) % 50 == 0:
print(f" Processed {i + 1}/{len(chunks)} documents")
conn.commit()
conn.commit()
cur.close()
conn.close()
print(f"Done! Upserted {len(chunks)} documents into Postgres")Upserting 527 documents into Postgres...
Processed 50/527 documents
Processed 100/527 documents
Processed 150/527 documents
Processed 200/527 documents
Processed 250/527 documents
Processed 300/527 documents
Processed 350/527 documents
Processed 400/527 documents
Processed 450/527 documents
Processed 500/527 documents
Done! Upserted 527 documents into Postgres
# Sanity check: verify documents are in the database
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM documents;")
count = cur.fetchone()[0]
cur.close()
conn.close()
print(f"Documents table contains {count} entries")Documents table contains 527 entries
Step 5: Retrieval¶
Now we can search our vector database to find chunks relevant to a user’s question.
We use cosine distance (<=>) to measure similarity between vectors.
def search_documents(query: str, match_count: int = 5) -> list[dict]:
"""
Search for documents similar to the query.
Args:
query: The search query
match_count: Number of results to return
Returns:
List of matching documents with their similarity scores
"""
# Get embedding for the query
query_embedding = get_embedding(query)
# Search for similar documents
conn = get_db_connection()
cur = conn.cursor()
"""
<=> is cosine distance operator (0 = identical, 2 = opposite)
%s::vector casts the Python list parameter to a Postgres vector type
1 - (embedding <=> %s::vector) as similarity - Converts distance to similarity (1 = identical, -1 = opposite)
"""
cur.execute("""
SELECT id, content, 1 - (embedding <=> %s::vector) as similarity
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s;
""", (query_embedding, query_embedding, match_count))
results = []
for row in cur.fetchall():
results.append({
"id": row[0],
"content": row[1],
"similarity": row[2]
})
cur.close()
conn.close()
return results# Test the search function
results = search_documents("How do I create a route in FastHTML?", match_count=3)
print(f"Found {len(results)} matching documents:\n")
for i, doc in enumerate(results):
print(f"--- Result {i+1} (similarity: {doc['similarity']:.4f}) ---")
print(doc['content'][:300] + "...")
print()Found 3 matching documents:
--- Result 1 (similarity: 0.7735) ---
### Minimal FastHTML Application Example
Source: https://www.fastht.ml/docs/ref/concise_guide.html
Demonstrates a basic FastHTML application setup. It includes defining an app instance, a route with type-constrained parameters, and serving the application. The example highlights FastHTML's approac...
--- Result 2 (similarity: 0.7589) ---
als/by_example.html
Demonstrates how to capture path parameters in FastHTML routes. The captured parameter 'nm' is used in the response string. It also shows how to test this route using TestClient.
```python
@app.get('/user/{nm}')
def _(nm:str): return f"Good day to you, {nm}!"
cli.get('/user/jp...
--- Result 3 (similarity: 0.7585) ---
----------------
### Initialize FastHTML App and Router
Source: https://www.fastht.ml/docs/tutorials/jupyter_and_fasthtml.html
Sets up a new FastHTML application instance and its associated router. The `pico=True` argument suggests enabling a lightweight mode for the application.
```python
app, ...
Step 6: Generation¶
Now we combine everything into a complete RAG pipeline:
Take the user’s question
Retrieve relevant documents from our vector database
Include those documents as context in the prompt
Generate an answer using the LLM
This is where the “Augmented” in Retrieval-Augmented Generation comes in — we augment the LLM’s knowledge with retrieved context.
def ask_fasthtml_tutor(question: str, num_docs: int = 5) -> str:
"""
Ask the FastHTML tutor a question.
Args:
question: The user's question about FastHTML
num_docs: Number of documents to retrieve for context
Returns:
The tutor's response
"""
# Step 1: Retrieve relevant documents
docs = search_documents(question, match_count=num_docs)
# Step 2: Build context from retrieved documents
context = "\n\n---\n\n".join([doc["content"] for doc in docs])
# Step 3: Create the prompt with context
system_prompt = """You are a helpful FastHTML tutor. Answer questions about FastHTML based on the provided documentation context.
If the context doesn't contain enough information to answer the question, say so honestly.
Always be accurate and cite specific examples from the documentation when possible."""
user_prompt = f"""Context from FastHTML documentation:
{context}
---
Question: {question}
Please provide a clear, helpful answer based on the documentation above."""
# Step 4: Generate response using Ollama
response = ollama.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
)
return response['message']['content']# Test the complete RAG pipeline
question = "How do I create a route in FastHTML?"
answer = ask_fasthtml_tutor(question)
print(f"Question: {question}\n")
print(f"Answer:\n{answer}")Question: How do I create a route in FastHTML?
Answer:
Based on the provided documentation from FastHTML, you can create a route by using the `@rt` decorator or the alternative way of defining routes using `app.route`.
Here are examples of both methods:
**Method 1: Using the `@rt` Decorator**
```python
from fasthtml.common import *
@app.get('/user/{nm}')
def _(nm:str): return f"Good day to you, {nm}!"
```
In this example, `/user/{nm}` is a route that accepts an HTTP GET request and expects a path parameter `nm`. The function `_` takes the `nm` parameter as a string.
**Method 2: Using `app.route`**
```python
from fasthtml.common import *
from fastht.ml/docs/ref/concise_guide.html
@app.route('/foo', methods=['GET','POST'])
def foo(nm=name):
# Function implementation here
```
In this example, `/foo` is a route that accepts both GET and POST HTTP requests. The `nm` parameter is type-annotated and passed as a query parameter.
Both of these examples demonstrate how to create routes in FastHTML.
Here is another question:¶
# Try your own question!
my_question = "What are FastTags in FastHTML?"
answer = ask_fasthtml_tutor(my_question)
print(f"Question: {my_question}\n")
print(f"Answer:\n{answer}")Question: What are FastTags in FastHTML?
Answer:
FastTags are direct m-expression mappings of HTML tags to Python functions with positional and named parameters. They allow you to define custom HTML elements using a concise syntax.
In the documentation, it's mentioned that when a tuple is returned, this returns concatenated HTML partials. Additionally, FastHTML will automatically return a complete HTML document with appropriate headers if a normal HTTP request is received.
For example, as shown in `files = {"file1": f1, "file2": ("filename", f2, "image/png")}`, the tuple returned from the function can result in concatenated HTML partials. Similarly, when defining FastTags like `Title`, `H1`, and `P` using the m-expression syntax, you are essentially creating functions that map to specific HTML tags.
The documentation also mentions that Python reserved words used as attribute names require aliases. This implies that FastTags allow for a flexible way of mapping Python variables to HTML attributes or tag parameters.
Overall, FastTags provide a powerful and concise way to work with HTML elements in your FastHTML applications, making it easier to build complex web interfaces without having to worry about the underlying HTML structure.
Closing Database Connections¶
When you’re done working with the database, it’s good practice to ensure all connections are properly closed. While our helper functions open and close connections within each call, you should be mindful of connection management in production applications.
Run the cell below to verify there are no lingering connections from this session.
# Verify connection status and demonstrate proper cleanup
# In our implementation, connections are opened and closed within each function,
# but here's how you would explicitly close a connection if needed:
def close_connection_safely(conn):
"""Safely close a database connection."""
if conn is not None and not conn.closed:
conn.close()
print("Connection closed successfully")
else:
print("No open connection to close")
# Example: Create a connection, use it, and close it properly
test_conn = get_db_connection()
cur = test_conn.cursor()
cur.execute("SELECT COUNT(*) FROM documents;")
count = cur.fetchone()[0]
print(f"Documents in database: {count}")
cur.close()
close_connection_safely(test_conn)
Documents in database: 527
Connection closed successfully
Exploring Ollama Models¶
Ollama provides a library of open-source models you can run locally. Browse available models at:
Types of Models
Chat/LLM Models — For text generation and conversation.
Examples: llama3.2, mistral, gemma2, phi3, qwen2.5
Embedding Models — For converting text to vectors (used in RAG)
Examples: mxbai-embed-large, nomic-embed-text, bge-large, all-minilm
Look for models with “embed” in the name
To discover the embedding dimension, run:
response = ollama.embed(model="model-name-here", input="test") len(response.embeddings[0])
Understanding Model Variants¶
Models often come in different sizes and quantizations:
llama3.2 - Default variant (usually the smallest recommended)
llama3.2:1b - 1 billion parameters (smaller, faster)
llama3.2:3b - 3 billion parameters (larger, smarter)
ll ama3.2:70b-q4 - 70B parameters, 4-bit quantization (reduced precision for lower memory)
Larger models = Better quality but slower and more RAM Quantization (q4, q8) = Compressed models that use less memory with slight quality trade-off
Useful Commands¶
ollama list # Show installed models
ollama pull <model> # Download a model
ollama rm <model> # Delete a model
ollama show <model> # Show model details (parameters, size, etc.)Documentation¶
Ollama Documentation: README.md
Model Library: https://
ollama .com /library Python API Reference: https://
github .com /ollama /ollama -python
Resetting the Database¶
If you want to re-run this notebook with a different embedding model, you’ll need to delete the existing table first. This is because:
Different embedding models produce different vector dimensions — The table schema is tied to a specific dimension.
Embeddings from different models are not comparable — Even if two models have the same dimension, their embeddings encode meaning differently. Mixing embeddings from different models would produce nonsensical similarity results.
To reset and start fresh:
Run the cell below to drop the documents table
Update
EMBEDDING_MODELandEMBEDDING_DIMin the configuration cell to match your new modelRe-run the notebook from “Initialize the Clients” onward
# Drop the documents table to start fresh
# Uncomment and run this cell only when you want to reset the database
# conn = get_db_connection()
# cur = conn.cursor()
# cur.execute("DROP TABLE IF EXISTS documents;")
# conn.commit()
# cur.close()
# conn.close()
# print("Documents table dropped. You can now re-run the notebook with a different embedding model.")Documents table dropped. You can now re-run the notebook with a different embedding model.