DEV Community

Cover image for How do you dedupe support tickets that don't share any words? Here's our messy attempt.
Kushagra
Kushagra

Posted on • Originally published at flowtux.com

How do you dedupe support tickets that don't share any words? Here's our messy attempt.

We build an internal helpdesk, and I want to talk through a problem we only partly solved — because I suspect a lot of you have hit it too, and I'd genuinely like to hear how you handled it.

The most requested thing from our users was never "better ticket forms." It was "please make the duplicates stop."

Here's the shape of it. A deploy goes slightly wrong at a 40-person company. Within ten minutes you have:

  • a handful of chat messages: "login is broken", "can't get into dashboard???", "deploy looks weird"
  • several error-tracker events (whatever you run — Sentry, Rollbar, an APM): TokenExpiredError ×2, a 401 spike on /api/auth, a 5xx spike on auth-svc
  • a couple of emails to IT: "access token expired", "need login reset"

Nine items across three channels. One root cause: token rotation broke in that deploy. Whoever's on rotation spends the morning proving that, instead of fixing anything.

We wanted to automate the recognition step — "these are the same thing" — not the fixing step. This is the honest version: what we tried, the small thing we actually shipped, and the parts we haven't cracked. If you've built something similar, I'd love to be told what we got wrong.

Attempt 1: rules and keywords (broke immediately)

The obvious first cut: normalize ticket text, match on keywords and categories, merge on high overlap.

It fails on the example above, and it fails structurally: "login is broken" and TokenExpiredError share zero tokens. The human on rotation isn't string-matching — they know a deploy just happened, they know what auth-svc does, they've seen this failure shape before. Rules encode none of that.

Rule systems also rot. Every incident teaches you a new synonym for "it's down," and six months in you own a regex museum nobody wants to touch. Maybe you've kept one of these healthy long-term — if so I'd honestly like to know how.

