Lesson 9: Embeddings Deep Dive | AI Engineering
Module 3: RAG Pipeline

Embeddings Deep Dive

Transform text into mathematical vectors that capture meaning, enabling semantic search and similarity computations at scale.

Lesson 9 ~25 min Intermediate

What Are Embeddings?

Core Idea

An embedding is a dense vector representation of text (or images, audio, etc.) in a continuous vector space. Words and sentences with similar meanings are mapped to nearby points in this space. Unlike sparse representations (like one-hot encoding with 50,000+ dimensions), embeddings compress meaning into 256–3072 dimensions.

Think of embeddings as coordinates in a "meaning space." Just as GPS coordinates tell you physical proximity, embedding vectors tell you semantic proximity. "King" is close to "Queen" and "Monarch" but far from "Bicycle."

Words in 2D Embedding Space (Simplified)

Dimension 1 (abstract concept) Dimension 2 Royalty king queen monarch Animals cat dog puppy Technology computer software algorithm Food pizza pasta

The Embedding Pipeline

Every embedding workflow follows the same pattern: raw text goes in, a fixed-size numeric vector comes out.

Key Properties

  • Fixed dimensionality: Regardless of input length, output is always the same size (e.g., 1536-d for OpenAI text-embedding-3-small)
  • Semantic preservation: Similar meanings → similar vectors
  • Compositionality: Sentence embeddings capture relationships between words, not just individual word meanings
  • Language-agnostic: Many models map "dog" (English) and "chien" (French) to nearby vectors

Distance Metrics: Measuring Similarity

Once you have vectors, you need to compare them. The three main metrics:

Metric Formula Range Best For
Cosine Similarity cos(θ) = (A·B) / (‖A‖·‖B‖) [-1, 1] Text similarity (direction matters, not magnitude)
Euclidean Distance ‖A - B‖₂ [0, ∞) When absolute position in space matters
Dot Product A · B = Σ(aᵢ × bᵢ) (-∞, ∞) When vectors are normalized (same as cosine)
💡 Rule of Thumb: For text embeddings, cosine similarity is almost always the right choice. OpenAI embeddings are normalized, so dot product and cosine are equivalent.
import numpy as np

def cosine_similarity(a: list[float], b: list[float]) -> float:
    """Calculate cosine similarity between two vectors."""
    a, b = np.array(a), np.array(b)
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

def euclidean_distance(a: list[float], b: list[float]) -> float:
    """Calculate Euclidean distance between two vectors."""
    a, b = np.array(a), np.array(b)
    return np.linalg.norm(a - b)

# Example
vec_king = [0.2, 0.8, 0.5, 0.1]
vec_queen = [0.21, 0.79, 0.48, 0.12]
vec_car = [0.9, 0.1, 0.3, 0.7]

print(f"king ↔ queen: {cosine_similarity(vec_king, vec_queen):.4f}")  # ~0.999
print(f"king ↔ car:   {cosine_similarity(vec_king, vec_car):.4f}")    # ~0.56

Embedding Models Comparison

Choosing the right embedding model is critical. Here's how the major options compare:

Model Provider Dimensions Max Tokens Cost (per 1M tokens) Best For
text-embedding-3-small OpenAI 1536 8191 $0.02 General purpose, cost-effective
text-embedding-3-large OpenAI 3072 8191 $0.13 Max quality, large-scale retrieval
all-MiniLM-L6-v2 Sentence Transformers 384 256 Free (local) Fast, lightweight, on-device
all-mpnet-base-v2 Sentence Transformers 768 384 Free (local) Best open-source quality
embed-english-v3.0 Cohere 1024 512 $0.10 Search-optimized, input types
voyage-large-2 Voyage AI 1536 16000 $0.12 Code + long documents
⚡ Performance Note: Dimensionality isn't everything. A well-trained 384-d model (MiniLM) can outperform a poorly-trained 1536-d model on domain-specific tasks. Always benchmark on YOUR data.

Batch Embedding with OpenAI

In production, you'll embed hundreds or thousands of texts. Here's the efficient approach:

from openai import OpenAI
import numpy as np
from typing import List

client = OpenAI()

