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’stext-embedding-3-small(1536 dimensions). Change the embedding model and this size must change with it.- The
hnswindex 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 fromOPENAI_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.baseURLat 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
globalForPgguard 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.stringifyoutput, which pgvector parses as[0.1, 0.2, ...]— the format matches the column typevector.
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 });
}
}
zodrejects 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/chatper 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 ANALYZEon a real query - Daily
pg_dumpbackup 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/overlapCharsinchunker.ts) and test with real questions, not lorem ipsum. - Cost spikes: cap the context window, cache identical questions, and limit
topKto 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-smallmismatches 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.