Building Smart Support: AI-Driven Customer Service with Java and Quarkus
Harness the power of LangChain4j, Ollama, and pgvector to create a reactive, intelligent support system.
What if your Java backend could classify incoming support tickets, search your knowledge base semantically, analyze screenshots of user issues, and notify a human only when truly necessary? That’s what we’ll build here.
In this hands-on guide, we’ll combine Quarkus, LangChain4j, Ollama, PostgreSQL with pgvector, and an in memory reactive messaging to create a real-time customer support backend capable of intelligent triage and analysis.
What We’re Building
A customer support system that:
Accepts support tickets with optional screenshots
Uses a local LLM (via Ollama) to classify each ticket
Performs a semantic search on a vectorized knowledge base
Pushes real-time updates to a dashboard using WebSockets
Uses Quarkus reactive messaging for orchestration
Key Technologies
Quarkus: Your friendly, small and smart Java companion
LangChain4j: Java-native AI framework
Ollama + llava model: Local multimodal LLM (text + image)
PostgreSQL + pgvector: For semantic search
Kafka (via Dev Services): For ticket processing workflows
Bootstrapping the Quarkus Project
Start by generating your Quarkus project with the necessary extensions.
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.support \
-DprojectArtifactId=intelligent-ticketing \
-DclassName="com.support.TicketResource" \
-Dpath="/tickets" \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql,langchain4j-ollama,websockets,quarkus-messaging,scheduler"
cd intelligent-ticketing
This gives you the complete stack: AI services, REST endpoints, persistence, messaging, tracing, and real-time communication. Don’t worry about postgresql or anything else. Quarkus Dev Services will start what we need when we need it. Just make sure you have Podman (or Docker) installed.
Also make sure to add the following three additional dependencies:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
<version>1.1.0-beta7</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-pgvector</artifactId>
<version>1.0.2</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-vector</artifactId>
<version>6.6.17.Final</version>
</dependency>
If you want to get started more quickly, please take a look at my Github repository.
Quarkus Configuration
In application.properties
, gets us set up with the minimum configuration for Hibernate and Ollama.
quarkus.langchain4j.ollama.chat-model.model-id=llava
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
Dev Services will automatically start Postgresql and Ollama.
Defining the Domain Model
Let’s model our world using Panache entities.
SupportTicket.java
Represents the lifecycle of a customer request.
package com.support;
import java.time.LocalDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
@Entity
public class SupportTicket extends PanacheEntity {
public String customerName;
public String customerEmail;
public String subject;
public String description;
public String imageUrl;
@Enumerated(EnumType.STRING)
public TicketStatus status = TicketStatus.CREATED;
public LocalDateTime createdAt = LocalDateTime.now();
public enum TicketStatus {
CREATED, AI_CLASSIFICATION, SOLUTION_LOOKUP, AGENT_ASSIGNED, IN_PROGRESS, RESOLVED, CLOSED
}
}
KnowledgeBaseArticle.java
Stores vector-embedded articles for semantic search.
package com.support;
import org.hibernate.annotations.Array;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@Entity
public class KnowledgeBaseArticle extends PanacheEntity {
public String title;
@Column(columnDefinition = "TEXT")
public String content;
@JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 384)
public float[] embedding;
}
Embedding & Seeding Knowledge Base
To make vector search possible, embed your knowledge base text using LangChain4j. You can use the REST endpoint in the example repository to just create some knowledge for your application. It generates 384-dimensional embeddings that you can paste into your import.sql
curl -X POST \
http://localhost:8080/api/knowledge-base/import/generate-sql \
-H "Content-Type: application/json" \
-d '[
{
"id": 1,
"title": "Understanding Your Invoice",
"content": "Your monthly invoice provides a detailed breakdown of all charges and fees. The invoice includes your base subscription fee, any usage-based charges, taxes, and applicable discounts."
},
{
"id": 2,
"title": "Troubleshooting Startup Crashes",
"content": "If your application crashes during startup, first check the system requirements. Ensure you have sufficient memory and disk space. Check the error logs located in the logs directory for specific error messages."
}
]'
Paste those embeddings into import.sql
:
INSERT INTO KnowledgeBaseArticle(id, title, content, embedding) VALUES
(1, 'Understanding Your Invoice', '...', '[...]'::vector),
(2, 'Troubleshooting Startup Crashes', '...', '[...]'::vector);
Quarkus will auto-import on dev startup.
Core AI Services
TicketClassifier
– LLM-Powered Categorization
@RegisterAiService
public interface TicketClassifier {
@SystemMessage("""
Classify a support ticket into category, priority, and sentiment.
""")
@UserMessage("Subject: {{subject}}, Description: {{description}}")
TicketClassification classify(String subject, String description);
}
Key Features:
Uses LangChain4j annotations to wrap LLM calls.
Produces structured JSON output mapped to a record.
SolutionRecommendationService
– Vector Similarity Search
@ApplicationScoped
public class SolutionRecommendationService {
public List<KnowledgeBaseArticle> findSimilarSolutions(String ticketDescription) {
Embedding embedding = embeddingModel.embed(ticketDescription).content();
return entityManager.createNativeQuery(
"SELECT * FROM KnowledgeBaseArticle ORDER BY embedding <-> :embedding LIMIT 3",
KnowledgeBaseArticle.class)
.setParameter("embedding", embedding.vector())
.getResultList();
}
}
Reactive Flow: Ingest, Classify, Search, Route
TicketResource
– REST Endpoint for Intake
package com.support;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
@Path("/tickets")
public class TicketResource {
@Inject
@Channel("raw-conversation-in")
Emitter<String> ticketEmitter;
@POST
@Transactional
public Response createTicket(SupportTicket ticket) {
ticket.persist();
ticketEmitter.send(Long.toString(0));
return Response.status(Response.Status.CREATED).entity(ticket).build();
}
}
TicketProcessingService
– Asynchronous Orchestration
package com.support;
import java.util.List;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import com.support.SupportTicket.TicketStatus;
import io.quarkus.logging.Log;
import io.smallrye.reactive.messaging.annotations.Blocking;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class TicketProcessingService {
@Inject
TicketClassifier ticketClassifier;
@Inject
SolutionRecommendationService solutionRecommendationService;
@Inject
TicketDashboardWebSocket dashboardWebSocket;
/**
* This method consumes messages from the "ticket-processing-in" channel (our
* Kafka topic).
*
* @Blocking ensures this runs on a worker thread, as AI calls can be slow.
*/
@Incoming("ticket-processing-out")
@Transactional
@Blocking
public void processTicket(Long ticketId) {
Log.infof("Received ticket ID %d for processing.", ticketId);
// 1. Fetch the ticket from the DB
SupportTicket ticket = SupportTicket.findById(ticketId);
if (ticket == null) {
Log.errorf("Ticket with ID %d not found!", ticketId);
return;
}
// 2. AI Classification Step
updateStatusAndBroadcast(ticket, TicketStatus.AI_CLASSIFICATION, "Classifying ticket...");
TicketClassification classification = ticketClassifier.classify(ticket.subject, ticket.description);
Log.infof("Ticket %d classified as: %s", ticketId, classification);
// In a full app, you would persist this classification data.
// 3. Solution Lookup Step
updateStatusAndBroadcast(ticket, TicketStatus.SOLUTION_LOOKUP, "Searching knowledge base...");
List<KnowledgeBaseArticle> suggestions = solutionRecommendationService.findSimilarSolutions(ticket.description);
if (suggestions.isEmpty()) {
Log.infof("No relevant solutions found for ticket %d.", ticketId);
} else {
Log.infof("Found %d potential solutions for ticket %d.", suggestions.size(), ticketId);
// Here you could auto-respond or attach suggestions for the agent.
}
// 4. Final Step: Assign to agent
updateStatusAndBroadcast(ticket, TicketStatus.AGENT_ASSIGNED, "Ticket assigned to an agent queue.");
Log.infof("Ticket %d processing complete. Final status: %s", ticketId, ticket.status);
}
private void updateStatusAndBroadcast(SupportTicket ticket, TicketStatus newStatus, String logMessage) {
ticket.status = newStatus;
ticket.persistAndFlush(); // Ensure change is committed before broadcasting
Log.info(logMessage);
dashboardWebSocket.broadcast(String.format("Ticket #%d: %s", ticket.id, logMessage));
}
}
Highlights:
Processes tickets reactively.
Coordinates AI tasks and updates ticket state.
Pushes updates to WebSocket clients.
Real-Time Updates & Scheduling
TicketDashboardWebSocket
package com.support;
import java.util.concurrent.CopyOnWriteArrayList;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;
@ServerEndpoint("/dashboard")
@ApplicationScoped
public class TicketDashboardWebSocket {
private final CopyOnWriteArrayList<Session> sessions = new CopyOnWriteArrayList<>();
@OnOpen
public void onOpen(Session session) {
sessions.add(session);
}
@OnClose
public void onClose(Session session) {
sessions.remove(session);
}
public void broadcast(String message) {
sessions.forEach(session -> {
session.getAsyncRemote().sendText(message);
});
}
}
MaintenanceScheduler
package com.support;
import io.quarkus.logging.Log;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class MaintenanceScheduler {
@Scheduled(cron = "0 0 4 * * ?") // Run every day at 4 AM
void archiveOldTickets() {
Log.info("Running scheduled job: Archiving old resolved tickets...");
// Add logic here to find tickets with status RESOLVED or CLOSED
// older than 90 days and archive them.
}
}
Run & Observe the System
Launch the app:
mvn quarkus:dev
Create a ticket with curl
:
curl -X POST -H "Content-Type: application/json" -d '{
"customerName": "Alice",
"customerEmail": "alice@example.com",
"subject": "Invoice confusion",
"description": "Why is my invoice higher this month?"
}' http://localhost:8080/tickets
And you should see a result similar to the following:
{"id":1,"customerName":"Alice","customerEmail":"alice@example.com","subject":"Invoice confusion","description":"Why is my invoice higher this month?","imageUrl":null,"status":"CREATED","createdAt":"2025-07-14T16:49:47.532439"}
Logs Worth Watching
Request:
TicketResource
logs ticket intake.LLM: AI classification output.
Vector: Hibernate logs similarity search.
Where to Go Next
This system forms a rock-solid foundation for intelligent support automation. Expand it with:
A Qute frontend showing ticket state in real-time
Sentiment-driven agent prioritization
Automatic resolution suggestions for low-risk tickets
Self-improving knowledge base using resolved ticket embeddings
You just built a full-stack, LLM-integrated customer support system in Java with Quarkus. Locally run, privately controlled, AI-infused, and enterprise-grade.
This is what modern Java looks like.