Embeddings: Similarity as Numbers

An embedding is a list of numbers representing text. Texts with similar meaning tend to produce vectors that are close together, which gives an application a way to rank results by similarity rather than exact keyword matches.

That is enough to build semantic search, recommendations and the retrieval step in a RAG system. The vector is not a readable explanation of the text, and similarity is not the same thing as truth; it is a ranking signal.

The basic flow

The model turns text into a vector. The application stores the vector alongside the original text, then embeds a query and compares it with the stored vectors.

The model uses context and relationships between tokens to produce the vector. The application still needs to choose sensible chunks, a distance function and a useful threshold for its domain.

Generate one with the Vercel AI SDK

The SDK exposes this through embed and embedMany.

// lib/embeddings/generate-embedding.ts
import { embed } from 'ai';
import { openai } from '@ai-sdk/openai';
import 'dotenv/config';

async function main() {
  const { embedding } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: 'The quick brown fox jumps over the lazy dog',
  });

  console.log(`Generated embedding with ${embedding.length} dimensions.`);
  console.log(embedding.slice(0, 10)); // Log the first 10 numbers
}

main();

You can also embed an array of texts in a single call using embedMany, which is much more efficient.

// lib/embeddings/generate-batch-embeddings.ts
import { embedMany } from 'ai';
import { openai } from '@ai-sdk/openai';
import 'dotenv/config';

async function main() {
  const documents = [
    'The sun rises in the east.',
    'It is a beautiful and sunny day.',
    'The stock market went up by 2% today.',
  ];

  const { embeddings } = await embedMany({
    model: openai.embedding('text-embedding-3-small'),
    values: documents,
  });

  console.log(`Generated ${embeddings.length} embeddings.`);
  console.log(embeddings.map(e => e.slice(0, 5))); // Log the first 5 numbers of each
}

main();

Measuring "Closeness": Similarity and Distance

Once you have these numerical vectors, you can compare them. The two most common methods are Cosine Similarity and Euclidean Distance.

Cosine Similarity

Cosine similarity is the most popular method for comparing text embeddings. It measures the angle between two vectors, which tells us about their orientation (i.e., their meaning) regardless of their magnitude.

  • A value of 1 means the texts are semantically identical.
  • A value of 0 means they are unrelated.
  • A value of -1 means they are semantically opposite.
