DEV Community

SunspireValerius59
SunspireValerius59

Posted on

Classify Text Data Using an Embeddings API in Node.js: Portable Invoice Field Decisions

Short answer: To classify invoice text data using embeddings in Node.js, compare snippets with application-owned field prototypes, keep an unknown result, and isolate the provider behind a batch adapter so the taxonomy survives an API change.

The practical way to classify text data using embeddings in Node.js is to keep the label decision in your application and treat the embeddings API as a replaceable vector service. For a B2B SaaS product extracting fields from supplier invoices, that means embedding OCR text, comparing it with versioned field examples, and rejecting uncertain matches before a value reaches an accounting workflow. The provider should return a vector; it should not own your taxonomy.

That boundary matters.

The index is disposable.

The trade-off is portability versus convenience. A direct classification feature can be quick to wire up, but its output contract follows a provider's model behavior. A small embedding adapter requires more design up front: dimensions, model identity, thresholds, retries, and evaluation data all become your responsibility. In return, changing the vector service does not require changing what payment_terms or tax_identifier means in your product.

The hard case is a supplier template that puts amount due, subtotal, and a tax percentage in the same OCR block. A nearest-neighbor result can look reasonable for all three, especially after a line break removes the visual layout. I keep the raw snippet reference, the taxonomy version, the top two scores, and the parser outcome together, then let the field-specific validator reject values that do not match the expected shape. That extra bookkeeping makes the system slower to design and easier to explain during a review. It also prevents a semantic guess from silently becoming an accounting fact, which is the failure mode I care about most.

How should a backend classify invoice text data using an embeddings API?

Start with a narrow decision, not “classify the invoice.” A useful unit is field_kind=payment_terms, field_kind=tax_identifier, or field_kind=line_item_description. The classifier routes a snippet to a field family; deterministic parsing and schema validation still decide whether Net 30 is a payment term or whether 13.50% is a valid tax rate.

For each field family, keep a stable label_id, representative prototype text, a taxonomy version, and an acceptance rule. Include an explicit unknown or human-review result. “Invoice number,” “supplier VAT ID,” and “amount due” are better prototypes than one abstract description because supplier invoices contain repeated headers, OCR line breaks, and neighboring monetary values that are semantically close.

The adapter should expose a small contract. It accepts text in batches, returns vectors in the same order, reports the model identity and dimension, and maps transport and provider errors into typed application errors. The rest of the service should not know which SDK produced the response.

from typing import Protocol


class EmbeddingProvider(Protocol):
    def embed(self, texts: list[str], model: str) -> list[list[float]]: ...


def cosine(a: list[float], b: list[float]) -> float:
    numerator = sum(x * y for x, y in zip(a, b))
    left = sum(x * x for x in a) ** 0.5
    right = sum(y * y for y in b) ** 0.5
    return numerator / (left * right) if left and right else -1.0


def classify(text, labels, provider, model):
    vectors = provider.embed(
        [text, *(label.prototype_text for label in labels)], model
    )
    if len(vectors) != len(labels) + 1:
        raise ValueError("embedding count does not match input count")

    scored = [
        (label, cosine(vectors[0], vector))
        for label, vector in zip(labels, vectors[1:])
    ]
    label, score = max(scored, key=lambda item: item[1])
    result = label.label_id if score >= label.min_score else "unknown"
    return result, score
Enter fullscreen mode Exit fullscreen mode

The production version also checks that every vector has the expected dimension. Those checks catch a malformed batch before it becomes a persisted invoice decision. They do not prove the decision is correct; they only protect the boundary.

What makes the embedding boundary portable across providers?

Portability starts with metadata, not with a promise that all embedding APIs are equivalent. Store the provider name, model identifier, vector dimension, preprocessing version, taxonomy version, and creation time beside every prototype and index. A vector from one model family cannot be compared meaningfully with a vector from another unless both the input and the prototypes were created in the same vector space.

