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.

full rag pipeline

This tutorial deliberately keeps its RAG pipeline simpler than the hosted version. Because chunking strategy, retrieval quality, and failure diagnosis were developed at length in the cloud RAG tutorial (https://github.com/tligorio/ai_workflow_rag_tutorial/tree/main), we hold those concerns fixed here so that your attention stays on what is actually new: getting a fully local stack running.

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. Open ternimal and 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 hides the pip install output to keep the notebook clean.

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)

Ollama is running. Available models: ['qwen2.5:7b', 'mxbai-embed-large:latest', 'nomic-embed-text:latest', 'llama3.2:latest']
Postgres connection successful to vectors

Verify the Models Are Installed

The check above confirms that the Ollama service is reachable. Now check that the model we want to use, CHAT_MODEL, is installed.

Aalso confirm that EMBEDDING_DIM matches what the embedding model actually produces, so we can use it to create the vector store table.

Found: llama3.2
Found: mxbai-embed-large
EMBEDDING_DIM 1024 matches mxbai-embed-large

Set Up the Database

We need to enable the pgvector extension and create our documents table.

Database setup complete: pgvector enabled and documents table created (vector(1024))

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.

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

Chunking Strategy

We’ll implement a simple character text splitter to demonstrate how chunking works:

  • Split the text into chunks of approximately 500 characters

  • Include 80 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.

Created 1046 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....

Chunk sizes: min=173, max=500, avg=464

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

Embeddings

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

Note: Generating embeddings for the whole document runs locally on your machine, so the embedding step below can take a few minutes depending on your hardware.

Pedagogical note: Always understand what you are working with! Explore response directly to understand its structure

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
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]
Generating embeddings for 1046 chunks...
  Processed 50/1046 chunks
  Processed 100/1046 chunks
  Processed 150/1046 chunks
  Processed 200/1046 chunks
  Processed 250/1046 chunks
  Processed 300/1046 chunks
  Processed 350/1046 chunks
  Processed 400/1046 chunks
  Processed 450/1046 chunks
  Processed 500/1046 chunks
  Processed 550/1046 chunks
  Processed 600/1046 chunks
  Processed 650/1046 chunks
  Processed 700/1046 chunks
  Processed 750/1046 chunks
  Processed 800/1046 chunks
  Processed 850/1046 chunks
  Processed 900/1046 chunks
  Processed 950/1046 chunks
  Processed 1000/1046 chunks
Done! Generated 1046 embeddings

Step 4: Store in Postgres

Now we store our chunks and their embeddings in our local Postgres database.

Before inserting the current chunk set, we clear the table so that re-running with different chunk settings does not leave stale chunks behind. We also use ON CONFLICT to keep the insert idempotent within a run: if a chunk with the same content hash already exists, we update it rather than create a duplicate.

Upserting 1046 documents into Postgres...
  Processed 50/1046 documents
  Processed 100/1046 documents
  Processed 150/1046 documents
  Processed 200/1046 documents
  Processed 250/1046 documents
  Processed 300/1046 documents
  Processed 350/1046 documents
  Processed 400/1046 documents
  Processed 450/1046 documents
  Processed 500/1046 documents
  Processed 550/1046 documents
  Processed 600/1046 documents
  Processed 650/1046 documents
  Processed 700/1046 documents
  Processed 750/1046 documents
  Processed 800/1046 documents
  Processed 850/1046 documents
  Processed 900/1046 documents
  Processed 950/1046 documents
  Processed 1000/1046 documents
Done! Upserted 1046 documents into Postgres
Documents table contains 1046 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.

Found 3 matching documents:

--- Result 1 (similarity: 0.7854) ---
action

Source: https://www.fastht.ml/docs/tutorials/jupyter_and_fasthtml.html

Defines a new route '/click' that responds to HTMX requests. This demonstrates the ability to dynamically add or modify routes in a running FastHTML application without restarting the server.

```python
@rt
def click(): ...

--- Result 2 (similarity: 0.7831) ---
x-target="#result">Load Content</button>
```

--------------------------------

### Alternative FastHTML Route Definition using app.route

Source: https://www.fastht.ml/docs/explains/routes.html

Shows an alternative way to define routes using `app.route`, where the function name dictates the HTTP m...

--- Result 3 (similarity: 0.7824) ---
tht.ml/docs/apilist.txt