// lib/embeddings/similarity.ts
function cosineSimilarity(vecA: number[], vecB: number[]): number {
  if (vecA.length !== vecB.length) {
    throw new Error('Embeddings must have the same dimensions');
  }

  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  for (let i = 0; i < vecA.length; i++) {
    dotProduct += vecA[i] * vecB[i];
    normA += vecA[i] * vecA[i];
    normB += vecB[i] * vecB[i];
  }

  if (normA === 0 || normB === 0) {
    return 0;
  }

  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

Euclidean Distance

Euclidean distance measures the straight-line distance between the tips of two vectors. Unlike cosine similarity, it's sensitive to the magnitude of the vectors. A smaller distance means the items are more similar.

This is less common for semantic search but very useful for clustering tasks, where you want to group similar items together and the "length" of the vector (which can represent things like document length or importance) matters.

// lib/embeddings/distance.ts
function euclideanDistance(vecA: number[], vecB: number[]): number {
  if (vecA.length !== vecB.length) {
    throw new Error('Embeddings must have the same dimensions');
  }

  let sumOfSquares = 0;
  for (let i = 0; i < vecA.length; i++) {
    sumOfSquares += (vecA[i] - vecB[i]) ** 2;
  }

  return Math.sqrt(sumOfSquares);
}

Practical Applications

Semantic Search

I’ll build a simple semantic search engine. It will find which document in a small in-memory "database" is most similar to a user's query using cosine similarity.

// lib/embeddings/semantic-search.ts
import { embed, embedMany } from 'ai';
import { openai } from '@ai-sdk/openai';
import 'dotenv/config';

// (Assume cosineSimilarity function is defined here)
function cosineSimilarity(vecA: number[], vecB: number[]): number {
  if (vecA.length !== vecB.length) throw new Error('Mismatched dimensions');
  let dotProduct = 0, normA = 0, normB = 0;
  for (let i = 0; i < vecA.length; i++) {
    dotProduct += vecA[i] * vecB[i];
    normA += vecA[i] ** 2;
    normB += vecB[i] ** 2;
  }
  if (normA === 0 || normB === 0) return 0;
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

async function semanticSearch() {
  const documents = [
    { id: 1, text: 'The new AI model from Google is setting records.' },
    { id: 2, text: 'Yesterday, the stock market saw a significant increase.' },
    { id: 3, text: 'Generative artificial intelligence is a rapidly growing field.' },
    { id: 4, text: 'What are the best recipes for a healthy breakfast?' },
  ];

  const query = 'What are the latest developments in AI?';

  // 1. Embed all documents and the query
  const { embeddings: docEmbeddings } = await embedMany({
    model: openai.embedding('text-embedding-3-small'),
    values: documents.map(doc => doc.text),
  });

  const { embedding: queryEmbedding } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  });

  // 2. Calculate similarities
  const similarities = docEmbeddings.map((docEmbedding, i) => ({
    id: documents[i].id,
    text: documents[i].text,
    similarity: cosineSimilarity(queryEmbedding, docEmbedding),
  }));

  // 3. Sort by similarity
  similarities.sort((a, b) => b.similarity - a.similarity);

  console.log(`Query: "${query}"\n`);
  console.log('Top 3 most similar documents:');
  similarities.slice(0, 3).forEach(result => {
    console.log(`- [${result.similarity.toFixed(3)}] ${result.text}`);
  });
}

semanticSearch();

Even though the query doesn't share many keywords with documents 1 and 3, the embeddings capture the semantic relationship and correctly identify them as the most relevant.

Content Recommendation

You can use the same principle to recommend content. If a user likes an article, you can find other articles with similar embeddings.

// lib/embeddings/recommendation.ts
async function getRecommendations(likedItemId: number, allItems: { id: number; text: string }[]) {
  // In a real app, embeddings would be pre-calculated and stored.
  const { embeddings } = await embedMany({
    model: openai.embedding('text-embedding-3-small'),
    values: allItems.map(item => item.text),
  });

  const itemEmbeddings = new Map(allItems.map((item, i) => [item.id, embeddings[i]]));
  const likedItemEmbedding = itemEmbeddings.get(likedItemId);

  if (!likedItemEmbedding) {
    throw new Error('Liked item not found');
  }

  const recommendations = allItems
    .filter(item => item.id !== likedItemId) // Exclude the liked item itself
    .map(item => ({
      ...item,
      similarity: cosineSimilarity(likedItemEmbedding, itemEmbeddings.get(item.id)!),
    }))
    .sort((a, b) => b.similarity - a.similarity);

  return recommendations.slice(0, 3);
}

async function main() {
    const articles = [
      { id: 1, text: 'A deep dive into React Server Components.' },
      { id: 2, text: 'How to optimize your Next.js application for performance.' },
      { id: 3, text: 'Getting started with server-side rendering in Vue.js.' },
      { id: 4, text: 'A guide to object-oriented programming in Python.' },
    ];

    const likedArticleId = 1;
    const recommendations = await getRecommendations(likedArticleId, articles);

    console.log(`Because you liked "${articles.find(a => a.id === likedArticleId)!.text}", you might also like:`);
    recommendations.forEach(rec => {
        console.log(`- [${rec.similarity.toFixed(3)}] ${rec.text}`);
    });
}

main();

Moving beyond memory

The in-memory examples are useful for understanding the calculation, but a real application needs durable storage and an index. A vector database—or PostgreSQL with pgvector—can search the vectors without loading the whole collection into the application.

The hard part is usually not generating the vector. It is deciding what to embed, how to update it, and how to verify that the nearest results are actually useful to users.