Semantic Search with Claude Code and pgvector
Add embeddings-backed semantic search to a Supabase app. Generate embeddings, store them in pgvector, rank results by meaning, and serve it all through a type-safe oRPC endpoint.
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
Keyword search fails the moment a user types "cancel my plan" and your docs say "end subscription." Semantic search fixes that by ranking on meaning instead of exact words. This guide wires embeddings-backed search into a Supabase app: generate embeddings, store them in pgvector, rank by cosine similarity, and serve results through a type-safe oRPC endpoint. Claude Code writes most of it, and this walkthrough is the map you hand it.
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
What Semantic Search Actually Does
A keyword search matches strings. If the stored text does not contain the words in the query, it returns nothing, no matter how related the meaning is.
Semantic search works differently. Every document gets converted into a list of numbers called an embedding. The numbers encode meaning, so two texts that mean similar things end up close together in that number space, even when they share no words. Search becomes a geometry problem: embed the query, then find the stored embeddings nearest to it.
pgvector is the Postgres extension that makes this practical. It adds a native vector column type and distance operators so nearest-neighbor search runs inside the same database that already holds your data. No separate vector service, no sync job between two systems. Supabase ships pgvector, so if you are already on Supabase you are one migration away.
The moving parts are small. You need an embedding model to turn text into vectors, a table to store them, a SQL function to rank by distance, and an API endpoint to call from your app. The rest of this guide builds each piece.
Enable pgvector on Supabase
pgvector ships with Supabase but is not enabled by default. You turn it on with a one-line migration. Keeping it in a migration file, rather than clicking through the dashboard, means the change is version-controlled and reproducible across environments.
Ask Claude Code to create the migration, then apply it. The if not exists guard makes the migration safe to run more than once.
-- supabase/migrations/0001_enable_pgvector.sql
create extension if not exists vector with schema extensions;Apply it with the Supabase CLI:
supabase migration upYou can confirm the extension is live by listing installed extensions in the SQL editor. If vector shows up, you are ready to store embeddings.
select * from pg_extension where extname = 'vector';Design the Documents Table
You need one table that holds the source text and its embedding side by side. The embedding column type carries a dimension that must match your model exactly. OpenAI text-embedding-3-small returns 1536 numbers, so the column is vector(1536). Pick the model first, because the dimension is baked into the column and the index.
This migration creates the table, enables row-level security, and adds an HNSW index for fast approximate search. The vector_cosine_ops operator class tells the index to rank by cosine distance, which is the standard choice for text embeddings.
-- supabase/migrations/0002_documents.sql
create table documents (
id uuid primary key default gen_random_uuid(),
content text not null,
metadata jsonb not null default '{}'::jsonb,
embedding vector(1536),
created_at timestamptz not null default now()
);
alter table documents enable row level security;
create policy "documents are readable by authenticated users"
on documents for select
to authenticated
using (true);
create index on documents
using hnsw (embedding vector_cosine_ops);A note on the index timing. HNSW builds a navigable graph over your existing rows, so it works best once the table has a representative amount of data. For a fresh table you can add the index immediately, but for a large backfill it is faster to load the rows first and create the index afterward. For a few thousand rows you can skip the index entirely and Postgres will scan every row, which is slower but returns exact results.
Generate Embeddings
Now you need a function that turns text into a vector. This is a single call to the embedding model. Keep it in one small module so both your ingestion code and your search endpoint import the same function and never drift apart.
Tell Claude Code to create the helper and read the API key from the environment. The function takes a string and returns a plain array of numbers, which is exactly what pgvector accepts.
// lib/embeddings.ts
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function embed(text: string): Promise<number[]> {
const input = text.replace(/\n/g, " ").trim();
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input,
});
return response.data[0].embedding;
}The newline replacement is a small quality step. Embedding models treat a raw newline as a token, and stray line breaks add noise to the vector without adding meaning. Collapsing them to spaces gives you a cleaner representation.
To load documents into the table, embed each one and insert the vector alongside the text. This ingestion script reads an array of strings, embeds them one at a time, and writes the rows. For a real backfill you would batch these, but the linear version is clearest to read.
// scripts/ingest.ts
import { createClient } from "@supabase/supabase-js";
import { embed } from "../lib/embeddings";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const docs = [
"You can cancel your subscription anytime from the billing page.",
"Export your data as CSV from the settings menu.",
"We support Google and email sign-in with magic links.",
];
async function main() {
for (const content of docs) {
const embedding = await embed(content);
const { error } = await supabase
.from("documents")
.insert({ content, embedding });
if (error) throw error;
console.log("inserted:", content.slice(0, 40));
}
}
main();Run it once to seed the table. The service role key bypasses row-level security, which is what you want for a trusted server-side script but never for client code.
npx tsx scripts/ingest.tsWrite the Match Function
Ranking happens in the database. You pass a query embedding into a SQL function, and it returns the closest rows. Doing the comparison in Postgres keeps the vectors where they live and lets the HNSW index do its job. The cosine distance operator in pgvector is <=>, and it returns a value between 0 and 2 where smaller means more similar.
This function takes the query vector, a similarity floor, and a result count. It converts distance into a similarity score with 1 - (embedding <=> query_embedding) so higher numbers read as better matches, filters out weak results, and returns the top rows.
-- supabase/migrations/0003_match_documents.sql
create or replace function match_documents (
query_embedding vector(1536),
match_threshold float default 0.3,
match_count int default 5
)
returns table (
id uuid,
content text,
metadata jsonb,
similarity float
)
language sql stable
as $$
select
documents.id,
documents.content,
documents.metadata,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
where documents.embedding is not null
and 1 - (documents.embedding <=> query_embedding) > match_threshold
order by documents.embedding <=> query_embedding
limit match_count;
$$;Two details matter here. The order by clause uses the raw distance operator, not the similarity score, because that is the expression the HNSW index knows how to accelerate. And the threshold is a starting point, not a law. Real thresholds depend on your content, so treat 0.3 as a dial to tune once you see results on your own data.
Serve It Through a Type-Safe oRPC Endpoint
The API layer is where types earn their keep. oRPC with Zod validates the incoming query and gives you an inferred return type, so the shape that leaves the server is the exact shape the client expects. Claude Code is good at generating this once you show it the pattern.
Define the procedure with an input schema and an output schema. The handler embeds the query text, calls the match function through the Supabase RPC interface, and returns the rows. Because the RPC result is loosely typed, you validate it against the output schema before returning, which turns any surprise from the database into a clear error instead of a runtime crash downstream.
// server/router/search.ts
import { os } from "@orpc/server";
import { z } from "zod";
import { createServerClient } from "@/lib/supabase/server";
import { embed } from "@/lib/embeddings";
const SearchResult = z.object({
id: z.string().uuid(),
content: z.string(),
metadata: z.record(z.string(), z.unknown()),
similarity: z.number(),
});
export const search = os
.input(
z.object({
query: z.string().min(1).max(500),
limit: z.number().int().min(1).max(20).default(5),
}),
)
.output(z.array(SearchResult))
.handler(async ({ input }) => {
const supabase = await createServerClient();
const queryEmbedding = await embed(input.query);
const { data, error } = await supabase.rpc("match_documents", {
query_embedding: queryEmbedding,
match_threshold: 0.3,
match_count: input.limit,
});
if (error) throw new Error(error.message);
return z.array(SearchResult).parse(data);
});The Supabase server client is created per request so it carries the caller's auth context. In Next.js 16 that client reads cookies, and reading cookies is asynchronous, so createServerClient is awaited. Getting this right means row-level security applies to searches the same way it applies to every other query.
// lib/supabase/server.ts
import { createServerClient as createClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createServerClient() {
const cookieStore = await cookies();
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
for (const { name, value, options } of cookiesToSet) {
cookieStore.set(name, value, options);
}
},
},
},
);
}Expose the router through a single route handler. oRPC mounts under one catch-all path, and the fetch handler wires the request straight into your procedures.
// app/api/rpc/[[...rest]]/route.ts
import { RPCHandler } from "@orpc/server/fetch";
import { search } from "@/server/router/search";
const handler = new RPCHandler({ search });
async function handle(request: Request) {
const { response } = await handler.handle(request, {
prefix: "/api/rpc",
});
return response ?? new Response("Not found", { status: 404 });
}
export const GET = handle;
export const POST = handle;Call It From a Search UI
The front end stays simple because the endpoint carries its types across the wire. This client component holds the query in state, calls the oRPC procedure, and renders the ranked results. The similarity score doubles as a relevance readout you can show during development and hide in production.
// app/search/search-box.tsx
"use client";
import { useState } from "react";
import { client } from "@/lib/orpc-client";
type Result = {
id: string;
content: string;
similarity: number;
};
export function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [loading, setLoading] = useState(false);
async function runSearch() {
if (!query.trim()) return;
setLoading(true);
const data = await client.search({ query, limit: 5 });
setResults(data);
setLoading(false);
}
return (
<div className="max-w-xl mx-auto py-12 space-y-4">
<div className="flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && runSearch()}
placeholder="Ask anything..."
className="flex-1 rounded-md border px-3 py-2"
/>
<button
onClick={runSearch}
disabled={loading}
className="rounded-md bg-black px-4 py-2 text-white"
>
{loading ? "Searching" : "Search"}
</button>
</div>
<ul className="space-y-2">
{results.map((r) => (
<li key={r.id} className="rounded-md border p-3">
<p>{r.content}</p>
<span className="text-xs text-gray-500">
{(r.similarity * 100).toFixed(1)}% match
</span>
</li>
))}
</ul>
</div>
);
}The typed client is a thin wrapper that points at your RPC route. Because the router type is imported, the argument to client.search and the shape of data are both checked at compile time, so a rename on the server surfaces as a red squiggle in the component.
// lib/orpc-client.ts
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import type { search } from "@/server/router/search";
const link = new RPCLink({ url: "/api/rpc" });
export const client: RouterClient<{ search: typeof search }> =
createORPCClient(link);Keep Embeddings in Sync
An embedding is a snapshot of the text at the moment you generated it. Edit the content and the stored vector goes stale, so the row keeps matching the old meaning. The fix is to re-embed whenever content changes.
For low write volume, re-embed inline in the same code path that updates the row. For higher volume, or when embedding latency would slow a user-facing write, push the work to a background job with Inngest so the update returns fast and the embedding lands a moment later. Either way, the rule is the same: content and embedding change together.
You can also RAG on top of this. Once search returns the most relevant documents, feed those passages into a prompt as context and let the model answer from them. Retrieval is the hard half, and you just built it.
Quality Gates Before You Ship
Vector code fails quietly. A dimension mismatch, a null embedding, or a wrong operator class does not throw at build time, it just returns bad results. So the checks matter more here than usual.
Type check with zero errors, then confirm a clean build:
npx tsc --noEmit && npm run buildThen verify behavior, not just compilation. Seed a handful of documents, run a query whose answer shares no keywords with any stored text, and confirm the right row still ranks first. That single test catches the mistakes types cannot see: a threshold set too high that filters everything out, or an index built on the wrong operator class that quietly returns noise.
Where the $29 Code Kit Fits
Everything above is standard Claude Code work. You describe the feature, hand it this map, and it writes the migrations, the endpoint, and the UI. The Code Kit is a harness that sits on top of that loop so the output is consistent every time: it plans the feature, builds it, evaluates the result, tests it against a real browser, and runs the quality gates so nothing ships with type errors, lint errors, or a broken build. It is $29 one time, no subscription. Claude Code itself runs on your own paid Anthropic plan, which is separate. The Kit makes a plan-build-evaluate-test pipeline repeatable so a vector search feature comes out the same whether it is the first one you build or the tenth.
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
Posted by @speedy_devv
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
Stripe Webhooks
Build a signature-verified Stripe webhook handler that survives retries. Idempotency, event dedupe, and processing offloaded to Inngest so a slow handler never drops a payment event.
Realtime Updates
Ship live-updating UI (presence, typing indicators, and instant list updates) using Supabase Realtime channels and Postgres change subscriptions, with no polling and no websocket server of your own.