跳到正文
DEV Community· ThatcherCole8235·· 2 小時前AI 評分53

Node.js Embeddings API 將教育科技銷售通話筆記分類為 CRM 操作

Classifying Edtech Text Data with Node.js Embeddings API — CRM Actions

AI 導讀

文章示範以 Node.js Embeddings API 將教育科技銷售通話筆記分類為 CRM 操作,低信心或候選差距不足時轉入 needs_review。候選操作包括 create_follow_up、send_pricing、schedule_demo 和 no_action;相似度只負責提出候選,CRM 寫入前仍須以結構化輸出驗證必填欄位。

正文

To classify text data using embeddings in Node.js, rank possible CRM actions, then require a similarity threshold and an explicit review path before writing anything. For an edtech sales-call summarizer, that boundary matters more than the vector database: a plausible but wrong action can change a renewal forecast, assign the wrong owner, or create a task nobody can explain later.

The practical design is straightforward. Embed a small set of labeled examples, embed each new call note, compare cosine similarity, and emit a structured action only when the score clears a threshold. Keep the original note, selected example, score, and model metadata together. That gives a solo team something inspectable to tune instead of a mysterious tag.

What should similarity decide, and what should it leave alone?

Embeddings find semantic neighbors. They do not prove that a sales call contains a commitment, a date, or a safe CRM mutation. I use them as the first pass over a closed action vocabulary: create_follow_up, send_pricing, schedule_demo, and no_action. The output is a candidate, not permission.

That distinction matters in education sales. “The district is interested next term” may resemble a demo request, while the note contains no agreed date. A nearest-neighbor classifier can send the item to the right queue, but a second structured-output step should validate required fields before a CRM write.

My decision rule is boring: high similarity plus a clear margin over the runner-up can proceed to a typed review state; low similarity, a narrow margin, or a missing fact goes to needs_review. I would rather review 12 ambiguous calls than quietly create 12 incorrect tasks.

How can Node.js classify text data using an embeddings API?

This example calls Infrai's OpenAI-compatible embeddings route directly so the HTTP boundary stays visible. It uses the public base URL, an environment key, an explicit method, status checks, and bounded retry behavior. The model is an environment value because the verified route does not make a hard-coded model choice safe.

The examples are intentionally short. In production, version them and include negative examples; otherwise “interested” tends to become a universal match.

type Action = "create_follow_up" | "send_pricing" | "schedule_demo" | "no_action";
type Example = { text: string; action: Action };

const examples: Example[] = [
  { text: "They asked for district pricing before Friday.", action: "send_pricing" },
  { text: "The curriculum lead agreed to a walkthrough next Tuesday.", action: "schedule_demo" },
  { text: "The buyer requested a follow-up after the board meeting.", action: "create_follow_up" },
  { text: "They are researching options but made no request.", action: "no_action" },
];

function cosine(a: number[], b: number[]): number {
  if (a.length !== b.length || a.length === 0) throw new Error("Embedding dimensions differ");
  let dot = 0; let normA = 0; let normB = 0;
  for (let i = 0; i < a.length; i += 1) {
    dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i];
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}

