TechWayFit
● Complete Learning Series Python & AI Engineering for .NET Developers

Working with Embeddings

Tech Buddy September 13, 2026 3 min read
Working with Embeddings

Embeddings are the foundation of modern AI search, recommendation, and RAG systems. They convert text into dense numeric vectors — lists of 768 to 3072 floating-point numbers — where semantically similar texts produce geometrically nearby vectors. This lesson covers how to generate embeddings, measure similarity, and build an efficient retrieval pipeline.

What Are Embeddings?

An embedding model takes a piece of text and outputs a fixed-size vector of floats. The key property: semantically similar texts produce similar vectors. "How do I fix a Python ImportError?" and "Python module import failing" produce vectors that are very close in the embedding space, even though they share few exact words.

from openai import OpenAI
                      
                      client = OpenAI()
                      
                      def embed_text(text: str, model: str = "text-embedding-3-small") -> list[float]:
                          """Generate an embedding for a single text."""
                          response = client.embeddings.create(
                              model=model,
                              input=text,
                          )
                          return response.data[0].embedding
                      
                      
                      def embed_batch(texts: list[str], model: str = "text-embedding-3-small") -> list[list[float]]:
                          """Generate embeddings for multiple texts in one API call."""
                          response = client.embeddings.create(
                              model=model,
                              input=texts,  # batch — more efficient than multiple single calls
                          )
                          # Response is ordered to match input
                          return [item.embedding for item in sorted(response.data, key=lambda x: x.index)]
                      
                      
                      # Example
                      query = "How do language models generate text?"
                      query_embedding = embed_text(query)
                      print(f"Embedding dimensions: {len(query_embedding)}")  # 1536 for text-embedding-3-small
                      print(f"First 5 values: {query_embedding[:5]}")

Vector Similarity: The Math

The most common similarity metric for embeddings is cosine similarity — it measures the angle between two vectors, independent of their magnitude. A score of 1.0 means identical direction (semantically the same), 0.0 means orthogonal (unrelated), -1.0 means opposite.

import numpy as np
                      from typing import Callable
                      
                      def cosine_similarity(a: list[float], b: list[float]) -> float:
                          """
                          Cosine similarity between two vectors.
                          Range: -1.0 (opposite) to 1.0 (identical).
                          """
                          a_arr = np.array(a)
                          b_arr = np.array(b)
                          dot = np.dot(a_arr, b_arr)
                          magnitude = np.linalg.norm(a_arr) * np.linalg.norm(b_arr)
                          if magnitude == 0:
                              return 0.0
                          return float(dot / magnitude)
                      
                      
                      def euclidean_distance(a: list[float], b: list[float]) -> float:
                          """Euclidean distance — smaller = more similar. Used by some vector DBs."""
                          return float(np.linalg.norm(np.array(a) - np.array(b)))
                      
                      
                      def dot_product(a: list[float], b: list[float]) -> float:
                          """
                          Dot product — fast when embeddings are normalized.
                          If vectors are unit-normalized, dot product == cosine similarity.
                          """
                          return float(np.dot(np.array(a), np.array(b)))
                      
                      
                      def normalize(vector: list[float]) -> list[float]:
                          """L2-normalize a vector — makes it a unit vector."""
                          arr = np.array(vector)
                          norm = np.linalg.norm(arr)
                          return (arr / norm).tolist() if norm > 0 else arr.tolist()
                      
                      
                      # Demo: semantic similarity
                      texts = [
                          "Python is a programming language",
                          "Python programming tutorial",
                          "Snake is a reptile",
                          "Machine learning with neural networks",
                      ]
                      
                      embeddings = embed_batch(texts)
                      query_emb = embed_text("Learn Python coding")
                      
                      scores = [
                          (text, cosine_similarity(query_emb, emb))
                          for text, emb in zip(texts, embeddings)
                      ]
                      scores.sort(key=lambda x: x[1], reverse=True)
                      for text, score in scores:
                          print(f"{score:.3f}  {text}")

Building an In-Memory Semantic Search

For small document collections (under 100K chunks), you don't need a vector database — NumPy can handle similarity search efficiently in memory:

