Build Your Own AI Chatbot with RAG

Build a RAG chatbot with Next.js, PostgreSQL + pgvector and an LLM API: embeddings, retrieval and streaming chat, deployed to a VPS.

Build a retrieval-augmented generation (RAG) chatbot that answers questions about your own documents, streams responses, and is cheap to run. Every file in the pipeline is below — no phantom helpers.

What we’re building

flowchart LR
    User --> Next[Next.js app]
    Step[Ingest: chunk + embed] --> PG[(PostgreSQL + pgvector)]
    User --> Chat[Chat route]
    Chat -->|embed question| PG
    Chat --> API[LLM API]
    PG --> Chat
    Chat -->|streamed answer| User

The data path: documents are split into chunks and embedded into vectors stored in pgvector. When the user asks something, the question is embedded the same way, the closest chunks are found with a similarity query, and those chunks are sent to the LLM with the question — the model answers from your content, not from memory.

What you’ll learn

  • How RAG really works: embed → store → retrieve → generate
  • How to chunk and embed documents correctly (and why the overlap matters)
  • How to stream LLM responses to the browser
  • How to size a VPS for a RAG workload

Prerequisites

  • Node.js 22+
  • Docker (for PostgreSQL + pgvector)
  • An LLM API key (OpenAI, Anthropic, or any OpenAI-compatible endpoint)

1. Create the project

npx create-next-app@latest rag-chat --ts --app
cd rag-chat
npm i pg openai ai @ai-sdk/openai zod

These are exactly the packages the code uses: pg talks to PostgreSQL, openai embeds text, and the ai + @ai-sdk/openai pair provide the streaming layer. Extra packages from older copies of this guide (a vector-store-style helper) are gone — everything is explicit.

2. Set up PostgreSQL with pgvector

docker-compose.yml (project root):

services:
  db:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: rag
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./migrations/001_vectors.sql:/docker-entrypoint-initdb.d/001_vectors.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

volumes:
  pgdata:

pgvector/pgvector:pg17 ships the vector extension built in — plain postgres:17 does not. The SQL mount creates tables and the index on first boot.

migrations/001_vectors.sql:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE docs (
  id        BIGSERIAL PRIMARY KEY,
  title     TEXT NOT NULL,
  content   TEXT NOT NULL,
  embedding vector(1536)
);

CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);
  • vector(1536) matches OpenAI’s text-embedding-3-small (1536 dimensions). Change the embedding model and this size must change with it.
  • The hnsw index makes similarity search fast on large tables. For a first version with under ~10k rows, even a sequential scan is fine — the index starts paying off later.
docker compose up -d

3. Environment variables

.env:

DATABASE_URL=postgres://postgres:dev@localhost:5432/rag
OPENAI_API_KEY=sk-...
EMBEDDING_MODEL=text-embedding-3-small
CHAT_MODEL=gpt-4o-mini
Variable Required Description
DATABASE_URL yes PostgreSQL connection string.
OPENAI_API_KEY yes platform.openai.com → API keys. Never commit this.
EMBEDDING_MODEL no Embedding model; default text-embedding-3-small (1536 dims).
CHAT_MODEL no Chat model; default gpt-4o-mini. Swap to any OpenAI-compatible model name.

4. Embeddings

lib/embed.ts:

import OpenAI from 'openai';

export const openai = new OpenAI(); // reads OPENAI_API_KEY from env automatically

export async function embed(texts: string[]): Promise<number[][]> {
  const res = await openai.embeddings.create({
    model: process.env.EMBEDDING_MODEL ?? 'text-embedding-3-small',
    input: texts,
  });
  return res.data.map((d) => d.embedding);
}
  • new OpenAI() without arguments reads the API key from OPENAI_API_KEY — no key lives in source code.
  • It accepts an array so one API call can embed a whole batch of chunks (cheaper and faster than one call per chunk).
  • To use Anthropic or a self-hosted endpoint, point openai.baseURL at it — the OpenAI-compatible API is the de facto standard.

5. Chunking

lib/chunker.ts:

// Rule of thumb: ~1,000 characters per chunk with ~200 chars of overlap.
export function chunkText(text: string, maxChars = 1000, overlapChars = 200): string[] {
  const chunks: string[] = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + maxChars, text.length);
    chunks.push(text.slice(start, end));
    if (end === text.length) break;
    start = end - overlapChars; // carry the tail into the next chunk
  }
  return chunks;
}

Why overlap: if the answer to a question is split exactly at a chunk boundary, either side alone may lack enough context to answer well. The 200-character tail ensures the sentence boundary is covered by at least one complete chunk. Lower maxChars for more precise retrieval on long documents; raise it for cheaper indexing.

6. Store the chunks

lib/store.ts:

import { Pool } from 'pg';
import { chunkText } from './chunker';
import { embed } from './embed';

const globalForPg = globalThis as unknown as { pool?: Pool };

export const pool =
  globalForPg.pool ??
  new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

if (process.env.NODE_ENV !== 'production') globalForPg.pool = pool;

// Splits + embeds + inserts a document in one call.
export async function ingestDocument(title: string, text: string) {
  const chunks = chunkText(text);
  const embeddings = await embed(chunks);

  for (let i = 0; i < chunks.length; i++) {
    await pool.query(
      'INSERT INTO docs (title, content, embedding) VALUES ($1, $2, $3)',
      [title, chunks[i], JSON.stringify(embeddings[i])]
    );
  }
}

