Short answer: for implementing text classification in Node.js, use an LLM as a bounded enrichment stage, not as the source of truth. Give it a small label vocabulary, require structured output, validate every field, and send uncertain records to a slower review path. For a logistics catalog, that usually gives a better quality/latency trade than asking one large prompt to clean, classify, and invent the whole product record.
| Route | Quality control | Latency shape | Best use |
|---|---|---|---|
| Rules and dictionaries | High for known patterns | Predictable | Carrier codes, units, exact SKU aliases |
| One LLM classification call | Good with constrained labels | Variable | Ambiguous descriptions with enough context |
| LLM plus review queue | Highest practical control | Fast path plus a tail | New suppliers and high-impact fields |
My default is the third route. It lets a solo SaaS operator ship weekly without pretending that a probabilistic classifier can resolve an incomplete carton description. The time trade is clear: outsource the undifferentiated wording work, retain ownership of the schema and the exceptions.
What should the classifier decide?
Start with one decision that downstream code can act on. In this case, classify a messy product description into a logistics category and extract only attributes that are present in the text.
For example, "12 CT nitrile exam gloves, blue, L, case of 100" might receive medical-disposable plus color=blue, size=L, and pack_count=100. The model must not convert an absent measurement into a guess. A missing field is useful data.
The boundary matters because catalog enrichment has two different failure costs. A wrong category can route inventory to the wrong warehouse workflow. A slow response can delay a batch import. Those costs should not be hidden inside one vague accuracy number.
I use three outcomes for each record: accepted, retryable, and review. Accepted means the JSON shape and label set are valid and the confidence policy passes. Retryable means the request failed before producing a usable result. Review means the response is syntactically valid but the evidence is weak, contradictory, or too important to trust automatically.
That is a deliberate asymmetry. A malformed answer is a software error. A plausible but unsupported answer is a data-quality error. They need different metrics and different operators.
How should Node.js handle text classification using a model?
The model-facing function should be small enough to replace. The catalog service should know about labels, validation, timeouts, and retry policy; it should not depend on a provider-specific response shape throughout the import pipeline.
Keep it boring.
Here is a compact TypeScript boundary using the chat-completions JSON schema option. The endpoint and model come from configuration so the surrounding workflow can test against a local fixture or another standards-compatible gateway. The schema is intentionally narrow. In a production worker, use 429 backoff with a bounded retry for transient 5xx responses, and attach an idempotency key to the import record so a retry cannot create a second enrichment result.
type Category = "apparel" | "industrial-part" | "medical-disposable" | "unknown";
type Classification = {
category: Category;
attributes: Record<string, string>;
confidence: number;
evidence: string;
};
const categories: Category[] = [
"apparel",
"industrial-part",
"medical-disposable",
"unknown",
];
function isClassification(value: unknown): value is Classification {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
return (
typeof item.category === "string" &&
categories.includes(item.category as Category) &&
typeof item.attributes === "object" &&
item.attributes !== null &&
Object.values(item.attributes as Record<string, unknown>)
.every((entry) => typeof entry === "string") &&
typeof item.confidence === "number" &&
item.confidence >= 0 &&
item.confidence <= 1 &&
typeof item.evidence === "string" &&
item.evidence.length <= 240
);
}
export async function classifyDescription(description: string): Promise<Classification> {
if (!description.trim() || description.length > 4000) {
throw new Error("description must contain 1-4000 characters");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(`${process.env.LLM_API_BASE}/v1/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
signal: controller.signal,
body: JSON.stringify({
model: process.env.LLM_MODEL,
temperature: 0,
messages: [
{
role: "system",
content:
"Classify the catalog description. Use only the supplied categories. " +
"Copy attributes from the text; never infer missing values. Return JSON only.",
},
{ role: "user", content: description },
],
response_format: {
type: "json_schema",
json_schema: {
name: "catalog_classification",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["category", "attributes", "confidence", "evidence"],
properties: {
category: { type: "string", enum: categories },
attributes: { type: "object", additionalProperties: { type: "string" } },
confidence: { type: "number", minimum: 0, maximum: 1 },
evidence: { type: "string", maxLength: 240 },
},
},
},
},
}),
});
if (!response.ok) throw new Error(`classification request failed: ${response.status}`);
const payload = await response.json() as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = payload.choices?.[0]?.message?.content;
const parsed: unknown = content ? JSON.parse(content) : null;
if (!isClassification(parsed)) throw new Error("classification failed validation");
return parsed;
} finally {
clearTimeout(timeout);
}
}
The code does not decide what confidence means. That policy belongs to the application. A useful starting point is to auto-accept a category only when confidence >= 0.85, the category is not unknown, and the evidence string overlaps the original description. Send the rest to review. Then tune the threshold against a labelled sample instead of treating 0.85 as a universal truth.
The limitation is important: this method is a poor fit for exact arithmetic, regulated decisions, or a catalog whose labels change on every import. Use deterministic validation and a human gate for those cases. The tradeoff is extra queue work in exchange for fewer silent catalog mutations. Consider a supplier line such as "bearing 6204 2RS, 20mm bore, 47mm OD, 14mm width, 10 pcs". A useful result can preserve the bearing category, the dimensions, and the quantity, but it should not claim a material, load rating, or compatibility standard that the line does not state. If the description instead says "bearing 6204, sealed", the missing dimensions should remain missing even when a product database might make a likely guess. That restraint is what makes the enrichment record auditable: a reviewer can compare the proposed fields with the source text and see why the item entered the review queue. It also keeps a later schema migration from turning an old guess into an apparently authoritative fact.
One operational detail earns its place here: cap the input. Supplier descriptions can contain pasted HTML, repeated marketing copy, or an entire spreadsheet row. A length limit protects latency, but it also makes evaluation repeatable. Normalize whitespace before classification and preserve the original text beside the result for audit.
Which quality and latency signals matter?
Measure the two decision axes separately. For quality, track category precision on the accepted path, review rate, invalid-response rate, and corrections by supplier. For latency, track p50 and p95 request time, queue age, timeout rate, and batch completion time. A p50 that looks healthy can hide a painful p95 during a supplier import. Node.js 20 or later gives the worker a native fetch, but the runtime version does not remove the need for timeout and retry metrics.
Keep a small, versioned evaluation set. Fifty carefully chosen descriptions are more useful than a large random export if the set includes abbreviations, mixed units, multilingual fragments, duplicate products, and descriptions that should become unknown. Record the prompt version, schema version, model configuration, and validation result with every evaluation run.
The failure mode I watch most closely is a valid answer with the wrong scope. A model may correctly identify “gloves” while missing that the line is a case of 100, or it may copy L as a product dimension when the supplier meant a garment size. Evidence makes that mistake visible; it does not fix it by itself. Add field-specific rules for attributes that affect storage, picking, or compliance.
Retries need a budget. Retry timeouts and transient server responses with exponential backoff, but do not retry a schema failure forever. After one bounded retry, mark the record for review or route it to a deterministic fallback. Infinite retries turn a quality problem into a latency incident.
When is the slower path the right choice?
Use rules first for stable, high-volume patterns. A carrier prefix, unit conversion, or known supplier alias is cheap to test and easy to explain. Use the LLM for the residual cases where language ambiguity is the actual problem. That hybrid split keeps the fast path boring. It is also a limitation: a rules-first design needs maintenance when suppliers introduce new abbreviations, while a model-first design pays in variable latency and review volume.
Choose the review queue when a wrong tag can cause a costly warehouse action, when the supplier format just changed, or when the classifier sees a new vocabulary. The queue does not need a large interface. It needs the original description, proposed tags, evidence, schema and prompt versions, and a one-click correction that becomes evaluation data.
Choose an all-automatic path only when the downstream consequence is reversible and a human can correct the catalog before the next operational step. A delayed import is often preferable to silently teaching a search index that a hazardous part belongs in an apparel category.
This is also where a one-person team should be strict about scope. Do not build a universal ontology before the first import. Pick the categories needed by the current workflow, expose unknown, and add a label only when real records demonstrate a recurring gap. Revenue per engineering hour improves when the schema follows an actual shipping decision.
The practical rule is simple: deterministic checks own certainty, the classifier handles ambiguity, and review owns the tail. That division keeps latency visible while giving catalog quality a place to improve.
Further reading
- OpenAI API reference for chat completions: https://platform.openai.com/docs/api-reference/chat
- OpenAI structured outputs guide: https://platform.openai.com/docs/guides/structured-outputs
- JSON Schema specification: https://json-schema.org/specification
- MDN Fetch API: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- LangChain ChatOpenAI integration documentation: https://python.langchain.com/docs/integrations/chat/openai/
Top comments (0)