def get_embeddings(texts: List[str], model: str = "text-embedding-3-small") -> List[List[float]]:
    """Batch embed texts using OpenAI API.
    
    The API supports up to 2048 texts per request.
    For larger batches, chunk into groups.
    """
    # Remove newlines (they degrade quality)
    texts = [t.replace("\n", " ").strip() for t in texts]
    
    response = client.embeddings.create(
        input=texts,
        model=model
    )
    
    # Return embeddings in input order
    return [item.embedding for item in response.data]


def batch_embed_large(texts: List[str], batch_size: int = 1000) -> List[List[float]]:
    """Handle large document collections with batching."""
    all_embeddings = []
    
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        embeddings = get_embeddings(batch)
        all_embeddings.extend(embeddings)
        print(f"Embedded {min(i + batch_size, len(texts))}/{len(texts)}")
    
    return all_embeddings


# Usage: Semantic similarity search
documents = [
    "Python is a high-level programming language",
    "Machine learning models require training data",
    "The cat sat on the mat",
    "Deep learning uses neural networks with many layers",
    "JavaScript runs in web browsers",
    "Natural language processing analyzes human text",
]

# Embed all documents
doc_embeddings = get_embeddings(documents)

# Embed a query
query = "How do neural networks learn?"
query_embedding = get_embeddings([query])[0]

# Calculate similarities
similarities = [
    cosine_similarity(query_embedding, doc_emb) 
    for doc_emb in doc_embeddings
]

# Rank by similarity
ranked = sorted(
    zip(documents, similarities), 
    key=lambda x: x[1], 
    reverse=True
)

print("\nQuery:", query)
print("\nResults (ranked by similarity):")
for doc, score in ranked[:3]:
    print(f"  [{score:.4f}] {doc}")

# Output:
#   [0.8234] Deep learning uses neural networks with many layers
#   [0.7891] Machine learning models require training data
#   [0.7102] Natural language processing analyzes human text

Local Embeddings with Sentence Transformers

For privacy-sensitive data or to avoid API costs, run embeddings locally:

from sentence_transformers import SentenceTransformer
import numpy as np

# Load model (downloads ~90MB on first use)
model = SentenceTransformer('all-MiniLM-L6-v2')

# Embed texts (runs on CPU or GPU)
sentences = [
    "Embeddings capture semantic meaning",
    "Vector representations encode text as numbers",
    "The weather is sunny today",
]

# Returns numpy array of shape (3, 384)
embeddings = model.encode(sentences, show_progress_bar=True)

print(f"Shape: {embeddings.shape}")  # (3, 384)
print(f"Type: {type(embeddings)}")   # numpy.ndarray

# Similarity between first two (semantically related)
sim_12 = np.dot(embeddings[0], embeddings[1]) / (
    np.linalg.norm(embeddings[0]) * np.linalg.norm(embeddings[1])
)
print(f"Semantic vs Vector: {sim_12:.4f}")  # ~0.72

# Similarity between first and third (unrelated)
sim_13 = np.dot(embeddings[0], embeddings[2]) / (
    np.linalg.norm(embeddings[0]) * np.linalg.norm(embeddings[2])
)
print(f"Semantic vs Weather: {sim_13:.4f}")  # ~0.15

Visualizing Embeddings: PCA & t-SNE

High-dimensional embeddings can be projected to 2D/3D for visualization. This helps you verify that your embeddings cluster semantically related content.

Two Main Approaches

  • PCA (Principal Component Analysis): Linear projection preserving maximum variance. Fast, deterministic, but may miss non-linear structure.
  • t-SNE (t-distributed Stochastic Neighbor Embedding): Non-linear, preserves local neighborhood structure. Better visual clusters, but slower and non-deterministic.
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

# Assume `embeddings` is shape (N, 1536) from OpenAI
# and `labels` is a list of category labels

# PCA: reduce to 2D
pca = PCA(n_components=2)
coords_pca = pca.fit_transform(embeddings)
print(f"Variance explained: {pca.explained_variance_ratio_.sum():.2%}")

# t-SNE: reduce to 2D (better for visualization)
tsne = TSNE(n_components=2, perplexity=30, random_state=42)
coords_tsne = tsne.fit_transform(embeddings)

# Plot t-SNE results
plt.figure(figsize=(10, 8))
categories = list(set(labels))
colors = plt.cm.tab10(range(len(categories)))

