Build a Similar-Incident Finder with Quarkus and Qdrant
Hash Java stack traces into searchable vectors, store them in Qdrant with Dev Services, and learn how payload filters keep search results useful.
A production incident usually starts with one boring question:
Have we seen this failure before?The answer is often somewhere in an old incident report, a Slack thread, a ticket, or a runbook. The text is there. The problem is shape. A new stack trace does not match the old one character for character. The service moved packages, the line numbers changed, and the error message gained one extra adjective because software enjoys small acts of drama.
Keyword search helps when the same words repeat. It misses cases where the failure looks similar but the text is not identical. Full AI retrieval can help too, but it brings model keys, embedding providers, latency, and a different operational story. Also we all are tired of all of this somedays. So for this article I want the smaller tool: turn a Java incident into a vector, store it in Qdrant, and ask for the nearest old failures.
No LLM is required or even used. We use deterministic feature hashing in plain Java. That makes the vectors inspectable and repeatable. Qdrant gives us similarity search, payload filters, Dev Services, and a normal Quarkus client.
What We Build
Incident Vector Search is a Quarkus service with three endpoints:
POST /incidents/seedloads five example incidentsPOST /incidentsindexes one incidentPOST /incidents/searchsearches for similar incidents
The app stores each incident as a Qdrant point: one vector plus payload. The vector captures the rough shape of the failure. The payload keeps the fields humans still care about: service, environment, exception type, message, resolution, and incident URL.
Two Qdrant details matter early:
A collection has a fixed vector size and distance metric
A point ID must be a 64-bit unsigned integer or a UUID
That second detail is easy to miss. Our business IDs look like INC-1001, so we keep those in payload and use deterministic UUIDs as Qdrant point IDs.
What You Need
You need a normal Quarkus setup and a container runtime for Qdrant Dev Services.
JDK 25
Podman or another Docker-compatible container runtime
curlandjqAbout two ☕️
This article uses Quarkus 3.37.0, Java 25, io.quarkiverse.qdrant:quarkus-qdrant:0.1.0, and Qdrant v1.18-unprivileged for Dev Services.
Create The Project
Create the base app and follow along or grab the repository from my Github:
quarkus create app dev.mainthread:incident-vector-search \
--extension=rest-jackson,smallrye-health \
--java=25 \
--no-codeUse these extensions:
quarkus-rest-jackson: JSON REST endpointsquarkus-smallrye-health: readiness checks, including the Qdrant extension check
Add Qdrant and validation to pom.xml:
<dependency>
<groupId>io.quarkiverse.qdrant</groupId>
<artifactId>quarkus-qdrant</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>quarkus-qdrant gives us an injectable QdrantClient. Hibernate Validator keeps bad incident documents out of the application code path. A missing stack trace should fail as HTTP 400, not as a weak vector that pollutes the index.
Configure Qdrant Dev Services
Create src/main/resources/application.properties:
quarkus.qdrant.devservices.qdrant-image-name=docker.io/qdrant/qdrant:v1.18-unprivileged
quarkus.qdrant.devservices.collections.incidents.vector-size=384
quarkus.qdrant.devservices.collections.incidents.distance=Cosine
quarkus.smallrye-health.root-path=/q/healthQdrant starts automatically in dev and test mode. The extension also creates the incidents collection before the app starts.
vector-size=384 is arbitrary but fixed. Every vector we write must have exactly 384 values. Change it later and old points no longer fit the collection. That is not a warning for Qdrant specifically; it is how vector collections work.
distance=Cosine compares direction more than magnitude. Since we normalize every vector to length one, cosine similarity is a good fit for this example.
Define The Incident Shape
Start with one request record:
package dev.mainthread.incidents;
import java.util.List;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
public record IncidentInput(
String id,
@NotBlank String service,
@NotBlank String environment,
@NotBlank String exceptionType,
@NotBlank String message,
@NotEmpty @Size(max = 20) List<@NotBlank String> stackTrace,
String resolvedBy,
String incidentUrl) {
}There is no database entity here. Qdrant stores the vector and payload. The record is the HTTP boundary and the indexing input.
The stack trace limit is intentionally small. We only need the top frames for this kind of matching. If you let clients send 5,000 frames, they will. Then you get worse vectors and a free denial-of-service shape. Very generous of them.
The search request wraps an incident and adds search controls:
package dev.mainthread.incidents;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public record SimilarIncidentRequest(
@NotNull @Valid IncidentInput incident,
@Min(1) @Max(20) Integer limit,
@DecimalMin("0.0") @DecimalMax("1.0") Float minScore,
String filterService,
String filterEnvironment,
Boolean onlyResolved) {
}The filters are not part of the vector. They stay explicit because they are business constraints. A checkout failure may look like a billing failure at the stack-trace level. If the operator asks for checkout incidents in prod, Qdrant should only search that slice.
Add the response records too:
package dev.mainthread.incidents;
public record IndexedIncident(String id, String pointId, String collection, int dimensions) {
}package dev.mainthread.incidents;
public record IncidentMatch(
String id,
float score,
String service,
String environment,
String exceptionType,
String message,
String resolvedBy,
String incidentUrl) {
}package dev.mainthread.incidents;
import java.util.List;
public record SearchResponse(int count, List<IncidentMatch> matches) {
}package dev.mainthread.incidents;
import java.util.List;
public record SeedResponse(int indexed, List<String> ids) {
}Now the boundary is clear. The API speaks in incident IDs like INC-1001; Qdrant receives UUID point IDs behind the service.
Turn Incidents Into Vectors
Feature hashing is the small trick in this app. We take tokens from the incident, hash each token into one of 384 buckets, add weighted counts, and normalize the result.
This is not a semantic embedding. It will not learn that “database pool exhausted” and “Agroal acquisition timeout” mean related things unless the tokens overlap. That is fine for this article. We want a simple vectorizer where every score is explainable from the input.
Create IncidentVectorizer:
package dev.mainthread.incidents;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
class IncidentVectorizer {
static final int DIMENSIONS = 384;
private static final Pattern TOKEN = Pattern.compile("[a-z0-9]+");
List<Float> vectorForPoint(IncidentInput incident) {
float[] values = vectorForSearch(incident);
List<Float> boxed = new ArrayList<>(values.length);
for (float value : values) {
boxed.add(value);
}
return boxed;
}
float[] vectorForSearch(IncidentInput incident) {
float[] vector = new float[DIMENSIONS];
addWeighted(vector, "service:" + incident.service(), 5);
addWeighted(vector, "environment:" + incident.environment(), 2);
addWeighted(vector, "exception:" + incident.exceptionType(), 6);
addText(vector, incident.exceptionType(), 3);
addText(vector, incident.message(), 2);
for (String frame : incident.stackTrace()) {
addWeighted(vector, "frame:" + frame, 3);
addText(vector, frame, 1);
}
normalize(vector);
return vector;
}
double cosine(float[] left, float[] right) {
double dot = 0;
double leftNorm = 0;
double rightNorm = 0;
for (int i = 0; i < left.length; i++) {
dot += left[i] * right[i];
leftNorm += left[i] * left[i];
rightNorm += right[i] * right[i];
}
if (leftNorm == 0 || rightNorm == 0) {
return 0;
}
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
}
private void addText(float[] vector, String text, int weight) {
if (text == null || text.isBlank()) {
return;
}
Matcher matcher = TOKEN.matcher(text.toLowerCase(Locale.ROOT));
while (matcher.find()) {
addWeighted(vector, matcher.group(), weight);
}
}
private void addWeighted(float[] vector, String rawToken, int weight) {
if (rawToken == null || rawToken.isBlank()) {
return;
}
String token = rawToken.toLowerCase(Locale.ROOT).trim();
int bucket = Math.floorMod(token.hashCode(), vector.length);
vector[bucket] += weight;
}
private void normalize(float[] vector) {
double length = 0;
for (float value : vector) {
length += value * value;
}
if (length == 0) {
return;
}
float norm = (float) Math.sqrt(length);
for (int i = 0; i < vector.length; i++) {
vector[i] = vector[i] / norm;
}
}
}Notice the weights. exceptionType and exact top frames matter more than general message words. service matters too, but we still keep service as a filter later. The vector says “these incidents have a similar failure shape.” The filter says “only search the operational slice I asked for.”
Before you continue, predict one thing: what happens if we remove normalization?
Longer stack traces start to score higher because they add more token counts. Normalization keeps the vector length stable, so the score is about overlap and direction rather than document size.
Add Seed Incidents
Create a small fixture class. These are realistic enough to make the first search interesting:
package dev.mainthread.incidents;
import java.util.List;
final class IncidentFixtures {
private IncidentFixtures() {
}
static List<IncidentInput> examples() {
return List.of(
new IncidentInput(
"INC-1001",
"checkout-service",
"prod",
"java.lang.NullPointerException",
"Cannot invoke DiscountPolicy.percentage because policy is null while applying coupon",
List.of(
"dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:84)",
"dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)",
"dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"),
"Guard missing coupon policy before discount calculation",
"https://runbooks.example.com/incidents/INC-1001"),
new IncidentInput(
"INC-1002",
"billing-service",
"prod",
"java.sql.SQLTransientConnectionException",
"Timed out waiting for database connection from the invoice pool",
List.of(
"dev.mainthread.billing.InvoiceRepository.findOpenInvoices(InvoiceRepository.java:118)",
"dev.mainthread.billing.InvoiceBatch.closeCurrentPeriod(InvoiceBatch.java:55)",
"io.agroal.pool.ConnectionPool.handlerFromSharedCache(ConnectionPool.java:321)"),
"Increase pool timeout and split invoice close job into smaller batches",
"https://runbooks.example.com/incidents/INC-1002"),
new IncidentInput(
"INC-1003",
"checkout-service",
"prod",
"java.util.concurrent.TimeoutException",
"Tax service call exceeded the 800 ms client timeout during payment authorization",
List.of(
"dev.mainthread.checkout.TaxClient.calculate(TaxClient.java:62)",
"dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:52)",
"dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"),
"Cache tax jurisdiction lookups and fail payment before authorization",
"https://runbooks.example.com/incidents/INC-1003"),
new IncidentInput(
"INC-1004",
"inventory-service",
"staging",
"jakarta.persistence.OptimisticLockException",
"Concurrent stock reservation updated the same SKU version",
List.of(
"dev.mainthread.inventory.StockRepository.reserve(StockRepository.java:77)",
"dev.mainthread.inventory.ReservationService.reserveCart(ReservationService.java:41)",
"dev.mainthread.inventory.InventoryResource.reserve(InventoryResource.java:29)"),
"Retry stock reservation once and return conflict after the second collision",
"https://runbooks.example.com/incidents/INC-1004"),
new IncidentInput(
"INC-1005",
"search-service",
"prod",
"com.fasterxml.jackson.databind.JsonMappingException",
"Cannot deserialize shipment filter because status contains an unknown enum value",
List.of(
"dev.mainthread.search.ShipmentSearchResource.search(ShipmentSearchResource.java:37)",
"com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3895)",
"io.quarkus.resteasy.reactive.jackson.runtime.serialisers.ServerJacksonMessageBodyReader.readFrom(ServerJacksonMessageBodyReader.java:92)"),
"Reject unknown status values at the API boundary",
"https://runbooks.example.com/incidents/INC-1005"));
}
}The sample set has two checkout incidents. One is a NullPointerException, the other is a timeout. That gives the search a real choice: same service is not enough. Similar failure shape should win.
Store Incidents In Qdrant
Now wrap the Qdrant client in one application service:
package dev.mainthread.incidents;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkiverse.qdrant.runtime.QdrantClient;
import io.quarkiverse.qdrant.runtime.model.PointStruct;
import io.quarkiverse.qdrant.runtime.model.ScoredPoint;
@ApplicationScoped
class IncidentArchive {
private static final String COLLECTION = "incidents";
private static final int DEFAULT_LIMIT = 5;
private static final float DEFAULT_MIN_SCORE = 0.68f;
private final QdrantClient qdrant;
private final IncidentVectorizer vectorizer;
private volatile boolean collectionChecked;
@Inject
IncidentArchive(QdrantClient qdrant, IncidentVectorizer vectorizer) {
this.qdrant = qdrant;
this.vectorizer = vectorizer;
}
IndexedIncident index(IncidentInput incident) {
ensureCollection();
String id = incidentId(incident);
String pointId = pointIdFor(incident);
qdrant.upsert(COLLECTION)
.point(new PointStruct(pointId, vectorizer.vectorForPoint(incident), payloadFor(id, incident)))
.execute();
return new IndexedIncident(id, pointId, COLLECTION, IncidentVectorizer.DIMENSIONS);
}
SeedResponse seed(List<IncidentInput> incidents) {
ensureCollection();
List<String> ids = new ArrayList<>(incidents.size());
List<PointStruct> points = new ArrayList<>(incidents.size());
for (IncidentInput incident : incidents) {
String id = incidentId(incident);
String pointId = pointIdFor(incident);
ids.add(id);
points.add(new PointStruct(pointId, vectorizer.vectorForPoint(incident), payloadFor(id, incident)));
}
qdrant.upsert(COLLECTION).points(points).execute();
return new SeedResponse(points.size(), ids);
}
SearchResponse search(SimilarIncidentRequest request) {
ensureCollection();
int limit = request.limit() == null ? DEFAULT_LIMIT : request.limit();
float minScore = request.minScore() == null ? DEFAULT_MIN_SCORE : request.minScore();
List<ScoredPoint> points = qdrant.search(COLLECTION)
.vector(vectorizer.vectorForSearch(request.incident()))
.limit(limit)
.scoreThreshold(minScore)
.withPayload(true)
.withVector(false)
.filter(filterFor(request))
.execute();
List<IncidentMatch> matches = points.stream()
.map(IncidentArchive::matchFrom)
.toList();
return new SearchResponse(matches.size(), matches);
}
private synchronized void ensureCollection() {
if (collectionChecked) {
return;
}
if (!qdrant.listCollections().contains(COLLECTION)) {
qdrant.createCollection(COLLECTION)
.vectorSize(IncidentVectorizer.DIMENSIONS)
.distance("Cosine")
.execute();
}
collectionChecked = true;
}
private static String incidentId(IncidentInput incident) {
if (incident.id() != null && !incident.id().isBlank()) {
return incident.id();
}
return pointIdFor(incident);
}
private static String pointIdFor(IncidentInput incident) {
String source = incident.service() + "|" + incident.environment() + "|" + incident.exceptionType() + "|"
+ incident.message() + "|" + incident.stackTrace();
if (incident.id() != null && !incident.id().isBlank()) {
source = incident.id() + "|" + source;
}
return UUID.nameUUIDFromBytes(source.getBytes(StandardCharsets.UTF_8)).toString();
}
private static Map<String, Object> payloadFor(String id, IncidentInput incident) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("id", id);
payload.put("pointId", pointIdFor(incident));
payload.put("service", incident.service());
payload.put("environment", incident.environment());
payload.put("exceptionType", incident.exceptionType());
payload.put("message", incident.message());
payload.put("stackTrace", incident.stackTrace());
payload.put("resolved", incident.resolvedBy() != null && !incident.resolvedBy().isBlank());
putIfPresent(payload, "resolvedBy", incident.resolvedBy());
putIfPresent(payload, "incidentUrl", incident.incidentUrl());
return payload;
}
private static Map<String, Object> filterFor(SimilarIncidentRequest request) {
List<Map<String, Object>> must = new ArrayList<>();
addMatch(must, "service", request.filterService());
addMatch(must, "environment", request.filterEnvironment());
if (Boolean.TRUE.equals(request.onlyResolved())) {
addMatch(must, "resolved", true);
}
if (must.isEmpty()) {
return null;
}
return Map.of("must", must);
}
private static void addMatch(List<Map<String, Object>> must, String key, Object value) {
if (value == null) {
return;
}
if (value instanceof String text && text.isBlank()) {
return;
}
must.add(Map.of("key", key, "match", Map.of("value", value)));
}
private static void putIfPresent(Map<String, Object> payload, String key, String value) {
if (value != null && !value.isBlank()) {
payload.put(key, value);
}
}
private static IncidentMatch matchFrom(ScoredPoint point) {
Map<String, Object> payload = point.getPayload();
return new IncidentMatch(
valueOrFallback(payload, "id", point.getId()),
point.getScore(),
stringValue(payload, "service"),
stringValue(payload, "environment"),
stringValue(payload, "exceptionType"),
stringValue(payload, "message"),
stringValue(payload, "resolvedBy"),
stringValue(payload, "incidentUrl"));
}
private static String stringValue(Map<String, Object> payload, String key) {
Object value = payload == null ? null : payload.get(key);
return value == null ? null : value.toString();
}
private static String valueOrFallback(Map<String, Object> payload, String key, String fallback) {
String value = stringValue(payload, key);
return value == null ? fallback : value;
}
}There are three decisions in this class worth keeping visible.
First, ensureCollection() checks the collection once. Dev Services already creates it for local runs, but the app can still create it when pointed at a fresh Qdrant instance. In production I would move collection creation to provisioning or migration code. Runtime creation is convenient for this hands-on path, not a full operations plan.
Second, pointIdFor() generates a deterministic UUID. Qdrant point loading is idempotent, so re-seeding the same incident overwrites the same point. That is exactly what you want when the input comes from a queue or retrying job.
Third, filterFor() builds Qdrant’s payload filter as a Map. The Quarkiverse client gives us a fluent search API and accepts raw filter JSON shape. For this article that is enough: match service, environment, and resolved status.
Expose The REST Endpoints
The resource stays thin:
package dev.mainthread.incidents;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/incidents")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class IncidentResource {
private final IncidentArchive archive;
@Inject
IncidentResource(IncidentArchive archive) {
this.archive = archive;
}
@POST
public IndexedIncident index(@Valid IncidentInput incident) {
return archive.index(incident);
}
@POST
@Path("/search")
public SearchResponse search(@Valid SimilarIncidentRequest request) {
return archive.search(request);
}
@POST
@Path("/seed")
@Consumes(MediaType.WILDCARD)
public SeedResponse seed() {
return archive.seed(IncidentFixtures.examples());
}
}@Valid is the useful part here. It makes Quarkus reject incomplete incident documents before the vectorizer sees them. The seed endpoint accepts an empty POST because it has no request body. Without the method-level @Consumes(MediaType.WILDCARD), some clients send a default form content type and get a 415. That is a small test-discovered annoyance, so we remove it.
Run It
Start dev mode:
./mvnw quarkus:devOn first run, Qdrant Dev Services pulls the Qdrant image and starts a container. After the app starts, seed the archive:
curl -s -X POST http://localhost:8080/incidents/seed | jq Expected output:
{
"ids": [
"INC-1001",
"INC-1002",
"INC-1003",
"INC-1004",
"INC-1005"
],
"indexed": 5
}Now search with a new checkout failure:
curl -s -X POST http://localhost:8080/incidents/search \
-H "Content-Type: application/json" \
-d '{
"incident": {
"service": "checkout-service",
"environment": "prod",
"exceptionType": "java.lang.NullPointerException",
"message": "Cannot invoke DiscountPolicy.percentage because policy is null while pricing cart",
"stackTrace": [
"dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:91)",
"dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)",
"dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"
]
},
"limit": 3,
"minScore": 0.60,
"filterService": "checkout-service",
"filterEnvironment": "prod",
"onlyResolved": true
}' | jq .Expected shape:
{
"count": 1,
"matches": [
{
"environment": "prod",
"exceptionType": "java.lang.NullPointerException",
"id": "INC-1001",
"incidentUrl": "https://runbooks.example.com/incidents/INC-1001",
"message": "Cannot invoke DiscountPolicy.percentage because policy is null while applying coupon",
"resolvedBy": "Guard missing coupon policy before discount calculation",
"score": 0.93308544,
"service": "checkout-service"
}
]
}The exact score can move if you change the vectorizer weights or sample data. The first ID should stay INC-1001. That is the behavior we care about.
Now try the same search without the service and environment filters. You may still get the right result first, but Qdrant is free to compare against all services. Add the filters back and the search becomes the shape an operator expects: similar incident, same service, same environment, already resolved.
That is the part of Qdrant I like for this use case. Vector search handles fuzzy similarity. Payload filtering keeps operational policy explicit.
Check Readiness
Add the health extension and the Qdrant extension registers a readiness check automatically:
curl -s http://localhost:8080/q/health/ready | jq .Expected shape:
{
"status": "UP",
"checks": [
{
"name": "Qdrant REST Client health check",
"status": "UP"
}
]
}If Qdrant is down, readiness should fail. That is the right failure mode for this app. Serving incident search without the vector store would give operators an empty answer at the worst possible time.
For an external Qdrant instance, configure the client like this:
quarkus.qdrant.host=qdrant.example.com
quarkus.qdrant.port=6333
quarkus.qdrant.api-key=${QDRANT_API_KEY}
quarkus.qdrant.use-tls=trueSetting quarkus.qdrant.host disables Dev Services. Local runs get a container. Production gets the configured service.
Test The Behavior
The unit test checks the vectorizer before Qdrant enters the picture:
package dev.mainthread.incidents;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
class IncidentVectorizerTest {
private final IncidentVectorizer vectorizer = new IncidentVectorizer();
@Test
void similarCheckoutFailuresScoreHigherThanUnrelatedBillingFailures() {
IncidentInput query = new IncidentInput(
null,
"checkout-service",
"prod",
"java.lang.NullPointerException",
"Cannot invoke DiscountPolicy.percentage because policy is null",
List.of(
"dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:91)",
"dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)"),
null,
null);
IncidentInput checkout = IncidentFixtures.examples().get(0);
IncidentInput billing = IncidentFixtures.examples().get(1);
double checkoutScore = vectorizer.cosine(vectorizer.vectorForSearch(query), vectorizer.vectorForSearch(checkout));
double billingScore = vectorizer.cosine(vectorizer.vectorForSearch(query), vectorizer.vectorForSearch(billing));
assertTrue(checkoutScore > 0.75, "Expected close checkout match but got " + checkoutScore);
assertTrue(checkoutScore > billingScore + 0.35,
"Expected checkout score " + checkoutScore + " to beat billing score " + billingScore);
}
@Test
void vectorsUseTheConfiguredCollectionDimension() {
float[] vector = vectorizer.vectorForSearch(IncidentFixtures.examples().get(0));
assertEquals(384, vector.length);
}
}The resource test starts Quarkus and Qdrant Dev Services:
package dev.mainthread.incidents;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class IncidentResourceTest {
@Test
void findsSimilarResolvedCheckoutIncident() {
given()
.when()
.post("/incidents/seed")
.then()
.statusCode(200)
.body("indexed", equalTo(5));
given()
.contentType("application/json")
.body("""
{
"incident": {
"service": "checkout-service",
"environment": "prod",
"exceptionType": "java.lang.NullPointerException",
"message": "Cannot invoke DiscountPolicy.percentage because policy is null while pricing cart",
"stackTrace": [
"dev.mainthread.checkout.CartPriceCalculator.applyDiscount(CartPriceCalculator.java:91)",
"dev.mainthread.checkout.CheckoutService.priceCart(CheckoutService.java:47)",
"dev.mainthread.checkout.CheckoutResource.pay(CheckoutResource.java:31)"
]
},
"limit": 3,
"minScore": 0.60,
"filterService": "checkout-service",
"filterEnvironment": "prod",
"onlyResolved": true
}
""")
.when()
.post("/incidents/search")
.then()
.statusCode(200)
.body("count", greaterThan(0))
.body("matches[0].id", equalTo("INC-1001"))
.body("matches[0].service", equalTo("checkout-service"));
}
@Test
void rejectsIncidentsWithoutStackFrames() {
given()
.contentType("application/json")
.body("""
{
"service": "checkout-service",
"environment": "prod",
"exceptionType": "java.lang.NullPointerException",
"message": "policy is null",
"stackTrace": []
}
""")
.when()
.post("/incidents")
.then()
.statusCode(400);
}
}Run the tests:
./mvnw testExpected result:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThese tests caught two real issues while building the sample: the empty seed POST needed looser content negotiation, and Qdrant point IDs needed UUIDs instead of INC-1001. That is the reason to keep integration tests in a demo. They find the boring edges before readers do.
Production Edges
This app is small, but it creates real production questions.
Vector quality starts with the feature design. Our hash vectorizer is deterministic and cheap, but it only captures token overlap. If you need semantic similarity, swap the vectorizer for an embedding model and keep the IncidentArchive boundary. The rest of the application should not care where the numbers came from.
Collection management should move out of request handling. The ensureCollection() method keeps the tutorial self-contained. Production systems should create collections, payload indexes, and distance settings through provisioning, migrations, or platform automation.
Payload filters need indexes at scale. Qdrant can filter by payload fields directly, and the Qdrant filtering guide recommends payload indexes for fields you filter on often. For this example the dataset has five points. For real incident history, create indexes for fields like service, environment, and resolved.
Scores are application policy. minScore=0.68 is a starting point for this deterministic vectorizer and sample data. Change the vectorizer and you must re-check the threshold. I would treat it like any relevance setting: measure on real incident pairs before trusting it during an outage.
Readiness should fail when Qdrant fails. An empty incident search is worse than a clear dependency failure. If your fallback is “show keyword-only results,” make that explicit and observable. Do not let a broken vector store look like no matching incidents exist.
Conclusion
We built a Quarkus service that stores Java incident reports as Qdrant vectors, searches by failure shape, and still keeps service and environment constraints explicit through payload filters. Just remember: vectors do not have to mean “AI app.” Sometimes they are just a practical index for data that is similar without being equal.


