Overview
The AI part of an AI product is usually a few hundred lines. The database schema underneath it is what decides whether the thing still works at ten thousand users.
This lesson is the boring, load-bearing half: how conversations and messages are actually stored, why tool-call logs are worth keeping from day one, how pgvector lets embeddings live on the same row as the text they came from, and why row-level security — not a WHERE clause you remembered to write — is what keeps one user's data away from another.
Theory
The shape is boring, and that is good. users → conversations → messages, each referencing its parent. Almost every chat product is this, and the boring version scales further than people expect.
Log tool calls. Store what was called, with what arguments, and what came back. It is your audit trail when something goes wrong and — more valuable — it becomes the eval set you will wish you had started collecting six months earlier.
pgvector means one database, not two. An embedding column sits on the same row as the text it came from. No second store to keep in sync, no distributed transaction, and a join against your ordinary tables still works.
Migrate additively while things are still changing. Add a nullable column, backfill it, then start reading it. Renaming or dropping in a single step breaks whatever is still running against the old shape, and something always is.
Index for the two queries you actually run. Messages by conversation ordered by time, and vectors by similarity. Everything else is speculative until a slow query proves otherwise.
Visual Diagram
The diagram groups three tables into one database: conversations (owned by a user), messages (owned by a
conversation, each tagged with the model and token counts that produced it), and a document_chunks table
carrying both content and an embedding column side by side. The point of the picture is that all of this
lives in one Postgres instance — there's no separate "AI database" bolted on next to your "real" database until
you have a specific, measured reason to split them out.
Examples
- A
conversations/messagespair as described above, the backbone of any chat product. - A
document_chunkstable with avectorcolumn, used for retrieval-augmented generation once a knowledge base needs to be searchable by meaning, not just keyword. - A
usage_eventstable logging every model call's token counts and estimated cost per user — the same table Day 10's per-user quota checks read from. - A
tool_callstable (or atoolrole onmessageswith structuredcontent) recording every function a model invoked, its arguments, and its result.
Code
-- migration: 0001_init_conversations.sql
create table conversations (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id),
title text,
created_at timestamptz not null default now()
);
create table messages (
id uuid primary key default gen_random_uuid(),
conversation_id uuid not null references conversations(id) on delete cascade,
role text not null check (role in ('system', 'user', 'assistant', 'tool')),
content text not null,
model text,
input_tokens integer,
output_tokens integer,
created_at timestamptz not null default now()
);
-- Keeps "load this conversation's messages in order" fast as history grows.
create index messages_conversation_id_idx on messages (conversation_id, created_at);-- migration: 0002_add_embeddings.sql — purely additive, safe to deploy
-- before any application code that populates it exists.
create extension if not exists vector;
create table document_chunks (
id uuid primary key default gen_random_uuid(),
content text not null,
embedding vector(1536), -- must match the embedding model's output dimension
created_at timestamptz not null default now()
);
-- Approximate nearest-neighbor index — exact search over large tables
-- doesn't stay fast, so this trades a small amount of recall for speed.
create index document_chunks_embedding_idx on document_chunks
using hnsw (embedding vector_cosine_ops);import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // server-only — never in client code
);
/**
* Loads the last N messages for a conversation, oldest first, in the shape
* a model provider expects. This runs on every chat request, so the
* (conversation_id, created_at) index above is what keeps it fast even
* once a conversation has hundreds of turns.
*/
export async function loadRecentMessages(conversationId: string, limit = 20) {
const { data, error } = await supabase
.from("messages")
.select("role, content")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: false })
.limit(limit);
if (error) throw error;
return data.reverse(); // back to chronological order for the model
}Real Product Example
Markprompt is a public Supabase customer case study and a clean illustration of pgvector in a shipped
product: an AI chatbot platform that lets teams turn their documentation into a natural-language Q&A widget.
Markprompt's retrieval pipeline chunks a customer's docs, embeds each chunk, and stores the embeddings directly in
Postgres via pgvector rather than a separate vector database — the case study specifically credits having "the
full features of Postgres, colocated with the embeddings" (row-level security, transactions, joins against normal
relational data) as the reason they chose it over a dedicated vector store. That's the same document_chunks
pattern from the Code section above, running at production scale with real customer traffic and GDPR
requirements layered on top through Postgres's existing row-level security rather than a bolted-on system.
Common Mistakes
Storing conversation history as one giant JSON blob per conversation
It's tempting because it mirrors the array-of-messages shape you get back from a model SDK, but it makes "show me every conversation where the assistant called tool X" or "sum tokens per user this month" require pulling every row into application code and parsing JSON, instead of a plain SQL query. Normalize messages into their own table.
Renaming or dropping a column in the same deploy that ships code using the new name
Rolling deploys mean old and new application instances run side by side for seconds to minutes. Renaming
content to body in the same migration that ships code reading body will 500 every request still hitting an
old instance. Add the new column, backfill, switch reads over, and drop the old column in a later, separate
deploy.
Leaving a vector column unindexed past a few thousand rows
It works fine in development against 200 test documents and then falls over in production when a customer
uploads a 50,000-page knowledge base — every similarity query does a full table scan and distance calculation.
Add an ivfflat or hnsw index before you need it, not after a query starts timing out.
Using a permissive client-side key for writes that should be server-only
A Supabase anon key paired with loose row-level security can let any authenticated user write rows they shouldn't be able to. Writes like "log this tool call" or "deduct from this user's quota" belong behind server-only code using the service-role key and your own authorization logic — not directly reachable from the browser.
Senior Tips
Senior tip
Write token counts and cost at insert time, not compute them later. Every model response includes a usage
object with exact input/output token counts for free — write them onto the message row immediately. Reconstructing
historical cost later means re-tokenizing old text with whatever tokenizer you happen to have on hand, which
won't exactly match what you were actually billed.
Senior tip
Track which model produced each embedding, not just the vector itself. vector(1536) matches one specific
embedding model's output dimension; a different model might output 1024 or 3072 dimensions, and even
same-dimension models from different providers aren't comparable. Mixing embeddings from two models in one column
without tracking which is which silently corrupts every similarity search — the distances become meaningless
without ever throwing an error.
Senior tip
Add a deleted_at column before you need one, not after a support ticket. Once a product has real
conversation history, a support request to "please delete my data" and a separate request to "can you look up
what I said last Tuesday" can both come from the same user in the same week. A nullable deleted_at filter gives
you both compliance and the ability to be forgiving about a hasty delete.
Exercises
Exercise
Extend the messages table with a tool_calls jsonb column, or design a separate tool_calls table keyed to a
message — pick one approach and write one sentence justifying it. Write the migration SQL for your choice.
Exercise
Write the SQL query that computes total input and output tokens per user over the last 30 days, joining
messages → conversations → auth.users. This is the query a billing or usage dashboard would run.
Exercise
In your own words, explain why an index created using hnsw is described as "approximate" nearest-neighbor
search rather than exact. Then describe one product where returning the 9th-closest match instead of the true
10th-closest would matter, and one where it wouldn't.
Mini Project
Conversation store. Build the schema this lesson describes, end to end.
- Write and apply the
users → conversations → messagesmigration from the Code section (Supabase or any local Postgres works). - Seed one conversation with five or six messages, alternating
userandassistantroles, including plausiblemodel,input_tokens, andoutput_tokensvalues on the assistant messages. - Implement
loadRecentMessagesfrom the Code section and confirm it returns messages in chronological order. - Write one additional query: total tokens used, grouped by conversation, ordered highest-cost first.
Resources
- Supabase — Vector columns (pgvector): setting up pgvector,
storing embeddings in a Postgres column, and querying by similarity — the basis for the
document_chunkstable above. - pgvector: the extension itself — distance metrics, index types
(
ivfflat,hnsw), and supported vector formats, straight from the source. - Supabase — Database Migrations: how to write, apply, and roll forward schema changes safely, relevant to the additive-migration pattern described above.