Build Multi-Tenant RAG in Next.js Without Leaking Customer Data A reference pattern for multi-tenant retrieval-augmented generation in Next.js enforces tenant isolation in PostgreSQL before retrieved text reaches the model, using pgvector 0.8.0 or newer and PostgreSQL 13 or newer, with row-level security and integration tests. The pattern stores embeddings from OpenAI's text-embedding-3-large at 3,072 dimensions in pgvector's vector type while building the HNSW index on a halfvec cast, because pgvector's HNSW index supports up to 2,000 dimensions for vector and up to 4,000 for halfvec; queries re-rank candidates using the original stored vectors to offset half-precision index search. The author, who cites about twenty years of software experience, frames the disclosure scenario as hypothetical rather than a production incident and argues that a better system prompt cannot repair a missing tenant filter, since authorization must be enforced before retrieved text reaches the model. Build Multi-Tenant RAG in Next.js Without Leaking Customer Data A practical pattern for tenant-scoped retrieval with PostgreSQL, pgvector, row-level security, and integration tests. I've spent about twenty years building software. A pattern worth watching for is a security check that exists in the first request path but gets missed when someone adds a second one . RAG makes that mistake particularly easy: vector search can return a highly relevant passage that the current user has no right to read. The failure scenario below is hypothetical, it is not a report of a production incident. Suppose you're building a knowledge assistant for several companies. Company A uploads contracts and internal policies; Company B uploads support documents. Both use the same application, database, and vector index. The first search endpoint checks organization membership and filters by tenant id, so the demo looks fine. Later, someone adds an agent tool and writes a second vector query. They copy the similarity-search part but forget the tenant filter. PostgreSQL still finds the closest chunks, and the model still gives a fluent answer. One of those chunks belongs to the other company. Once the foreign passage is in the prompt, the mistake has already become a data disclosure. A better system prompt can't repair it; authorization has to be enforced before retrieved text reaches the model. I'll use Next.js, PostgreSQL, pgvector, and the OpenAI API to show how I'd enforce that boundary. This is a reference pattern rather than a drop-in application. Authentication and membership lookup depend on your schema, and the snippets still need to be tested against the versions you deploy. Before choosing an embedding model or tuning similarity search, settle where the tenant ID comes from. It should come from a trusted server-side authentication and membership check. It must not come from a JSON field the browser can change, and it must not be inferred from an uploaded filename or document metadata. A URL such as /org/acme/knowledge tells the application which organization the user wants to open. It does not establish that the user is allowed to open it. Resolve the organization against the authenticated user's memberships, then pass the verified tenant ID into retrieval. The example below assumes every member can search every document in their organization. If your app supports private folders, document-level ACLs, or roles within a tenant, those permissions must be part of the retrieval query too. A tenant filter only enforces tenant-level isolation. The example uses text-embedding-3-large with 3,072 dimensions. PostgreSQL stores the embedding in its regular vector type, while the HNSW index uses a halfvec cast. That matters because pgvector's HNSW index supports up to 2,000 dimensions for vector and up to 4,000 for halfvec. The trade-off is that index search uses half precision, so the query below re-ranks candidates using the original stored vectors. The schema assumes PostgreSQL 13 or newer and pgvector 0.8.0 or newer. CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE public.kb documents id uuid PRIMARY KEY DEFAULT gen random uuid , tenant id uuid NOT NULL, title text NOT NULL, source url text, created at timestamptz NOT NULL DEFAULT now , -- Required as the target of the composite foreign key below. UNIQUE tenant id, id ; CREATE TABLE public.kb chunks id uuid PRIMARY KEY DEFAULT gen random uuid , tenant id uuid NOT NULL, document id uuid NOT NULL, chunk index integer NOT NULL CHECK chunk index = 0 , content text NOT NULL, embedding vector 3072 NOT NULL, created at timestamptz NOT NULL DEFAULT now , UNIQUE document id, chunk index , FOREIGN KEY tenant id, document id REFERENCES public.kb documents tenant id, id ON DELETE CASCADE ; CREATE INDEX kb chunks tenant id idx ON public.kb chunks tenant id ; CREATE INDEX kb chunks embedding hnsw idx ON public.kb chunks USING hnsw embedding::halfvec 3072 halfvec cosine ops ; The composite foreign key prevents a chunk from claiming Tenant A while pointing to a document owned by Tenant B. A query filter could hide such a row, but it would not repair the inconsistent data underneath. During ingestion, derive tenant id from the verified organization context. Check that the document belongs to that tenant and write the document/chunk records transactionally. If embeddings are generated by a background worker, enqueue a server-created job with the document and tenant IDs; don't let the worker take ownership from metadata inside the uploaded file. This condition must apply in the query that selects the context for the model: WHERE c.tenant id = $1 Filtering after the vector search is too late, and filtering in the UI is not an access control. The following function uses the pg package. It assumes that the caller has already authenticated the user and resolved their tenant ID. // lib/rag/retrieve.ts import { Pool } from 'pg'; let pool: Pool | undefined; function getPool : Pool { if pool return pool; const connectionString = process.env.DATABASE URL; if connectionString { throw new Error 'DATABASE URL must be set' ; } // DATABASE URL must use the restricted app runtime database role. pool = new Pool { connectionString } ; return pool; } export type RetrievedChunk = { title: string; content: string; source url: string | null; }; export async function retrieveChunks tenantId: string, embedding: number , : Promise