The shared helpers first
Four files under supabase/functions/_shared/ are used by both functions. Write them once.
_shared/db.ts — the service-role client
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
// One service-role client, shared by both functions. The service role bypasses
// RLS, so it's the only thing that can reach the RLS-locked `documents` table.
// It lives ONLY here, server-side — never shipped to the browser.
export const db: SupabaseClient = createClient(
Deno.env.get("PROJECT_URL")!,
Deno.env.get("SERVICE_ROLE_KEY")!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);This is the payoff of Step 2's locked table: the service-role key bypasses RLS, so this client reads and writes documents freely — and because the key lives only in the function's secrets, only server-side code ever benefits from it. Note the env var names avoid the SUPABASE_ prefix — that's reserved by the Edge runtime, which injects its own values and refuses overrides.
_shared/cors.ts — CORS + a JSON helper
The browser calls these functions cross-origin, so both must answer the OPTIONS preflight and echo CORS headers:
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
export function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}"*" is fine for the demo; lock it to your Vercel domain in production (Step 7).
_shared/embed.ts — Supabase's built-in embeddings
Embeddings don't need an external API at all. Supabase's Edge Runtime ships a built-in text-embedding model, gte-small, that runs inside the function — no key, no network call, no per-request bill:
// Ambient declaration for the Edge Runtime's `Supabase.ai` global. The runtime
// provides it; we declare it here only so `deno check` knows the shape.
declare const Supabase: {
ai: {
Session: new (model: string) => {
run(
input: string,
opts?: { mean_pool?: boolean; normalize?: boolean },
): Promise<number[]>;
};
};
};
// gte-small outputs 384 dimensions — this MUST match the vector(384) column.
export const EMBED_DIM = 384;
// One inference session for the lifetime of the function instance; the model
// is loaded once, not per call.
const session = new Supabase.ai.Session("gte-small");
// gte-small has no document/query task-type distinction, so the same call is
// used for both the write phase and the read phase.
export async function embed(text: string): Promise<number[]> {
return await session.run(text, { mean_pool: true, normalize: true });
}Three things carry the design:
- No key, no external call. The model runs in Supabase's Edge Runtime, so embeddings never leave your project and cost nothing per call. (The only model key in this build is Gemini's, for generating the answer — Step 4.)
- 384 dimensions is the other half of the
vector(384)contract from Step 2.gte-smallfixes the size at 384; the column must match. mean_pool+normalizecompress the token vectors into one unit-length sentence vector — exactly what cosine search wants. And there's noRETRIEVAL_DOCUMENT/RETRIEVAL_QUERYsplit:gte-smallembeds questions and documents the same way, into the same space, so nearest-vector still means closest-in-meaning.
gte-smallis English-only and caps input around 512 tokens — comfortably more than our ~1000-character chunks. For other languages or longer inputs you'd swap models (and re-embed at the new dimension).
The ingest function
Now supabase/functions/ingest/index.ts — the write phase end to end.
Chunking
// Slice text into overlapping windows. Overlap keeps a sentence that straddles
// a boundary from being split away from its context. Simple on purpose.
function chunk(text: string, size = 1000, overlap = 200): string[] {
const clean = text.replace(/\s+/g, " ").trim();
const out: string[] = [];
for (let i = 0; i < clean.length; i += size - overlap) {
const slice = clean.slice(i, i + size).trim();
if (slice) out.push(slice);
}
return out;
}Chunks are ~1000 characters with 200 of overlap. The overlap matters: a fact that lands right on a chunk boundary would otherwise get sliced away from the sentence that explains it. Overlapping windows mean each idea appears whole in at least one chunk. This is deliberately the simplest thing that works — Step 7 covers smarter, semantic chunking.
Fetching a URL server-side
// Fetch a URL server-side (no browser CORS) and strip it down to rough text.
async function textFromUrl(url: string): Promise<string> {
const res = await fetch(url);
const html = await res.text();
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}Doing the fetch on the server dodges browser CORS entirely — the function can pull any public URL. The regex strip is crude (drop <script>/<style>, then all tags) but good enough to turn a web page into rough text.
The handler: embed each chunk, bulk-insert
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
try {
const { text, url, source } = await req.json();
const content = url ? await textFromUrl(url) : (text ?? "");
const label = source ?? url ?? "pasted text";
if (!content.trim()) throw new Error("nothing to ingest — send `text` or `url`");
const chunks = chunk(content);
// Embed each chunk, then bulk-insert. (For big docs you'd batch the embed
// calls and run ingestion in the background — see the production chapter.)
const rows = [];
for (const c of chunks) {
const embedding = await embed(c);
rows.push({ source: label, content: c, embedding });
}
const { error } = await db.from("documents").insert(rows);
if (error) throw error;
return json({ ok: true, source: label, chunks: rows.length });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return json({ error: message }, 400);
}
});The flow reads top to bottom exactly like the write phase from Step 1: take text or url, resolve it to clean content, chunk it, embed each chunk, and bulk-insert { source, content, embedding } rows in one call. It replies with how many chunks it stored so the UI can say "Stored 7 chunks."
One honest caveat baked into the comment: the embed loop is synchronous — one inference per chunk, in sequence. Because gte-small runs in-process there's no network round-trip, but it's still CPU work on the function, so a 200-page document will crawl. Fine for a paste or a short PDF; for anything large you'd run ingestion as a background job. That's the first item in the production chapter (Step 7). Step 4 builds the read phase that turns these stored vectors into answers.