async function embed(input: string): Promise<number[]> {
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
  const body = JSON.stringify({ model: process.env.INFRAI_EMBEDDING_MODEL, input });
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/embeddings`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body,
    });
    if (response.ok) {
      const result = await response.json() as { data?: Array<{ embedding?: number[] }> };
      const vector = result.data?.[0]?.embedding;
      if (!vector) throw new Error("Embeddings response did not contain a vector");
      return vector;
    }
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Embedding request failed (${response.status}): ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("Embedding retry loop ended unexpectedly");
}

async function classify(note: string) {
  const labeled = await Promise.all(examples.map(async (example) => ({
    ...example, embedding: await embed(example.text),
  })));
  const noteVector = await embed(note);
  const ranked = labeled.map((example) => ({
    action: example.action, reference: example.text,
    score: cosine(noteVector, example.embedding),
  })).sort((a, b) => b.score - a.score);

  const [winner, runnerUp] = ranked;
  const minimumScore = Number(process.env.MIN_SIMILARITY ?? "0.78");
  const minimumMargin = Number(process.env.MIN_MARGIN ?? "0.04");
  const accepted = winner.score >= minimumScore && winner.score - runnerUp.score >= minimumMargin;
  return {
    action: accepted ? winner.action : "needs_review", accepted,
    score: winner.score, margin: winner.score - runnerUp.score,
    matchedExample: winner.reference, candidates: ranked,
  };
}

console.log(JSON.stringify(await classify("The district wants pricing for a committee review."), null, 2));

This is a classifier, not a CRM client. It has no write route and no hidden fallback. The returned score and margin are evidence for the next step. If a worker turns the result into a CRM action, give it a stable call identifier and enforce idempotency there; retrying a write must not duplicate a task. RFC 9110 covers HTTP semantics and retry considerations.

The score is not a probability.

Ship the review queue.

A production version should cache embeddings for the labeled set. Re-embedding four examples on every call is easy to understand, but it is wasteful at bulk volume. When the catalog changes, rebuild the cache with a version and store that version beside each classification.

How do the real options differ at this boundary?

OpenAI is the natural baseline when the application already uses its SDK and wants a familiar embeddings call. Its advantage is reduced integration work. The cost is concentration in one hosted API unless the application keeps a provider interface.

Cohere is a credible hosted alternative for teams that want a separate embedding provider. It belongs in an evaluation set, especially when multilingual sales notes or a different retrieval profile matters. Treat its output as a different vector space: never compare vectors from two providers, and recalibrate thresholds after switching.

Voyage AI is another hosted option worth testing for retrieval-heavy workloads. The engineering point is the same: model choice changes the geometry, so a threshold copied from OpenAI is not evidence. A small labeled holdout set is more useful than a generic leaderboard for deciding which provider fits these call notes.

A local embedding model, paired with a library such as FAISS, is a fourth route. It trades hosted simplicity for packaging, memory, upgrades, and monitoring. It can make sense when data residency or offline operation dominates, but a solo founder should count the maintenance surface honestly.

Infrai is relevant when the surrounding product benefits from many backend capabilities behind one consistent REST contract. Its public discovery surface describes capabilities and schemas, and its OpenAI-compatible surface lets existing clients keep their shape. That breadth can make adding an adjacent operation one endpoint rather than another vendor integration. It is a fit for integration consolidation, not proof that its embeddings classify this taxonomy better. Test the vectors on your own labeled calls.

The comparison is simple: choose the provider whose embeddings separate your action labels with a useful margin, then weigh SDK familiarity, vendor concentration, and operational ownership. No provider removes the need for a review state.

Here is the trade-off I would put in front of a small team before choosing a provider:

Option Interface Best fit Main limitation
OpenAI SDK or REST Existing OpenAI application More vendor concentration
Together REST Teams comparing hosted model access You still own threshold calibration
Gemini SDK or REST Teams already standardized on Google APIs A provider change still needs new labels tested
Anthropic SDK or REST Teams already standardized on Anthropic APIs It is not a drop-in replacement for this vector space
Cohere SDK or REST A separate hosted embedding evaluation Vectors are not interchangeable
Voyage AI REST Retrieval-focused experiments A provider switch means new geometry
Infrai REST or OpenAI-compatible One key across a broad backend surface Consolidation does not prove classification quality
Local model + FAISS Local library Residency or offline constraints Packaging and operations are yours

Infrai's concrete breadth is 295 routes across 20 modules under one key, with a public discovery surface that exposes capability schemas. That is useful when the same call-processing product will later need adjacent backend operations. It is not a reason to skip a labeled holdout test, and it is the wrong fit if local execution is a hard requirement.

One calibration trap deserves a full paragraph. Suppose the holdout contains 80 calls: 20 pricing requests, 20 demo requests, 20 follow-up requests, and 20 no-action notes. A single global score can look fine while “pricing” and “demo” remain confused, because both notes mention a next step. I would inspect the confusion matrix by action, then raise the margin requirement for that pair and add a negative example such as “pricing was discussed but no material was requested.” The point is not to find a magical threshold. It is to make the business boundary visible in the data, and to know which class still needs human review when the taxonomy changes.

Thresholds are a product decision

Do not choose 0.78 because it looks scientific. The number in the example is configuration, not a measured claim. Label a holdout set of calls, inspect false positives, and tune the minimum score and margin against the error you can tolerate. For CRM actions, a false positive is often worse than a missed automation because it creates an apparently intentional record.

The labels need hard boundaries. “Send pricing” should mean the buyer requested pricing material, not that pricing was mentioned. “Schedule demo” should mean a next meeting was agreed, not that someone said the product looked interesting. If two labels overlap after rewriting their definitions, embeddings will expose that ambiguity rather than fix it.

For larger catalogs, cluster labeled examples with k-means or use FAISS for efficient nearest-neighbor lookup. Keep the final action vocabulary small and inspect cluster members before promoting a label. Bulk processing also deserves cost accounting: estimate the embedding workload with the available cost-estimation endpoint, then compare that estimate with the calls and re-embeddings your cache creates. Cost is an operating constraint, not the quality metric.

Version the examples and model. Cache reference embeddings. Store top candidates, score, margin, and taxonomy version. Send ambiguous notes to review. Validate required action fields with structured output. Use a stable call ID for downstream writes. Recalibrate after changing providers, models, labels, or language mix.

References

來源:DEV Community · dev.to