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

> Source: <https://dev.to/raja-abbas-affandi/how-i-built-a-multi-tenant-rag-knowledge-base-with-source-cited-answers-pipeline-multi-tenancy-22oo>
> Published: 2026-08-15 08:41:03+00:00

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](https://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 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 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](https://ratechnologies.netlify.app), 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.
