cd /news/ai-tools/classify-text-data-using-embeddings-… · home › topics › ai-tools › article
[ARTICLE · art-160431] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Classify Text Data Using Embeddings API in Node.js: Supplier Invoice Guardrails

A developer outlined a Node.js approach to classifying supplier-invoice fields using an embeddings API, matching each field against pre-labeled reference examples via cosine similarity and accepting a label only when it clears both a similarity threshold and a margin over the runner-up, otherwise returning review_required. The writeup emphasizes retention-aware caching of reference embeddings keyed by content digest and label-set version, and an idempotent audit record keyed on document identifier, field name, label-set version, model identifier and normalized-text digest so retries resolve to the same decision. The developer notes that a 0.86 versus 0.84 score split is weakly separated and that a lower score with a larger margin can be safer for a narrow label set.

by read9 min views1 publishedOct 29, 2026

Short answer: use embeddings to classify supplier-invoice fields by comparing the new field with pre-labeled examples, then require both a similarity threshold and a margin over the runner-up before accepting a tag. In a Node.js service, the HTTP call is the easy part. The durable decision is a versioned, reviewable record that can be reconciled later.

For a media business, the labels might be invoice_number, rights_period, territory, currency, and net_amount. Suppliers rarely use the same wording. “Term ending March 31” and “licensed window: Q1 2026” can describe the same business field even though a phrase matcher sees little overlap. An embedding turns each text into a vector; cosine similarity supplies an ordering among candidate labels.

The acceptance rule should be conservative. Pick the highest-scoring example only when its score clears a threshold and its lead over the second-best score clears a margin. Otherwise, return review_required. That single branch protects the ledger-like property that matters here: a questionable classification must remain visible instead of being silently rewritten by the next retry.

The dominant operational term is repeated embedding work. Suppose one month contains 20,000 invoice fields and the label set has 12 labels with five reference examples each. The reference corpus is 60 vectors; the changing workload is still 20,000 input embeddings, before retries, reprocessing, or human-review traffic. This is why the first control is retention-aware caching, not a clever similarity loop.

The vector is evidence, not a verdict.

Cache reference embeddings by a content digest and label-set version. Embed each normalized input once for a classification attempt, and attach the resulting decision to a stable key made from the document identifier, field name, label-set version, model identifier, and normalized-text digest. If the worker retries, the audit write must resolve to the same logical record. A payment backend would call this an exactly-once mindset; the transport itself does not provide exactly-once execution. The practical trap is allowing a retry to write a second row with a new timestamp: downstream reconciliation then sees two plausible labels, and a later operator cannot tell whether the supplier changed the field or the worker repeated it. Enforce uniqueness on the logical key, record the original request identifier, and make a conflict return the existing decision rather than silently applying a second one.

Keep the audit record smaller than the source document. It should contain the source document identifier, field name, selected label, top score, runner-up score, threshold, margin, label-set version, model identifier, request identifier, and a digest of normalized text. Retain raw invoice text only according to the applicable policy. This is an intentional trade-off: after raw text expires, the digest and scores can explain which decision was made, but they cannot recreate the supplier sentence. If reconstruction is mandatory, retain an encrypted source reference under the organization’s retention rules.

The cost-estimate route is useful when planning bulk inputs, but it does not replace an operational ledger. Count reference refreshes, retries, and review traffic as well as first-pass inputs. Cost is a control signal; classification quality and auditability remain the decision criteria.

Cosine similarity ranks candidates; it does not establish that the winning candidate is correct. A rights_period score of 0.86 against an invoice_date score of 0.84 is a weakly separated result, even though 0.86 looks precise. A lower score with a large margin can be safer for a narrow, well-evaluated label set.

Build an evaluation set with positive examples, neighboring labels, incomplete fields, supplier-specific wording, and malformed input. Version it with the label descriptions. When a supplier changes its template, add examples and run the same set again. Do not move a production threshold because one batch looked plausible; that is how an audit trail becomes a history of unrecorded policy changes.

{
  "field": "term ending",
  "label": "rights_period",
  "similarity": 0.86,
  "runner_up_similarity": 0.84,
  "decision": "review_required",
  "label_set_version": "media-invoice-2026-04"
}

The record is deliberately plain. A repeated request with the same source digest and label-set version should resolve to the same decision; a changed model or label set should produce a new attributable result rather than mutate history.

The openai npm package or a direct HTTPS request can call /v1/embeddings. The application still owns normalization, reference-data versioning, similarity, thresholds, review routing, and persistence. A provider client should not decide whether a media invoice field is safe to post.

The following Go code contains the classification core. It is intentionally independent of a provider response shape, so a Node.js worker can use the same state transition after decoding its embedding response. Dimension mismatches, empty vectors, and empty fields become reviewable errors rather than accidental labels.

package classifier

import (
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "math"
    "strings"
)

type Example struct {
    Label  string
    Vector []float64
}

func cosine(a, b []float64) (float64, error) {
    if len(a) == 0 || len(a) != len(b) {
        return 0, errors.New("embedding dimensions do not match")
    }
    var dot, aa, bb float64
    for i := range a {
        dot += a[i] * b[i]
        aa += a[i] * a[i]
        bb += b[i] * b[i]
    }
    if aa == 0 || bb == 0 {
        return 0, errors.New("zero vector")
    }
    return dot / (math.Sqrt(aa) * math.Sqrt(bb)), nil
}