// Returns the k chunks closest to the question's embedding.
export async function retrieve(query: string, topK = 5): Promise<string[]> {
  const [queryEmbedding] = await embed([query]);
  const { rows } = await pool.query(
    `SELECT content FROM docs
      ORDER BY embedding <=> $1::vector
      LIMIT $2`,
    [JSON.stringify(queryEmbedding), topK]
  );
  return rows.map((r) => r.content);
}
  • The globalForPg guard keeps one connection pool across hot reloads — without it dev mode exhausts connections.
  • <=> is cosine distance in pgvector: smaller = more similar. The query asks Postgres to sort all rows by distance and keep the 5 closest — that’s the entire retrieval algorithm.
  • Insert and query both pass vectors as JSON.stringify output, which pgvector parses as [0.1, 0.2, ...] — the format matches the column type vector.

7. RAG answer with streaming

app/api/chat/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { retrieve } from '@/lib/store';

const bodySchema = z.object({
  question: z.string().min(3).max(2000),
});

export async function POST(req: NextRequest) {
  try {
    const parsed = bodySchema.safeParse(await req.json());
    if (!parsed.success) {
      return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
    }

    const context = await retrieve(parsed.data.question); // PostgreSQL query — can fail separately

    const result = streamText({
      model: openai(process.env.CHAT_MODEL ?? 'gpt-4o-mini'),
      system:
        'Answer using only the provided context. Cite the source for every claim. If the answer is not in the context, say "I don\'t know".',
      prompt: `Context:\n${context.join('\n\n')}\n\nQuestion: ${parsed.data.question}`,
    });

    return result.toDataStreamResponse();
  } catch (err) {
    console.error('chat failed', err);
    return NextResponse.json({ error: 'internal error' }, { status: 500 });
  }
}
  • zod rejects empty or huge questions before the embeddings call — bad input costs you a paid API call otherwise.
  • Retrieval happens before streaming starts; the LLM only ever sees your document chunks around the question.
  • streamText + toDataStreamResponse() handles the whole streaming protocol over HTTP — the feedable stream format the UI below reads.
  • The system prompt forces citations and an honest “I don’t know” — the cheapest hallucination guard there is.

8. Chat UI

app/page.tsx:

'use client';
import { useState } from 'react';

export default function Chat() {
  const [input, setInput] = useState('');
  const [answer, setAnswer] = useState('');

  async function ask() {
    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ question: input }),
    });
    if (!res.ok) return setAnswer('Request failed — check the server logs.');

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let text = '';
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      text += decoder.decode(value, { stream: true });
      setAnswer(text); // re-render with each new chunk
    }
  }

  return (
    <main style={{ maxWidth: 640, margin: 'auto', padding: 24 }}>
      <textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        rows={3}
        style={{ width: '100%' }}
      />
      <button onClick={ask}>Ask</button>
      <pre style={{ whiteSpace: 'pre-wrap' }}>{answer}</pre>
    </main>
  );
}

getReader() reads the streaming response chunk by chunk; each chunk is appended and the state update re-renders the answer as it arrives. Want to show the retrieval sources too? Have the route return them as the first streamed event.

9. Run locally

docker compose up -d            # postgres + pgvector, creates the schema on first boot
export $(grep -v '^#' .env | xargs)
npm run dev

Ingest a document from a Node one-liner (in the project root, with the env loaded):

node -e "import('./lib/store.mjs').then(m => m.ingestDocument('manual', (await import('node:fs')).readFileSync('manual.txt', 'utf8')))"

Then open http://localhost:3000 and ask a question about that text. The answer streams in token by token.

10. Deploy to VPS

Dockerfile (requires output: 'standalone' in next.config.ts):

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

On the VPS, add to docker-compose.yml:

  app:
    build: .
    environment:
      DATABASE_URL: postgres://postgres:dev@db:5432/rag
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      CHAT_MODEL: gpt-4o-mini
    depends_on:
      db: { condition: service_healthy }
    restart: unless-stopped

  caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro

Caddyfile:

YOUR-DOMAIN.com {
	reverse_proxy app:3000
}

Caddy issues and renews the HTTPS certificate automatically. Never expose the database port publicly: it is not in the compose ports: section for the VPS deployment.

Cost (assumptions: 1k queries/mo, 100 documents, ~200k chat tokens)

Service Monthly cost
VPS 2 GB (Hetzner CX22) $4.49
LLM API (gpt-4o-mini) $5–10
Embeddings (text-embedding-3-small) <$1
Total ≈ $10–15/month

The LLM line is the only variable one: it is metered per token, so replace the assumptions with your real call volume before trusting the number. Embeddings are a one-time cost per document (re-embedded only when you re-ingest).

Production checklist

  • Rate limit /api/chat per user/IP before exposing publicly
  • Input sanitization + prompt-injection resistance (system prompt already instructs “context only”)
  • pgvector index created (done in the migration) — verify with EXPLAIN ANALYZE on a real query
  • Daily pg_dump backup with a tested restore
  • API key rotation plan for the LLM provider
  • Auth (currently the chat is public — add accounts before real users)

Common problems

  • Bad retrieval: tune chunk size/overlap (maxChars/overlapChars in chunker.ts) and test with real questions, not lorem ipsum.
  • Cost spikes: cap the context window, cache identical questions, and limit topK to what the answer actually needs.
  • HNSW index slow to build: create the index after the initial bulk insert, or batch inserts (the migration creates it once at boot).
  • text-embedding-3-small mismatches the column: if you change models, vector(1536) in the migration must match the new output size or inserts fail.

Improvements

  • File upload + async ingestion queue (Redis + a worker; upload returns a job id)
  • Source citations with chunk titles and scores surfaced in the UI
  • Conversation memory (short-term window of previous turns in the prompt)
  • Local embeddings (e.g. via Ollama) to drop the embeddings cost to $0

Conclusion

RAG is a small app plus one Postgres extension: a Next.js chat UI, pgvector for retrieval and a streaming LLM call. The whole pipeline — chunk, embed, store, retrieve, answer — is in this guide and runs on ~$10–15/month self-hosted.

Related guides