主页
I run a small daily AI-briefing pipeline on my personal site — a scheduled agent researches one AI topic a day, writes it up, and publishes it. The pipeline had no real memory: retrieval meant grepping files. I wanted a proper memory layer — semantic search over everything the pipeline has ever written — and I wanted it on Postgres.
There was a second reason. I lead a customer-facing technical team for a living, and the only way I trust myself to talk about a platform is to run it, break it, and fix it myself. Reading docs produces opinions; running the thing produces judgment. So: one afternoon, local Supabase stack, real data, and a promise to write down whatever surprised me.
It surprised me twice.
The whole thing is small on purpose:
supabase init && supabase start — about ten minutes including image pulls, and I had Postgres, auth, storage, a REST API, and a dashboard on localhost.briefings table with a vector(384) column, a match_briefings() SQL function for cosine search, and a row-level-security policy for anonymous reads.all-MiniLM-L6-v2 — no API keys, embeddings computed on my laptop.match_briefings via RPC.The schema is the only part worth showing:
create extension if not exists vector;
create table briefings (
id bigint generated always as identity primary key,
title text not null,
content text not null,
source_url text,
published_at timestamptz default now(),
embedding vector(384)
);
create function match_briefings(query_embedding vector(384), match_count int default 5)
returns table (id bigint, title text, source_url text, similarity float)
language sql stable as $$
select b.id, b.title, b.source_url,
1 - (b.embedding <=> query_embedding) as similarity
from briefings b
where b.embedding is not null
order by b.embedding <=> query_embedding
limit match_count;
$$;
First search, real data, and it just worked: querying "MCP tool design" returned my four MCP-related briefings at the top, correctly ranked. Good. Now for the part I actually came for.
I ran the first search with EXPLAIN ANALYZE on. The plan:
Limit -> Sort (top-N heapsort) -> Seq Scan on briefings (rows=18) Execution Time: 0.070 ms
A sequential scan — and that is the correct plan. At 18 rows, walking the whole table costs nothing; an index would be pure overhead. If your reflex is "seq scan = add an index," the planner is smarter than your reflex. This matters in real support conversations: an index recommendation without row counts is a guess wearing a lab coat.
But 18 rows proves nothing about scale, so I made the problem real: a demo table with 20,000 random 384-dimension vectors, no index, nearest-neighbor query.
Before:
Limit (cost=4590.50..4590.51 rows=5 ...) (actual time=3.472..3.472 rows=5)
-> Sort (top-N heapsort, Memory: 25kB)
Sort Key: ((vec_demo.embedding <=> ...))
-> Seq Scan on vec_demo (rows=20000) (actual time=0.011..2.977)
Execution Time: 3.497 ms
Exactly what it looks like: Postgres computes the distance for all 20,000 rows, then keeps the top 5. There is no index that helps an ORDER BY distance — until you install one designed for it.
create index on vec_demo using hnsw (embedding vector_cosine_ops); -- Time: 625.067 ms
After:
Limit (cost=582.38..591.21 rows=5 ...) (actual time=0.063..0.066 rows=5)
-> Index Scan using vec_demo_hnsw on vec_demo
Order By: (embedding <=> ...)
Execution Time: 0.079 ms
3.497 ms → 0.079 ms. A 44× speedup, from one index.
Three honest footnotes, because the headline number hides them:
Here's the part I didn't get from the docs.
My migration had a clean RLS policy:
alter table briefings enable row level security; create policy "public read" on briefings for select to anon using (true);
Policy says: anonymous users can read everything. So I called my Edge Function (which runs as anon) and got:
{ "error": "permission denied for table briefings" }
Permission denied — with a policy that literally says using (true). The diagnosis was one command:
\dp briefings anon=Dxtm/postgres <- no "r"
The anon role had never been granted SELECT on the table. And that's the lesson: Postgres has two gates, and RLS is the second one. GRANT decides whether you may touch the table at all. Policies decide which rows you see once you're in. Writing a policy without a grant is opening the inner door while the outer door stays locked.
grant select on briefings to anon, authenticated;
Fixed — and baked back into the migration so db reset stays reproducible.
Then I ran the experiment in reverse: grants in place, but the policy flipped to using (false). Now the same request returns:
{ "results": [] }
No error. HTTP 200. Just… nothing. And this is the operationally interesting part — the two gates fail with different fingerprints:
| Symptom | Broken gate | First command |
|---|---|---|
permission denied error | GRANT (outer gate) | \dp <table> |
| Empty result, no error | Policy (inner gate) | select * from pg_policies |
| service_role works, anon doesn't | One of the two above | Compare the same query with both keys |
| "But I can see the data in the dashboard!" | Nothing — Studio runs as postgres, which bypasses RLS | Stop debugging in the dashboard |
That last row deserves a sentence. The dashboard proves your data exists. It proves nothing about whether any API path can reach it — the dashboard walks in through the staff entrance. If I had a euro for every debugging session that starts with "but it's right there in the admin panel," I could fund the index builds.
My day job is running technical evaluations and competitive migrations — I led a displacement program in the mobile-measurement world that switched 300+ accounts on a playbook I wrote — so I can't help scoping this like one. If a team were evaluating Supabase against their incumbent backend, I'd insist on three things before anyone writes code:
PoVs die of scope drift. The criteria document is the vaccine.
hnsw.ef_search instead of trusting defaults.The whole project — migration, scripts, Edge Function, and the raw EXPLAIN archives from this post — runs locally with supabase start. One afternoon, two genuine surprises, and a query planner that turned out to have better judgment than my reflexes. That's a good trade.