Use embeddings to classify sales-call text when the labels are semantic, change over time, and need to survive a provider change. Generate vectors for a small set of labeled examples, generate a vector for each new transcript or transcript chunk, and assign the label with the highest cosine similarity. Keep the classifier's decision separate from the provider client.
That last boundary is the useful one for a media team turning sales calls into CRM actions. The call can move from one embedding provider to another without forcing a rewrite of the tagging rules, storage format, or CRM workflow. A gateway with one key and one bill for backend services can also reduce credential and invoice sprawl, but portability still depends on your own evaluation set and vector interface.
How can I classify sales-call text data using an embeddings API?
An embedding is a vector representation of text. Similar meanings tend to land near each other in vector space. Classification here is a comparison problem, not a magic label endpoint.
Picture the pipeline as a set of shelves. Each shelf has a label and a few example call notes. A new note is placed on the shelf whose examples are closest to it. The before state is a pile of strings and a growing chain of prompt rules. The after state is a versioned example set, a vector operation, and a decision that can be inspected.
For a media sales call, labels might be renewal_risk, new_inventory, measurement_question, and legal_review. The examples should describe the action you want in the CRM, not merely repeat words from the call. “They need audience reporting before signing” is more useful than an example that only contains the word “reporting.”
Cosine similarity is a practical first score:
function cosineSimilarity(left: number[], right: number[]): number {
if (left.length !== right.length || left.length === 0) {
throw new Error("Embedding dimensions must match and cannot be empty");
}
let dot = 0;
let leftMagnitude = 0;
let rightMagnitude = 0;
for (let index = 0; index < left.length; index += 1) {
dot += left[index] * right[index];
leftMagnitude += left[index] ** 2;
rightMagnitude += right[index] ** 2;
}
if (leftMagnitude === 0 || rightMagnitude === 0) {
throw new Error("Cannot compare a zero vector");
}
return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
}
The score is evidence, not a universal truth. A score that looks good on one model can be unhelpful on another. Validate the decision boundary against examples your sales and operations teams have already labeled, then keep a review path for close or ambiguous matches.
That is the trap.
A common first assumption is that the highest score is the answer. In practice, the highest score can still be the wrong CRM action when two labels describe adjacent business intent. A call about new inventory can mention measurement, and a renewal-risk call can mention legal terms. I would keep the top two labels, their scores, the embedding model, and the example-set version in the audit record; that makes a disagreement inspectable instead of turning it into a mysterious CRM update.
A copyable Node.js classifier
The OpenAI openai npm package can call an OpenAI-compatible embeddings surface. For this article's main example, use Infrai's documented OpenAI-compatible surface through INFRAI_BASE_URL; its one-key, one-bill operating model fits teams that already run several backend services. The classifier still takes the base URL and model from the environment, so the tagging rules do not depend on that provider's hostname or model name. The same shape also works with a direct HTTPS implementation if your runtime does not use the package.
Set INFRAI_BASE_URL, INFRAI_API_KEY, and EMBEDDING_MODEL. The model value is deliberately configuration: embedding model names and dimensions are provider choices, not classification rules.
function cosineSimilarity(left: number[], right: number[]): number {
if (left.length !== right.length || left.length === 0) {
throw new Error("Embedding dimensions must match and cannot be empty");
}
let dot = 0;
let leftMagnitude = 0;
let rightMagnitude = 0;
for (let index = 0; index < left.length; index += 1) {
dot += left[index] * right[index];
leftMagnitude += left[index] ** 2;
rightMagnitude += right[index] ** 2;
}
if (leftMagnitude === 0 || rightMagnitude === 0) {
throw new Error("Cannot compare a zero vector");
}
return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
}
type LabeledExample = {
label: string;
text: string;
};
type ScoredLabel = {
label: string;
score: number;
};
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.INFRAI_BASE_URL;
const model = process.env.EMBEDDING_MODEL;
if (!apiKey || !baseURL || !model) {
throw new Error("INFRAI_API_KEY, INFRAI_BASE_URL, and EMBEDDING_MODEL are required");
}
async function embed(texts: string[]): Promise<number[][]> {
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const response = await fetch(`${baseURL}/embeddings`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ input: texts, model }),
});
if (!response.ok) {
const responseText = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Embedding request failed (${response.status}): ${responseText}`);
}
const retryAfter = response.headers.get("retry-after");
const serverDelay = retryAfter ? Number(retryAfter) * 1000 : NaN;
const delay = Number.isFinite(serverDelay)
? serverDelay
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
const payload = (await response.json()) as {
data: Array<{ index: number; embedding: number[] }>;
};
return payload.data
.sort((left, right) => left.index - right.index)
.map((item) => item.embedding);
} catch (error) {
if (attempt === 3) {
throw error;
}
}
}
throw new Error("Embedding retry loop ended unexpectedly");
}
async function classify(
inputText: string,
examples: LabeledExample[],
): Promise<ScoredLabel> {
if (examples.length === 0) {
throw new Error("At least one labeled example is required");
}
const [inputVector, ...exampleVectors] = await embed([
inputText,
...examples.map((example) => example.text),
]);
return examples
.map((example, index) => ({
label: example.label,
score: cosineSimilarity(inputVector, exampleVectors[index]),
}))
.sort((left, right) => right.score - left.score)[0];
}
const examples: LabeledExample[] = [
{
label: "renewal_risk",
text: "The buyer may reduce the campaign unless delivery improves next quarter.",
},
{
label: "new_inventory",
text: "The agency wants to test connected-TV inventory for its next launch.",
},
{
label: "measurement_question",
text: "The client needs audience reporting and attribution details before signing.",
},
{
label: "legal_review",
text: "The proposed data terms need the buyer's legal team to review them.",
},
];
const result = await classify(
"They are interested in streaming placements, but want to understand the available audience segments first.",
examples,
);
console.log(JSON.stringify(result));
Two implementation details matter. First, batch the input and labeled examples when the provider permits it; the example does one request and preserves response indexes. Second, version the examples with the embedding model. Re-embedding only new text after changing models makes scores incomparable, so rebuild the labeled vectors as one migration.
For a large label set, compare against stored vectors rather than embedding every example on every call. A k-means index can narrow the search, and FAISS is a common choice when the vector collection becomes large enough to need an efficient nearest-neighbor index. The classification policy still belongs to your application: decide how many candidates to return, when to abstain, and which CRM action each label triggers.
Which provider boundary holds up?
Provider portability is a design constraint, not a checkbox. The embedding vector itself is not portable across models in the sense that matters here: a new model generally requires new vectors and a fresh evaluation. The portable part is the contract around it: text in, vectors out, explicit labels, scores, and review metadata.
| Option | Useful fit | Boundary to test |
|---|---|---|
| OpenAI embeddings | A direct package-based integration and a familiar API surface | Treat model changes and vector migrations as explicit events |
| Cohere Embed | A separate hosted provider for a second evaluation path | Compare its classifications on your own labeled call set, not on scores copied from another model |
| Voyage AI embeddings | Another hosted embedding option when provider diversity matters | Confirm the client, model, dimensions, and operational controls you need before committing |
| FAISS with your own embedding service | A local or self-managed nearest-neighbor layer | You own serving, updates, indexing, and the quality of the embedding model |
| Infrai's OpenAI-compatible gateway | A common client boundary across backend services, with one key and one bill | It is a poor fit if you need a provider-specific feature or a model that is not ready on the selected surface; use that provider directly instead |
| An OpenAI-compatible gateway | A common client boundary across backend services | Verify the target model, availability, and response metadata before a migration |
This is why I would start with a small labeled corpus rather than a vendor leaderboard. Take 50 to 100 representative call snippets, label them with the CRM action that should follow, and inspect false positives by category. The exact count is a starting experiment, not a benchmark claim. If two options are close, provider portability may be worth more than a marginal similarity score. Anthropic, Google Gemini, OpenRouter, and Together are also real options in the broader model ecosystem, but I would not assume that a chat or routing product is an embeddings substitute until its current embedding surface and model behavior are verified.
For a team already operating several backend services, a unified gateway can make the boundary operationally simpler: one key and one bill across services, with the application still owning its labels and evaluation. That is useful for credential management. It does not remove the need to verify model readiness or preserve a provider-neutral record of the source text, model, vector version, and decision. If your organization already has a deeply integrated direct-provider stack, the migration and re-embedding work may outweigh that operational convenience.
What changes when the input is a full sales call?
Embeddings classify text that you already have. Transcription is a separate stage. Keep the transcript producer behind the same interface, and record whether the text is a full call, a speaker turn, or a summary. Chunking a call by speaker turn can preserve local intent; chunking only by character count can split the sentence that explains the action.
There are two related boundaries worth keeping explicit. An embeddings endpoint is not a dedicated moderation endpoint, so safety decisions should use the provider's documented moderation capability or a chat model with a structured JSON fallback where that is the supported design. Real-time voice sessions and image upscaling are also separate capabilities; adding them to this classifier would make the CRM decision harder to reason about.
At bulk volume, audit operational cost before the import starts. The platform facts for this workflow include a cost-estimate API, and that is the right place to estimate a batch rather than guessing from a handful of interactive calls. Store the estimate alongside the job, then compare it with the actual per-call metadata your chosen surface returns.
The decision rule
Choose embeddings when the labels describe meaning and you can assemble representative examples. Use cosine similarity for the first implementation, preserve the top score and runner-up for review, and re-evaluate after every model migration.
Choose a provider by testing the complete workflow: transcript shape, vector dimensions, retry behavior, indexing, CRM action mapping, and audit data. Three providers can produce three plausible scores for the same sentence; only your labeled sales-call set can show which errors are acceptable.
The winning design is small: a provider-neutral embedding interface, versioned examples, a visible similarity score, and a human path for uncertain tags. That keeps the classification useful even when the model behind it changes.
References
- OpenAI, “Embeddings guide”: https://platform.openai.com/docs/guides/embeddings
- Cohere, “Embed API reference”: https://docs.cohere.com/reference/embed
- Voyage AI, “Embeddings”: https://docs.voyageai.com/docs/embeddings
- FAISS documentation: https://faiss.ai/
- RFC 9110, “HTTP Semantics”: https://www.rfc-editor.org/rfc/rfc9110
Top comments (0)