Advanced · about 8-12 hours

RAG App on Supabase pgvector with Cited Answers and Evals

Build retrieval-augmented generation end to end: chunk your documents, embed them, store them in Postgres with pgvector, retrieve the closest chunks for a question, have Claude answer with citations to those chunks, and measure quality with a small eval set you rerun on every change.

You'll end up with: Staff ask a question about company policy and get an answer that links to the exact document passages it came from, or a clear "not in the docs".

Supabase (Postgres + pgvector)Voyage AI embeddingsClaude APITypeScript (Node.js)supabase-js

Before you start

  • □A Supabase project (the free tier is enough) and access to its SQL editor
  • □A Claude API key and an embeddings API key
  • □10 to 50 internal documents exported as Markdown or plain text
  • □Comfort with TypeScript, SQL and environment variables

Architecture

  1. Source documents

    Markdown exports of policies, SOPs and FAQs, each with a stable path used as its citation source.

  2. Chunking

    An ingestion script splits each document on headings, then into overlapping chunks, and hashes each chunk so unchanged ones are skipped.

  3. Embeddings

    Each chunk is embedded as a document. Questions are embedded later with the same model as a query.

  4. pgvector table

    Chunks, their source path and their vectors live in one Postgres table with an HNSW index and RLS enabled.

  5. Retrieval

    A SQL function returns the closest chunks above a similarity threshold, called through supabase.rpc.

  6. Cited answer

    Claude receives the chunks as search_result blocks with citations enabled and answers only from them.

  7. Evals

    A fixed set of questions with expected sources measures retrieval and refusals every time you change chunking, model or threshold.

Build it, step by step

Tick steps off as you build.

