A Complete Mini-Example: Customer Support RAG Bot
End-to-end code combining streaming, RAG, observability, and rate limiting — a real customer-support assistant in ~60 lines.
A Complete Mini-Example: Customer Support RAG Bot
In one line: A real customer-support assistant in ~60 lines — combining ingestion, vector retrieval, streaming generation, rate limiting, and observability.
This page is the end-to-end glue: take everything from the previous patterns (RAG, streaming, observability, rate limits) and combine them into one working feature. The point isn't the exact code — it's seeing how little each part costs once you understand the pieces.
Putting it all together — a customer support assistant that answers questions using your docs:
// 1. Ingestion (run once / on doc updates)
async function ingestDocs() {
const docs = await fetchDocsFromSource();
for (const doc of docs) {
const chunks = chunkMarkdown(doc.content, { maxTokens: 500 });
for (const chunk of chunks) {
const embedding = await embed(chunk.text);
await db.insert(embeddings).values({
content: chunk.text,
docTitle: doc.title,
docUrl: doc.url,
embedding,
});
}
}
}
// 2. Chat endpoint
// app/api/support/route.ts
export async function POST(req: Request) {
// AI SDK 5: `messages` are typed UIMessages (a `parts` array, not `.content`).
const { messages }: { messages: UIMessage[] } = await req.json();
const last = messages[messages.length - 1];
const userQuestion = last.parts.map(p => (p.type === 'text' ? p.text : '')).join('');
// Rate-limit per session
const sessionId = req.headers.get('x-session-id');
const rate = await rateLimiter.check(`support:${sessionId}`, { limit: 30, window: '1h' });
if (!rate.allowed) {
return Response.json({ error: 'Please slow down' }, { status: 429 });
}
// Retrieve relevant docs
const queryEmbedding = await embed(userQuestion);
const chunks = await db.execute(sql`
SELECT content, doc_title, doc_url
FROM embeddings
ORDER BY embedding <=> ${queryEmbedding}
LIMIT 5
`);
const context = chunks.map((c, i) =>
`[Source ${i+1}: ${c.doc_title}]\n${c.content}`
).join('\n\n---\n\n');
// Generate response
const result = streamText({
model: anthropic('claude-haiku-4-5'), // Cheap for support
system: `You are a customer support assistant for Acme Corp.
Answer questions using ONLY the provided documentation. If the answer
isn't in the docs, say "I don't have information about that. Please
contact support@acme.com for help."
Always cite sources by mentioning the document name.`,
messages: [
// Convert prior UIMessages → ModelMessages, then append the RAG-augmented turn.
...convertToModelMessages(messages.slice(0, -1)),
{
role: 'user',
content: `Documentation:\n${context}\n\n---\n\nQuestion: ${userQuestion}`,
},
],
onFinish: async (event) => {
// Log for observability
await logInteraction({
sessionId,
question: userQuestion,
response: event.text,
tokensUsed: event.usage,
chunks: chunks.map(c => c.doc_url),
});
},
});
return result.toUIMessageStreamResponse();
}
In English: Five things happen on every request, in order:
- Rate-limit the session (stop runaway costs and abuse).
- Embed the user's question and pull the 5 most relevant doc chunks from pgvector.
- Build a context block listing each chunk with its source title.
- Stream a Claude Haiku response that's instructed to use only those chunks and to cite sources.
- On finish, log everything (question, response, tokens, which sources were used) for observability and evals.
About 60 lines of code for a real RAG-based support bot. Plus the UI, the docs ingestion script, observability, and evaluation — but the core pattern is straightforward.
Each piece of the snippet maps directly to a previous chapter:
| Code section | Pattern from earlier |
|---|---|
ingestDocs() | RAG ingestion |
rateLimiter.check(...) | Cost control |
embed(userQuestion) + ORDER BY <=> | Embeddings + RAG retrieval |
streamText(...) + toUIMessageStreamResponse() | Streaming chat |
system: 'Answer using ONLY...' | Safety: anti-hallucination |
onFinish: logInteraction(...) | Observability |
Choice of claude-haiku-4-5 | Tiered models |
This isn't a toy. It's the shape of every real RAG product in 2026 — just with more UI polish, better chunking, and a much bigger eval set.
The 60-line snippet is honest about the happy path. Real production deployments also need:
- Eval set with 50+ representative questions, run on every prompt change (see observability).
- Citation UI showing which sources the answer used (linking to
doc_url). - Fallback path when retrieval returns no relevant chunks (route to a human).
- Auth check before exposing the endpoint (rate-limiting by session is necessary but not sufficient).
- PII redaction if user questions might contain sensitive data (see safety).
- A/B testing harness for prompt iteration.
The patterns scale to all of those. The core is unchanged.
Common mistakes
- Treating the 60-line snippet as production-ready. It's a shape, not a finished feature. Before shipping, add the items the page calls out — auth on the endpoint, an eval set, retrieval-miss fallback, citation UI, and PII redaction — none of which the snippet implements.
- Rate-limiting by
x-session-idfrom the client. That header is attacker-controlled: a malicious client just rotates session IDs to dodge the limit. Anchor the key on something the server controls (authenticated user id, then IP as a fallback) — never on a value the client sets. - Logging the full prompt and response with PII in plaintext. The
onFinishlog is convenient and dangerous: customer questions often contain account numbers, addresses, or emails. Redact at the logging boundary and set a retention window — your trace store should not be a long-term PII archive. - Pinning Haiku forever "because the docs said it was cheap." The right model depends on your eval, not on the example. Re-run evals when models update (which happens silently) and when your retrieval quality changes — sometimes the cheap model stops being good enough; sometimes a newer cheap model becomes great.
- Skipping a "no relevant chunks" path. When the retrieval returns nothing useful, the model still tries to answer — usually by inventing something plausible. Check the top chunk's distance score; if it's above a threshold, short-circuit to a "I don't have that information, here's how to reach support" response instead of generating.
Page checkpoint
Did the complete example stick?
RequiredWhat's next
→ Continue to When Not to Use AI — AI is a hammer; not everything is a nail.