Attempt 2: embed everything, cluster by similarity (the one we didn't ship)

The tempting next move: embed ticket text, cluster on cosine similarity, call anything close enough a duplicate.

We already use embeddings elsewhere in the product — for retrieving relevant code context from a customer's repo, not for tickets. When we thought about pointing the same machinery at ticket dedup, two things gave us pause:

  1. It can't cross the complaint/cause gap. "Can't get into dashboard???" and TokenExpiredError at auth.rotate_credentials aren't semantically similar — they're related only through a fact that lives outside both tickets (the deploy). Embedding text you don't have doesn't help.
  2. Pure text similarity produces confident false merges. "Dashboard is slow" and "dashboard is broken" embed close together and often have unrelated causes. And a false merge is the expensive failure: a missed duplicate wastes a few minutes; a wrong merge buries a real incident inside an unrelated one.

So we didn't ship the vector-clustering pipeline for tickets. It felt like a lot of moving parts to end up with worse precision on exactly the merges we most wanted to get right. Possible we were wrong to skip it — if you've made embedding-based dedup work well, I'd love to hear what made the difference.

Attempt 3: what we actually shipped — one strict Tux AI call

The version in production is deliberately small. When a new ticket arrives, we make a single Tux AI call (our own triage model, tuned low-temperature for consistency) that gets:

  • the new message text
  • a small candidate list of existing open tickets (title / description / status)
  • optionally, code context to disambiguate which part of the app is meant

The prompt is short and strict — the load-bearing instruction is roughly "only mark this a duplicate when it's genuinely the same underlying issue; be strict." The model returns a duplicate flag, the matched ticket, and a confidence score. That's the whole dedup engine. No embeddings, no clustering, no vector store for tickets.

Two choices are doing most of the work:

The candidate set is tiny on purpose. We don't ask "is this a duplicate of anything, ever?" We ask "is this a duplicate of these few open tickets?" — the reporter's own open tickets on the chat intake path, or a short list of recent open tickets from the same source on the monitoring path. A small, same-source candidate list is what keeps a fast model accurate. It's also our biggest limitation — more below.

We tuned the threshold toward precision. Duplicate handling only fires on high confidence. Below that, the ticket just gets created normally. We'd rather miss a duplicate than merge two things that shouldn't be.

What "duplicate" does depends on the channel, and here's a rough edge I'm not proud of:

  • Chat intake: if the new message looks like a duplicate, we route the reporter to update the existing ticket instead of opening a new one. Non-destructive, reversible.
  • Monitoring intake (error trackers, alerting): if a new event matches an existing open ticket, we drop the new event silently — no comment, no link, no "merged because…" record. It just doesn't get created.

That silent drop is the part that keeps me up a little, and it leads into the honest limitations.

What we haven't cracked (and would take advice on)

I'll be blunt, because dev.to comments are unforgiving and I'd rather you trust the parts that are true:

  • No stored merge reason. The confidence and matched-ticket come back, route the request, and then get discarded. We don't persist a "merged because all reference auth failures after deploy #482" string yet. We want to — an auditable, reversible merge record is the next thing on the list — but it doesn't exist today.
  • No cross-channel dedup. A chat complaint, a monitoring event, and an email about the same outage are compared only within their own channel's candidate list. The nine-item example that opens this post? We'd catch some same-channel duplicates and miss the cross-channel links. Unifying those channels into one queue is the direction we're building toward, but honest dedup across them is still open.
  • The silent monitoring drop. No audit trail on a suppressed event means when the model is wrong, nobody can see why. First thing we're fixing: link the dropped event to the ticket it matched and store the reasoning, so a bad drop is debuggable.
  • Slow-burn duplicates. Two tickets a week apart about the same flaky config rarely connect — not in each other's small, recent candidate window.
  • The first ticket of an incident. By definition there's nothing to match it against, so confidence is worst exactly when the incident is quietest. The system gets more useful as the incident gets louder, which is backwards from what you'd want.

The thing we think unlocks the rest — and are building now

The gap under all of these is that the tickets don't carry their own cause. The human on rotation solves it by knowing the state of the world: auth-svc deployed at 10:02, TokenExpiredError started 10:04, the dashboard depends on auth, therefore these are one incident.

So the piece we're actively working on is service-topology / blast-radius reasoning — a dependency map plus deploy timing, so "auth-svc is failing" can explain "dashboard logins are failing" even when the tickets share no words. If it works, cross-channel dedup and the "first ticket of an incident" problem both get easier: you're correlating on shared cause, not shared text.

I'm genuinely unsure of the right shape here, and this is the part I'd most like a reality check on:

  • How do you get a trustworthy service dependency graph without asking every team to hand-maintain one that goes stale in a month?
  • Deploy-time correlation feels powerful but noisy — how do you keep "something shipped recently" from swallowing every unrelated ticket in the window?
  • For those of you running incident tooling: does topology actually pull its weight, or is it the feature that demos well and rots in prod?

Takeaways so far

  1. Deduplication is a "shared cause" problem wearing a "text similarity" costume. The cause usually isn't written in the tickets.
  2. A small, same-source candidate set beats a clever global search for keeping a fast model accurate — at least until you have real cause signals to correlate on.
  3. Tune for precision; false merges are the expensive failure. The costs aren't symmetric, so the threshold shouldn't be.
  4. If your dedup path is destructive, make it auditable and reversible before you make it smart. A silent correct drop and a silent wrong drop look identical to the humans who have to trust the system.

If you've built ticket dedup, incident correlation, or topology-aware triage — especially the topology part — I'd really like to hear what worked and what you'd never do again. That's mostly why I wrote this.


I work on FlowTux, where this dedup path runs across chat, email, and monitoring intake. We're building toward one deduplicated inbox across your alerting, error tracking, and code — the topology piece above is the current frontier. Happy to go deeper in the comments, including the parts that still embarrass us.

Top comments (0)