◢ build log

Building a Production-Ready RAG System with Supabase and TypeScript

Learn how to build a robust Retrieval-Augmented Generation (RAG) system using Supabase for vector storage and TypeScript for application logic.

Large Language Models (LLMs) are powerful, but they often lack specific, up-to-date knowledge. They can also hallucinate, generating plausible but incorrect information. This is where Retrieval-Augmented Generation (RAG) comes in. RAG systems ground LLMs in your own data, ensuring more accurate and relevant responses. For production applications, you need a reliable backend and a type-safe language. Supabase and TypeScript are a strong combination for this.

Why RAG for Production?

Shipping an LLM application that provides consistent, factual answers requires more than just calling an API. You need to control the information the LLM uses. RAG achieves this by retrieving relevant context from a knowledge base before generating a response. This process mitigates hallucinations and keeps your LLM responses grounded in your specific domain data.

Without RAG, an LLM might invent details about your product, your company, or technical specifications. With RAG, you feed it documentation, support tickets, or internal wikis, and it uses that information directly. This is critical for customer-facing applications, internal tools, and any system where accuracy matters.

Supabase for Vector Storage

Supabase is an open-source Firebase alternative built on PostgreSQL. Its key advantage for RAG is the pgvector extension. This extension turns PostgreSQL into a capable vector database. You get the familiarity and robustness of a relational database, plus native vector search capabilities, all within a single backend.

This simplifies your architecture. You don't need a separate vector database service. Supabase handles authentication, storage, and real-time capabilities alongside your vector data. It's a unified platform that reduces operational overhead.

To get started, enable the pgvector extension in your Supabase project's database settings.

Embedding Your Data

The first step in RAG is to convert your raw text data into numerical representations called embeddings. An embedding is a high-dimensional vector that captures the semantic meaning of text. Texts with similar meanings will have vectors that are close to each other in the vector space.

You'll need an embedding model to perform this conversion. For production, you typically chunk your documents into smaller, manageable pieces before embedding. This ensures that each chunk is semantically cohesive and fits within the embedding model's token limit.

Here’s a conceptual TypeScript example of how you might embed a text chunk:

import { createClient } from '@supabase/supabase-js';
// Assume an embedding service/model is available
// import { getEmbeddings } from './embeddingService'; 

interface EmbeddingService {
  createEmbedding(text: string): Promise<number[]>;
}

// Placeholder for your actual embedding service integration
const embeddingService: EmbeddingService = {
  createEmbedding: async (text: string) => {
    // In a real application, this would call an external API or local model
    console.log(`Generating embedding for: "${text.substring(0, 30)}..."`);
    // Return a dummy vector for demonstration
    return Array.from({ length: 1536 }, () => Math.random()); 
  }
};

async function embedDocumentChunk(chunk: string): Promise<number[]> {
  try {
    const embedding = await embeddingService.createEmbedding(chunk);
    return embedding;
  } catch (error) {
    console.error('Error creating embedding:', error);
    throw error;
  }
}

// Example usage:
// const myChunk = "Supabase provides a powerful backend for developers.";
// const embedding = await embedDocumentChunk(myChunk);
// console.log(embedding.length); // e.g., 1536

Storing Embeddings in Supabase

Once you have your embeddings, you need to store them in a Supabase table. You'll create a table with a vector column type provided by pgvector.

First, set up your table. Here's a SQL example:

CREATE TABLE documents (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  content text,
  embedding vector(1536) -- Adjust dimension based on your embedding model
);

-- Create an index for faster similarity search
CREATE INDEX ON documents USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);

Then, insert your embedded data. The supabase-js client handles the vector type seamlessly.

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!); 

async function storeEmbedding(content: string, embedding: number[]) {
  const { data, error } = await supabase
    .from('documents')
    .insert({ content, embedding });

  if (error) {
    console.error('Error storing embedding:', error);
    throw error;
  }
  console.log('Stored document:', data);
}

// Example:
// const chunk = "Supabase is an open-source alternative to Firebase.";
// const embedding = await embedDocumentChunk(chunk); // from previous step
// await storeEmbedding(chunk, embedding);

