Building a RAG Chatbot with Claude API, Supabase pgvector, and Next.js
Learn how to build a production-grade RAG chatbot using Claude API, Supabase pgvector for semantic search, and Next.js with the Vercel AI SDK. Covers embeddings, context injection, streaming, long-context handling, and evaluation strategies.
Building a RAG Chatbot with Claude API, Supabase pgvector, and Next.js
By Elhassane Mehdioui — AI & Automation Consultant
Retrieval-Augmented Generation (RAG) has become the backbone of most enterprise AI deployments in 2026. Instead of relying solely on a model's pre-trained knowledge, RAG grounds every response in documents you control — your documentation, your product data, your internal knowledge base. The result is a chatbot that is accurate, auditable, and keeps pace with your latest content without a fine-tuning cycle.
In this guide I will walk through a production-ready RAG chatbot that combines three powerful tools:
- Claude API (
claude-opus-4-8) for generation, context handling, and streaming - Supabase pgvector for vector storage and semantic search
- Next.js + Vercel AI SDK for a streaming chat UI
By the end you will have a working architecture that you can drop into any Next.js application — complete with TypeScript code you can copy and run today.
What Is RAG, Exactly?
A standard language model is a closed system. Its knowledge is frozen at training time and it cannot search your private documents. Ask it about your company's latest pricing tier and it will either hallucinate or admit ignorance.
RAG breaks the loop in three steps:
- Index — chunk your documents and embed each chunk into a dense vector using an embedding model.
- Retrieve — when a user sends a query, embed the query and find the k most semantically similar chunks in your vector database.
- Generate — inject those chunks as context into a prompt, then ask the language model to answer from that context.
The model never needs to memorise your data. It reasons over the retrieved snippets at inference time. This makes the system auditable (you know exactly what context the model saw), updatable (re-embed a document and the change is live immediately), and dramatically cheaper than fine-tuning.
pgvector in Supabase
pgvector is a PostgreSQL extension that adds a vector data type and efficient approximate nearest-neighbour (ANN) search operators. Supabase ships pgvector out of the box and exposes it through the standard Postgres connection, which means you get a familiar SQL interface, row-level security, and Supabase's generous free tier — all in one place.
Schema
-- Enable the extension once per project
create extension if not exists vector;
-- Documents table
create table documents (
id bigserial primary key,
title text not null,
body text not null,
url text,
created_at timestamptz default now()
);
-- Chunks table with 1536-dim embeddings (OpenAI text-embedding-3-small)
create table document_chunks (
id bigserial primary key,
document_id bigint references documents(id) on delete cascade,
chunk_index int not null,
content text not null,
embedding vector(1536),
metadata jsonb default '{}'
);
-- IVFFlat index — approximate, fast, good recall at k ≤ 50
create index on document_chunks
using ivfflat (embedding vector_cosine_ops)
with (lists = 100);
The vector_cosine_ops operator class measures cosine similarity, which is the standard choice for normalised embedding vectors from transformer models.
Project Structure
my-rag-app/
├── app/
│ ├── api/
│ │ ├── chat/route.ts # Streaming chat endpoint
│ │ └── ingest/route.ts # Document ingestion endpoint
│ └── page.tsx # Chat UI
├── lib/
│ ├── supabase.ts # Supabase client
│ ├── embeddings.ts # Embedding helpers
│ └── retrieval.ts # Semantic search
└── .env.local
npm install @anthropic-ai/sdk @supabase/supabase-js ai openai
# .env.local
ANTHROPIC_API_KEY=sk-ant-...
SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
OPENAI_API_KEY=sk-... # for embeddings only
Note on embeddings: Claude does not expose a public embedding API at the time of writing, so this guide uses OpenAI's
text-embedding-3-small(1536 dimensions, inexpensive). You can swap in any embedding model as long as the dimensionality matches yourvector(N)column.
Step 1: Generating Embeddings
// 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 response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text.replace(/\n/g, " "),
});
return response.data[0].embedding;
}
export function chunkText(text: string, maxTokens = 512): string[] {
// Rough heuristic: ~4 chars per token
const chunkSize = maxTokens * 4;
const overlap = 100;
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
chunks.push(text.slice(start, start + chunkSize));
start += chunkSize - overlap;
}
return chunks;
}
Step 2: Ingestion Route
This route accepts a document, chunks it, embeds each chunk, and upserts into Supabase.
// app/api/ingest/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { embed, chunkText } from "@/lib/embeddings";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(req: NextRequest) {
const { title, body, url } = await req.json();
// 1. Insert parent document
const { data: doc, error: docErr } = await supabase
.from("documents")
.insert({ title, body, url })
.select("id")
.single();
if (docErr) return NextResponse.json({ error: docErr.message }, { status: 500 });
// 2. Chunk and embed
const chunks = chunkText(body);
const rows = await Promise.all(
chunks.map(async (content, chunk_index) => ({
document_id: doc.id,
chunk_index,
content,
embedding: await embed(content),
}))
);
// 3. Upsert chunks
const { error: chunkErr } = await supabase
.from("document_chunks")
.insert(rows);
if (chunkErr) return NextResponse.json({ error: chunkErr.message }, { status: 500 });
return NextResponse.json({ id: doc.id, chunks: chunks.length });
}
Step 3: Semantic Search
// lib/retrieval.ts
import { createClient } from "@supabase/supabase-js";
import { embed } from "./embeddings";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export interface RetrievedChunk {
content: string;
document_id: number;
chunk_index: number;
similarity: number;
}
export async function semanticSearch(
query: string,
topK = 6,
threshold = 0.75
): Promise<RetrievedChunk[]> {
const queryEmbedding = await embed(query);
// pgvector cosine similarity via Supabase RPC
const { data, error } = await supabase.rpc("match_chunks", {
query_embedding: queryEmbedding,
match_count: topK,
similarity_threshold: threshold,
});
if (error) throw new Error(error.message);
return data ?? [];
}
Register the Postgres function once in Supabase's SQL editor:
create or replace function match_chunks(
query_embedding vector(1536),
match_count int default 6,
similarity_threshold float default 0.75
)
returns table (
document_id bigint,
chunk_index int,
content text,
similarity float
)
language sql stable
as $$
select
document_id,
chunk_index,
content,
1 - (embedding <=> query_embedding) as similarity
from document_chunks
where 1 - (embedding <=> query_embedding) > similarity_threshold
order by similarity desc
limit match_count;
$$;
Step 4: Building Context for Claude
The quality of your RAG system lives or dies in how you construct the context block. A few principles:
- De-duplicate overlapping chunks — adjacent chunks from the same document often repeat sentences.
- Sort by similarity so the highest-signal content is closest to the question.
- Include metadata (document title, URL) so Claude can cite sources.
- Set hard token limits to prevent the context from crowding out the conversation history.
// lib/buildContext.ts
import { RetrievedChunk } from "./retrieval";
export function buildContextBlock(chunks: RetrievedChunk[]): string {
if (chunks.length === 0) return "";
const deduplicated = chunks.filter(
(chunk, index, self) =>
index === self.findIndex((c) => c.content === chunk.content)
);
const formatted = deduplicated
.map(
(c, i) =>
`<source index="${i + 1}" similarity="${c.similarity.toFixed(3)}">\n${c.content}\n</source>`
)
.join("\n\n");
return `<retrieved_context>\n${formatted}\n</retrieved_context>`;
}
Step 5: Streaming with the Vercel AI SDK
The Vercel AI SDK's streamText primitive wraps the Anthropic SDK's streaming interface and returns a ReadableStream that Next.js can pipe directly to the browser. The client sees tokens as they arrive — no waiting for the full response.
// app/api/chat/route.ts
import { NextRequest } from "next/server";
import Anthropic from "@anthropic-ai/sdk";
import { semanticSearch } from "@/lib/retrieval";
import { buildContextBlock } from "@/lib/buildContext";
const client = new Anthropic();
const SYSTEM_PROMPT = `You are a helpful assistant with access to a curated knowledge base.
When answering questions:
1. Base your answer on the <retrieved_context> provided in each user message.
2. If the context does not contain sufficient information, say so clearly — do not fabricate.
3. Cite sources by their index number, e.g. [1].
4. Be concise and direct.`;
export async function POST(req: NextRequest) {
const { messages } = await req.json();
// The latest user message drives retrieval
const lastUserMessage = [...messages]
.reverse()
.find((m: { role: string }) => m.role === "user");
const query = lastUserMessage?.content ?? "";
// Semantic search
const chunks = await semanticSearch(query, 6);
const contextBlock = buildContextBlock(chunks);
// Inject context into the last user message
const augmentedMessages = messages.map(
(m: { role: string; content: string }, i: number) =>
i === messages.length - 1 && m.role === "user"
? { ...m, content: `${contextBlock}\n\n${m.content}` }
: m
);
// Stream from Claude
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 2048,
thinking: { type: "adaptive" },
system: SYSTEM_PROMPT,
messages: augmentedMessages,
});
// Return as a plain ReadableStream
const readable = new ReadableStream({
async start(controller) {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
controller.enqueue(
new TextEncoder().encode(event.delta.text)
);
}
}
controller.close();
},
});
return new Response(readable, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
Step 6: The Chat UI
// app/page.tsx
"use client";
import { useState, useRef, useEffect } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
export default function ChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function send() {
if (!input.trim()) return;
const userMsg: Message = { role: "user", content: input };
const history = [...messages, userMsg];
setMessages(history);
setInput("");
setLoading(true);
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let assistantText = "";
setMessages([...history, { role: "assistant", content: "" }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
assistantText += decoder.decode(value);
setMessages([
...history,
{ role: "assistant", content: assistantText },
]);
}
setLoading(false);
}
return (
<main className="max-w-2xl mx-auto p-4 flex flex-col h-screen">
<h1 className="text-2xl font-bold mb-4">RAG Chatbot</h1>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((m, i) => (
<div
key={i}
className={`p-3 rounded-lg whitespace-pre-wrap ${
m.role === "user"
? "bg-blue-100 ml-8"
: "bg-gray-100 mr-8"
}`}
>
<span className="font-semibold capitalize">{m.role}: </span>
{m.content}
</div>
))}
<div ref={bottomRef} />
</div>
<div className="flex gap-2">
<input
className="flex-1 border rounded px-3 py-2"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && send()}
placeholder="Ask anything..."
disabled={loading}
/>
<button
onClick={send}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
Send
</button>
</div>
</main>
);
}
Handling Long Context
Claude Opus 4.8 supports a 1 million token context window, which is enormous — but you should still be deliberate about what you inject. A few strategies:
1. Tiered Retrieval
Run a fast ANN search first (high recall, lower precision), then re-rank with a cross-encoder model to select the top 3-5 most relevant chunks. This keeps context tight without sacrificing coverage.
2. Context Compression
If a retrieved chunk is long, summarise it before injection:
async function compressChunk(chunk: string, query: string): Promise<string> {
const response = await client.messages.create({
model: "claude-haiku-4-5", // cheap model for compression
max_tokens: 256,
messages: [
{
role: "user",
content: `Extract only the sentences from the following text that are relevant to: "${query}"\n\nText:\n${chunk}`,
},
],
});
const block = response.content[0];
return block.type === "text" ? block.text : chunk;
}
3. Conversation Compaction
For long multi-turn conversations, enable server-side compaction so earlier context is automatically summarised when approaching the limit:
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"],
model: "claude-opus-4-8",
max_tokens: 4096,
messages: conversationHistory,
context_management: {
edits: [{ type: "compact_20260112" }],
},
system: SYSTEM_PROMPT,
});
// Always append the full content block — not just the text
conversationHistory.push({ role: "assistant", content: response.content });
The key rule: append response.content (the full array, including any compaction blocks), not just the extracted text. Stripping the compaction block breaks the server-side state.
Evaluation
A RAG system you cannot measure is a RAG system you cannot improve. Here are three metrics to instrument from day one:
1. Retrieval Recall
Did the correct chunk appear in the top-k results? For each test question you have a ground-truth document — check whether it is in the retrieved set.
function recall(
retrieved: RetrievedChunk[],
groundTruthDocId: number
): boolean {
return retrieved.some((c) => c.document_id === groundTruthDocId);
}
2. Answer Faithfulness
Did the model's answer stay within the retrieved context, or did it introduce hallucinations? You can use Claude itself as a judge:
async function faithfulnessScore(
answer: string,
context: string
): Promise<number> {
const response = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 64,
messages: [
{
role: "user",
content: `Rate on a scale of 0-10 how faithfully the answer below is supported by the provided context. Return only the number.\n\nContext:\n${context}\n\nAnswer:\n${answer}`,
},
],
});
const block = response.content[0];
const text = block.type === "text" ? block.text : "0";
return parseInt(text.trim(), 10) || 0;
}
3. Latency Breakdown
Log retrieval time and generation TTFT (time to first token) separately. A slow embedding call or under-provisioned Supabase instance will show up in retrieval latency, not generation latency.
const t0 = Date.now();
const chunks = await semanticSearch(query);
const retrievalMs = Date.now() - t0;
console.log({ event: "retrieval", ms: retrievalMs, chunks: chunks.length });
Production Checklist
Before shipping to users, run through these:
- Cache embeddings — If the same query is sent repeatedly (e.g. autocomplete suggestions), cache the embedding vector in Redis or Upstash to avoid redundant API calls.
- Rate limit ingestion — Embedding 10,000 documents in a burst will hit OpenAI's rate limits. Use a queue (Upstash QStash, BullMQ) to process documents at a controlled pace.
- Supabase RLS — Add row-level security to
document_chunksso users can only retrieve chunks from documents they are authorised to read. This is critical for multi-tenant applications. - Prompt caching — The system prompt and any static instructions can be cached using
cache_control: { type: "ephemeral" }. For a high-traffic chatbot this can cut input token costs by 80-90%. - Error handling — Wrap the retrieval step in a try/catch and fall back to answering without context rather than returning a 500 to the user.
Conclusion
You now have a complete, production-oriented RAG chatbot:
- Supabase pgvector stores and retrieves document embeddings with sub-10ms query times
- Semantic search via cosine similarity surfaces the most relevant chunks for each user query
- Claude Opus 4.8 generates grounded, cited answers from injected context
- Streaming via the Vercel AI SDK delivers a responsive UI even for long responses
- Compaction handles multi-turn conversations that grow beyond 200K tokens
- Evaluation hooks let you measure and improve retrieval recall and answer faithfulness over time
The architecture scales horizontally. Ingestion is asynchronous, retrieval is read-only, and generation is stateless. Each layer can be optimised or replaced independently as your requirements evolve.
RAG keywords to track as this space matures in 2026: RAG chatbot Next.js, Claude API RAG, Supabase pgvector, and retrieval augmented generation 2026 — the tooling is evolving fast and the gap between a proof-of-concept and a production system is closing quickly.
Elhassane Mehdioui is an AI and automation consultant helping teams design and ship AI-powered products. If you are building a RAG system and want expert guidance, reach out at intelligentb2b.com.