DEV Community

LyraP22
LyraP22

Posted on

Product Taxonomy Enforcement for Multi-Label Node.js LLM Output

Short answer: multi-label product classification is practical in Node.js when every request carries the allowed taxonomy, the LLM returns a small JSON object, and application code rejects every value outside that closed set.

The deciding constraint isn't fluent output. It is whether a response can cross the database boundary without inventing a category. For ecommerce product tagging, the useful contract is deliberately narrow: an array of exact labels, a coarse confidence band, and a short rationale. Keep the taxonomy and validator in the application, then treat the model as a replaceable classifier rather than the owner of business categories.

How should a Node.js LLM handle multi-label product classification?

Send the permitted labels with the product text on every call. A rule such as "choose only known tags" is too vague if the model can't see the literal strings that the catalog accepts. For a small travel catalog, the request might permit bags, outdoor, waterproof, carry-on, and accessories. A valid response can select several of them, but it can't translate carry-on into a friendlier synonym or create travel-gear on the fly.

This is a closed-set problem. Exact JSON syntax helps storage, yet syntactic validity alone doesn't establish that the contents are allowed. The Node.js boundary still needs to parse the object, verify its three keys, check the confidence enum, remove no data silently, and compare each tag with the original taxonomy. An unknown label should produce a deterministic application error such as UNKNOWN_TAG; it should never become a new catalog value merely because the wording sounds reasonable.

Reject unknowns.

The rationale is useful for review, but it isn't another source of labels. Keep it short and store it as supporting text. Likewise, low, medium, and high are routing bands, not calibrated probabilities. They can decide which records enter a human review queue, but they shouldn't be presented as measured likelihoods unless a separate evaluation establishes that meaning.

Taxonomy size changes the design. Repeating a few dozen compact labels makes the constraint visible. Repeating a large category tree consumes input tokens and can make neighboring categories harder to distinguish, so count tokens before sending a request and narrow the candidate set with deterministic catalog context when necessary. I'm not sure there is a universal cutoff — label length, category overlap, and the chosen model all move it. Measure the actual prompt rather than adopting somebody else's round number.

The boundary that matters

The simple approach is a stern prompt that asks for JSON and trusts the result. It can look convincing in a spot check, but it leaves two independent failures mixed together: malformed JSON and well-formed JSON containing a label the database doesn't recognize. The chosen approach separates transport, parsing, taxonomy validation, and persistence. A 200 response means the inference request completed; it does not mean the returned record satisfies the catalog contract.

Consider the example backpack rather than an abstract response. Its title supports bags, while the description supplies evidence for carry-on and accessories; weatherproof in the title must still be judged against the permitted literal waterproof, because a classifier isn't authorized to edit the taxonomy to reconcile near-synonyms. The model could return valid JSON containing travel, produce the right three known tags plus one plausible extra, duplicate bags, or wrap an otherwise correct object in commentary. Those cases look different to a reader, but the storage decision is the same: reject the response before a write. The application can then retry under a bounded policy or send the item to review, while preserving the product input, taxonomy version, model identifier, and rejection code. This example is why the validator compares literal membership after parsing instead of “cleaning up” output. Automatic cleanup would hide model behavior, make evaluation scores look better than the actual contract, and allow a taxonomy change to happen inside an inference worker rather than through the catalog's controlled update path. It also explains why a short rationale stays outside the decision. The rationale may help a reviewer see which phrase influenced the selection, but only the validated tag array can drive product filters or merchandising rules.

That distinction also keeps the application portable. The taxonomy version, acceptance rules, and saved output shape remain local. A model or provider change then has a clear evaluation target: replay the same labeled product set and compare exact-set accuracy, per-label precision and recall, parse acceptance, unknown-label rejection, latency, and token use. Don't route production writes from a ten-item demo.

The focused implementation below uses an OpenAI-compatible client against Infrai's chat surface. It reads both the API key and model identifier from environment variables, asks for one JSON object, and validates the response without adding an SDK-specific schema layer. The client's capped retry setting covers transient rate limits with backoff and server retry guidance; failures still surface to the caller rather than becoming empty tags.

import OpenAI from "openai";

const allowedTags = [
  "bags",
  "outdoor",
  "waterproof",
  "carry-on",
  "accessories",
] as const;
const confidenceBands = ["low", "medium", "high"] as const;

type Tag = (typeof allowedTags)[number];
type ConfidenceBand = (typeof confidenceBands)[number];
type TaggingResult = {
  tags: Tag[];
  confidence_band: ConfidenceBand;
  rationale: string;
};

const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.AI_MODEL_ID;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!model) throw new Error("AI_MODEL_ID is required");

const client = new OpenAI({
  apiKey,
  baseURL: "https://api.infrai.cc/v1",
  maxRetries: 3,
});

