DEV Community

Text Data Classification Using an Embeddings API in Node.js: Auditable CRM Actions

Auditable Classification with Embeddings

Use embeddings to classify text data from fintech sales calls when quality matters more than shaving every last millisecond.

Using an embeddings API in a Node.js service, the practical pattern is straightforward: embed labeled examples, embed each new transcript or excerpt, compare vectors with cosine similarity, and require a confidence margin before creating a CRM action.

For a small label set, nearest-neighbor matching is easier to audit than asking a chat model to invent a label on every request. The integration can live behind a Node.js service using the openai npm package or a direct HTTPS request to /v1/embeddings. The classification policy should remain independent of that transport.

That separation is useful in a notebook, where I can inspect false matches quickly, and in production, where retries, batching, and cost accounting matter more than a clever prompt.

What a Sales-Call Classifier Should Decide

Start with a closed vocabulary owned by the CRM team. For example, a call may produce follow_up, compliance_review, pricing_question, or no_action. These are actions, not a transcript summary.

A summary can be generated later; the classifier's job is to choose a routing label that another workflow can safely consume.

The reference set needs examples that sound like real calls, including short fragments and awkward language. A labeled example such as “The prospect needs our security team to review data residency before signing” carries more signal than a label description alone.

Store the example text, its label, and the embedding produced for the version of the embedding model you selected. When that model changes, rebuild the reference vectors together. Mixing vector spaces is a quiet failure mode.

Conservative Decision Rule

The decision rule I use is conservative:

  1. Retrieve the nearest labeled examples by cosine similarity.
  2. Accept the top label only when its score clears a threshold and beats the runner-up by a margin.
  3. Send ambiguous text to review instead of turning uncertainty into a CRM write.

That third step is the quality-versus-latency trade. A lower threshold creates fewer review tasks and more incorrect actions. A higher threshold protects downstream systems but increases manual work.

Measure both outcomes with a labeled evaluation set; do not infer quality from a handful of impressive transcripts.

How Classification with an Embeddings API Works

An embedding is a vector representation. Similar meanings tend to occupy nearby regions, so cosine similarity gives us a usable ranking without requiring a generative answer. It does not understand the CRM policy by itself. The labels, examples, threshold, and review path supply that policy.

Here is the small, inspectable core I keep in an evaluation notebook. It deliberately makes the scores visible. In a Node.js service, the same request can use the openai npm package; this Python listing keeps the transport and the matching policy visible in one place, while the matching logic remains independent of the client.

from collections import defaultdict
from math import sqrt

def cosine_similarity(left, right):
    """Return cosine similarity for two equal-length vectors."""
    dot = sum(a * b for a, b in zip(left, right))
    left_norm = sqrt(sum(value * value for value in left))
    right_norm = sqrt(sum(value * value for value in right))
    if left_norm == 0 or right_norm == 0:
        return 0.0
    return dot / (left_norm * right_norm)

def classify(query_vector, labeled_vectors, threshold = 0.78, margin = 0.05):
    """Return a label, review, or no_match decision with its evidence."""
    scored = [(cosine_similarity(query_vector, example["vector"]), example) for example in labeled_vectors]
    scored.sort(key = lambda item: item[0], reverse = True)
    if not scored:
        return {"decision": "no_match", "score": 0.0}
    best_score, best_example = scored[0]
    second_score = scored[1][0] if len(scored) > 1 else 0.0
    if best_score < threshold or best_score - second_score < margin:
        return {
            "decision": "review",
            "score": round(best_score, 4),
            "candidate": best_example["label"],
            "runner_up_score": round(second_score, 4),
        }
    return {
        "decision": "accept",
        "label": best_example["label"],
        "score": round(best_score, 4),
    }

def fetch_embedding(text):
    """Call the configured OpenAI-compatible embeddings route."""
    import json
    import os
    import time
    import requests
    api_key = os.environ["INFRAI_API_KEY"]
    embeddings_url = os.environ["EMBEDDINGS_URL"]
    model = os.environ["EMBEDDING_MODEL"]
    body = {"model": model, "input": text}
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    for attempt in range(4):
        response = requests.request(
            method = "POST",
            url = embeddings_url,
            headers = headers,
            json = body,
            timeout = 30,
        )
        if 200 <= response.status_code < 300:
            payload = response.json()
            return payload["data"][0]["embedding"]
        if response.status_code != 429 or attempt == 3:
            raise RuntimeError(f"embedding request failed: {response.status_code} : {response.text}")
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)

query_vector = fetch_embedding("The prospect needs a data residency review before signing.")
examples = [
    {"label": "compliance_review", "vector": [0.91, 0.12, 0.03]},
    {"label": "pricing_question", "vector": [0.12, 0.90, 0.10]},
    {"label": "follow_up", "vector": [0.18, 0.22, 0.88]},
]
print(classify([0.86, 0.18, 0.08], examples))

The threshold 0.78 and margin 0.05 are starting points for an evaluation, not universal truths. The numeric values in this example are illustrative vectors, not a benchmark.

Tune them against representative calls, then keep the chosen policy under version control with the label set and embedding model identifier.

