DEV Community

Tomo
Tomo

Posted on AI-assisted

Semantic Search in Postgres with Supabase Edge Functions: Auto-Embeddings and a Small App

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

The finished page comparing semantic and keyword search for

What you will build

Browser, memo-api function, Postgres table, queue with cron, and embed function, wired in a loop.

  1. You insert a note.
  2. A trigger puts a job in a queue (pgmq).
  3. pg_cron runs every 10 seconds and calls the embed Edge Function for each job.
  4. embed turns the text into 384 numbers with the built-in gte-small model and writes them to the row.
  5. 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 embed call 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-small is 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
Enter fullscreen mode Exit fullscreen mode

Check that all four are on:

SQL Editor result listing pg_cron, pg_net, pgmq and vector with their versions.

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();
Enter fullscreen mode Exit fullscreen mode

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();');
Enter fullscreen mode Exit fullscreen mode

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.

SQL Editor showing the process-embeddings cron job, every 10 seconds, active.

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");
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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.

Edge Functions list showing embed, memo-api and search, with a placeholder project ref in the URLs.

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:

Query result right after the insert: all 8 rows have embedding_is_null = true.

Roughly 15 seconds later, without touching anything:

Same query later: all 8 rows have embedding_is_null = false.

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?"}'
Enter fullscreen mode Exit fullscreen mode

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:

SQL Editor result: coworker, customers leaving, led a team, work from home, incident and spending plan all return 0 hits.

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The page on first load: search box with example questions, an add-note form and the list of saved notes.

Adding a note shows the pipeline working. The page saves the note, then polls status every 2 seconds until the embedding exists:

Add-note form showing step 1 done and step 2 in progress; the new note is listed as preparing.

In my run the note became searchable in about 8 seconds. Then a question that shares no words with it:

Searching

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.

The page showing

I mapped the two cases to plain messages in api():

The page showing

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

  • embed and search run with Verify JWT off, and embed accepts 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-api is protected by a public key. Treat it as a demo lock.
  • Never put a service_role or secret key in config.js or 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');
Enter fullscreen mode Exit fullscreen mode

Then delete the three functions.

Recap

  • Built-in gte-small plus pgmq plus pg_cron gives 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)