Tagging support tickets without fine-tuning: zero-shot, rerank or embeddings
Three cheaper architectures than a fine-tuned classifier for ticket tagging, priced against Infrai's live model catalogue, with runnable calls for each.
Skip the fine-tune. Ticket tagging has three cheaper architectures: a zero-shot chat call with a closed tag list, a reranker that scores the ticket against tag descriptions, and an embeddings classifier trained on your own labels. All three run on Infrai today on one key, so the choice is about how many labels you have and how long your taxonomy is — not about what’s wired up.
Fine-tuning isn’t wrong. It’s just expensive to reach and expensive to keep — every taxonomy change means a fresh training run, a fresh eval set and one more model version to pin, and support taxonomies change roughly every quarter.
The three architectures, side by side
| Approach | Labels needed | Per-ticket work | Where it breaks |
|---|---|---|---|
| Zero-shot chat | none | one chat call | long tag lists inflate the prompt; near-identical tags tie |
| Rerank vs tag descriptions | none — good descriptions instead | one rerank call | one candidate per tag, so 200 tags means a 200-candidate request |
| Embeddings + nearest centroid | a few dozen per tag | one embed call, then local dot products | cold start, and a re-embed whenever you change model |
| Fine-tuned classifier | thousands | one chat call | retrain per taxonomy change; not offered here |
Price the queue before you pick anything
Tagging is a high-volume, low-token job, so the model tier matters more than the architecture does. Read the catalogue rather than a blog post:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/ai/models?capability=chat&available=true" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"object": "list",
"capability": "chat",
"count": 22,
"data": [
{ "id": "glm-4-flash", "owned_by": "zhipu", "price_input_per_mtok": 0.0, "price_output_per_mtok": 0.0, "unit": "USD/1M tokens" },
{ "id": "glm-4-air", "owned_by": "zhipu", "price_input_per_mtok": 0.07, "price_output_per_mtok": 0.07, "unit": "USD/1M tokens" },
{ "id": "gpt-5-mini", "owned_by": "openai", "price_input_per_mtok": 0.25, "price_output_per_mtok": 2.0, "unit": "USD/1M tokens" }
]
}
Verified 2026-07-26: 22 chat models were servable, glm-4-air sat at $0.07 per Mtok in both directions and gpt-5-mini at $0.25 in / $2.00 out. The durable shape of that table matters more than the digits — the cheapest China-origin models sit roughly an order of magnitude below the cheapest Western ones, and output is priced above input almost everywhere. Rates drift downward and discount campaigns run, so what you read today is probably lower than what we read.
Ten thousand tickets at ~350 prompt tokens each is 3.5 Mtok, which is about $0.25 of input on glm-4-air. That is the whole reason not to fine-tune: the inference bill is not the constraint.
Zero-shot, with the tag list closed
The trick that makes zero-shot usable isn’t the model, it’s forcing the output into a set you control.
curl -sS -X POST "https://api.infrai.cc/v1/chat/completions" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"model": "glm-4-flash",
"response_format": {"type": "json_object"},
"max_tokens": 60,
"messages": [
{"role": "system", "content": "Pick exactly one tag from: billing_duplicate_charge, billing_refund, auth_password_reset, data_export, other. Reply as JSON with keys tag and confidence between 0 and 1."},
{"role": "user", "content": "My card was charged twice for invoice 5512 and support never replied."}
]
}'
{
"id": "chatcmpl-57f36fd9c34d44fabcfde93c",
"model": "glm-4-flash",
"choices": [{ "index": 0, "message": { "role": "assistant", "content": "{\"tag\":\"billing_duplicate_charge\",\"confidence\":0.94}" }, "finish_reason": "stop" }],
"usage": { "prompt_tokens": 96, "completion_tokens": 18, "total_tokens": 114 },
"infrai": { "cost_usd": 0.0, "vendor": "zhipu", "region": "china", "model": "glm-4-flash", "cache": false }
}
That extra infrai object is the per-call cost, vendor and region, and it also arrives as X-Infrai-Cost-Usd and X-Infrai-Vendor headers — useful when you want tagging spend attributed per tenant later.
response_format: {"type":"json_object"} gets you a parseable object; {"type":"json_schema"} goes further and pins the keys. Neither pins the values, so keep validating the returned tag against your own list before it reaches the database. A well-formed response naming a tag you retired last quarter is the failure mode that survives every format guarantee.
Rerank when the taxonomy gets long
Once you’re past about thirty tags, stuffing them into a system prompt costs tokens on every ticket and blurs the decision. Reranking flips it: the ticket becomes the query, each tag’s description becomes a candidate, and you get scores back.
curl -sS -X POST "https://api.infrai.cc/v1/ai/rerank" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"query": "My card was charged twice for invoice 5512",
"candidates": [
"Billing: the customer was charged more than once for the same invoice or subscription period.",
"Auth: the customer cannot reset their password or receive the reset email.",
"Data: the customer wants an export of their account data."
],
"top_k": 2
}'
{
"ok": true,
"data": {
"ranked": [
{ "index": 0, "score": 0.743972 },
{ "index": 2, "score": 0.309953 }
],
"metadata": { "cost_usd": 0.0001, "vendor": "alibaba_intl", "latency_ms": 112 }
}
}
The margin is what you want: 0.74 against 0.31 is a decision, and a top score under about 0.35 is your “route to a human” signal. index points back into the array you sent, and top_k trims the response — ask for two and you get two, which keeps the parse trivial on a 200-tag request. A single rerank call cost $0.0001 flat, verified 2026-07-26; read it live rather than memorise it:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | grep -o '"ai.rerank"[^}]*}[^}]*}' | head -1
The embeddings arm
The academic answer to this query is usually the embeddings classifier — encode the ticket, compare it to your labelled examples — and the benchmark literature is right that it wins on accuracy once labels exist. It’s one call on the same key:
curl -sS -X POST "https://api.infrai.cc/v1/embeddings" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"model": "auto", "input": ["Card charged twice for invoice 5512."]}'
model: "auto" picks whatever embedding model is serving — on 2026-07-26 that resolved to text-embedding-v4 at 1024 dimensions and $0.07 per Mtok, with the exact charge for each call echoed back in infrai.cost_usd. Pin an id from GET /v1/ai/models?capability=embed&available=true once you’re indexing for real, because vectors from two different models aren’t comparable, and pass dimensions if you want them shorter.
Nearest-centroid over a few dozen examples per tag is usually enough, and it’s about thirty lines:
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function embed(input) {
const res = await fetch("https://api.infrai.cc/v1/embeddings", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "text-embedding-v4", input }),
});
if (!res.ok) throw new Error(`embed failed: ${res.status}`);
const { data } = await res.json();
return data.map((d) => d.embedding);
}
const labelled = {
billing_duplicate_charge: ["charged twice for the same invoice", "double billed this month"],
auth_password_reset: ["reset link never arrives", "cannot log in after changing my password"],
};
const centroids = {};
for (const [tag, examples] of Object.entries(labelled)) {
const vs = await embed(examples);
centroids[tag] = vs[0].map((_, i) => vs.reduce((sum, v) => sum + v[i], 0) / vs.length);
}
const cosine = (a, b) => {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] ** 2; nb += b[i] ** 2; }
return dot / Math.sqrt(na * nb);
};
const [ticket] = await embed(["My card was charged twice for invoice 5512."]);
const scored = Object.entries(centroids)
.map(([tag, c]) => ({ tag, score: cosine(ticket, c) }))
.sort((a, b) => b.score - a.score);
console.log(scored[0]);
Embedding 10,000 archived tickets at ~90 tokens each is 0.9 Mtok — call it six cents, once. The recurring cost is re-embedding the archive when you change model, which is exactly why you pin an id rather than ride auto in production.
Backfill the archive in one job
Whatever you pick, you have a few thousand historical tickets to label once. That’s a batch, not a loop.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const tickets = [
"Card charged twice for invoice 5512.",
"Reset link never arrives in my inbox.",
];
const TAGS = "billing_duplicate_charge, billing_refund, auth_password_reset, data_export, other";
const res = await fetch("https://api.infrai.cc/v1/ai/batch/submit", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
requests: tickets.map((text) => ({
model: "glm-4-flash",
max_tokens: 24,
messages: [
{ role: "system", content: `Reply with one tag from: ${TAGS}` },
{ role: "user", content: text },
],
})),
metadata: { job: "ticket-backfill" },
store: true,
}),
});
if (!res.ok) throw new Error(`batch submit failed: ${res.status}`);
const { data } = await res.json();
console.log(`batch ${data.batch_id} state=${data.state} count=${data.total_count}`);
Poll GET /v1/ai/batch/status/{id} until state reaches a terminal value — completed, partial, failed, expired or cancelled — then page GET /v1/ai/batch/results/{id} with next_cursor and check ok on each item. partial is the one people forget to handle: some rows succeeded, some didn’t, and the job will never move past it. Both reads are free, and GET /v1/ai/batch/list finds the job again if you lose the id.
What we’d ship
Start zero-shot on the cheapest model that holds format, measure agreement against a hand-labelled sample of 200 tickets, and only reach for rerank when the tag list outgrows the prompt. Move to embeddings once you’ve accumulated real labels — that’s the arm that keeps improving without a prompt rewrite.
The limitation to be clear about: none of this gives you a trained decision boundary. If your tags encode judgement no prompt or description can express — nuanced severity, contractual intent — a fine-tune is the right answer, and OpenAI’s fine-tuning guide is where to start. If your ticket volume is huge and steady, self-hosting an embedding model under Ollama drives the marginal cost to zero, at the price of running it.
What you get by staying here is that the tagging call, the embeddings, the batch backfill, the queue that feeds it and the error capture around it are one credential and one invoice, with per-tenant cost as a query instead of a reconciliation exercise. That’s the argument. The rate is only the evidence.