for cat, color in zip(categories, colors):
    mask = [l == cat for l in labels]
    plt.scatter(
        coords_tsne[mask, 0], 
        coords_tsne[mask, 1], 
        c=[color], label=cat, alpha=0.7
    )

plt.legend()
plt.title("Document Embeddings (t-SNE)")
plt.savefig("embedding_clusters.png", dpi=150)
⚠️ Caution: t-SNE is for visualization only. The distances in the 2D plot are NOT proportional to actual high-dimensional distances. Don't use t-SNE coordinates for similarity calculations.

Embedding Best Practices

🔑 Key Principles

  • Preprocess text: Remove excessive whitespace, normalize unicode, strip irrelevant boilerplate
  • Respect token limits: Text beyond the model's max tokens is silently truncated — chunk first!
  • Match query/doc style: If you embed docs as paragraphs, query with full questions (not keywords)
  • Batch for efficiency: Single API call with 100 texts is cheaper and faster than 100 individual calls
  • Cache embeddings: Store computed embeddings — never re-embed unchanged content
  • Use input types (Cohere): Some models accept "search_document" vs "search_query" for asymmetric search
  • Dimensionality reduction: OpenAI's v3 models support dimensions parameter to truncate while preserving quality
# OpenAI's native dimensionality reduction (v3 models only)
response = client.embeddings.create(
    input="Hello world",
    model="text-embedding-3-large",
    dimensions=256  # Reduce from 3072 → 256 (saves storage + search speed)
)

# The returned vector is already normalized at the lower dimension
embedding = response.data[0].embedding
print(len(embedding))  # 256

🛠️ Mini-Project: Semantic Search Over Notes

Build a local semantic search engine over your personal notes or documents.

Steps:

  1. Create a folder with 10–20 text/markdown files (meeting notes, articles, ideas)
  2. Read all files and embed each one using OpenAI or Sentence Transformers
  3. Store embeddings in a JSON file alongside metadata (filename, first 100 chars)
  4. Build a search function: embed query → compute cosine similarity against all docs → return top-3
  5. Add a CLI interface that accepts a natural language query and prints ranked results
  6. Bonus: Visualize your note embeddings with t-SNE to discover hidden topic clusters
"""Semantic Search Over Notes - Starter Code"""
import json
import os
from pathlib import Path
from openai import OpenAI

client = OpenAI()
NOTES_DIR = "./my_notes"
INDEX_FILE = "./notes_index.json"

def build_index():
    """Embed all notes and save index."""
    notes = []
    for filepath in Path(NOTES_DIR).glob("*.md"):
        content = filepath.read_text()
        notes.append({
            "path": str(filepath),
            "content": content,
            "preview": content[:100]
        })
    
    # Batch embed all note contents
    texts = [n["content"] for n in notes]
    embeddings = get_embeddings(texts)
    
    # Save index
    index = [
        {**note, "embedding": emb} 
        for note, emb in zip(notes, embeddings)
    ]
    
    with open(INDEX_FILE, "w") as f:
        json.dump(index, f)
    
    print(f"Indexed {len(index)} notes")
    return index

def search(query: str, top_k: int = 3):
    """Search notes by semantic similarity."""
    # Load index
    with open(INDEX_FILE) as f:
        index = json.load(f)
    
    # Embed query
    query_emb = get_embeddings([query])[0]
    
    # Rank by similarity
    results = []
    for note in index:
        score = cosine_similarity(query_emb, note["embedding"])
        results.append((note["path"], note["preview"], score))
    
    results.sort(key=lambda x: x[2], reverse=True)
    return results[:top_k]

if __name__ == "__main__":
    import sys
    if "--build" in sys.argv:
        build_index()
    else:
        query = input("Search: ")
        for path, preview, score in search(query):
            print(f"\n[{score:.3f}] {path}")
            print(f"  {preview}...")

📋 Key Takeaways

  • Embeddings map text to dense vectors where semantic similarity = geometric proximity
  • Cosine similarity is the standard metric for comparing text embeddings
  • OpenAI's text-embedding-3-small offers best cost/quality ratio; local models are free but less capable
  • Always batch your embedding calls and cache results
  • Embeddings are the foundation of RAG — the quality of your embeddings determines retrieval quality