Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Simple AI Workflow with Local RAG using Ollama and Postgres

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:

  1. Document Loading — Load the FastHTML documentation from a text file

  2. Chunking — Split the documentation into manageable pieces

  3. Embedding — Create vector embeddings for each chunk using Ollama

  4. Storage — Store the embeddings in a local Postgres database with pgvector

  5. Retrieval — Query the database for relevant chunks based on user questions

  6. 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:

  1. Ollama — Local LLM server

  2. Docker — To run Postgres with pgvector

Step 1 — Install and Set Up Ollama

  1. Download and install Ollama from https://ollama.com/download

  2. 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 serve in a terminal (keep it running, or run as a background service).

  3. 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-large
  1. Verify the models are available:

ollama list

You should see both models listed.

Step 2 — Install Docker and Start Postgres with pgvector

  1. Download and install Docker from https://docs.docker.com/get-docker/

  2. 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 (or sudo service docker start on older systems).

  3. 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:pg16
  1. Verify it’s running:

docker ps

You 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 insertions

Ollama 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:

  1. Ollama — Already running as a service, we just use the ollama module

  2. Postgres connection — Connect to our local database

We’ll also define the models we’ll use:

  • Chat model: llama3.2 for generating responses

  • Embedding model: mxbai-embed-large for 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:

  1. Embedding models have token limits — Most embedding models can only process a limited amount of text at once

  2. 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

  1. Text goes into the embedding model

  2. The model outputs a vector of 1024 floating-point numbers

  3. These numbers encode the semantic meaning of the text

  4. 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:

  1. Take the user’s question

  2. Retrieve relevant documents from our vector database

  3. Include those documents as context in the prompt

  4. 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:

https://ollama.com/library

Types of Models

  1. Chat/LLM Models — For text generation and conversation.

  • Examples: llama3.2, mistral, gemma2, phi3, qwen2.5

  1. 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

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:

  1. Different embedding models produce different vector dimensions — The table schema is tied to a specific dimension.

  2. 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:

  1. Run the cell below to drop the documents table

  2. Update EMBEDDING_MODEL and EMBEDDING_DIM in the configuration cell to match your new model

  3. Re-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.