That makes migration a data operation. Build a new index, embed the same held-out examples with the candidate service, and compare field-level outcomes before changing the active route. Reusing old vectors while changing the model is a subtle failure: the code still runs, scores still look numeric, and the semantic comparison is invalid.

The common integration shapes have different boundaries:

Approach Access method (SDK/REST) Onboarding cost Suitable scenario Main limitation
OpenAI embeddings SDK or REST Low to medium A hosted embedding service with established client libraries Network and data-processing dependency; model changes require index evaluation
Cohere Embed SDK or REST Low to medium Applications that need an embedding API with input-type controls Provider-specific request semantics and a new vector space during migration
Voyage AI embeddings SDK or REST Low to medium Teams evaluating model-specific embedding capabilities Model-specific dimensions and behavior still require adapter and index work

The table is a comparison of integration boundaries, not a ranking. OpenAI, Cohere, and Voyage AI all require an application-owned contract if the invoice taxonomy must survive a provider change. A self-hosted embedding service moves the network dependency inward, but the team then owns serving capacity, patching, and observability. A hosted API reduces that infrastructure surface and adds a data-handling dependency. Neither choice is automatically portable.

The deciding question is what your team can operate and what your data policy permits. If supplier documents cannot leave a controlled environment, a hosted API may be the wrong fit even if its SDK is convenient. If the team cannot operate model serving, self-hosting may create more operational risk than it removes. The right boundary is the one that lets you replace the vector producer without rewriting label meaning or mixing incompatible indexes.

How do thresholds handle ambiguous invoice fields?

One global threshold is attractive and usually wrong. total due is close to subtotal; a tax identifier has a different shape and often benefits from syntax checks. Give labels separate acceptance rules, and keep a reject path. Returning the nearest label for every input turns uncertainty into corrupted data.

I initially treated the highest cosine score as the decision. The failure was predictable once the evaluation set included invoices with both “amount due” and “subtotal”: the top result could be plausible while the runner-up was nearly tied. The useful rule became two-dimensional: the top score must clear that label's threshold, and it must beat the second candidate by a recorded margin. A review queue is less exciting than a confident wrong field, but it is much easier to audit.

Build a versioned evaluation set from redacted invoice snippets. Include clean OCR, broken line wraps, multilingual supplier names, repeated headers, empty values, and ambiguous phrases. Keep positive and hard-negative examples together. For each taxonomy version, record the selected label, score, expected label, and whether the result was accepted or sent to review. A confusion matrix says more than an average similarity score.

Keep financial values and personally identifying data out of logs by default. A request identifier, taxonomy version, model identifier, vector dimension, latency, outcome, and score band are usually enough to investigate routing behavior. Store the source-snippet reference under the same retention policy as the invoice, not in an unrestricted application log.

What should retries and rollout protect?

Embedding calls sit on a network boundary, so rate limits, timeouts, connection failures, and partial batches are normal operating cases. Use bounded backoff and a stable job identity. Do not retry every exception forever, and do not assume a transport failure tells you whether the remote service processed the request.

RFC 9110 separates request semantics from transport behavior and defines when retrying can be safe. For a batch job, make the persistence step idempotent with a key such as (document_id, taxonomy_version, model_id, snippet_hash). A repeated attempt can then converge on one stored result instead of creating duplicate review work. If a batch returns fewer vectors, fail the batch and preserve the job for replay; do not zip the available vectors into the wrong invoice fields.

Roll out one field family at a time. Freeze a taxonomy version, create prototypes from representative redacted text, and run the held-out set before production traffic. During a migration, build a parallel index and shadow both classifiers against the same snippets. Compare accepted labels, unknowns, hard negatives, review volume, latency, and error classes. Switch the active route only when the acceptance criteria and rollback action are written down.

The compact rule is simple: own the labels, isolate the embedding call, measure uncertainty per field, and rebuild the index when the vector space changes. That preserves provider choice without pretending that embeddings are interchangeable.

Sources

Top comments (0)