Adds a standard HTTP route to the FastHTML application. This method allows specifying the path, allowed HTTP methods, route name, schema inclusion, and a body wrapper function.

```python
@patch
def route(self, path, methods=['GET'], name=None, include_in_schema=True, body_w...

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.

Retrieved evidence becomes context

Pin the temperature to 0

temperature controls how much randomness the model uses when picking each next token. Ollama’s default is greater than 0, so asking the same question twice can give you two different answers — as you will see if you re-run a generation cell before making this change.

We set temperature=0 so the model always takes its most likely continuation. This buys us two things that matter in a tutorial:

  1. The outputs stored in this notebook are the outputs you should get. If your answer differs, that is a signal worth investigating, not sampling noise.

  2. Comparisons become meaningful. When we change the prompt, the chunk size, or the number of retrieved documents, any difference in the answer is attributable to that change rather than to chance.

A production assistant often wants some variation in its phrasing. An example you are expected to reason about does not.

Question: How do I create a route in FastHTML?

Answer:
According to the documentation, there are two ways to define routes in FastHTML:

1. **Using `@rt` decorator**:
   ```python
@rt
def click(): return P('You clicked me!')
```
   This method defines a new route '/click' that responds to HTMX requests.

2. **Using `app.route` function**:
   ```python
rt = app.route

@rt('/')
def post(): return "Going postal!"
```
   This method allows specifying the path, allowed HTTP methods, route name, schema inclusion, and a body wrapper function.

3. **Using `@patch` decorator with custom route definition**:
   ```python
@patch
def route(self, path, methods=['GET'], name=None, include_in_schema=True, body_wrap=None):
    """Add a route at `path`"""
    pass
```
   This method allows specifying the path, allowed HTTP methods, route name, schema inclusion, and a body wrapper function.

All of these methods can be used to create routes in FastHTML.

Check the answer

All three code snippets appear verbatim in FastHTML.txt (test with a quick search over the file).

Only the first method is both correct and correctly described. The answer for items 2 and 3 is wrong in a way that is harder to see.

The third “method” is not a way to create a route, but it comes from a section describing FastHTML’s API internals, and its body is pass. The second item then repeats that same section’s description instead of its own.

What kind of failure is this?

Not fabrication, and not a retrieval miss. Every chunk retrieved is genuinely about routes, and the information needed to answer well was present.

One problem is that our corpus mixes two kinds of material: tutorial examples written for users, and API reference describing the library’s internals, and nothing in a chunk marks which is which. At 500 characters each chunk arrives looking self-contained and equally authoritative, stripped of the surrounding context that would tell you one of them is reference documentation.
Lecture 6 names the remedies. Metadata travels with every chunk, so a chunk can carry the fact that it came from a section that describes API internals. Metadata filters can then exclude reference material from a “how do I” question.

Notice too that we pass the retrieved chunks to the model as one undifferentiated block. In this tutorial we did not include the [1] labels distinguishing the chunks and saying where each piece came from, so there is nothing for the model to keep its descriptions anchored to, and nothing for it to cite.

Does this contradict the idea that RAG reduces hallucination?

At first glance, yes. Retrieval supplied accurate, relevant evidence and the answer was still partly wrong. So it is worth being precise about what RAG actually promises.

Without RAG

The cell below asks the same model the same question, with no retrieved context at all.

Question: How do I create a route in FastHTML?

Answer with no retrieved context:
In FastHTML, you can create routes using the `@route` decorator.

Here's an example of how to define a simple route:

```rust
use fasthtml::prelude::*;

#[fasthtml]
fn index() -> Html {
    html! {
        <div>
            <h1>Welcome to my app!</h1>
        </div>
    }
}

#[fasthtml::route("/")]
fn home() -> Html {
    html! {
        <div>
            <h1>Home Page</h1>
        </div>
    }
}
```

In this example, we define two routes: `/` and `/`. The `@route` decorator is used to specify the route for each function.

You can also use path parameters in your routes by adding them inside parentheses:

```rust
#[fasthtml::route("/users/{username}")]
fn user(username: String) -> Html {
    html! {
        <div>
            <h1>Welcome, {username}!</h1>
        </div>
    }
}
```

In this example, the `/users/{username}` route will capture a `username` parameter and pass it to the `user` function.

You can also use multiple routes for the same function by using the `@route` decorator with different path patterns:

```rust
#[fasthtml::route("/")]
fn index() -> Html {
    html! {
        <div>
            <h1>Welcome to my app!</h1>
        </div>
    }
}

