Every "build a RAG chatbot" tutorial ends the same way: embed a few paragraphs, call similaritySearch
, print the answer. That gets you a demo, not a product. The gap between a RAG demo and a RAG product you'd trust with a company's documents is where all the real engineering lives.
I built KnowBase AI, a multi-tenant SaaS knowledge base where businesses upload documents and an AI assistant answers questions grounded in their own content — with source citations you can click. This post covers the RAG pipeline, how multi-tenancy changes the design, and the decisions I'd repeat.
Live demo: knowbase-ai.netlify.app — no login needed, fully functional (it runs in demo mode with mock responses).
RAG sounds simple: retrieve relevant context, feed it to the LLM, get a grounded answer. In production it means:
Each step is a small product on its own. Here's the pipeline.
Documents arrive as files, URLs, or manual entries. The key decision is chunking — too big and retrieval is fuzzy, too small and you lose context. The pipeline chunks text with overlap so no meaning falls through the gaps:
export function chunkText(text: string, size = 800, overlap = 200): string[] {
const chunks: string[] = [];
let i = 0;
while (i < text.length) {
chunks.push(text.slice(i, i + size));
i += size - overlap;
}
return chunks;
}
Each chunk becomes a DocumentChunk
tied to its source document, so retrieval can always trace back to where the information came from.
A single-user RAG app and a multi-tenant SaaS share almost no code after the demo stage. Every query, chunk, and conversation must be scoped to a workspace:
// Every AI retrieval is scoped by workspaceId — a tenant can never
// retrieve another tenant's chunks, even if the embedding matches.
export async function retrieve(workspaceId: string, query: string) {
return prisma.documentChunk.findMany({
where: {
document: { source: { workspaceId } },
text: { contains: query },
},
take: 5,
});
}
The data model enforces isolation at the schema level:
Workspace
— tenant containerWorkspaceMember
— roles: Owner / Admin / Member (RBAC via NextAuth.js v5)KnowledgeSource
-
Document -
DocumentChunk
— the RAG layer, always under a workspaceConversation
Message
— chat sessions, scoped per workspaceApiUsage
— token tracking per workspaceOne of the best decisions: never hard-code a model. A thin provider interface means the product runs on OpenAI, Google Gemini, or Anthropic Claude by configuration, and it made the demo mode trivial:
export interface AIProvider {
chat(messages: Message[]): AsyncIterable<string>;
}
export const providers = {
openai: OpenAIProvider,
gemini: GeminiProvider,
claude: ClaudeProvider,
};
// No API keys configured? Run a fully functional mock.
export function getProvider(): AIProvider {
const configured = Object.entries(providers)
.find(([, P]) => new P().isConfigured());
return configured ? new configured[1]() : new MockProvider();
}
Demo mode was the reason I could publish a real demo without leaking keys or asking visitors to sign up.
An AI answer with no receipts is just a guess. KnowBase streams responses over SSE and attaches the chunks that informed each answer, so users can click through to the source document:
// Simplified — stream tokens, then emit the citations that grounded them
export async function chat(conversationId: string, content: string) {
const chunks = await retrieve(workspaceId, content);
const stream = await provider.chat([
{ role: "system", content: buildRagPrompt(chunks) },
...history,
{ role: "user", content },
]);
return new Response(sse(stream, chunks), {
headers: { "Content-Type": "text/event-stream" },
});
}
This single feature separates a gimmick from a support tool. Customer-support teams don't trust "trust me" — they trust a cited answer they can verify in two clicks.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| Language | TypeScript (strict) |
| Database | SQLite + Prisma 7 (@prisma/adapter-libsql ) |
| Auth | NextAuth.js v5 (Auth.js) + JWT |
| AI | OpenAI / Gemini / Claude (provider abstraction) |
| Styling | Tailwind CSS v4 + shadcn/ui |
| Charts / Markdown | Recharts + React Markdown (remark-gfm) |
| Forms | React Hook Form + Zod |
| Deployment | Netlify |
I'm a full-stack developer at RA Technologies, where we build SaaS and AI products with exactly this architecture — one codebase, strict types, a database that needs no server, and a demo anyone can click.
@prisma/adapter-libsql
gives you a real production-ish setup with zero config. Swap to PostgreSQL later only if you actually need it — most knowledge-base products don't.workspaceId
at the query layer, never rely on the UI to filter.If you've built RAG in production, what did I get wrong? I'd love to hear it in the comments.