Building a RAG Chatbot with Claude API and Supabase pgvector
A complete guide to building a Retrieval-Augmented Generation (RAG) chatbot with Claude — covering document chunking, embeddings with Supabase pgvector, semantic search, and prompt caching on retrieved context.
RAG (Retrieval-Augmented Generation) is the most practical pattern for building AI assistants over your own data — documentation, support articles, product catalogues, legal documents. Instead of fine-tuning Claude on your data, you retrieve relevant content at query time and include it in the context. This guide builds a complete, production-ready RAG pipeline with Supabase pgvector and Claude.
Architecture Overview
The RAG pipeline has two phases. Ingestion (runs once per document): chunk the document, generate embeddings for each chunk, store chunks and embeddings in Supabase. Query (runs on every chat message): embed the user's query, retrieve the top-k most similar chunks, pass the chunks as context to Claude with prompt caching enabled, stream the response to the user.
Setting Up Supabase pgvector
Enable the pgvector extension in your Supabase project and create the documents and chunks tables. The embedding column stores the vector — set the dimension to match your embedding model (1024 for voyage-3, 1536 for OpenAI text-embedding-3-small).
-- Enable pgvector
create extension if not exists vector;
-- Documents table
create table documents (
id uuid primary key default gen_random_uuid(),
title text not null,
source_url text,
created_at timestamptz default now()
);
-- Chunks table with embeddings
create table document_chunks (
id uuid primary key default gen_random_uuid(),
document_id uuid references documents(id) on delete cascade,
content text not null,
embedding vector(1024), -- voyage-3 dimension
chunk_index integer not null,
created_at timestamptz default now()
);
-- IVFFlat index for approximate nearest-neighbour search
create index on document_chunks
using ivfflat (embedding vector_cosine_ops)
with (lists = 100);
-- Similarity search function
create or replace function match_chunks(
query_embedding vector(1024),
match_count int default 5,
min_similarity float default 0.5
)
returns table (
id uuid,
content text,
similarity float
)
language sql stable as $$
select
id,
content,
1 - (embedding <=> query_embedding) as similarity
from document_chunks
where 1 - (embedding <=> query_embedding) > min_similarity
order by embedding <=> query_embedding
limit match_count;
$$;Document Chunking
Split documents into overlapping chunks before embedding. Overlap ensures that content spanning a chunk boundary isn't lost in retrieval. A recursive character splitter that respects paragraph and sentence boundaries produces better chunks than splitting at fixed character counts.
// lib/chunker.js
export function chunkText(text, chunkSize = 500, overlap = 50) {
const words = text.split(/\s+/);
const chunks = [];
for (let i = 0; i < words.length; i += chunkSize - overlap) {
const chunk = words.slice(i, i + chunkSize).join(' ');
if (chunk.trim()) chunks.push(chunk);
if (i + chunkSize >= words.length) break;
}
return chunks;
}
// Prefer splitting at paragraph boundaries
export function chunkByParagraph(text, maxWords = 500) {
const paragraphs = text.split(/\n{2,}/);
const chunks = [];
let current = [];
let wordCount = 0;
for (const para of paragraphs) {
const paraWords = para.split(/\s+/).length;
if (wordCount + paraWords > maxWords && current.length > 0) {
chunks.push(current.join('\n\n'));
current = [];
wordCount = 0;
}
current.push(para);
wordCount += paraWords;
}
if (current.length > 0) chunks.push(current.join('\n\n'));
return chunks;
}Generating and Storing Embeddings
Use Voyage AI's voyage-3 model for embeddings — it's optimised for retrieval tasks and pairs well with Claude. Batch embed chunks to stay within rate limits. Store each chunk with its document reference and chunk index for attribution.
// lib/embed.js
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY,
);
async function embedBatch(texts) {
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'voyage-3', input: texts }),
});
const { data } = await res.json();
return data.map((d) => d.embedding);
}
export async function ingestDocument(title, text, sourceUrl) {
// Create document record
const { data: doc } = await supabase
.from('documents')
.insert({ title, source_url: sourceUrl })
.select()
.single();
const chunks = chunkByParagraph(text);
const BATCH_SIZE = 20;
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE);
const embeddings = await embedBatch(batch);
await supabase.from('document_chunks').insert(
batch.map((content, j) => ({
document_id: doc.id,
content,
embedding: embeddings[j],
chunk_index: i + j,
})),
);
}
return doc.id;
}Retrieval: Finding Relevant Chunks
At query time, embed the user's question and run a cosine similarity search against the chunks table. The match_chunks function returns the top-k most relevant chunks above a similarity threshold. Retrieve 5–8 chunks for most queries — enough context without exceeding what Claude can use effectively.
// lib/retrieve.js
export async function retrieveContext(query, matchCount = 6) {
// Embed the query using the same model as the documents
const [queryEmbedding] = await embedBatch([query]);
const { data: chunks, error } = await supabase.rpc('match_chunks', {
query_embedding: queryEmbedding,
match_count: matchCount,
min_similarity: 0.5,
});
if (error) throw error;
// Join chunks into a single context string
return chunks
.map((c, i) => `[Source ${i + 1}]\n${c.content}`)
.join('\n\n---\n\n');
}Generating with Claude + Prompt Caching
The retrieved context goes into a cached system prompt block. Because the same document chunks are frequently retrieved for similar queries, prompt caching means you pay full price for the first retrieval of a context window and 90% less for subsequent hits within the 5-minute cache window — essential for cost control on a high-traffic chatbot.
// lib/rag.js
import Anthropic from '@anthropic-ai/sdk';
import { retrieveContext } from './retrieve';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
export async function ragQuery(userMessage, conversationHistory = []) {
const context = await retrieveContext(userMessage);
const stream = await client.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: [
{
type: 'text',
text:
'You are a helpful assistant. Answer questions using ONLY the ' +
'provided context. If the context does not contain the answer, ' +
'say "I don\'t have information about that in my knowledge base." ' +
'Cite the source number when referencing specific information.',
},
{
type: 'text',
text: `CONTEXT:\n\n${context}`,
cache_control: { type: 'ephemeral' }, // cache the retrieved context
},
],
messages: [
...conversationHistory,
{ role: 'user', content: userMessage },
],
});
return stream;
}The Complete Route Handler
Wire retrieval and generation together in a streaming Route Handler. The client gets streamed tokens as Claude generates the response — latency is dominated by the Supabase vector search (typically 50–150ms) plus the Claude streaming start time.
// app/api/rag-chat/route.js
import { ragQuery } from '@/lib/rag';
export async function POST(req) {
const { message, history = [] } = await req.json();
const stream = await ragQuery(message, history);
const readable = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
controller.enqueue(
new TextEncoder().encode(chunk.delta.text),
);
}
}
} finally {
controller.close();
}
},
});
return new Response(readable, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}Improving Retrieval Quality
Pure semantic search misses exact keyword matches — product codes, error messages, and proper nouns. Add PostgreSQL full-text search alongside vector search and combine the scores with Reciprocal Rank Fusion (RRF). This hybrid approach consistently outperforms either search method alone and requires no additional infrastructure beyond what pgvector already provides.
Monitoring and Evaluation
Log every RAG query with: the user message, the retrieved chunk IDs, the Claude response, and response.usage.cache_read_input_tokens versus cache_creation_input_tokens. Monitoring cache hit rates tells you whether similar queries are clustering (good — low cost) or each query hits completely different chunks (may indicate your chunking or retrieval needs tuning). A cache hit rate above 60% on a production chatbot is a good target.
Further Reading
Frequently Asked Questions
What is RAG (Retrieval-Augmented Generation)?
RAG is a pattern where you retrieve relevant context from your own data before generating a response with an LLM. Instead of fine-tuning the model on your data, you search a vector database for relevant chunks at query time and pass them to the model's context. This keeps your data current and your AI grounded in facts rather than hallucinating.
Why use Supabase pgvector instead of a dedicated vector database?
Supabase pgvector stores vectors in PostgreSQL alongside your regular application data. For most applications, this eliminates the need for a separate vector database service. You get ACID transactions, familiar SQL, and a single database for everything. Dedicated vector databases like Pinecone offer more advanced vector indexing at very large scale (100M+ vectors).
What embedding model should I use with Claude for RAG?
Voyage AI's voyage-3 model produces embeddings optimised for retrieval tasks and works excellently with Claude. OpenAI's text-embedding-3-small is a cost-effective alternative. The embedding model and retrieval model don't need to be from the same provider — you can embed with Voyage and generate with Claude.
How large should RAG chunks be?
For most document types, chunks of 400–600 tokens with a 50-token overlap work well. Smaller chunks (200 tokens) give more precise retrieval but less context per chunk. Larger chunks (800+ tokens) provide more context but reduce retrieval precision. Test different sizes against your specific documents and query patterns.
How do I keep my RAG knowledge base up to date?
Re-embed documents whenever they change. For frequently updated data, trigger a re-embedding pipeline on document save events. For static documents like PDFs and knowledge bases, batch-process them once. Use a lastUpdated timestamp on your embeddings table so you can identify and re-process stale chunks.
How much does running a RAG chatbot cost?
Costs have three components: embedding (fractions of a cent per document, one-time), vector search (database query cost, negligible), and Claude API calls (input tokens for retrieved context + output tokens). With prompt caching on the retrieved context, repeated similar queries are dramatically cheaper. A typical support chatbot query with 5 retrieved chunks costs $0.001–0.005 with Claude Sonnet.
What is hybrid search in RAG?
Hybrid search combines semantic vector similarity search with keyword (BM25) full-text search. Semantic search finds conceptually similar content even with different wording. Keyword search catches exact matches for technical terms, product names, or error codes. Combining both with a weighted score improves retrieval quality significantly over vector search alone.
How do I prevent a RAG chatbot from hallucinating?
Instruct Claude in the system prompt to answer only from the provided context and to say 'I don't know' when the context doesn't contain the answer. Include the source documents in the context with clear attribution markers so Claude can cite them. Increase the number of retrieved chunks for questions that require broad knowledge.
More Articles
Need help with this?
I'm available for Sharetribe Flex, Shopify, Next.js, and AI integration projects.
Get In Touch