#[fasthtml::route("/users/{username}")]
fn user(username: String) -> Html {
    html! {
        <div>
            <h1>Welcome, {username}!</h1>
        </div>
    }
}
```

In this example, the `/` route will match the `index` function, and the `/users/{username}` route will match the `user` function.

Without retrieval, the model answers in Rust. Not a wrong FastHTML API, the wrong programming language.

This is the unavailable evidence problem from Lecture 6. FastHTML is a small Python library that a 3-billion-parameter model has essentially never seen, so without retrieval there is nothing for it to be accurate about. Every FastHTML-specific thing the tutor got right earlier in this notebook (the @rt decorator, def click(): return P('You clicked me!'), FastTags as m-expression mappings, positional parameters becoming children) came from the corpus.

What RAG does not do

RAG does not guarantee that the model uses the evidence well. Lecture 6 names two places a RAG system can fail, and retrieval addresses only the first. It puts relevant passages in front of the model; nothing compels the model to read them correctly, to tell reference material from a worked example, or to keep each description with the code it belongs to. That is exactly what we saw with the route question.

What RAG buys even when generation disappoints

Both answers are wrong, but only one is easy to check. Disproving the route answer means searching your local knowledge base. Disproving the Rust answer means searching the web, then judging which sources are authoritative and what they actually say, a judgement not easily automated in evals.

Grounding turns a claim that is expensive to check into one that is cheap to check, and more importantly checkable the same way multiple times over evaluation cycles.

That difference is what makes evaluation possible. With a known corpus you can write test questions, say which passage should support each answer, and check mechanically whether it was retrieved and used. Ungrounded text leaves you re-deciding, by hand and by eye, whether each answer sounds right.

The levers you have, and what each one is for

LeverWhat it addressesWhat it costs
Retrieval (RAG)Supplies knowledge the model does not have at allA corpus to curate and an ingestion pipeline to maintain
ChunkingWhich passages reach the model, and how much surrounding context survives the splitA choice you have to evaluate; no chunk size is right for every corpus
Metadata and filtersWhether the model can tell reference documentation from a worked example before it answersMetadata has to be captured during ingestion and kept accurate
Prompt designHow firmly the model is told to stay inside its evidence and to abstain when it runs outCheap to try, but it cannot make a small model reliable
A larger modelHow faithfully the supplied evidence is usedDisk, memory, and slower answers, and its claims still need checking

Optional: try a larger model yourself

This notebook defaults to llama3.2 (3 billion parameters, about 2 GB) because the model has to fit on your machine. Unlike the other tutorials in this course, this stack is local, so you run whatever your own hardware can hold.

A larger model may improve how faithfully the retrieved evidence is used. qwen2.5:7b is a reasonable next step up if you would like to find out.

Hardware required. Roughly 5 GB of disk, and comfortably 16 GB of RAM. On an 8 GB machine a 7-billion-parameter model will become very slow. Expect noticeably longer waits for each answer than with llama3.2, whatever your hardware.

If your want to try it, pull the model in a terminal:

ollama pull qwen2.5:7b

Then set the model in a new cell and re-run the two question cells above:

CHAT_MODEL = "qwen2.5:7b"

How to compare, if you try it.

Apply the same checks we applied above:

  • Take each code block and search for it in FastHTML.txt. Is it there, or invented?

  • Take each description and find the chunk it came from. Does it describe the code it is attached to?

  • Does the answer distinguish user-facing examples from API reference, or present both as the same kind of advice?

  • Where the documentation does not answer the question, does the model say so, or fill the gap?

See Exploring Ollama Models at the end of this notebook for exploring models.

Closing Database Connections

When you’re done working with the database, it’s good practice to make sure connections are properly closed. Our get_db_connection() helper opens a new connection each time it is called, and every function above closes its own connection before returning, so nothing is left open between steps.

In production code you will often hold a single connection open across many operations instead, and then closing it becomes your responsibility. The cell below makes that full cycle explicit.

Documents in database: 1046
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)

  • llama3.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

Changing only the chunk size or overlap does not require this reset, because the storage step clears and rewrites the table on each run.