A 15-person e-commerce company gets 200 support tickets per week. Their support team manually writes help center articles for recurring issues, but they're always behind. Many tickets are about the same problems, yet no article exists. They need a way to automatically cluster closed tickets by topic, extract common questions and answers, and generate draft articles ready for review.
Example artifact
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building a Support Ticket to Help Center Article Generator — a Next.js application that ingests support tickets, clusters them by topic using an LLM, retrieves relevant context with hybrid RAG, and generates draft help center articles ready for review. You’ll wire up Neon (Postgres) for persistence, an OpenAI-compatible LLM for clustering and writing, and the REAA Hybrid RAG stack for semantic and BM25 search.
By the end, you’ll have three REST API endpoints (/api/tickets, /api/clusters, /api/articles) and a dashboard UI that shows the pipeline at a glance. This tutorial is for developers comfortable with TypeScript and React who want to see a production-ish RAG + LLM pipeline wired end to end.
Prerequisites
Node.js 22+ and pnpm installed
A Neon Postgres database (free tier works) — get your DATABASE_URL
An OpenAI-compatible LLM API key (OpenAI, Groq, OpenRouter, etc.)
A Qdrant vector database (localhost via Docker or a cloud instance)
An embedding API key (OpenAI text-embedding-3-small or compatible)
Step 1: Scaffold and configure the project
Start by creating a Next.js project with the App Router and TypeScript:
Install the exact pinned dependencies. These include the AI SDK for LLM calls, Neon for Postgres, Zod for validation, and the REAA Hybrid RAG packages:
Fill in real values for DATABASE_URL, LLM_API_KEY, and EMBEDDING_API_KEY. For local development, the Qdrant defaults (http://localhost:6333) work if you run Qdrant locally:
terminal
docker run -p 6333:6333 qdrant/qdrant
Expected output: The .env.local file exists with your real credentials. The server reads these at runtime via process.env.X.
Step 3: Define the data schemas with Zod
Create src/lib/schemas.ts — this is the single source of truth for all data shapes in the system. Every entity — ticket, cluster, article — is validated at the boundary through these Zod schemas.
Expected output:pnpm typecheck passes. These schemas ensure every ticket, cluster, and article that enters the system has the correct shape.
Step 4: Set up the Neon database layer
Create src/lib/neon.ts. This module connects to Neon Postgres via @neondatabase/serverless and provides CRUD operations for the three database tables: tickets, clusters, and articles. It also validates incoming documents using the DocumentValidator from @reaatech/hybrid-rag-ingestion.
ts
import { neon } from "@neondatabase/serverless";import { DocumentValidator } from "@reaatech/hybrid-rag-ingestion";import type { Ticket, TicketInput, Cluster, Article } from "../lib/schemas";const sql = neon(process.env.DATABASE_URL ?? "");const docValidator = new DocumentValidator({ minContentLength: 1, maxFileSize: 10 * 1024 * 1024 });const CREATE_TABLES = `CREATE TABLE IF NOT EXISTS tickets ( id TEXT PRIMARY KEY, subject TEXT NOT NULL, description TEXT NOT NULL, tags JSONB DEFAULT '[]'::jsonb, category TEXT, status TEXT NOT NULL DEFAULT 'closed', source TEXT, metadata JSONB DEFAULT '{}'::jsonb, cluster_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), closed_at TIMESTAMPTZ);CREATE TABLE IF NOT EXISTS clusters ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, ticket_ids JSONB DEFAULT '[]'::jsonb, status TEXT NOT NULL DEFAULT 'pending', article_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE IF NOT EXISTS articles ( id TEXT PRIMARY KEY, cluster_id TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', metadata JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());`;export async function initializeDatabase(): Promise<void> { await sql`${sql.unsafe(CREATE_TABLES)}`;}
The module also exports insertTicket, getTicket, listTickets, getUnclusteredTickets, insertCluster, getCluster, listClusters, updateCluster, insertArticle, getArticle, listArticles, and updateArticle — each doing the obvious SQL operation and returning properly parsed domain objects. You can read the full file in the artifact; here’s the pattern:
Each query uses parameterized $1..$N syntax (safe from SQL injection) and RETURNING * so you always get back a full row.
Expected output:pnpm typecheck passes. You could call initializeDatabase() during startup to auto-create the tables.
Step 5: Create the LLM provider
Create src/lib/llm.ts. This module wraps the Vercel AI SDK with an OpenAI-compatible provider. It supports both free-text generation (generateLlmText) and structured object generation (generateLlmObject) using Zod schemas for type-safe LLM output.
ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";import { generateText, Output, type ModelMessage } from "ai";import { z } from "zod";const llmProvider = createOpenAICompatible({ name: "provider", baseURL: process.env.LLM_BASE_URL ?? "https://api.openai.com/v1", apiKey: process.env.LLM_API_KEY,});export function getModel() { return llmProvider(process.env.LLM_MODEL ?? "gpt-4o-mini");}export async function generateLlmText({ system, prompt, messages,}: { system?: string; prompt: string; messages?: ModelMessage[];}): Promise<string> { const model = getModel(); const result = messages ? await generateText({ model, system, messages }) : await generateText({ model, system, prompt }); return result.text;}export async function generateLlmObject<T>({ system, prompt, schema,}: { system?: string; prompt: string; schema: z.ZodType<T>;}): Promise<T> { const result = await generateText({ model: getModel(), system, prompt, output: Output.object({ schema }), }); return result.output;}
Expected output:pnpm typecheck passes. The generateLlmObject function is the key building block — it lets the clustering service ask the LLM for a typed array of clusters.
Step 6: Build the RAG pipeline
Create src/lib/pipeline.ts. This initializes the RAGPipeline from @reaatech/hybrid-rag-pipeline with a hybrid search configuration — combining dense vector embeddings and BM25 keyword search.
The pipeline is a singleton — you call getPipeline() anywhere in the app and always get the same configured instance. The hybrid setup uses 70% vector similarity weight and 30% BM25 keyword weight, fused via reciprocal rank fusion (RRF).
Expected output:pnpm typecheck passes. When the app starts and calls getPipeline(), it lazily creates the RAG pipeline connected to your Qdrant instance.
Step 7: Create the data access API layer
The src/api/ directory contains three modules — tickets.ts, clusters.ts, and articles.ts — that provide a thin data-access layer over the Neon database. These are used by both the route handlers and the service classes.
Create src/api/tickets.ts:
ts
import { neon } from "@neondatabase/serverless";import { nanoid } from "nanoid";import { TicketInputSchema, type Ticket, type TicketInput } from "../lib/schemas";const sql = neon(process.env.DATABASE_URL ?? "");export async function createTicket(data: TicketInput): Promise<Ticket> { const parsed = TicketInputSchema.parse(data); const id = parsed.id ?? nanoid(); const now = new Date().toISOString(); const res = await sql.query( `INSERT INTO tickets (id, subject, description, tags, category, status, source, metadata, created_at, closed_at) VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8::jsonb, $9::timestamptz, $10::timestamptz) RETURNING *`, [id, parsed.subject, parsed.description, JSON.stringify(parsed.tags), parsed.category ?? null, parsed.status, parsed.source ?? null, parsed.metadata ? JSON.stringify(parsed.metadata) : null, parsed.createdAt ?? now, parsed.closedAt ?? null], ); const [row] = res as Record<string, unknown>[]; return ticketFromRow(row);}export async function getTicketById(id: string): Promise<Ticket | null> { const res = await sql`SELECT * FROM tickets WHERE id = ${id}`; const rows = res as Record<string, unknown>[]; if (rows.length === 0) return null; return ticketFromRow(rows[0]);}export async function queryTickets(filters?: { status?: string; category?: string; limit?: number; offset?: number;}): Promise<Ticket[]> { const conditions: string[] = []; const values: unknown[] = []; let idx = 1; if (filters?.status) { conditions.push(`status = $${String(idx++)}`); values.push(filters.status); } if (filters?.category) { conditions.push(`category = $${String(idx++)}`); values.push(filters.category); } const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""; const limitClause = filters?.limit ? ` LIMIT ${String(filters.limit)}` : ""; const offsetClause = filters?.offset ? ` OFFSET ${String(filters.offset)}` : ""; const res = await sql.query( `SELECT * FROM tickets${whereClause} ORDER BY created_at DESC${limitClause}${offsetClause}`, values, ); return (res as Record<string, unknown>[]).map(ticketFromRow);}
The clusters.ts and articles.ts modules follow the same pattern — each exports createX, getXById, listX, and updateX functions, all using parameterized SQL with proper JSONB casting for arrays and records.
Expected output:pnpm typecheck passes. You now have a complete data access layer that validates, inserts, queries, and updates all three entities.
Step 8: Build the ticket ingestion service
Create src/services/ticket-service.ts. This is the first of three service classes that orchestrate the workflow. TicketService ingests support tickets: it normalizes them (text preprocessing), validates them, stores them in Neon, and indexes them into the RAG pipeline for future similarity search.
Expected output:pnpm typecheck passes. Each ticket is validated, written to Postgres, chunked, embedded, and indexed into Qdrant.
Step 9: Build the clustering service
Create src/services/clustering-service.ts. This service uses the LLM to analyze support tickets and group them into topic clusters. It calls generateLlmObject with a Zod schema so the LLM returns a structured ClusteringResult[].
ts
import { z } from "zod";import { ClusteringResultSchema } from "../lib/schemas";import { generateLlmObject, generateLlmText } from "../lib/llm";import { getTicketById } from "../api/tickets";import { createCluster, getClusterById as getClusterApiById, listClusters, linkTicketsToCluster } from "../api/clusters";const ClusteringResultArraySchema = z.array(ClusteringResultSchema);export class ClusteringService { async clusterTickets(ticketIds: string[]): Promise<ClusteringResult[]> { if (ticketIds.length === 0) return []; const tickets = (await Promise.all(ticketIds.map((id) => getTicketById(id)))) .filter((t): t is NonNullable<typeof t> => t !== null); if (tickets.length === 0) return []; const ticketDescriptions = tickets.map((t) => `- [${t.id}] ${t.subject}: ${t.description.substring(0, 500)}`).join("\n"); const system = "You are a support ticket clustering assistant. Group tickets by common issues and return structured cluster results."; const prompt = `Analyze the following support tickets and group them into clusters...\n\nTickets:\n${ticketDescriptions}`; const clusters = await generateLlmObject<ClusteringResult[]>({ system, prompt, schema: ClusteringResultArraySchema }); return clusters; } async saveClusters(results: ClusteringResult[]): Promise<Cluster[]> { const clusters: Cluster[] = []; for (const result of results) { const cluster = await createCluster({ id: crypto.randomUUID(), name: result.clusterName, description: result.clusterDescription, ticketIds: result.ticketIds, status: "completed", }); await linkTicketsToCluster(cluster.id, result.ticketIds); clusters.push(cluster); } return clusters; }}
The LLM prompt asks it to group tickets by common issues. Each result gets saved as a Cluster row in the database, and the individual tickets are linked to their cluster via cluster_id.
Expected output:pnpm typecheck passes. You can call clusterTickets() with an array of ticket IDs and get back a list of clusters with names, descriptions, and member ticket IDs.
Step 10: Build the article generator service
Create src/services/article-generator.ts. This is the most sophisticated service — it takes a cluster, retrieves relevant ticket content from the RAG pipeline, generates a title and structured body using the LLM, and persists the draft article.
ts
import { z } from "zod";import { ArticleSchema, type Article, type ArticleGenerationInput } from "../lib/schemas";import { generateLlmObject, generateLlmText } from "../lib/llm";import { RAGPipeline } from "@reaatech/hybrid-rag-pipeline";import { HybridRetriever } from "@reaatech/hybrid-rag-retrieval";import { createLogger, getMetricsCollector, logQueryStart, logQueryComplete } from "@reaatech/hybrid-rag-observability";import { getClusterById } from "../api/clusters";import { createArticle, getArticleById, listArticles, updateArticle } from "../api/articles";const ArticleBodySchema = z.object({ introduction: z.string(), sections: z.array(z.object({ heading: z.string(), content: z.string() })).min(1), steps: z.array(z.object({ order: z.number(), description: z.string() })).optional(), faq: z.array(z.object({ question: z.string(), answer: z.string() })).optional(), conclusion: z.string().optional(),});export class ArticleGeneratorService { private pipeline: RAGPipeline; constructor(pipeline: RAGPipeline) { this.pipeline = pipeline; } async generateArticle(clusterId: string, options?: ArticleGenerationInput): Promise<Article> { const cluster = await getClusterById(clusterId); if (!cluster) throw new Error(`Cluster not found: ${clusterId}`); // Retrieve relevant content from the RAG pipeline const retrievalResults = await this.pipeline.query( `Help article about: ${cluster.name} - ${cluster.description}`, { topK: 10 }, ); const contextChunks = retrievalResults.map((r) => r.content); // Generate title const title = await this.generateArticleTitle(cluster, contextChunks); // Generate structured body const bodyPayload = await this.generateArticleBody({ clusterName: cluster.name, clusterDescription: cluster.description, contextChunks, }, options?.style); const content = this.formatArticleBody(bodyPayload); const article = await createArticle({ id: crypto.randomUUID(), clusterId, title, content, status: "draft" as const, metadata: { clusterName: cluster.name, ticketCount: cluster.ticketIds.length, tone: options?.tone, style: options?.style }, }); return article; }}
The formatArticleBody method converts the structured object (introduction, sections, steps, FAQ, conclusion) into a clean Markdown string:
ts
private formatArticleBody(body: ArticleBody): string { const parts: string[] = []; parts.push(body.introduction); for (const section of body.sections) { parts.push(`## ${section.heading}`); parts.push(section.content); } if (body.steps && body.steps.length > 0) { parts.push("## Steps"); for (const step of body.steps) parts.push(`${String(step.order)}. ${step.description}`); } if (body.faq && body.faq.length > 0) { parts.push("## Frequently Asked Questions"); for (const item of body.faq) { parts.push(`**Q: ${item.question}**`); parts.push(item.answer); } } if (body.conclusion) { parts.push("## Conclusion"); parts.push(body.conclusion); } return parts.join("\n\n");}
Expected output:pnpm typecheck passes. The pipeline is now complete: tickets in → clusters → articles out.
Step 11: Create the API routes
Now wire up the Next.js App Router API routes under app/api/. Each route handler instantiates its services and delegates to them.
app/api/tickets/route.ts — POST ingests tickets (single or batch), GET lists them with optional status, category, limit, offset query params:
ts
import { NextRequest, NextResponse } from "next/server.js";import { TicketService } from "../../../src/services/ticket-service";import { RAGPipeline } from "@reaatech/hybrid-rag-pipeline";import { TicketInputSchema, type TicketInput } from "../../../src/lib/schemas";const pipeline = new RAGPipeline({ qdrantUrl: process.env.QDRANT_URL || "http://localhost:6333" });const ticketService = new TicketService(pipeline);export async function POST(req: NextRequest) { try { const body = await req.json() as Record<string, unknown> | Record<string, unknown>[]; const tickets = Array.isArray(body) ? body : [body]; if (tickets.length === 0) return NextResponse.json({ error: "No tickets provided" }, { status: 400 }); const validated: TicketInput[] = tickets.map((t) => TicketInputSchema.parse(t)); const ingested = await ticketService.ingestTickets(validated); return NextResponse.json({ ingested: ingested.length, ids: ingested.map((t) => t.id) }, { status: 201 }); } catch (err: unknown) { if (err instanceof Error && "issues" in err) return NextResponse.json({ error: (err as any).issues }, { status: 400 }); return NextResponse.json({ error: (err as Error).message || "Internal server error" }, { status: 400 }); }}export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); const filter: Record<string, any> = {}; if (searchParams.get("status")) filter.status = searchParams.get("status")!; if (searchParams.get("category")) filter.category = searchParams.get("category")!; if (searchParams.get("limit")) filter.limit = Number(searchParams.get("limit")); if (searchParams.get("offset")) filter.offset = Number(searchParams.get("offset")); const tickets = await ticketService.listTickets(filter); return NextResponse.json({ tickets, total: tickets.length }); } catch { return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}
app/api/clusters/route.ts — POST runs ticket clustering, GET lists all clusters:
app/api/articles/route.ts — GET lists all articles; app/api/articles/[id]/route.ts — GET fetches a single article, PATCH updates its status (draft / review / published):
ts
// app/api/articles/[id]/route.tsexport async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; const body = await req.json() as Record<string, unknown>; const status = body.status as string; if (!status || !["draft", "review", "published"].includes(status)) return NextResponse.json({ error: "Invalid status" }, { status: 400 }); const article = await articleService.updateArticleStatus(id, status as any); return NextResponse.json({ article }); } catch (err) { const message = (err as Error).message || "Failed to update article"; if (message.includes("not found")) return NextResponse.json({ error: message }, { status: 404 }); return NextResponse.json({ error: message }, { status: 500 }); }}
Expected output:pnpm typecheck passes. You now have six API endpoints: POST/GET /api/tickets, POST/GET /api/clusters, POST /api/clusters/:id/articles, GET /api/articles, and GET/PATCH /api/articles/:id.
Step 12: Build the dashboard UI
Replace app/page.tsx with a dashboard that shows tickets, clusters, and articles in a three-column layout. It fetches all three on load and provides a “Re-cluster Tickets” button:
Expected output: Running pnpm dev shows a dashboard at http://localhost:3000 with three columns. Start the dev server with pnpm dev to try it.
Step 13: Wire up the barrel exports
Create or update src/index.ts to re-export everything from your modules. This gives consumers of the package a clean import surface:
ts
export type { Ticket, TicketInput, Cluster, Article, ArticleGenerationInput, ClusteringResult } from "./lib/schemas";export { TicketSchema, TicketInputSchema, ClusterSchema, ArticleSchema, ArticleGenerationInputSchema, ClusteringResultSchema } from "./lib/schemas";export { initializeDatabase, insertTicket, getTicket, listTickets, getUnclusteredTickets, insertCluster, getCluster, listClusters, updateCluster, insertArticle, getArticle, listArticles, updateArticle } from "./lib/neon";export { getModel, generateLlmText, generateLlmObject } from "./lib/llm";export { createTicket, getTicketById, queryTickets } from "./api/tickets";export { createCluster, getClusterById, listClusters as listApiClusters, updateCluster as updateApiCluster, linkTicketsToCluster } from "./api/clusters";export { createArticle, getArticleById, listArticles as listApiArticles, updateArticle as updateApiArticle } from "./api/articles";
Expected output:pnpm typecheck passes. The package consumers can now import { TicketService, ClusteringService, ArticleGeneratorService } from "agnostic-support-doc-generator".
Step 14: Run the test suite
The project ships with unit and integration tests covering schemas, services, and API routes. Run them with coverage:
Expected output:pnpm test exits 0, numFailedTests=0, coverage meets the configured thresholds (lines, branches, functions, statements all >= 90%).
Next steps
Add a webhook ingestion endpoint — accept tickets from Zendesk or Intercom via a POST /api/webhooks/zendesk route that transforms the payload into your TicketInput schema.
Implement article review workflow — add a review UI and a PATCH endpoint that moves articles from draft to review to published, with human-in-the-loop editing before publishing.
Automatic scheduling — use a cron job (Vercel Cron Jobs or a background worker) that periodically clusters unclustered tickets and generates articles for any cluster that doesn’t have one yet.
Topic trend analysis — extend the clustering service to track how cluster membership changes over time, surfacing recurring issues before they escalate.