Blog
Practical notes on design, product, and shipping for startups.
Practical notes on design, product, and shipping for startups.
Ship RAG on Supabase pgvector and Next.js by locking chunking, embeddings, RLS, and retrieval before you polish the chat UI. This checklist covers schema, ingest, query, evals, and cost controls so the first release answers real questions without leaking tenant data.
Ship RAG on Next.js and Supabase pgvector by locking chunking, embeddings, row-level security, and retrieval quality before you polish the chat UI. Use one embedding model end to end, store chunk metadata, filter by tenant on every query, and run a small eval set on every change. This checklist is the production path Luminix Studio uses when startups need answers from their own docs, not a generic chatbot.
Last updated: October 15, 2026.
| Layer | Choice | Why |
|---|
| App | Next.js (App Router) | One TypeScript codebase for ingest jobs, API routes, and the chat UI |
| Data + auth | Supabase Postgres | RLS keeps tenant docs isolated without a second ACL system |
| Vectors | pgvector | Same database as your product data; fewer moving parts for an MVP |
| Embeddings | One model, fixed version | Mixing models breaks similarity; pin the model id in metadata |
| Generation | Your LLM of choice | Keep retrieval separate from the model so you can swap either side |
You do not need a separate vector product on day one. You do need strict tenant filters, stable chunk IDs, and a way to re-embed when the model changes.
Store documents and chunks as separate tables. Documents are the source of truth. Chunks are what you embed and retrieve.
-- Enable once per project
create extension if not exists vector;
create table knowledge_documents (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references workspaces(id),
title text not null,
source_url text,
status text not null default 'ready',
created_at timestamptz not null default now()
);
create table knowledge_chunks (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references knowledge_documents(id) on delete cascade,
workspace_id uuid not null,
chunk_index int not null,
content text not null,
token_count int,
embedding vector(1536), -- match your embedding dimensions
embedding_model text not null,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index on knowledge_chunks using ivfflat (embedding vector_cosine_ops)
with (lists = 100);
alter table knowledge_documents enable row level security;
alter table knowledge_chunks enable row level security;RLS policies must include workspace_id on every select. Never query embeddings with only a document id and hope the join is safe.
chunk_index, document_id, title, and source URL on every row.embedding_model on every chunk.Ingest from a Next.js route or a background job. Prefer a job queue for large uploads so the request path stays fast.
// Illustrative ingest step (server-only)
import { createClient } from "@supabase/supabase-js";
import { embedTexts } from "@/lib/embeddings";
export async function upsertChunks(params: {
workspaceId: string;
documentId: string;
chunks: string[];
model: string;
}) {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // server only
);
const vectors = await embedTexts(params.chunks, params.model);
const rows = params.chunks.map((content, chunk_index) => ({
document_id: params.documentId,
workspace_id: params.workspaceId,
chunk_index,
content,
embedding: vectors[chunk_index],
embedding_model: params.model,
metadata: { version: 1 },
}));
const { error } = await supabase.from("knowledge_chunks").upsert(rows);
if (error) throw error;
}Use the service role only on the server. Browser clients must go through RLS-scoped APIs.
Retrieve first, then generate. Cap top-k, require a minimum similarity, and always filter by workspace_id.
| Step | Default for an MVP | Why |
|---|---|---|
| Embed the user question | Same model as chunks | Comparable vectors |
| Similarity search | Cosine, top 4 to 8 | Enough context without blowing the prompt |
| Hard filter | workspace_id (+ optional doc set) | Stops cross-tenant leaks |
| Threshold | Drop weak matches | Better to say "I don't know" than invent |
| Prompt | Cite chunk titles or URLs | Users can verify answers |
| Logging | Query, chunk ids, latency, token use | You cannot improve what you never measure |
A typical pattern is an RPC like match_knowledge_chunks(query_embedding, workspace_id, match_count) that applies the filter inside Postgres, then a Next.js route that builds the prompt and calls the LLM.
Use this as a go / no-go list before you open the feature to customers.
You do not need a research lab. You need a spreadsheet of real questions, expected doc ids or must-include phrases, and a script that fails the build when retrieval or answers regress.
If retrieval is wrong, fix chunking and filters before you tweak prompts.
| Failure | Symptom | Fix |
|---|---|---|
| Mixed embedding models | Random low scores | Pin model; re-embed all chunks |
| No tenant filter | Wrong workspace answers | Enforce workspace_id in SQL |
| Chunks too large | Vague, noisy context | Smaller chunks + overlap |
| Chunks too small | Missing meaning | Merge by heading; raise size |
| No refusal path | Confident nonsense | Threshold + "answer only from context" |
| Ingest in the request path | Timeouts on upload | Background job + status field |
Luminix Studio builds web and mobile apps and the AI automations around them for startups worldwide. For RAG features we usually start on Next.js and Supabase pgvector, prove retrieval quality with a small eval set, then layer chat UI and agent tools once the checklist above is green. If you want help scoping or shipping that path, see luminixstudio.com.