import numpy as np
                      from dataclasses import dataclass, field
                      
                      @dataclass
                      class InMemoryVectorStore:
                          """Simple in-memory vector store for prototyping and small datasets."""
                      
                          _documents: list[str] = field(default_factory=list)
                          _metadata: list[dict] = field(default_factory=list)
                          _embeddings: list[list[float]] = field(default_factory=list)
                          _embed_fn: Callable = embed_text
                      
                          def add_documents(
                              self,
                              documents: list[str],
                              metadata: list[dict] | None = None,
                          ) -> None:
                              """Add documents and compute their embeddings."""
                              if metadata is None:
                                  metadata = [{} for _ in documents]
                      
                              # Batch embed for efficiency
                              embeddings = embed_batch(documents)
                      
                              self._documents.extend(documents)
                              self._metadata.extend(metadata)
                              self._embeddings.extend(embeddings)
                      
                          def search(
                              self,
                              query: str,
                              top_k: int = 5,
                              min_score: float = 0.0,
                          ) -> list[dict]:
                              """Find the most similar documents to a query."""
                              if not self._embeddings:
                                  return []
                      
                              query_embedding = np.array(self._embed_fn(query))
                      
                              # Matrix multiplication for all similarities at once — O(n*d) not O(n*d*n)
                              emb_matrix = np.array(self._embeddings)  # shape: (n_docs, dim)
                      
                              # Normalize both query and document embeddings
                              query_norm = query_embedding / np.linalg.norm(query_embedding)
                              doc_norms = emb_matrix / np.linalg.norm(emb_matrix, axis=1, keepdims=True)
                      
                              # All cosine similarities in one operation
                              similarities = np.dot(doc_norms, query_norm)  # shape: (n_docs,)
                      
                              # Get top_k indices sorted by similarity
                              top_indices = np.argsort(similarities)[::-1][:top_k]
                      
                              results = []
                              for idx in top_indices:
                                  score = float(similarities[idx])
                                  if score >= min_score:
                                      results.append({
                                          "document": self._documents[idx],
                                          "metadata": self._metadata[idx],
                                          "score": score,
                                      })
                      
                              return results
                      
                      
                      # Usage
                      store = InMemoryVectorStore()
                      
                      # Index your corpus
                      store.add_documents(
                          documents=[
                              "Python asyncio enables concurrent I/O operations",
                              "Pydantic validates data using Python type hints",
                              "Vector databases store and search embeddings efficiently",
                              "RAG combines retrieval with language model generation",
                          ],
                          metadata=[
                              {"source": "lesson_1.3"},
                              {"source": "lesson_1.2"},
                              {"source": "lesson_3.3"},
                              {"source": "lesson_3.1"},
                          ],
                      )
                      
                      # Search
                      results = store.search("How do I handle many API calls at once?", top_k=2)
                      for r in results:
                          print(f"Score: {r['score']:.3f} | {r['document']}")

Embedding Models Comparison

Model Dimensions Max Tokens Speed Best For
text-embedding-3-small 1536 8191 Fast Production RAG, general use
text-embedding-3-large 3072 8191 Slower High-accuracy retrieval
sentence-transformers/all-MiniLM-L6-v2 384 256 Very fast Local/offline, high volume
BAAI/bge-large-en-v1.5 1024 512 Moderate Open-source, competitive quality

Batch Embedding with Rate Limit Handling

Embedding a whole corpus means thousands of API calls, so doing it one at a time is painfully slow and doing it all at once trips rate limits. The production pattern batches multiple texts per request (the embeddings API accepts a list), runs several batches concurrently with a semaphore to cap parallelism, and retries on failure. This combines the concurrency from Lesson 1.3 with the resilience from Lesson 2.1.

import asyncio
                      import openai
                      from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
                      
                      async_openai = openai.AsyncOpenAI()
                      
                      
                      @retry(
                          retry=retry_if_exception_type(openai.RateLimitError),
                          wait=wait_exponential(multiplier=2, min=2, max=30),
                          stop=stop_after_attempt(5),
                      )
                      async def embed_batch_async(
                          texts: list[str],
                          model: str = "text-embedding-3-small",
                      ) -> list[list[float]]:
                          """Async batch embedding with retry."""
                          response = await async_openai.embeddings.create(model=model, input=texts)
                          return [item.embedding for item in sorted(response.data, key=lambda x: x.index)]
                      
                      
                      async def embed_corpus(
                          documents: list[str],
                          batch_size: int = 100,
                          max_concurrent: int = 5,
                      ) -> list[list[float]]:
                          """Embed a large corpus concurrently in batches."""
                          semaphore = asyncio.Semaphore(max_concurrent)
                      
                          async def embed_one_batch(batch: list[str]) -> list[list[float]]:
                              async with semaphore:
                                  return await embed_batch_async(batch)
                      
                          batches = [documents[i:i+batch_size] for i in range(0, len(documents), batch_size)]
                          batch_results = await asyncio.gather(*[embed_one_batch(b) for b in batches])
                      
                          # Flatten batch results
                          return [emb for batch in batch_results for emb in batch]

Key Takeaways

  • Embeddings are fixed-size float vectors where similar texts produce geometrically nearby vectors — the basis of semantic search
  • Always batch embed (pass multiple texts in one API call) — it's 5–10x more efficient than single-text calls
  • Cosine similarity is the standard metric: 1.0 = identical direction, 0.0 = unrelated. Always normalize vectors first for dot-product search
  • For small datasets (<100K chunks), NumPy matrix multiplication gives you fast similarity search without a vector database
  • Use async embedding with a Semaphore for large corpora — respect the API's rate limits while maximizing throughput
  • Consider dimension reduction for large-scale production: OpenAI's models can output 256/512 dimensions instead of 1536 for faster search with modest quality trade-off

Comments

Loading comments…

Leave a comment