I wanted to search meeting notes by meaning, so that "How do I add a coworker?" finds a note that says "Accounts for new employees are created from the admin panel." Keyword search returns nothing for that. This post builds the fix end to end: a Postgres table with pgvector, a queue that creates embeddings automatically, two Edge Functions, and a plain HTML page on top. Everything runs on the Supabase Free plan, with no outside AI service.
AI disclosure: I used Claude to help draft this post. I ran every step in my own project, checked the results, and edited the text. The mistakes are mine.
Repo: https://github.com/tomozilla/semantic-search-en-demo
Business-side companion (no code, no product names): https://medium.com/p/004eda4da6d4
What you will build
- You insert a note.
- A trigger puts a job in a queue (
pgmq). -
pg_cronruns every 10 seconds and calls theembedEdge Function for each job. -
embedturns the text into 384 numbers with the built-ingte-smallmodel and writes them to the row. - A search question goes through the same model, and Postgres returns the nearest notes.
Before you start
-
Cost. I used the Free plan and nothing billable. Edge Functions include 500,000 invocations a month on Free (Supabase docs, checked 2026-10-22). Each search through the page is 1 call. Each new note is 1 call from the page plus 1
embedcall from the queue, and the page polls status every 2 seconds until the note is ready (about 4 polls in my runs). I did not look at the usage page after the run, so I have no measured call count. -
Model.
gte-smallis built into the Edge Function runtime. The docs say it only supports English and truncates anything past 512 tokens. Both matter later. - Security. Two functions run with Verify JWT off, as a demo shortcut. Read the "Security" section before you reuse anything.
- Not verified. Anything beyond 8 or 9 notes, other browsers, and a real login system.
1. Extensions
In the SQL Editor:
create extension if not exists vector with schema extensions; -- stores embeddings
create extension if not exists pgmq; -- job queue
create extension if not exists pg_net with schema extensions; -- lets the database call HTTP endpoints
create extension if not exists pg_cron; -- scheduled jobs
Check that all four are on:
2. Table, queue, trigger, cron
create schema if not exists util;
create table public.meeting_notes (
id bigint primary key generated always as identity,
kind text not null check (kind in ('meeting', 'interview')),
title text not null,
content text not null,
embedding extensions.vector(384), -- gte-small produces 384 numbers per text
created_at timestamptz default now()
);
alter table public.meeting_notes enable row level security;
create index on public.meeting_notes
using hnsw (embedding extensions.vector_cosine_ops);
select pgmq.create('embedding_jobs');
create function util.queue_embedding() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
perform pgmq.send('embedding_jobs', jsonb_build_object('id', new.id));
return new;
end; $$;
create trigger embed_on_insert after insert on public.meeting_notes
for each row execute function util.queue_embedding();
create trigger embed_on_update after update of title, content on public.meeting_notes
for each row execute function util.queue_embedding();
Then the worker, which reads up to 10 jobs and calls the function for each one. Replace <PROJECT_REF> with your project ref:
create function util.process_embeddings() returns void language plpgsql as $$
declare job record;
begin
for job in select * from pgmq.read('embedding_jobs', 60, 10) loop
perform net.http_post(
url => 'https://<PROJECT_REF>.supabase.co/functions/v1/embed',
headers => jsonb_build_object('Content-Type', 'application/json'),
body => jsonb_build_object('id', (job.message->>'id')::bigint, 'jobId', job.msg_id));
end loop;
end; $$;
select cron.schedule('process-embeddings', '10 seconds', 'select util.process_embeddings();');
pgmq.read(..., 60, 10) hides a job for 60 seconds after reading it. If the function fails, the job reappears after that and is retried. That is the whole retry mechanism.
RLS is on and there are no policies, so the table is closed to the public API. I checked: a REST call with the publishable key returns []. The functions connect directly to the database, so they are not affected.
3. Two Edge Functions
embed reads one queued note, creates its embedding, writes it back, and deletes the job last:
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import postgres from "https://deno.land/x/postgresjs@v3.4.5/mod.js";
const sql = postgres(Deno.env.get("SUPABASE_DB_URL")!);
// gte-small is built into the Edge Function runtime: no API key, no outside service
const session = new Supabase.ai.Session("gte-small");
Deno.serve(async (req) => {
const { id, jobId } = await req.json();
const [row] = await sql`select title, content from public.meeting_notes where id = ${id}`;
if (!row) return new Response("row not found", { status: 404 });
const embedding = await session.run(`${row.title}\n\n${row.content}`, { mean_pool: true, normalize: true });
await sql`update public.meeting_notes
set embedding = ${JSON.stringify(Array.from(embedding))}::extensions.vector
where id = ${id}`;
// Delete the job last. If anything above fails, the job stays in the queue and is retried automatically
await sql`select pgmq.delete('embedding_jobs', ${jobId}::bigint)`;
return new Response("ok");
});
search embeds the question the same way and asks Postgres for the 3 nearest notes. <=> is cosine distance, so 1 - distance is the similarity:
const sql = postgres(Deno.env.get("SUPABASE_DB_URL")!);
const session = new Supabase.ai.Session("gte-small");
Deno.serve(async (req) => {
const { query } = await req.json();
const embedding = JSON.stringify(Array.from(await session.run(query, { mean_pool: true, normalize: true })));
const results = await sql`
select id, kind, title,
round((1 - (embedding <=> ${embedding}::extensions.vector))::numeric, 3) as similarity
from public.meeting_notes
where embedding is not null
order by embedding <=> ${embedding}::extensions.vector
limit 3`;
return Response.json({ query, results });
});
Deploy both (I used the dashboard editor) and turn Verify JWT off for both. embed has to be off because the cron job calls it without a login token.
In this screenshot I replaced my real project ref with a fake one, and hid unrelated functions from my other demos.
4. Insert notes and watch the embeddings fill in
I wrote 8 fake notes: 5 meeting minutes and 3 interview notes (sql/03_sample_data.sql in the repo). Right after the insert, every embedding is null:
Roughly 15 seconds later, without touching anything:
In the first check, all 8 were filled within 15 seconds. I did not measure it more finely than that, because cron fires every 10 seconds.
5. Search by meaning
Call search with questions that avoid the notes' wording:
curl -s -X POST "https://<PROJECT_REF>.supabase.co/functions/v1/search" \
-H "Content-Type: application/json" \
-d '{"query": "How do I add a coworker to the app?"}'
Top result: New hire onboarding, similarity 0.843. I ran 7 questions:
| Question | First result | Score | Right? |
|---|---|---|---|
| How do I add a coworker to the app? | New hire onboarding | 0.843 | yes |
| Why are customers leaving? | Weekly sync (Support) | 0.833 | yes |
| Someone who has led a team | Interview notes (Sales, round 1) | 0.829 | yes |
| What to spend money on next year | Leadership meeting | 0.806 | yes |
| Candidate who wants to work from home | Interview notes (Engineer, round 2) | 0.835 | yes |
| Reason the launch is late | Release review | 0.873 | yes |
| Root cause of the incident | Weekly sync (Support) | 0.791 | no |
Six of seven. For the last one, the correct note (Weekly sync (Engineering), about an outage) was second at 0.789. Two thousandths apart.
For comparison, plain substring search (sql/04_keyword_compare.sql) with the matching words:
Zero hits for all six. The honest caveat: eight notes I wrote myself, with questions I wrote knowing the answers. That is a demo, not a benchmark.
6. A small web page on top
The page is plain HTML and JavaScript with no build step. It talks to one more Edge Function, memo-api, which handles list, search, keyword, add and status. The function checks that the request carries your publishable key:
const PUBLISHABLE_KEYS: string[] = Object.values(JSON.parse(Deno.env.get("SUPABASE_PUBLISHABLE_KEYS") ?? "{}"));
// ...
const key = req.headers.get("apikey");
if (!key || !PUBLISHABLE_KEYS.includes(key)) return json({ error: "unauthorized" }, 401);
This is an entry ticket, not authentication. The publishable key ships in the page, so anyone who opens the page has it.
To run it: copy app/config.example.js to app/config.js, put in your project URL and publishable key, then:
cd app
python3 -m http.server 8765 --bind 127.0.0.1
# open http://127.0.0.1:8765/index.html
Adding a note shows the pipeline working. The page saves the note, then polls status every 2 seconds until the embedding exists:
In my run the note became searchable in about 8 seconds. Then a question that shares no words with it:
What went wrong, and what I changed
An unhelpful error. With a wrong publishable key, the page said only Search failed: HTTP 401. With a wrong project URL it said Failed to fetch. Neither tells you what to fix.
I mapped the two cases to plain messages in api():
Scores live in a narrow band. The model gave 0.70 to 0.92 for everything I tried. A nonsense question ("best pizza in town") still scored 0.703 and returned three notes. Search always ranks and never says "no match." In my 8 notes a cutoff near 0.80 happened to separate right answers (0.806–0.873) from the wrong first place (0.791) and the nonsense (0.703), but I would not trust a cutoff tuned on my own test.
Opposites look alike. "Candidate who does NOT want remote work" returned the person who asked about remote work first, at 0.869.
Long notes are cut off. This is the one that would hurt in real use. I inserted a 4,600-character note with one distinctive sentence at the very end ("move the company picnic to a rainy-day venue"). Asking about the picnic did not return that note in the top 3. Asking about the topic at the start returned it first, at 0.924. That matches the docs: the model reads about 512 tokens. The page limits notes to 2,000 characters, which is below that but close; I did not test where it starts to lose content.
Edits queue a new job, and the old vector stays until it is replaced. The update trigger queues another job; nothing clears the old embedding first. I rewrote one note and asked a question about the new text 5 seconds later. The score was 0.916, higher than anything the old text had scored, so I believe the new embedding was already in. I did not check the window directly, but by design it exists.
Security
-
embedandsearchrun with Verify JWT off, andembedaccepts any note id and job id. For real use, have the cron job send a secret header from Vault, or keep JWT on and call with a key that never reaches a browser. -
memo-apiis protected by a public key. Treat it as a demo lock. - Never put a
service_roleor secret key inconfig.jsor in a repo.
Taking it further (untested)
- Split long notes into chunks, one embedding per chunk.
- Combine keyword and semantic search. Supabase has a hybrid search guide.
- Real access control: Supabase Auth plus RLS policies, so people only search what they may see.
- Tune the index and test at 100k rows. I ran 8.
Cleanup
select cron.unschedule('process-embeddings');
drop table public.meeting_notes cascade;
select pgmq.drop_queue('embedding_jobs');
Then delete the three functions.
Recap
- Built-in
gte-smallpluspgmqpluspg_crongives automatic embeddings with no outside service. - 6 of 7 rephrased questions found the right note first; keyword search found none of them.
- Expect failures at opposites, near ties, nonsense queries and long notes. Show results as candidates.
- Everything here is demo-grade and tested on 8 notes.
The full code is in the repo: https://github.com/tomozilla/semantic-search-en-demo. If you want the plain-language version for the non-technical people who will use this, it is here: https://medium.com/p/004eda4da6d4.













Top comments (0)