0/6
  1. Create the table, index and match function

    Enable pgvector, then create one table for chunks. The vector size must equal the dimensions your embedding model returns: 1024 is the default for most of the Voyage models listed in Anthropic's embeddings guide, so change it if yours differs. Turn on RLS with no policies so only server code holding the secret key can read the table.

    The match function uses cosine distance, which is a safe default when you don't know whether your vectors are normalised, and caps the result count so one bad call can't pull the whole table.

    sql
    create extension if not exists vector with schema extensions;
    
    create table doc_chunks (
      id bigint primary key generated always as identity,
      doc_path text not null,
      doc_title text not null,
      chunk_index int not null,
      content text not null,
      content_hash text not null,
      embedding extensions.vector(1024) not null,
      unique (doc_path, chunk_index)
    );
    
    alter table doc_chunks enable row level security;
    
    create index on doc_chunks using hnsw (embedding vector_cosine_ops);
    
    create or replace function match_doc_chunks (
      query_embedding extensions.vector(1024),
      match_threshold float,
      match_count int
    )
    returns table (id bigint, doc_path text, doc_title text, content text, similarity float)
    language sql stable
    as $$
      select c.id, c.doc_path, c.doc_title, c.content,
             1 - (c.embedding <=> query_embedding) as similarity
      from doc_chunks c
      where c.embedding <=> query_embedding < 1 - match_threshold
      order by c.embedding <=> query_embedding asc
      limit least(match_count, 20);
    $$;
  2. Set up keys and the embedding call

    Anthropic doesn't offer an embedding model, and its docs point to Voyage AI. Voyage takes an input_type of document or query, and its docs say to always set it for retrieval. Keep the model name and endpoint in environment variables so you can swap them without a code change.

    Use Supabase's secret key (sb_secret_...) only on the server. It bypasses every RLS policy, so it must never reach a browser or a repo.

    typescript
    // src/embed.ts
    export function requireEnv(name: string): string {
      const value = process.env[name];
      if (!value) throw new Error(`Missing environment variable ${name}`);
      return value;
    }
    
    export async function embed(texts: string[], inputType: "document" | "query"): Promise<number[][]> {
      const res = await fetch(requireEnv("EMBEDDINGS_URL"), {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${requireEnv("VOYAGE_API_KEY")}`,
        },
        body: JSON.stringify({ input: texts, model: requireEnv("EMBEDDING_MODEL"), input_type: inputType }),
      });
      if (!res.ok) throw new Error(`Embedding request failed with ${res.status}`);
      const json = (await res.json()) as { data: { embedding: number[]; index: number }[] };
      return json.data.sort((a, b) => a.index - b.index).map((item) => item.embedding);
    }
  3. Chunk documents on structure, not just length

    Split on headings first so a chunk rarely mixes two topics, then cut long sections into overlapping windows so a sentence on a boundary appears whole in at least one chunk. Start around 1,500 characters with 200 of overlap and let your evals tell you whether to change it. Keep the heading text inside the chunk: it carries meaning the body often leaves implicit.

    typescript
    // src/chunk.ts
    export function chunkMarkdown(text: string, maxChars = 1500, overlap = 200): string[] {
      const sections = text.split(/\n(?=#{1,3} )/);
      const chunks: string[] = [];
      for (const section of sections) {
        for (let start = 0; start < section.length; start += maxChars - overlap) {
          chunks.push(section.slice(start, start + maxChars).trim());
          if (start + maxChars >= section.length) break;
        }
      }
      return chunks.filter((chunk) => chunk.length > 0);
    }
  4. Ingest idempotently

    For each document, chunk it, hash each chunk, and embed only chunks whose hash changed. Upsert on (doc_path, chunk_index) so a rerun updates rows instead of duplicating them, then delete leftover chunks when a document got shorter. Running ingestion twice in a row should make no changes and cost no embedding calls. If it does, you'll pay for duplicates and retrieval will return the same passage twice.

    typescript
    // src/ingest.ts (core of the per-document loop)
    import { createHash } from "node:crypto";
    import { createClient } from "@supabase/supabase-js";
    import { chunkMarkdown } from "./chunk";
    import { embed, requireEnv } from "./embed";
    
    const supabase = createClient(requireEnv("SUPABASE_URL"), requireEnv("SUPABASE_SECRET_KEY"));
    
    export async function ingestDocument(docPath: string, docTitle: string, text: string): Promise<void> {
      const chunks = chunkMarkdown(text);
      const hashes = chunks.map((chunk) => createHash("sha256").update(chunk).digest("hex"));
    
      const { data: existing, error: readError } = await supabase
        .from("doc_chunks").select("chunk_index, content_hash").eq("doc_path", docPath);
      if (readError) throw new Error(`Read failed for ${docPath}: ${readError.message}`);
      const known = new Map((existing ?? []).map((row) => [row.chunk_index, row.content_hash]));
    
      const changed = chunks
        .map((content, index) => ({ content, index, hash: hashes[index] }))
        .filter((chunk) => known.get(chunk.index) !== chunk.hash);
    
      if (changed.length > 0) {
        const vectors = await embed(changed.map((chunk) => chunk.content), "document");
        const rows = changed.map((chunk, i) => ({
          doc_path: docPath, doc_title: docTitle, chunk_index: chunk.index,
          content: chunk.content, content_hash: chunk.hash, embedding: JSON.stringify(vectors[i]),
        }));
        const { error } = await supabase.from("doc_chunks").upsert(rows, { onConflict: "doc_path,chunk_index" });
        if (error) throw new Error(`Upsert failed for ${docPath}: ${error.message}`);
      }
    
      const { error: pruneError } = await supabase
        .from("doc_chunks").delete().eq("doc_path", docPath).gte("chunk_index", chunks.length);
      if (pruneError) throw new Error(`Prune failed for ${docPath}: ${pruneError.message}`);
    }
  5. Retrieve, then answer with real citations

    Embed the question as a query, call the match function, and stop early if nothing clears the threshold: an empty context is the right moment to say "not in the docs" without spending a model call. Otherwise pass each chunk to Claude as a search_result block with citations enabled. Claude then attaches citations to the sentences that use a chunk, and each citation carries the source string you set, so your UI can link straight to the document.

    No temperature or top_p: current API versions reject them.

    typescript
    // src/answer.ts
    import Anthropic from "@anthropic-ai/sdk";
    import { createClient } from "@supabase/supabase-js";
    import { embed, requireEnv } from "./embed";
    
    const supabase = createClient(requireEnv("SUPABASE_URL"), requireEnv("SUPABASE_SECRET_KEY"));
    const client = new Anthropic(); // reads ANTHROPIC_API_KEY
    
    interface Match { doc_path: string; doc_title: string; content: string; similarity: number }
    
    export async function answer(question: string) {
      const [queryEmbedding] = await embed([question], "query");
      const { data, error } = await supabase.rpc("match_doc_chunks", {
        query_embedding: queryEmbedding,
        match_threshold: 0.5,
        match_count: 6,
      });
      if (error) throw new Error(`Retrieval failed: ${error.message}`);
      const matches = (data ?? []) as Match[];
      if (matches.length === 0) return { text: "I couldn't find this in the docs.", sources: [] as string[] };
    
      const response = await client.messages.create({
        model: "claude-sonnet-5-5",
        max_tokens: 1024,
        system: "Answer only from the search results. If they don't answer the question, say so plainly.",
        messages: [{
          role: "user",
          content: [
            ...matches.map((match) => ({
              type: "search_result" as const,
              source: match.doc_path,
              title: match.doc_title,
              content: [{ type: "text" as const, text: match.content }],
              citations: { enabled: true },
            })),
            { type: "text" as const, text: question },
          ],
        }],
      });
    
      const textBlocks = response.content.filter((block) => block.type === "text");
      const sources = textBlocks.flatMap((block) =>
        (block.citations ?? []).flatMap((citation) =>
          citation.type === "search_result_location" ? [citation.source] : [],
        ),
      );
      return { text: textBlocks.map((block) => block.text).join(""), sources: [...new Set(sources)] };
    }
  6. Build a tiny eval set and run it on every change

    Write 10 to 20 real questions with the document that should answer each one, plus a few that the docs can't answer. The script checks two things: the expected document is among the cited sources, and out-of-scope questions come back with no citations. Run it before and after any change to chunk size, threshold, embedding model or prompt, and keep the results in the repo so you can see regressions.

    This is a retrieval and grounding check, not a full quality score. Add a human review of a sample of answers before you trust the bot with customers.

    typescript
    // evals/run.ts
    import { answer } from "../src/answer";
    
    const cases: { question: string; expectSource: string | null }[] = [
      { question: "How many days do customers have to request a refund?", expectSource: "policies/refunds.md" },
      { question: "Who approves discounts above 20 percent?", expectSource: "sales/discount-approval.md" },
      { question: "Do we offer weekend support?", expectSource: "support/hours.md" },
      { question: "What is the CEO's home address?", expectSource: null },
    ];
    
    let passed = 0;
    for (const testCase of cases) {
      const result = await answer(testCase.question);
      const ok = testCase.expectSource === null
        ? result.sources.length === 0
        : result.sources.includes(testCase.expectSource);
      if (ok) passed++;
      console.log(ok ? "PASS" : "FAIL", testCase.question, "->", result.sources.join(", ") || "(no sources)");
    }
    console.log(`${passed}/${cases.length} passed`);
    if (passed < cases.length) process.exitCode = 1;

Example run

  1. Ingestion reads 24 Markdown files. policies/refunds.md becomes 3 chunks. The log shows: docs=24 chunks=131 embedded=131 skipped=0.
  2. You fix a typo in refunds.md and rerun. Only chunk 1 of that file has a new hash, so the log shows: embedded=1 skipped=130.
  3. A staff member asks: "Can a client get a refund after 20 days?" The match function returns 4 chunks above 0.5, the top one from policies/refunds.md at similarity 0.71.
  4. Claude answers: "Refund requests are accepted within 30 days of purchase, so a request at day 20 qualifies." That sentence carries a citation with source policies/refunds.md and the cited text.
  5. The UI shows the answer with a link to policies/refunds.md next to the cited sentence.
  6. Someone asks: "What is our parental leave policy?" No chunk clears the threshold, so the app replies "I couldn't find this in the docs." without calling Claude.
  7. The eval run prints 17/18 passed. The failure is a discount question citing an old pricing page, which tells you to retire that document or tighten the threshold.

Test it

  • ✓Run ingestion twice in a row. The second run must embed 0 chunks and leave the row count unchanged.
  • ✓Shorten a document to one section and reingest. Its stale higher-numbered chunks must be deleted.
  • ✓Failure case: set an invalid embeddings key. Ingestion must stop with the HTTP status and write no rows for that document.
  • ✓Failure case: ask an out-of-scope question. The app must return the not-found message with no sources, and the eval must count it as a pass.
  • ✓Failure case: query from the browser with the publishable key. With RLS on and no policies, you must get either no rows or a permission error, never chunk content.
  • ✓Change match_threshold from 0.5 to 0.7 and rerun the eval set. Record how many in-scope questions start failing before you settle on a value.

Watch out for

  • !Mismatched dimensions. The vector column size must match the model's output, and every row and query must come from the same embedding model. Vectors from different models can't be compared.
  • !Skipping input_type. Embedding questions as documents, or the reverse, quietly lowers retrieval quality.
  • !A threshold copied from a tutorial. Similarity scores depend on the model and your data. Tune the threshold with your eval set.
  • !Chunks that are too big or too small. Big chunks dilute the match; tiny ones lose context. Change the size only when your evals show a reason.
  • !Re-embedding everything on every run. Without hashes you pay for every chunk each time and risk duplicate rows.
  • !Trusting the answer without the citation. Show sources in the UI so readers can check, and treat answers with no citation as unverified.
  • !Using the secret key client-side. It bypasses RLS entirely, so anyone who finds it can read and delete every row.

Concepts used