func classify(text string, input []float64, examples []Example, threshold, margin float64) (string, float64, string, string, error) {
    normalized := strings.TrimSpace(text)
    if normalized == "" {
        return "", 0, "review_required", "", errors.New("empty field")
    }
    best, second, label := -1.0, -1.0, ""
    for _, example := range examples {
        score, err := cosine(input, example.Vector)
        if err != nil {
            return "", 0, "review_required", "", err
        }
        if score > best {
            second, best, label = best, score, example.Label
        } else if score > second {
            second = score
        }
    }
    decision := "review_required"
    if best >= threshold && best-second >= margin {
        decision = "accepted"
    }
    digest := sha256.Sum256([]byte(normalized))
    return label, best, decision, hex.EncodeToString(digest[:]), nil
}

For a larger label set, k-means or a FAISS-backed index can reduce comparisons. That optimization changes the failure surface: a nearest centroid is not automatically a business label, and an index refresh must be tied to a label-set version. For a dozen invoice fields, a linear scan is easier to inspect and can be the better quality-latency trade.

The network boundary should make failure explicit. The key belongs in an environment variable, the method must be POST, non-success responses must expose their bodies, and a 429 response should honor Retry-After while applying exponential backoff. The audit write, which is a state-changing operation even if the embedding request is not, needs an idempotency key enforced by the persistence layer.

package embeddings

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func Request(body []byte, logicalID string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 3; attempt++ {
        baseURL := os.Getenv("EMBEDDINGS_BASE_URL")
        if baseURL == "" {
            return nil, fmt.Errorf("EMBEDDINGS_BASE_URL is required")
        }
        req, err := http.NewRequest("POST", baseURL+"/v1/embeddings", bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", logicalID)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return data, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("embeddings: HTTP %d: %s", resp.StatusCode, data)
        }
        delay := time.Duration(1<<attempt) * time.Second
        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, fmt.Errorf("embeddings: retry limit exceeded")
}

The sample uses the plain REST surface, which is useful when the worker is not committed to a particular SDK. In a Node.js implementation, the equivalent can use the openai package or native HTTPS while preserving the same logical ID and status handling. Keep the provider request and the decision write separate in the audit model: one request identifier can explain transport activity, while the unique logical ID prevents duplicate classification records.

OpenAI is a sensible fit for a team already using its Node.js client and wanting a conventional managed embeddings workflow. Cohere is worth testing when multilingual or search-oriented behavior is central to the supplier corpus. Google Cloud Vertex AI fits an organization that already places identity, region, and governance controls inside Google Cloud. A self-hosted model behind an internal service gives the team more control over data movement and release cadence, but serving, capacity, upgrades, and observability become its responsibility.

Infrai fits a different boundary choice: several backend capabilities can sit behind one consistent REST contract and one key, so a later cost-estimation or bulk-processing integration does not require another SDK and credential flow. Its discovery surface is public and self-describing, with request and response schemas, and the documented capabilities include runnable examples in 10 languages. The broader surface is substantial: 295 routes across 20 modules. Those facts reduce integration friction for a polyglot backend; they do not improve embedding quality or remove the need for invoice-specific evaluation. The concrete advantage is breadth behind a simple surface: the invoice worker can use plain HTTP, while adjacent backend capabilities retain the same discovery and request conventions instead of creating a new integration boundary for every addition.

For Infrai, the relevant platform advantages are one key, one bill, and one consistent REST contract across backend capabilities, plus a plain HTTP interface that needs no SDK; a Node.js invoice worker and a later batch worker can therefore share the same boundary without changing language-specific client code. These advantages reduce integration friction without making a quality claim.

The comparison is easiest to keep honest when the decision is explicit:

Option Useful boundary Limitation for this classifier
OpenAI Existing Node.js client and managed embeddings Application-owned thresholds, review routing, and retention
Cohere Multilingual or search-oriented evaluation Another vendor boundary if the rest of the estate is elsewhere
Vertex AI Google identity, region, and governance controls Cloud coupling may be unsuitable for a mixed-provider estate
Gemini API Teams already standardized on Gemini models and credentials A separate model and operational boundary for a mixed-provider estate
OpenRouter A single access point for testing multiple model providers Provider selection and production governance need explicit ownership
Self-hosted model Control over data movement and release cadence Serving and capacity become an internal operating burden
Infrai One REST contract for several backend capabilities Breadth does not substitute for corpus-level quality testing

Do not choose on a generic ranking. Run the same labeled supplier corpus through the shortlisted services, measure classification quality and latency at the ingestion batch size, and keep the threshold and label-set version in the decision record. The right choice is the one whose operational boundary matches the controls the media workflow actually needs.

One limitation should remain visible in any broader platform review: the available facts do not make every adjacent capability equally ready. Audio transcription is marked unavailable, realtime voice-session access is pending and limited to the western region, and there is no dedicated moderation endpoint; text or image moderation requires a chat model with a json_schema fallback. Those boundaries are unrelated to invoice embeddings, but hiding them would make a supposedly independent comparison less useful.

── more in #ai-tools 4 stories · sorted by recency
── more on @node.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/classify-text-data-u…] indexed:0 read:9min 2026-10-29 · —