Skip to main content
AI Integration progress
advancedPage 15 of 17

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.

In plain English

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:

  1. Rate-limit the session (stop runaway costs and abuse).
  2. Embed the user's question and pull the 5 most relevant doc chunks from pgvector.
  3. Build a context block listing each chunk with its source title.
  4. Stream a Claude Haiku response that's instructed to use only those chunks and to cite sources.
  5. 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.

Worked example: mapping the code back to the patterns

Each piece of the snippet maps directly to a previous chapter:

Code sectionPattern 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-5Tiered 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.

Highlight: what's still missing in the snippet (and worth adding before you ship)

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

Where people commonly trip up
  • 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-id from 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 onFinish log 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

Checkpoint Quiz

Did the complete example stick?

Required

What's next

→ Continue to When Not to Use AI — AI is a hammer; not everything is a nail.