For a larger label catalog, comparing every query with every example becomes wasteful. Cluster examples with k-means or use FAISS for approximate nearest-neighbor retrieval, then apply the same threshold and margin to the candidates. The acceleration changes retrieval; it should not silently remove the reject-and-review branch.

Which Runtime Is the Right Fit

There are four reasonable deployment shapes, and they solve different problems.

OpenAI's embeddings API is the direct reference point for a team already using its SDK and account controls. It has a familiar client ecosystem, so a Node.js worker can call the embeddings route and pass the returned vectors into the classifier. The trade-off is that you own the surrounding concerns: provider configuration, usage accounting, retries, vector storage, and the operational boundary between the model call and the CRM write. Its documentation is the first place to verify the current request and response contract.

Amazon Bedrock is a better fit when the organization already centralizes model access in AWS, uses IAM boundaries, and wants model choice inside an existing cloud control plane. Its advantage is governance alignment. Its cost is another abstraction layer and a model-by-model interface surface, which can make a small classifier feel heavier than it needs to be.

Google Vertex AI suits teams whose data, monitoring, and identity controls already live in Google Cloud. It offers a broad managed AI environment, but that breadth means the integration decision is tied to Google project configuration and regional policy. That can be valuable for a regulated deployment and inconvenient for a portable service.

Hugging Face Inference Providers is attractive when model portability and access to an ecosystem of open models matter more than one tightly managed provider path. It gives a team more room to compare models, but the team must be precise about provider differences, model availability, and evaluation drift. “Open” does not remove the need to own a reference set and a rollback plan.

Option Access shape Best fit Main limitation
OpenAI SDK or REST A focused embedding service with a familiar client You own routing, retries, and accounting around it
Amazon Bedrock AWS-managed APIs Teams already using AWS identity and governance More cloud configuration and model-specific surface area
Google Vertex AI Google Cloud APIs Data and controls already centered in Google Cloud Portability is tied to project and regional setup
Hugging Face Inference Providers Provider-backed inference APIs Model portability and open-model comparison Availability and provider behavior need active evaluation
Infrai OpenAI-compatible REST One key and bill across backend services A unified gateway does not choose thresholds or CRM policy

The table is a decision aid, not a leaderboard. An independent REST gateway such as Infrai can fit a team that wants one key and one bill across backend services, while retaining an OpenAI-compatible path for this call. Its public discovery surface and per-call cost and latency metadata are useful when the same Python or Node.js application grows from one AI feature into several.

The boundary is important: a unified interface does not decide the right embedding model, threshold, data retention policy, or CRM idempotency rule for you. Treat it as a routing and accounting choice, not as a substitute for evaluation.

This is why I would not rank these options with a single winner. Existing cloud controls, portability, provider breadth, and operational ownership are different constraints. For the sales-call case, the best choice is the one that lets the team replay a fixed evaluation set and explain every accepted CRM action.

Where Latency and Quality Collide

Embedding one new call is usually only half the work. The transcript may be split into useful chunks, each chunk may need a label, and the final CRM action may require agreement across chunks. Chunking too aggressively lowers latency but loses context; sending the entire call every time preserves context at a larger token and response burden.

A practical compromise is to classify a transcript summary or decision-bearing excerpts, then retain links to the source spans for audit.

Cache embeddings by a content hash plus the model identifier. This avoids recomputing unchanged reference examples and makes a notebook-to-prod promotion less surprising. Batch reference generation when the catalog changes. For bulk inputs, estimate and audit operational cost with /v1/ai/cost/estimate rather than hiding usage inside a queue. Cost is an operating signal here, not the decision criterion.

Retries need a boundary too. Retry transient embedding requests with exponential backoff and honor Retry-After when the service provides it. Do not retry the CRM write as an anonymous side effect: give that write a client-generated idempotency key, or make the consumer deduplicate by call ID and classifier version. HTTP semantics describe why a retry policy must distinguish a safe replay from a request that may have been applied already.

The service should log the classifier version, embedding model identifier, top score, runner-up score, selected label, and review decision. Avoid logging the full transcript by default; sales calls can contain financial and personal information. Keep the evidence needed to reproduce a decision without making raw conversation text the default observability payload.

One sentence belongs in the runbook: a low-confidence prediction is a review task, not a CRM action.

What Should Ship in the First Production Version

Before release, freeze a small gold set drawn from the actual CRM label definitions, with difficult borderline examples included. Split evaluation data by call rather than by sentence so fragments from one conversation cannot leak across train and test sets. Review false positives by business consequence: a wrong compliance route may deserve a stricter threshold than a missed follow-up.

Then make the operational checklist part of the design. Version the labels, examples, model, threshold, margin, and vector index together. Validate embedding dimensions before comparing vectors. Record review outcomes as new evaluation cases, but do not silently add them to training data. Watch queue age and retry counts alongside classification quality.

Finally, make the CRM action conditional on an accepted decision and an idempotent call identifier; a plausible label is not permission to mutate customer records.

The result is intentionally modest: embeddings rank meaning, while your policy decides what confidence is sufficient. That division keeps the system fast enough to operate, clear enough to audit, and honest about the cases it cannot classify safely.

References

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.