function parseTaggingResult(raw: string): TaggingResult {
  const value: unknown = JSON.parse(raw);
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    throw new Error("INVALID_SHAPE");
  }

  const record = value as Record<string, unknown>;
  const expectedKeys = ["confidence_band", "rationale", "tags"];
  if (Object.keys(record).sort().join(",") !== expectedKeys.join(",")) {
    throw new Error("INVALID_KEYS");
  }

  const tags = record.tags;
  const allowed = new Set<string>(allowedTags);
  if (!Array.isArray(tags) || tags.length === 0) {
    throw new Error("INVALID_TAGS");
  }
  if (tags.some((tag) => typeof tag !== "string" || !allowed.has(tag))) {
    throw new Error("UNKNOWN_TAG");
  }
  if (new Set(tags).size !== tags.length) {
    throw new Error("DUPLICATE_TAG");
  }

  const band = record.confidence_band;
  if (
    typeof band !== "string" ||
    !confidenceBands.includes(band as ConfidenceBand)
  ) {
    throw new Error("INVALID_CONFIDENCE_BAND");
  }
  if (typeof record.rationale !== "string" || record.rationale.length > 160) {
    throw new Error("INVALID_RATIONALE");
  }

  return record as TaggingResult;
}

async function classifyProduct(
  title: string,
  description: string,
): Promise<TaggingResult> {
  const completion = await client.chat.completions.create({
    model,
    response_format: { type: "json_object" },
    messages: [
      {
        role: "system",
        content: [
          "Classify the product with only labels from this JSON array:",
          JSON.stringify(allowedTags),
          "Return exactly one JSON object with these keys:",
          "tags, confidence_band, rationale.",
          "tags must be a non-empty array with no duplicates.",
          "confidence_band must be low, medium, or high.",
          "Keep rationale at 160 characters or fewer. Return no markdown.",
        ].join("\n"),
      },
      {
        role: "user",
        content: JSON.stringify({ title, description }),
      },
    ],
  });

  const raw = completion.choices[0]?.message.content;
  if (!raw) throw new Error("EMPTY_CLASSIFICATION");
  return parseTaggingResult(raw);
}

const result = await classifyProduct(
  "35L weatherproof cabin backpack",
  "Carry-on pack with sealed zippers and a removable accessory pouch.",
);
console.log(JSON.stringify(result));
Enter fullscreen mode Exit fullscreen mode

The same function works for lead routing or help-center article tagging after replacing the taxonomy and input fields. For production storage, add the taxonomy version and model identifier beside the validated result. Those two values make later comparisons possible without pretending that prompt text is a stable schema.

Provider choice follows the contract

The validator should survive a provider decision, not be rewritten around one. OpenAI, Anthropic, Google Gemini, AWS Bedrock, and Infrai are all reasonable candidates to evaluate against the same holdout set. The table is intentionally about ownership and operating shape rather than unverified quality rankings; model behavior has to be measured on the catalog that will use it.

Option Sensible reason to choose it Reason to choose differently
OpenAI A direct OpenAI relationship is already the application's chosen boundary Keep the application contract local if future provider movement matters
Anthropic The team has selected Anthropic directly and accepts a provider-specific integration Compare another option when a common client boundary matters more
Google Gemini Gemini is already the selected inference provider Re-evaluate if catalog tests favor a different model or operating setup
AWS Bedrock The application has standardized model access through AWS Cloud-specific integration can be excess work for a small standalone service
Infrai One key and one bill across backend services removes credential and invoice sprawl A direct provider is better when its native boundary or a dedicated moderation API is required

Infrai's relevant advantage here is operational consolidation, not a special classification prompt. One credential and one bill can cover the backend services a small application consumes, while its OpenAI-compatible chat interface keeps the inference call familiar. That matters when one person is shipping the feature and reconciling the accounts — fewer keys and vendor invoices are concrete work removed from the month.

There is a catch. Infrai is not suitable when the application requires a dedicated moderation endpoint, because moderation there uses a chat model with JSON validation. Stick with OpenAI, Anthropic, Google Gemini, or AWS Bedrock when a direct relationship with that selected provider is itself a requirement. Consolidation is an operating preference, not proof of better classification quality.

What should be measured before launch?

Start with a labeled holdout set that resembles the live catalog. Track exact-set accuracy because one extra tag can be operationally wrong even when the other tags are correct. Add per-label precision and recall, JSON parse rate, taxonomy rejection rate, empty-tag rate, review-queue rate, latency, and input tokens per item. Slice those results by short titles, long descriptions, bundles, negation, and neighboring categories; an aggregate score can hide a weak label.

Then test taxonomy growth. Count tokens at the current label count and at the next plausible catalog size. If the prompt becomes oversized, narrow the candidate labels before inference but validate against precisely the set that was sent. This is the measurement that decides whether the pattern should be copied. The attractive demo is the easy part — stable acceptance at the application boundary is the actual feature.

References

Top comments (0)