Querying for Context

When a user asks a question, you convert their query into an embedding using the same model you used for your documents. Then, you perform a similarity search in your Supabase table to find the most relevant document chunks.

import { createClient } from '@supabase/supabase-js';
// Assume embeddingService is available from previous step

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!); 

async function queryContext(userQuery: string, limit: number = 5): Promise<string[]> {
  const queryEmbedding = await embeddingService.createEmbedding(userQuery);

  const { data, error } = await supabase.rpc('match_documents', {
    query_embedding: queryEmbedding,
    match_threshold: 0.78, // Adjust based on your data and embedding model
    match_count: limit
  });

  if (error) {
    console.error('Error querying context:', error);
    throw error;
  }

  return data.map((d: { content: string }) => d.content);
}

// The 'match_documents' function is a custom PostgreSQL function you'd define:
// CREATE OR REPLACE FUNCTION match_documents (
//   query_embedding vector(1536),
//   match_threshold float,
//   match_count int
// ) RETURNS TABLE (id uuid, content text, similarity float) LANGUAGE plpgsql AS $$
// BEGIN
//   RETURN QUERY
//   SELECT
//     documents.id,
//     documents.content,
//     1 - (documents.embedding <=> query_embedding) AS similarity
//   FROM documents
//   WHERE 1 - (documents.embedding <=> query_embedding) > match_threshold
//   ORDER BY similarity DESC
//   LIMIT match_count;
// END;
// $$;

// Example:
// const userQuestion = "What is pgvector?";
// const context = await queryContext(userQuestion);
// console.log('Retrieved context:', context);

The match_documents function uses the pgvector <=> operator, which calculates the Euclidean distance between vectors. 1 - (a <=> b) gives you a similarity score where 1 is identical and 0 is maximally dissimilar.

Integrating with Your LLM

With the relevant context in hand, you construct a prompt for your LLM. This prompt includes both the user's original query and the retrieved context. This is the core of RAG.

async function generateResponse(userQuery: string) {
  const context = await queryContext(userQuery);

  const prompt = `You are a helpful assistant. Use the following context to answer the user's question. If the answer is not in the context, state that you don't have enough information.

Context:
${context.join('\n\n')}

Question: ${userQuery}

Answer:`;

  // In a real application, you would send this prompt to an LLM API
  // const llmResponse = await llmService.getResponse(prompt);
  // return llmResponse;
  console.log('LLM Prompt:\n', prompt);
  return "(LLM response based on prompt)";
}

// Example:
// const response = await generateResponse("How do I enable pgvector in Supabase?");
// console.log(response);

This structured prompt guides the LLM to use the provided context, preventing it from relying on its general training data, which might be outdated or incorrect for your specific use case. For more advanced RAG applications, consider exploring AI agent development.

Production Considerations

Building a production-ready RAG system involves more than just the core logic. Consider these points:

  • Chunking Strategy: How you split your documents greatly impacts retrieval quality. Experiment with different chunk sizes, overlaps, and metadata attachment.
  • Embedding Model: The choice of embedding model matters. Different models perform better on different types of data and languages.
  • Indexing: For large datasets, pgvector supports various indexing strategies (like IVFFlat) to speed up similarity search. Choose one appropriate for your scale.
  • Monitoring and Evaluation: Track the quality of your retrieved context and LLM responses. Set up metrics to identify when your RAG system isn't performing well.
  • Re-embedding: Your data will change. Set up processes to automatically re-embed and update your vector store when source documents are modified.
  • Security: Ensure your Supabase access policies are locked down. Only allow necessary permissions for your application to interact with the documents table.

Where to go from here

Start by experimenting with a small dataset to understand the RAG flow. Then, focus on robust data ingestion and chunking for your specific use case. If you need expert guidance, consider AI consulting.

  • RAG system
  • Supabase RAG
  • TypeScript RAG
  • pgvector
  • vector database
  • LLM context
  • production AI
  • embedding data
  • retrieval augmented generation
05 — execute

Ready to build
the future?

Join the developers learning to build with AI. New tutorials, tools and breakdowns every week — straight to your feed.

$ aidevguy --start --now_