DEV Community

Cover image for How I Built a Multi-Tenant RAG Knowledge Base with Source-Cited Answers — Pipeline, Multi-Tenancy, and Lessons
Raja Abbas Affandi
Raja Abbas Affandi

Posted on

How I Built a Multi-Tenant RAG Knowledge Base with Source-Cited Answers — Pipeline, Multi-Tenancy, and Lessons

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).

What RAG actually requires

RAG sounds simple: retrieve relevant context, feed it to the LLM, get a grounded answer. In production it means:

  • Ingestion: upload documents, chunk them intelligently, store them
  • Retrieval: find the chunks that actually answer the question
  • Grounding: build a prompt from the retrieved context, no hallucinated answers
  • Trust: show the user why the model said that — source citations

Each step is a small product on its own. Here's the pipeline.

The ingestion 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;
}
Enter fullscreen mode Exit fullscreen mode

Each chunk becomes a DocumentChunk tied to its source document, so retrieval can always trace back to where the information came from.

Multi-tenancy changes everything

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

The data model enforces isolation at the schema level:

  • Workspace — tenant container
  • WorkspaceMember — roles: Owner / Admin / Member (RBAC via NextAuth.js v5)
  • KnowledgeSource + Document + DocumentChunk — the RAG layer, always under a workspace
  • Conversation + Message — chat sessions, scoped per workspace
  • ApiUsage — token tracking per workspace

The provider abstraction (OpenAI / Gemini / Claude)

One 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();
}
Enter fullscreen mode Exit fullscreen mode

Demo mode was the reason I could publish a real demo without leaking keys or asking visitors to sign up.

Source citations — the feature that makes it trustworthy

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" },
  });
}
Enter fullscreen mode Exit fullscreen mode

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.

The stack

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.

Lessons learned

  1. SQLite-first was the right call. @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.
  2. Scoping is security. Multi-tenant bugs are data-leak bugs. Enforce workspaceId at the query layer, never rely on the UI to filter.
  3. A working demo beats a password gate. Removing auth for the MVP made the demo clickable for everyone — and it still demonstrates the full RAG experience.

Try it

If you've built RAG in production, what did I get wrong? I'd love to hear it in the comments.


Top comments (0)