Cosine Similarity and Vector Databases
Cosine Similarity measures how similar two vectors are by calculating the cosine of the angle between them, regardless of their magnitudes. In vector databases, this becomes a powerful tool for finding semantically similar content.
The mathematical formula is:
\[\displaylines{ \mathit{cosine\_similarity}(A, B) = (A · B) / (||A|| × ||B||) }\]Dot Product $(A · B)$: sums the products of corresponding dimensions. If you have vectors
\[A = [a₁, a₂, a₃] and B = [b₁, b₂, b₃]\]then \(A · B = a₁b₁ + a₂b₂ + a₃b₃\)
The dot product captures how much the vectors point in the same direction.
Vector Magnitude \((\Vert{A}\Vert)\): This is the length of the vector, calculated as \(√(a₁² + a₂² + a₃²)\). It represents how “strong” or “intense” the vector is.
import math
def cosine_similarity(vector1, vector2):
"""
Calculate cosine similarity between two vectors
Formula: cos(θ) = (A · B) / (||A|| × ||B||)
Args:
vector1: List of numbers
vector2: List of numbers
Returns:
Float between -1 and 1
"""
# Check if vectors have same length
if len(vector1) != len(vector2):
raise ValueError("Vectors must have the same length")
# Calculate dot product
dot_product = sum(a * b for a, b in zip(vector1, vector2))
# Calculate magnitudes
magnitude1 = math.sqrt(sum(x * x for x in vector1))
magnitude2 = math.sqrt(sum(x * x for x in vector2))
# Avoid division by zero
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
# Calculate cosine similarity
return dot_product / (magnitude1 * magnitude2)
# Example usage
if __name__ == "__main__":
# Test vectors
vec1 = [1, 2, 3, 4, 5]
vec2 = [2, 4, 6, 8, 10]
vec3 = [1, 0, -1, 0, 1]
print(f"Vector 1: {vec1}")
print(f"Vector 2: {vec2}")
print(f"Vector 3: {vec3}")
print()
print(f"Cosine similarity (vec1, vec2): {cosine_similarity(vec1, vec2):.4f}")
print(f"Cosine similarity (vec1, vec3): {cosine_similarity(vec1, vec3):.4f}")
print(f"Cosine similarity (vec2, vec3): {cosine_similarity(vec2, vec3):.4f}")
The Division: By dividing the dot product by the product of magnitudes, we normalize the result to fall between -1 and 1. This removes the effect of vector length and focuses purely on direction.
In vector databases, this is crucial because you want to find documents or embeddings that are conceptually similar, not just those with similar magnitudes. For example, a short tweet and a long article about the same topic should have high cosine similarity even though their embedding vectors might have very different magnitudes.
The result ranges from -1 (completely opposite) to 1 (identical direction), with 0 meaning perpendicular (no similarity). In practice, most semantic embeddings produce positive values, so you’re typically working in the 0 to 1 range where higher values indicate greater similarity.
This makes cosine similarity ideal for tasks like semantic search, recommendation systems, and clustering in high-dimensional embedding spaces where the angle between vectors captures meaningful relationships better than raw distance measures.
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# Manual implementation of cosine similarity
def calculate_cosine_similarity(vec1, vec2):
# Calculate dot product
dot_product = np.dot(vec1, vec2)
# Calculate magnitudes (norms)
norm_vec1 = np.linalg.norm(vec1)
norm_vec2 = np.linalg.norm(vec2)
# Calculate cosine similarity
cosine_sim = dot_product / (norm_vec1 * norm_vec2)
return cosine_sim
# Example vectors (could represent document embeddings)
doc1 = np.array([1, 2, 3, 4, 5])
doc2 = np.array([2, 4, 6, 8, 10]) # Similar direction, different magnitude
doc3 = np.array([1, 0, -1, 0, 1]) # Different direction
print("Manual calculation:")
print(f"Similarity between document 1 and document 2: {calculate_cosine_similarity(doc1, doc2):.3f}")
print(f"Similarity between document 1 and document 3: {calculate_cosine_similarity(doc1, doc3):.3f}")
# Using sklearn for comparison
print("\nUsing sklearn:")
vectors = np.array([doc1, doc2, doc3])
similarity_matrix = cosine_similarity(vectors)
print(f"Similarity matrix:\n{similarity_matrix}")
# Simple vector store example
class SimpleVectorStore:
def __init__(self):
self.vectors = []
self.metadata = []
def add_document(self, vector, metadata):
self.vectors.append(vector)
self.metadata.append(metadata)
def search(self, query_vector, top_k=3):
if not self.vectors:
return []
# Calculate similarities with all stored vectors
similarities = []
for i, stored_vector in enumerate(self.vectors):
sim = calculate_cosine_similarity(query_vector, stored_vector)
similarities.append((sim, i, self.metadata[i]))
# Sort by similarity (highest first) and return top_k
similarities.sort(key=lambda x: x[0], reverse=True)
return similarities[:top_k]
# Example usage of the vector store
store = SimpleVectorStore()
# Add some "documents" (represented as vectors)
store.add_document(np.array([1, 2, 3, 4, 5]), "Document about AI")
store.add_document(np.array([2, 4, 6, 8, 10]), "Document about machine learning")
store.add_document(np.array([1, 0, -1, 0, 1]), "Document about cooking")
store.add_document(np.array([5, 4, 3, 2, 1]), "Document about data science")
# Search with a query vector
query = np.array([1.5, 3, 4.5, 6, 7.5])
results = store.search(query, top_k=2)
print("\nVector store search results:")
for similarity, index, metadata in results:
print(f"Similarity: {similarity:.3f} - {metadata}")
The output shows that document 1 and document 2 have very high similarity (close to 1.0) because they point in nearly the same direction, while doc1 and doc3 have lower similarity due to different orientations. This illustrates how cosine similarity captures semantic relationships regardless of magnitude differences.
Cosine similarity plays several critical roles in LLMs and decoder-style models, both during training and inference.
Attention Mechanisms
In transformer architectures, attention weights are computed using scaled dot-product attention, which is essentially a form of cosine similarity. The query and key vectors are compared to determine how much attention each token should pay to every other token. The similarity calculation helps the model focus on relevant parts of the input sequence.
# Simplified attention calculation
attention_scores = torch.matmul(query, key.transpose(-2, -1)) / sqrt(d_k)
# This dot product is related to cosine similarity
The normalization in attention mechanisms serves a similar purpose to cosine similarity’s magnitude normalization, ensuring that vector lengths don’t dominate the similarity calculations.
Embedding Similarity and Retrieval
LLMs use cosine similarity extensively when working with embeddings. For semantic search, the model embeds both queries and documents into the same vector space, then uses cosine similarity to find the most relevant matches. This is fundamental to retrieval-augmented generation (RAG) systems.
When you ask an LLM a question that requires external knowledge, the system often embeds your query, searches a vector database using cosine similarity, and retrieves relevant context to include in the response generation.
Token Prediction and Vocabulary Scoring
During text generation, decoder models compute similarity between the current hidden state and all possible token embeddings in the vocabulary. The output layer essentially performs a similarity calculation (often using dot product, which relates to cosine similarity) to determine which tokens are most likely to come next.
Fine-tuning and Alignment
Cosine similarity helps measure how much model representations change during fine-tuning. Researchers use it to track whether fine-tuning preserves general knowledge while adapting to specific tasks. It’s also used in alignment techniques to ensure that model outputs remain consistent with intended behaviors.
Semantic Clustering and Analysis
LLMs use cosine similarity to group semantically similar concepts. This helps in understanding how the model organizes knowledge internally and can reveal biases or gaps in understanding. For instance, researchers might examine how similar the model considers different demographic groups or concepts.
Knowledge Distillation
When training smaller models to mimic larger ones (knowledge distillation), cosine similarity between the hidden representations of teacher and student models helps ensure the smaller model captures similar semantic relationships.
In-Context Learning
The model’s ability to learn from examples within a prompt partly relies on recognizing patterns through similarity measures. Cosine similarity helps the model identify when current context resembles patterns it has seen before, enabling few-shot learning capabilities.
Memory and Retrieval Mechanisms
Some advanced architectures incorporate explicit memory components that use cosine similarity for retrieval. The model can store and later retrieve relevant information based on similarity to current context, extending its effective context length.
The key insight is that cosine similarity captures semantic relationships while being invariant to magnitude differences. This is crucial in LLMs because the absolute values of embeddings matter less than their relative positions in the semantic space. Two concepts that are semantically related should have high cosine similarity regardless of how “intense” or “frequent” they are in the training data.
This mathematical foundation enables LLMs to perform complex reasoning, maintain coherent conversations, and generate contextually appropriate responses by continuously computing and leveraging these similarity relationships throughout their processing.
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from torch.utils.data import DataLoader, Dataset
# 1. Cosine Similarity Loss for Training Embeddings
class CosineSimilarityLoss(nn.Module):
def __init__(self, margin=0.5):
super().__init__()
self.margin = margin
def forward(self, anchor, positive, negative):
"""
Triplet loss using cosine similarity
anchor: current word embedding
positive: context word (should be similar)
negative: random word (should be dissimilar)
"""
pos_sim = F.cosine_similarity(anchor, positive, dim=1)
neg_sim = F.cosine_similarity(anchor, negative, dim=1)
# We want positive similarity to be higher than negative by margin
loss = torch.clamp(neg_sim - pos_sim + self.margin, min=0.0)
return loss.mean()
# 2. Attention Mechanism Using Cosine Similarity
class CosineAttention(nn.Module):
def __init__(self, embed_dim):
super().__init__()
self.embed_dim = embed_dim
self.query_proj = nn.Linear(embed_dim, embed_dim)
self.key_proj = nn.Linear(embed_dim, embed_dim)
self.value_proj = nn.Linear(embed_dim, embed_dim)
def forward(self, x):
batch_size, seq_len, embed_dim = x.shape
# Project to query, key, value
Q = self.query_proj(x) # [batch, seq_len, embed_dim]
K = self.key_proj(x)
V = self.value_proj(x)
# Compute cosine similarity between queries and keys
# Normalize Q and K to unit vectors
Q_norm = F.normalize(Q, p=2, dim=-1)
K_norm = F.normalize(K, p=2, dim=-1)
# Cosine similarity = normalized dot product
attention_scores = torch.matmul(Q_norm, K_norm.transpose(-2, -1))
# Apply softmax to get attention weights
attention_weights = F.softmax(attention_scores, dim=-1)
# Apply attention to values
output = torch.matmul(attention_weights, V)
return output, attention_weights
# 3. Contrastive Learning for Language Model Pre-training
class ContrastiveLM(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.transformer = nn.TransformerEncoder(
nn.TransformerEncoderLayer(embed_dim, nhead=8, dim_feedforward=hidden_dim),
num_layers=6
)
self.projection_head = nn.Linear(embed_dim, embed_dim)
def forward(self, input_ids):
# Get embeddings and pass through transformer
x = self.embedding(input_ids)
x = self.transformer(x)
# Use [CLS] token representation (first token)
sentence_embedding = x[:, 0, :]
# Project to contrastive learning space
projected = self.projection_head(sentence_embedding)
projected = F.normalize(projected, p=2, dim=1) # L2 normalize
return projected
def contrastive_loss(embeddings, temperature=0.1):
"""
InfoNCE loss using cosine similarity
Used in models like SimCSE for learning sentence representations
"""
batch_size = embeddings.shape[0]
# Compute cosine similarities between all pairs
similarity_matrix = F.cosine_similarity(
embeddings.unsqueeze(1),
embeddings.unsqueeze(0),
dim=2
)
# Scale by temperature
similarity_matrix = similarity_matrix / temperature
# Create labels (diagonal elements are positive pairs)
labels = torch.arange(batch_size, device=embeddings.device)
# Compute cross-entropy loss
loss = F.cross_entropy(similarity_matrix, labels)
return loss
# 4. Knowledge Distillation Using Cosine Similarity
class DistillationLoss(nn.Module):
def __init__(self, temperature=3.0, alpha=0.7):
super().__init__()
self.temperature = temperature
self.alpha = alpha
def forward(self, student_logits, teacher_logits, true_labels):
# Standard cross-entropy loss
ce_loss = F.cross_entropy(student_logits, true_labels)
# Distillation loss using cosine similarity between representations
student_probs = F.softmax(student_logits / self.temperature, dim=1)
teacher_probs = F.softmax(teacher_logits / self.temperature, dim=1)
# Cosine similarity between probability distributions
cosine_sim = F.cosine_similarity(student_probs, teacher_probs, dim=1)
distill_loss = 1 - cosine_sim.mean() # Convert similarity to loss
# Combined loss
total_loss = self.alpha * distill_loss + (1 - self.alpha) * ce_loss
return total_loss
# 5. Training Loop Example
def train_contrastive_model():
# Initialize model and optimizer
model = ContrastiveLM(vocab_size=30000, embed_dim=768, hidden_dim=3072)
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
# Dummy training loop
for epoch in range(5):
model.train()
total_loss = 0
# Simulate batch of sentence pairs (for contrastive learning)
for batch_idx in range(100): # 100 batches per epoch
# Simulate input (batch of tokenized sentences)
batch_size = 32
seq_len = 128
input_ids = torch.randint(0, 30000, (batch_size, seq_len))
# Forward pass
embeddings = model(input_ids)
# Compute contrastive loss
loss = contrastive_loss(embeddings)
# Backward pass
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / 100
print(f"Epoch {epoch+1}, Average Loss: {avg_loss:.4f}")
# 6. Similarity-based Evaluation During Training
def evaluate_similarity_tasks(model, test_pairs):
"""
Evaluate model on semantic similarity tasks during training
"""
model.eval()
similarities = []
with torch.no_grad():
for sentence1, sentence2, human_score in test_pairs:
# Get embeddings for both sentences
emb1 = model(sentence1.unsqueeze(0))
emb2 = model(sentence2.unsqueeze(0))
# Compute cosine similarity
sim = F.cosine_similarity(emb1, emb2, dim=1).item()
similarities.append((sim, human_score))
# Compute correlation with human judgments
model_scores = [s[0] for s in similarities]
human_scores = [s[1] for s in similarities]
correlation = np.corrcoef(model_scores, human_scores)[0, 1]
return correlation
# 7. Regularization Using Cosine Similarity
class CosineRegularizer(nn.Module):
def __init__(self, lambda_reg=0.01):
super().__init__()
self.lambda_reg = lambda_reg
def forward(self, embeddings):
"""
Encourage diversity in embeddings by penalizing high cosine similarities
"""
# Compute pairwise cosine similarities
norm_embeddings = F.normalize(embeddings, p=2, dim=1)
similarity_matrix = torch.matmul(norm_embeddings, norm_embeddings.T)
# Zero out diagonal (self-similarities)
similarity_matrix = similarity_matrix - torch.diag(torch.diag(similarity_matrix))
# Penalize high similarities (encourage diversity)
reg_loss = self.lambda_reg * similarity_matrix.abs().mean()
return reg_loss
# Example usage in training
if __name__ == "__main__":
print("Training contrastive language model with cosine similarity...")
train_contrastive_model()
# Example of using cosine attention
print("\nTesting cosine attention mechanism...")
attention_layer = CosineAttention(embed_dim=768)
sample_input = torch.randn(2, 10, 768) # batch_size=2, seq_len=10, embed_dim=768
output, weights = attention_layer(sample_input)
print(f"Attention output shape: {output.shape}")
print(f"Attention weights shape: {weights.shape}")
This code demonstrates several key applications:
Contrastive Learning: The InfoNCE loss trains models to produce similar embeddings for related sentences and dissimilar embeddings for unrelated ones, using cosine similarity as the core metric.
Attention Mechanisms: Shows how cosine similarity can replace scaled dot-product attention, often providing more stable training dynamics.
Knowledge Distillation: Uses cosine similarity between teacher and student model outputs to transfer knowledge more effectively than traditional KL divergence.
Regularization: Encourages embedding diversity by penalizing high cosine similarities between different examples.
Evaluation: Demonstrates how cosine similarity is used to evaluate model performance on semantic similarity benchmarks during training.
These techniques are fundamental to training modern language models, helping them learn meaningful semantic representations and maintain stable training dynamics. The key insight is that cosine similarity’s magnitude invariance makes it particularly suitable for high-dimensional embedding spaces where relative relationships matter more than absolute values.
How RAG Works
The RAG pipeline has several stages where cosine similarity plays a crucial role:
1. Document Indexing: External documents are converted into embeddings using an encoder model and stored in a vector database with their cosine similarity optimized for fast search.
2. Query Processing: When a user asks a question, it’s encoded into the same embedding space as the documents.
3. Retrieval: Cosine similarity is computed between the query embedding and all document embeddings to find the most relevant passages.
4. Augmentation: The retrieved documents are combined with the original query to create an enriched prompt.
5. Generation: The language model generates a response using both its parametric knowledge and the retrieved context.
Cosine Similarity’s Role in Each Stage
In the retrieval stage, cosine similarity is preferred over other distance metrics because it captures semantic similarity regardless of document length or embedding magnitude. Two documents about the same topic should have high cosine similarity even if one is much longer than the other.
The key insight is that cosine similarity measures the angle between vectors, focusing on direction rather than magnitude. This means semantically similar content will cluster together in the embedding space regardless of factors like document length, word frequency, or writing style.
Here’s a comprehensive code example showing how cosine similarity powers a RAG system:
import torch
import torch.nn.functional as F
import numpy as np
from transformers import AutoTokenizer, AutoModel
import faiss
from typing import List, Tuple
import json
class RAGSystem:
def __init__(self, encoder_model_name="sentence-transformers/all-MiniLM-L6-v2"):
# Initialize encoder for creating embeddings
self.tokenizer = AutoTokenizer.from_pretrained(encoder_model_name)
self.encoder = AutoModel.from_pretrained(encoder_model_name)
self.encoder.eval()
# Vector store for efficient similarity search
self.vector_store = None
self.documents = []
self.embeddings = []
def encode_text(self, texts: List[str]) -> torch.Tensor:
"""Convert text to embeddings using the encoder model"""
encoded = self.tokenizer(texts, padding=True, truncation=True,
return_tensors='pt', max_length=512)
with torch.no_grad():
outputs = self.encoder(**encoded)
# Use mean pooling of token embeddings
embeddings = outputs.last_hidden_state.mean(dim=1)
# Normalize for cosine similarity
embeddings = F.normalize(embeddings, p=2, dim=1)
return embeddings
def build_index(self, documents: List[str]):
"""Build vector index from documents"""
print(f"Indexing {len(documents)} documents...")
# Store documents
self.documents = documents
# Create embeddings in batches
batch_size = 32
all_embeddings = []
for i in range(0, len(documents), batch_size):
batch = documents[i:i + batch_size]
batch_embeddings = self.encode_text(batch)
all_embeddings.append(batch_embeddings)
# Concatenate all embeddings
self.embeddings = torch.cat(all_embeddings, dim=0)
# Build FAISS index for fast cosine similarity search
embedding_dim = self.embeddings.shape[1]
# Use inner product index (equivalent to cosine similarity for normalized vectors)
self.vector_store = faiss.IndexFlatIP(embedding_dim)
self.vector_store.add(self.embeddings.numpy())
print(f"Index built with {len(documents)} documents")
def retrieve_similar_docs(self, query: str, top_k: int = 5) -> List[Tuple[str, float]]:
"""Retrieve most similar documents using cosine similarity"""
if self.vector_store is None:
raise ValueError("Index not built. Call build_index() first.")
# Encode query
query_embedding = self.encode_text([query])
# Search using cosine similarity (inner product on normalized vectors)
similarities, indices = self.vector_store.search(
query_embedding.numpy(), top_k
)
# Return documents with similarity scores
results = []
for i, (idx, sim) in enumerate(zip(indices[0], similarities[0])):
if idx != -1: # Valid index
results.append((self.documents[idx], float(sim)))
return results
def manual_cosine_search(self, query: str, top_k: int = 5) -> List[Tuple[str, float]]:
"""Manual implementation of cosine similarity search (for demonstration)"""
query_embedding = self.encode_text([query])
# Compute cosine similarities with all documents
similarities = F.cosine_similarity(
query_embedding, self.embeddings, dim=1
)
# Get top-k most similar
top_similarities, top_indices = torch.topk(similarities, top_k)
results = []
for idx, sim in zip(top_indices, top_similarities):
results.append((self.documents[idx.item()], sim.item()))
return results
# Advanced RAG with re-ranking and filtering
class AdvancedRAG(RAGSystem):
def __init__(self, encoder_model_name="sentence-transformers/all-MiniLM-L6-v2",
similarity_threshold=0.3):
super().__init__(encoder_model_name)
self.similarity_threshold = similarity_threshold
def retrieve_with_filtering(self, query: str, top_k: int = 10) -> List[Tuple[str, float]]:
"""Retrieve documents with similarity threshold filtering"""
# Get more candidates than needed
candidates = self.retrieve_similar_docs(query, top_k * 2)
# Filter by similarity threshold
filtered = [(doc, sim) for doc, sim in candidates
if sim >= self.similarity_threshold]
# Return top-k after filtering
return filtered[:top_k]
def diverse_retrieval(self, query: str, top_k: int = 5,
diversity_lambda: float = 0.5) -> List[Tuple[str, float]]:
"""Retrieve diverse documents using MMR (Maximal Marginal Relevance)"""
# Get initial candidates
candidates = self.retrieve_similar_docs(query, top_k * 3)
query_embedding = self.encode_text([query])
selected = []
candidate_docs = [doc for doc, _ in candidates]
candidate_embeddings = self.encode_text(candidate_docs)
for _ in range(min(top_k, len(candidates))):
if not selected:
# First document: highest similarity to query
best_idx = 0
selected.append(candidates[best_idx])
else:
# Subsequent documents: balance relevance and diversity
best_score = -float('inf')
best_idx = -1
for i, (doc, query_sim) in enumerate(candidates):
if (doc, query_sim) in selected:
continue
# Compute max similarity to already selected documents
selected_embeddings = self.encode_text([s[0] for s in selected])
doc_embedding = candidate_embeddings[i:i+1]
max_sim_to_selected = F.cosine_similarity(
doc_embedding, selected_embeddings, dim=1
).max().item()
# MMR score: balance relevance and diversity
mmr_score = (diversity_lambda * query_sim -
(1 - diversity_lambda) * max_sim_to_selected)
if mmr_score > best_score:
best_score = mmr_score
best_idx = i
if best_idx != -1:
selected.append(candidates[best_idx])
return selected
# Evaluation and analysis tools
class RAGAnalyzer:
def __init__(self, rag_system: RAGSystem):
self.rag_system = rag_system
def analyze_retrieval_distribution(self, queries: List[str]) -> dict:
"""Analyze the distribution of similarity scores"""
all_similarities = []
for query in queries:
results = self.rag_system.retrieve_similar_docs(query, top_k=10)
similarities = [sim for _, sim in results]
all_similarities.extend(similarities)
return {
'mean_similarity': np.mean(all_similarities),
'std_similarity': np.std(all_similarities),
'min_similarity': np.min(all_similarities),
'max_similarity': np.max(all_similarities),
'percentiles': {
'25': np.percentile(all_similarities, 25),
'50': np.percentile(all_similarities, 50),
'75': np.percentile(all_similarities, 75),
'90': np.percentile(all_similarities, 90)
}
}
def find_optimal_threshold(self, queries: List[str],
ground_truth_relevant: List[List[str]]) -> float:
"""Find optimal similarity threshold using precision-recall analysis"""
thresholds = np.arange(0.1, 1.0, 0.05)
best_f1 = 0
best_threshold = 0.5
for threshold in thresholds:
total_precision = 0
total_recall = 0
valid_queries = 0
for query, relevant_docs in zip(queries, ground_truth_relevant):
results = self.rag_system.retrieve_similar_docs(query, top_k=20)
retrieved = [doc for doc, sim in results if sim >= threshold]
if len(retrieved) > 0 and len(relevant_docs) > 0:
relevant_retrieved = set(retrieved) & set(relevant_docs)
precision = len(relevant_retrieved) / len(retrieved)
recall = len(relevant_retrieved) / len(relevant_docs)
total_precision += precision
total_recall += recall
valid_queries += 1
if valid_queries > 0:
avg_precision = total_precision / valid_queries
avg_recall = total_recall / valid_queries
if avg_precision + avg_recall > 0:
f1 = 2 * (avg_precision * avg_recall) / (avg_precision + avg_recall)
if f1 > best_f1:
best_f1 = f1
best_threshold = threshold
return best_threshold
# Example usage and demonstration
def demonstrate_rag_with_cosine_similarity():
# Sample knowledge base
documents = [
"Machine learning is a subset of artificial intelligence that focuses on algorithms.",
"Deep learning uses neural networks with multiple layers to process data.",
"Natural language processing helps computers understand human language.",
"Computer vision enables machines to interpret and understand visual information.",
"Reinforcement learning teaches agents to make decisions through trial and error.",
"Python is a popular programming language for data science and AI.",
"Transformers are a type of neural network architecture used in NLP.",
"Gradient descent is an optimization algorithm used in machine learning.",
"Overfitting occurs when a model performs well on training data but poorly on new data.",
"Cross-validation is a technique for evaluating model performance."
]
# Initialize RAG system
rag = AdvancedRAG()
# Build index
rag.build_index(documents)
# Test queries
queries = [
"What is machine learning?",
"How do neural networks work?",
"What programming language is best for AI?",
"How to prevent overfitting in models?"
]
print("=== RAG Retrieval Results ===")
for query in queries:
print(f"\nQuery: {query}")
# Standard retrieval
results = rag.retrieve_similar_docs(query, top_k=3)
print("Standard retrieval:")
for i, (doc, sim) in enumerate(results, 1):
print(f" {i}. (sim: {sim:.3f}) {doc}")
# Diverse retrieval
diverse_results = rag.diverse_retrieval(query, top_k=3)
print("Diverse retrieval:")
for i, (doc, sim) in enumerate(diverse_results, 1):
print(f" {i}. (sim: {sim:.3f}) {doc}")
# Analysis
analyzer = RAGAnalyzer(rag)
stats = analyzer.analyze_retrieval_distribution(queries)
print(f"\n=== Similarity Statistics ===")
print(f"Mean similarity: {stats['mean_similarity']:.3f}")
print(f"Std deviation: {stats['std_similarity']:.3f}")
print(f"90th percentile: {stats['percentiles']['90']:.3f}")
if __name__ == "__main__":
demonstrate_rag_with_cosine_similarity()
Key Applications of Cosine Similarity in RAG
Semantic Matching: Cosine similarity captures semantic relationships better than exact keyword matching. A query about “neural networks” will match documents about “deep learning” even if they don’t share exact terms.
Scale Invariance: Documents of different lengths get fair consideration since cosine similarity normalizes for magnitude differences.
Efficiency: When combined with approximate nearest neighbor search (like FAISS), cosine similarity enables sub-linear search times even with millions of documents.
Threshold-based Filtering: You can set similarity thresholds to ensure only sufficiently relevant documents are retrieved, improving generation quality.
Diversity Control: Advanced RAG systems use cosine similarity in MMR (Maximal Marginal Relevance) algorithms to balance relevance with diversity in retrieved results.
Cross-lingual Retrieval: In multilingual RAG systems, cosine similarity works well with multilingual embeddings to find relevant documents across languages.
The effectiveness of RAG heavily depends on the quality of these similarity calculations. Poor similarity measures lead to irrelevant context being fed to the generator, which can cause hallucinations or off-topic responses. This is why cosine similarity’s mathematical properties - particularly its focus on semantic direction rather than magnitude - make it so crucial for RAG success.
import torch
import torch.nn.functional as F
import numpy as np
from transformers import AutoTokenizer, AutoModel, AutoModelForCausalLM
import faiss
from typing import List, Tuple, Dict
import json
import time
from dataclasses import dataclass
@dataclass
class Document:
id: str
content: str
metadata: Dict = None
class EmbeddingModel:
"""Handles text encoding for RAG retrieval"""
def __init__(self, model_name="sentence-transformers/all-MiniLM-L6-v2"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModel.from_pretrained(model_name)
self.model.eval()
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(self.device)
def encode(self, texts: List[str], batch_size: int = 32) -> np.ndarray:
"""Encode texts into normalized embeddings for cosine similarity"""
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch_texts = texts[i:i + batch_size]
# Tokenize
encoded = self.tokenizer(
batch_texts,
padding=True,
truncation=True,
max_length=512,
return_tensors='pt'
).to(self.device)
with torch.no_grad():
# Get embeddings
outputs = self.model(**encoded)
# Mean pooling
attention_mask = encoded['attention_mask']
token_embeddings = outputs.last_hidden_state
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
embeddings = torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)
# L2 normalize for cosine similarity
embeddings = F.normalize(embeddings, p=2, dim=1)
all_embeddings.append(embeddings.cpu().numpy())
return np.vstack(all_embeddings)
class VectorStore:
"""Vector database using FAISS for fast cosine similarity search"""
def __init__(self, embedding_dim: int):
# Use Inner Product index - equivalent to cosine similarity for normalized vectors
self.index = faiss.IndexFlatIP(embedding_dim)
self.documents = []
def add_documents(self, documents: List[Document], embeddings: np.ndarray):
"""Add documents and their embeddings to the store"""
self.documents.extend(documents)
self.index.add(embeddings.astype(np.float32))
def search(self, query_embedding: np.ndarray, top_k: int = 5,
similarity_threshold: float = 0.0) -> List[Tuple[Document, float]]:
"""Search for most similar documents using cosine similarity"""
# Ensure query embedding is normalized
query_embedding = query_embedding / np.linalg.norm(query_embedding)
query_embedding = query_embedding.reshape(1, -1).astype(np.float32)
# Search using inner product (cosine similarity for normalized vectors)
similarities, indices = self.index.search(query_embedding, top_k)
results = []
for idx, sim in zip(indices[0], similarities[0]):
if idx != -1 and sim >= similarity_threshold:
results.append((self.documents[idx], float(sim)))
return results
def manual_cosine_search(self, query_embedding: np.ndarray, top_k: int = 5) -> List[Tuple[Document, float]]:
"""Manual cosine similarity computation for demonstration"""
if self.index.ntotal == 0:
return []
# Get all stored embeddings
all_embeddings = self.index.reconstruct_n(0, self.index.ntotal)
# Compute cosine similarities manually
query_norm = query_embedding / np.linalg.norm(query_embedding)
similarities = []
for i, doc_embedding in enumerate(all_embeddings):
doc_norm = doc_embedding / np.linalg.norm(doc_embedding)
cosine_sim = np.dot(query_norm, doc_norm)
similarities.append((i, cosine_sim))
# Sort by similarity (descending)
similarities.sort(key=lambda x: x[1], reverse=True)
# Return top-k results
results = []
for i, sim in similarities[:top_k]:
results.append((self.documents[i], float(sim)))
return results
class RAGSystem:
"""Complete RAG implementation with cosine similarity retrieval"""
def __init__(self, encoder_model: str = "sentence-transformers/all-MiniLM-L6-v2",
generator_model: str = "microsoft/DialoGPT-small"):
# Initialize components
self.encoder = EmbeddingModel(encoder_model)
self.vector_store = None
# Initialize generator (simplified - in practice you'd use a larger model)
self.generator_tokenizer = AutoTokenizer.from_pretrained(generator_model)
self.generator_model = AutoModelForCausalLM.from_pretrained(generator_model)
if self.generator_tokenizer.pad_token is None:
self.generator_tokenizer.pad_token = self.generator_tokenizer.eos_token
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.generator_model.to(self.device)
def index_documents(self, documents: List[Document]):
"""Index documents for retrieval"""
print(f"Indexing {len(documents)} documents...")
# Extract text content
texts = [doc.content for doc in documents]
# Generate embeddings
embeddings = self.encoder.encode(texts)
# Initialize vector store
embedding_dim = embeddings.shape[1]
self.vector_store = VectorStore(embedding_dim)
# Add to vector store
self.vector_store.add_documents(documents, embeddings)
print(f"Indexing complete. Embedding dimension: {embedding_dim}")
def retrieve(self, query: str, top_k: int = 5, similarity_threshold: float = 0.3) -> List[Tuple[Document, float]]:
"""Retrieve relevant documents using cosine similarity"""
if self.vector_store is None:
raise ValueError("No documents indexed. Call index_documents() first.")
# Encode query
query_embedding = self.encoder.encode([query])[0]
# Search for similar documents
results = self.vector_store.search(
query_embedding,
top_k=top_k,
similarity_threshold=similarity_threshold
)
return results
def retrieve_with_reranking(self, query: str, initial_k: int = 20, final_k: int = 5) -> List[Tuple[Document, float]]:
"""Advanced retrieval with re-ranking based on query-document similarity"""
# Initial broad retrieval
candidates = self.retrieve(query, top_k=initial_k, similarity_threshold=0.1)
if not candidates:
return []
# Re-rank by computing more detailed similarity
query_embedding = self.encoder.encode([query])[0]
reranked = []
for doc, initial_sim in candidates:
# Compute similarity between query and document
doc_embedding = self.encoder.encode([doc.content])[0]
# Detailed cosine similarity computation
detailed_sim = np.dot(query_embedding, doc_embedding) / (
np.linalg.norm(query_embedding) * np.linalg.norm(doc_embedding)
)
reranked.append((doc, float(detailed_sim)))
# Sort by re-ranked similarity
reranked.sort(key=lambda x: x[1], reverse=True)
return reranked[:final_k]
def generate_response(self, query: str, retrieved_docs: List[Tuple[Document, float]],
max_length: int = 200) -> str:
"""Generate response using retrieved context"""
# Prepare context from retrieved documents
context_parts = []
for doc, similarity in retrieved_docs:
context_parts.append(f"Context (similarity: {similarity:.3f}): {doc.content}")
context = "\n".join(context_parts)
# Create prompt
prompt = f"Context:\n{context}\n\nQuestion: {query}\nAnswer:"
# Tokenize and generate
inputs = self.generator_tokenizer.encode(prompt, return_tensors='pt').to(self.device)
with torch.no_grad():
outputs = self.generator_model.generate(
inputs,
max_length=len(inputs[0]) + max_length,
num_return_sequences=1,
temperature=0.7,
pad_token_id=self.generator_tokenizer.eos_token_id,
do_sample=True
)
# Decode response
full_response = self.generator_tokenizer.decode(outputs[0], skip_special_tokens=True)
# Extract just the answer part
answer_start = full_response.find("Answer:") + len("Answer:")
answer = full_response[answer_start:].strip()
return answer
def query(self, question: str, top_k: int = 3, use_reranking: bool = True) -> Dict:
"""Complete RAG query pipeline"""
start_time = time.time()
# Retrieve relevant documents
if use_reranking:
retrieved_docs = self.retrieve_with_reranking(question, initial_k=10, final_k=top_k)
else:
retrieved_docs = self.retrieve(question, top_k=top_k)
retrieval_time = time.time() - start_time
# Generate response
generation_start = time.time()
response = self.generate_response(question, retrieved_docs)
generation_time = time.time() - generation_start
return {
'question': question,
'answer': response,
'retrieved_documents': [
{
'content': doc.content,
'similarity': sim,
'metadata': doc.metadata
}
for doc, sim in retrieved_docs
],
'timing': {
'retrieval_time': retrieval_time,
'generation_time': generation_time,
'total_time': retrieval_time + generation_time
}
}
class RAGEvaluator:
"""Evaluation tools for RAG system performance"""
def __init__(self, rag_system: RAGSystem):
self.rag_system = rag_system
def evaluate_retrieval_quality(self, test_queries: List[str],
ground_truth: List[List[str]]) -> Dict:
"""Evaluate retrieval using cosine similarity thresholds"""
total_precision = 0
total_recall = 0
similarity_scores = []
for query, relevant_docs in zip(test_queries, ground_truth):
retrieved = self.rag_system.retrieve(query, top_k=10)
retrieved_contents = [doc.content for doc, _ in retrieved]
similarities = [sim for _, sim in retrieved]
similarity_scores.extend(similarities)
# Calculate precision and recall
relevant_retrieved = len(set(retrieved_contents) & set(relevant_docs))
precision = relevant_retrieved / len(retrieved_contents) if retrieved_contents else 0
recall = relevant_retrieved / len(relevant_docs) if relevant_docs else 0
total_precision += precision
total_recall += recall
avg_precision = total_precision / len(test_queries)
avg_recall = total_recall / len(test_queries)
f1_score = 2 * (avg_precision * avg_recall) / (avg_precision + avg_recall) if (avg_precision + avg_recall) > 0 else 0
return {
'precision': avg_precision,
'recall': avg_recall,
'f1_score': f1_score,
'avg_similarity': np.mean(similarity_scores),
'similarity_std': np.std(similarity_scores),
'similarity_distribution': {
'min': np.min(similarity_scores),
'max': np.max(similarity_scores),
'25th_percentile': np.percentile(similarity_scores, 25),
'50th_percentile': np.percentile(similarity_scores, 50),
'75th_percentile': np.percentile(similarity_scores, 75),
'90th_percentile': np.percentile(similarity_scores, 90)
}
}
# Demo and example usage
def create_sample_knowledge_base() -> List[Document]:
"""Create a sample knowledge base for testing"""
knowledge_base = [
Document("1", "Machine learning is a method of data analysis that automates analytical model building using algorithms that iteratively learn from data without being explicitly programmed.", {"topic": "ML"}),
Document("2", "Deep learning is part of machine learning methods based on artificial neural networks with representation learning.", {"topic": "DL"}),
Document("3", "Natural language processing (NLP) is a subfield of linguistics, computer science, and artificial intelligence concerned with interactions between computers and human language.", {"topic": "NLP"}),
Document("4", "Computer vision is an interdisciplinary scientific field that deals with how computers can gain high-level understanding from digital images or videos.", {"topic": "CV"}),
Document("5", "Reinforcement learning is an area of machine learning concerned with how intelligent agents ought to take actions in an environment to maximize cumulative reward.", {"topic": "RL"}),
Document("6", "Python is an interpreted, high-level programming language with dynamic semantics and is widely used for artificial intelligence and machine learning applications.", {"topic": "Programming"}),
Document("7", "TensorFlow is an end-to-end open source platform for machine learning with comprehensive tools, libraries and community resources.", {"topic": "Tools"}),
Document("8", "PyTorch is an open source machine learning library based on the Torch library, used for applications such as computer vision and natural language processing.", {"topic": "Tools"}),
Document("9", "Neural networks are computing systems vaguely inspired by biological neural networks and are used to estimate functions that depend on a large number of inputs.", {"topic": "ML"}),
Document("10", "Transformers are a neural network architecture that has become dominant in natural language processing tasks since 2017.", {"topic": "NLP"})
]
return knowledge_base
def demonstrate_cosine_similarity_in_rag():
"""Comprehensive demonstration of cosine similarity in RAG"""
print("=== RAG System with Cosine Similarity Demo ===\n")
# Create knowledge base
documents = create_sample_knowledge_base()
# Initialize RAG system
rag = RAGSystem()
# Index documents
rag.index_documents(documents)
# Test queries
test_queries = [
"What is machine learning?",
"How do neural networks work?",
"What tools are available for ML?",
"Explain reinforcement learning",
"What programming language is best for AI?"
]
print("=== Query Results ===")
for query in test_queries:
print(f"\nQuery: {query}")
# Show detailed retrieval process
print("\n--- Retrieval Process ---")
retrieved_docs = rag.retrieve(query, top_k=3)
for i, (doc, similarity) in enumerate(retrieved_docs, 1):
print(f"{i}. Similarity: {similarity:.4f}")
print(f" Content: {doc.content[:100]}...")
print(f" Metadata: {doc.metadata}")
# Show manual cosine similarity calculation
if rag.vector_store:
print("\n--- Manual Cosine Similarity Verification ---")
query_embedding = rag.encoder.encode([query])[0]
manual_results = rag.vector_store.manual_cosine_search(query_embedding, top_k=2)
for i, (doc, sim) in enumerate(manual_results, 1):
print(f"Manual calc {i}: {sim:.4f} - {doc.content[:50]}...")
# Full RAG response
print("\n--- RAG Response ---")
result = rag.query(query, top_k=2)
print(f"Answer: {result['answer']}")
print(f"Retrieval time: {result['timing']['retrieval_time']:.3f}s")
print(f"Generation time: {result['timing']['generation_time']:.3f}s")
print("-" * 80)
# Evaluation
print("\n=== System Evaluation ===")
evaluator = RAGEvaluator(rag)
# Simple ground truth for demonstration
ground_truth = [
["Machine learning is a method of data analysis that automates analytical model building"],
["Neural networks are computing systems vaguely inspired by biological neural networks"],
["TensorFlow is an end-to-end open source platform", "PyTorch is an open source machine learning library"],
["Reinforcement learning is an area of machine learning concerned with how intelligent agents"],
["Python is an interpreted, high-level programming language"]
]
eval_results = evaluator.evaluate_retrieval_quality(test_queries, ground_truth)
print(f"Precision: {eval_results['precision']:.3f}")
print(f"Recall: {eval_results['recall']:.3f}")
print(f"F1 Score: {eval_results['f1_score']:.3f}")
print(f"Average Similarity: {eval_results['avg_similarity']:.3f}")
print(f"Similarity Std Dev: {eval_results['similarity_std']:.3f}")
print(f"Similarity Range: {eval_results['similarity_distribution']['min']:.3f} - {eval_results['similarity_distribution']['max']:.3f}")
if __name__ == "__main__":
demonstrate_cosine_similarity_in_rag()
This comprehensive implementation shows exactly how cosine similarity powers each component of RAG:
Key Cosine Similarity Applications Demonstrated:
1. Document Encoding: The EmbeddingModel class shows how documents are encoded into normalized vectors specifically for cosine similarity computation.
2. Vector Search: The VectorStore class uses FAISS with inner product search (equivalent to cosine similarity for normalized vectors) for efficient retrieval.
3. Manual Verification: The manual_cosine_search method shows the exact mathematical computation of cosine similarity for transparency.
4. Threshold Filtering: Documents below a similarity threshold are filtered out to ensure quality.
5. Re-ranking: Advanced retrieval uses cosine similarity to re-rank initial candidates for better relevance.
6. Evaluation: The system measures retrieval quality using similarity score distributions and precision/recall metrics.
Why Cosine Similarity is Critical Here:
- Semantic Understanding: Queries like “What is machine learning?” match documents about ML concepts even without exact keyword overlap
- Length Invariance: Short queries match long documents fairly because cosine similarity normalizes for magnitude
- Efficiency: FAISS can perform approximate cosine similarity search in sub-linear time
- Quality Control: Similarity thresholds prevent irrelevant documents from corrupting the generation process
The code demonstrates that cosine similarity isn’t just a mathematical detail—it’s the fundamental mechanism that enables RAG systems to understand semantic relationships and retrieve contextually relevant information for high-quality response generation.
Embeddings
An embedding takes discrete data like words, sentences, or documents and maps them to points in a multi-dimensional space (typically 100-1000+ dimensions). The key insight is that this mapping preserves semantic relationships - words with similar meanings end up near each other in this space.
For example, the words “king” and “queen” would have embeddings that are close together, while “king” and “bicycle” would be far apart. More remarkably, embeddings can capture complex relationships: the vector from “king” to “queen” might be similar to the vector from “man” to “woman.”
How Cosine Similarity Applies to Embeddings
Cosine similarity is the preferred metric for comparing embeddings because it measures the angle between vectors rather than their absolute distance. This is crucial because:
Direction Matters More Than Magnitude: In embedding spaces, the direction of a vector encodes semantic meaning, while magnitude often represents intensity or frequency rather than meaning.
Scale Invariance: Two embeddings might represent the same concept but have different magnitudes due to training differences, document length, or other factors. Cosine similarity treats these as equivalent if they point in the same direction.
Normalized Comparison: By focusing on angles, cosine similarity provides a standardized way to compare embeddings regardless of their original scales.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModel
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import seaborn as sns
from typing import List, Dict, Tuple
class EmbeddingDemo:
"""Comprehensive demonstration of embeddings and cosine similarity"""
def __init__(self, model_name="sentence-transformers/all-MiniLM-L6-v2"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModel.from_pretrained(model_name)
self.model.eval()
def create_word_embeddings(self, words: List[str]) -> np.ndarray:
"""Create embeddings for individual words"""
embeddings = []
for word in words:
# Tokenize and encode
inputs = self.tokenizer(word, return_tensors='pt', padding=True, truncation=True)
with torch.no_grad():
outputs = self.model(**inputs)
# Use mean pooling for word representation
embedding = outputs.last_hidden_state.mean(dim=1)
# Normalize for cosine similarity
embedding = F.normalize(embedding, p=2, dim=1)
embeddings.append(embedding.squeeze().numpy())
return np.array(embeddings)
def create_sentence_embeddings(self, sentences: List[str]) -> np.ndarray:
"""Create embeddings for sentences"""
embeddings = []
for sentence in sentences:
inputs = self.tokenizer(sentence, return_tensors='pt', padding=True,
truncation=True, max_length=512)
with torch.no_grad():
outputs = self.model(**inputs)
# Mean pooling with attention mask
attention_mask = inputs['attention_mask']
token_embeddings = outputs.last_hidden_state
# Weighted average based on attention mask
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
sum_embeddings = torch.sum(token_embeddings * input_mask_expanded, 1)
sum_mask = torch.clamp(input_mask_expanded.sum(1), min=1e-9)
embedding = sum_embeddings / sum_mask
embedding = F.normalize(embedding, p=2, dim=1)
embeddings.append(embedding.squeeze().numpy())
return np.array(embeddings)
def compute_cosine_similarity_matrix(embeddings: np.ndarray) -> np.ndarray:
"""Compute pairwise cosine similarities between embeddings"""
# Normalize embeddings (though they should already be normalized)
normalized = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
# Compute cosine similarity matrix
similarity_matrix = np.dot(normalized, normalized.T)
return similarity_matrix
def demonstrate_word_embeddings():
"""Show how word embeddings capture semantic relationships"""
print("=== Word Embeddings and Cosine Similarity ===\n")
demo = EmbeddingDemo()
# Related word groups
words = [
# Animals
'cat', 'dog', 'lion', 'tiger',
# Technology
'computer', 'laptop', 'smartphone', 'tablet',
# Colors
'red', 'blue', 'green', 'yellow',
# Sports
'football', 'basketball', 'tennis', 'golf'
]
# Create embeddings
embeddings = demo.create_word_embeddings(words)
# Compute similarity matrix
similarity_matrix = compute_cosine_similarity_matrix(embeddings)
print("Word Similarity Matrix (Cosine Similarity):")
print("Words:", words)
print(similarity_matrix.round(3))
# Find most similar word pairs
print("\nMost Similar Word Pairs:")
for i in range(len(words)):
for j in range(i+1, len(words)):
sim = similarity_matrix[i, j]
if sim > 0.7: # High similarity threshold
print(f"{words[i]} - {words[j]}: {sim:.3f}")
return words, embeddings, similarity_matrix
def demonstrate_semantic_relationships():
"""Show how embeddings capture semantic relationships"""
print("\n=== Semantic Relationships in Embeddings ===\n")
demo = EmbeddingDemo()
# Analogy examples: A is to B as C is to D
analogies = [
("king", "queen", "man", "woman"),
("paris", "france", "london", "england"),
("big", "bigger", "small", "smaller"),
("run", "running", "walk", "walking")
]
for a, b, c, d in analogies:
words = [a, b, c, d]
embeddings = demo.create_word_embeddings(words)
# Vector arithmetic: king - man + woman ≈ queen
vector_ab = embeddings[1] - embeddings[0] # queen - king
vector_cd = embeddings[3] - embeddings[2] # woman - man
# Compute cosine similarity between the relationship vectors
relationship_similarity = np.dot(vector_ab, vector_cd) / (
np.linalg.norm(vector_ab) * np.linalg.norm(vector_cd)
)
print(f"Analogy: {a}:{b} :: {c}:{d}")
print(f"Relationship similarity: {relationship_similarity:.3f}")
# Test the analogy: compute a - b + d and see if it's close to c
predicted_c = embeddings[0] - embeddings[1] + embeddings[3]
predicted_c = predicted_c / np.linalg.norm(predicted_c)
actual_c = embeddings[2] / np.linalg.norm(embeddings[2])
analogy_score = np.dot(predicted_c, actual_c)
print(f"Analogy completion accuracy: {analogy_score:.3f}")
print()
def demonstrate_sentence_embeddings():
"""Show sentence-level embeddings and similarity"""
print("=== Sentence Embeddings and Similarity ===\n")
demo = EmbeddingDemo()
sentences = [
"The cat sits on the mat",
"A feline rests on the carpet",
"Dogs are loyal animals",
"Canines are faithful pets",
"I love programming in Python",
"Python is my favorite coding language",
"The weather is sunny today",
"It's a beautiful day outside",
"Machine learning is fascinating",
"AI algorithms are interesting"
]
# Create sentence embeddings
embeddings = demo.create_sentence_embeddings(sentences)
# Compute similarity matrix
similarity_matrix = compute_cosine_similarity_matrix(embeddings)
print("Sentence Similarity Analysis:")
for i in range(len(sentences)):
print(f"{i}: {sentences[i]}")
print("\nMost Similar Sentence Pairs:")
pairs = []
for i in range(len(sentences)):
for j in range(i+1, len(sentences)):
sim = similarity_matrix[i, j]
pairs.append((sim, i, j))
# Sort by similarity
pairs.sort(reverse=True)
# Show top 5 most similar pairs
for sim, i, j in pairs[:5]:
print(f"Similarity: {sim:.3f}")
print(f" Sentence {i}: {sentences[i]}")
print(f" Sentence {j}: {sentences[j]}")
print()
return sentences, embeddings, similarity_matrix
class EmbeddingVisualizer:
"""Visualize embeddings in 2D space"""
@staticmethod
def visualize_embeddings(embeddings: np.ndarray, labels: List[str],
title: str = "Embedding Visualization"):
"""Visualize high-dimensional embeddings in 2D"""
# Reduce dimensions using t-SNE
tsne = TSNE(n_components=2, random_state=42, perplexity=min(30, len(embeddings)-1))
embeddings_2d = tsne.fit_transform(embeddings)
# Create visualization
plt.figure(figsize=(12, 8))
scatter = plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1],
c=range(len(labels)), cmap='tab20', s=100)
# Add labels
for i, label in enumerate(labels):
plt.annotate(label, (embeddings_2d[i, 0], embeddings_2d[i, 1]),
xytext=(5, 5), textcoords='offset points', fontsize=10)
plt.title(title)
plt.xlabel('t-SNE Dimension 1')
plt.ylabel('t-SNE Dimension 2')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
@staticmethod
def plot_similarity_heatmap(similarity_matrix: np.ndarray, labels: List[str],
title: str = "Cosine Similarity Heatmap"):
"""Plot cosine similarity matrix as heatmap"""
plt.figure(figsize=(10, 8))
sns.heatmap(similarity_matrix,
xticklabels=labels,
yticklabels=labels,
annot=True,
cmap='coolwarm',
center=0,
square=True,
fmt='.3f')
plt.title(title)
plt.tight_layout()
plt.show()
def demonstrate_embedding_arithmetic():
"""Show vector arithmetic with embeddings"""
print("=== Embedding Arithmetic ===\n")
demo = EmbeddingDemo()
# Create embeddings for arithmetic examples
words = ['king', 'man', 'woman', 'queen', 'prince', 'princess', 'boy', 'girl']
embeddings = demo.create_word_embeddings(words)
word_to_embedding = {word: emb for word, emb in zip(words, embeddings)}
def find_closest_word(target_embedding, word_embeddings, words, exclude=[]):
"""Find the word with embedding closest to target"""
target_norm = target_embedding / np.linalg.norm(target_embedding)
best_similarity = -1
best_word = None
for word, embedding in word_embeddings.items():
if word in exclude:
continue
embedding_norm = embedding / np.linalg.norm(embedding)
similarity = np.dot(target_norm, embedding_norm)
if similarity > best_similarity:
best_similarity = similarity
best_word = word
return best_word, best_similarity
# Perform vector arithmetic
arithmetic_examples = [
("king", "man", "woman", "Should be close to 'queen'"),
("prince", "boy", "girl", "Should be close to 'princess'")
]
for word_a, word_b, word_c, description in arithmetic_examples:
# Compute: word_a - word_b + word_c
result_embedding = (word_to_embedding[word_a] -
word_to_embedding[word_b] +
word_to_embedding[word_c])
closest_word, similarity = find_closest_word(
result_embedding, word_to_embedding,
exclude=[word_a, word_b, word_c]
)
print(f"Vector arithmetic: {word_a} - {word_b} + {word_c}")
print(f"Closest word: {closest_word} (similarity: {similarity:.3f})")
print(f"Expected: {description}")
print()
def comprehensive_embedding_demo():
"""Run complete demonstration of embeddings and cosine similarity"""
print("🔍 COMPREHENSIVE EMBEDDINGS AND COSINE SIMILARITY DEMO\n")
print("=" * 60)
# 1. Word embeddings
words, word_embeddings, word_similarity = demonstrate_word_embeddings()
# 2. Semantic relationships
demonstrate_semantic_relationships()
# 3. Sentence embeddings
sentences, sentence_embeddings, sentence_similarity = demonstrate_sentence_embeddings()
# 4. Vector arithmetic
demonstrate_embedding_arithmetic()
# 5. Visualizations (optional - requires matplotlib)
try:
print("=== Visualizations ===\n")
visualizer = EmbeddingVisualizer()
# Visualize word embeddings
print("Generating word embedding visualization...")
visualizer.visualize_embeddings(word_embeddings, words, "Word Embeddings (t-SNE)")
# Plot similarity heatmap
print("Generating similarity heatmap...")
visualizer.plot_similarity_heatmap(word_similarity[:8, :8], words[:8],
"Word Cosine Similarity Heatmap")
except ImportError:
print("Visualization libraries not available. Skipping plots.")
# 6. Summary insights
print("\n=== Key Insights ===")
print("1. Embeddings map discrete data to continuous vector spaces")
print("2. Cosine similarity captures semantic relationships effectively")
print("3. Similar concepts cluster together in embedding space")
print("4. Vector arithmetic can capture analogical relationships")
print("5. Sentence embeddings capture meaning beyond individual words")
print("6. Similarity thresholds help filter relevant vs irrelevant matches")
if __name__ == "__main__":
comprehensive_embedding_demo()
Key Concepts Demonstrated:
1. Semantic Similarity: Words like “cat” and “dog” have high cosine similarity because they’re both animals, even though they’re different words.
2. Relationship Preservation: The relationship “king→queen” is similar to “man→woman” in embedding space, showing how embeddings capture semantic patterns.
3. Magnitude Independence: Cosine similarity ensures that rare words and common words can be compared fairly - a short document about “quantum physics” can match a long document about “quantum mechanics.”
4. Dimensionality: Real embeddings typically have 100-1000+ dimensions, allowing them to capture complex, nuanced relationships that simpler representations can’t.
5. Vector Arithmetic: The famous example “king - man + woman ≈ queen” works because embeddings preserve directional relationships between concepts.
Why This Matters:
Embeddings transform the discrete, symbolic world of human language into the continuous, mathematical world that computers excel at. Cosine similarity then provides the bridge that lets us ask: “How similar are these two pieces of content?” in a way that captures genuine semantic meaning rather than just surface-level text matching.
This combination powers everything from search engines and recommendation systems to language models and AI assistants - they all rely on embeddings to understand meaning and cosine similarity to find relevant, related content.
Why GPUs Excel at Cosine Similarity
Parallel Architecture: Cosine similarity involves computing dot products and norms across thousands or millions of vectors simultaneously. GPUs have thousands of cores (vs CPUs’ 4-16 cores) that can perform these calculations in parallel.
SIMD Operations: Single Instruction, Multiple Data operations are perfect for cosine similarity since you’re applying the same mathematical operation (dot product, normalization) across many data points simultaneously.
High Memory Bandwidth: GPUs have much higher memory bandwidth (500+ GB/s vs ~50 GB/s for CPUs), crucial when processing large embedding matrices.
Specialized Tensor Operations: Modern GPUs have tensor cores specifically designed for the matrix operations that cosine similarity requires.
import torch
import numpy as np
import time
import matplotlib.pyplot as plt
from typing import List, Tuple
import psutil
import GPUtil
class PerformanceComparator:
"""Compare CPU vs GPU performance for cosine similarity operations"""
def __init__(self):
self.device_cpu = torch.device('cpu')
self.device_gpu = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
self.gpu_available = torch.cuda.is_available()
if self.gpu_available:
print(f"GPU Available: {torch.cuda.get_device_name()}")
print(f"GPU Memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
else:
print("No GPU available - will simulate results")
def generate_embeddings(self, num_vectors: int, embedding_dim: int, device: torch.device) -> torch.Tensor:
"""Generate random normalized embeddings for testing"""
# Generate random embeddings
embeddings = torch.randn(num_vectors, embedding_dim, device=device, dtype=torch.float32)
# Normalize for cosine similarity (crucial step)
embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)
return embeddings
def cosine_similarity_cpu_naive(self, embeddings: torch.Tensor) -> torch.Tensor:
"""Naive CPU implementation of cosine similarity"""
embeddings = embeddings.cpu()
n = embeddings.shape[0]
similarities = torch.zeros(n, n)
# Nested loops - very slow but demonstrates the computation
for i in range(n):
for j in range(n):
if i <= j: # Only compute upper triangle
dot_product = torch.dot(embeddings[i], embeddings[j])
norm_i = torch.norm(embeddings[i])
norm_j = torch.norm(embeddings[j])
similarities[i, j] = dot_product / (norm_i * norm_j)
similarities[j, i] = similarities[i, j] # Symmetric
return similarities
def cosine_similarity_cpu_optimized(self, embeddings: torch.Tensor) -> torch.Tensor:
"""Optimized CPU implementation using vectorized operations"""
embeddings = embeddings.cpu()
# Since embeddings are already normalized, cosine similarity = dot product
similarities = torch.mm(embeddings, embeddings.t())
return similarities
def cosine_similarity_gpu(self, embeddings: torch.Tensor) -> torch.Tensor:
"""GPU implementation of cosine similarity"""
if not self.gpu_available:
return self.cosine_similarity_cpu_optimized(embeddings)
embeddings = embeddings.to(self.device_gpu)
# Matrix multiplication on GPU - highly optimized
similarities = torch.mm(embeddings, embeddings.t())
return similarities
def cosine_similarity_gpu_batched(self, embeddings: torch.Tensor, batch_size: int = 1000) -> torch.Tensor:
"""GPU implementation with batching for memory efficiency"""
if not self.gpu_available:
return self.cosine_similarity_cpu_optimized(embeddings)
embeddings = embeddings.to(self.device_gpu)
n = embeddings.shape[0]
# Pre-allocate result matrix
similarities = torch.zeros(n, n, device=self.device_gpu)
# Process in batches to avoid memory issues
for i in range(0, n, batch_size):
end_i = min(i + batch_size, n)
batch_i = embeddings[i:end_i]
for j in range(0, n, batch_size):
end_j = min(j + batch_size, n)
batch_j = embeddings[j:end_j]
# Compute similarity for this block
similarities[i:end_i, j:end_j] = torch.mm(batch_i, batch_j.t())
return similarities
def benchmark_methods(self, sizes: List[int], embedding_dim: int = 512) -> dict:
"""Benchmark different cosine similarity methods"""
results = {
'sizes': sizes,
'cpu_naive': [],
'cpu_optimized': [],
'gpu_standard': [],
'gpu_batched': []
}
for size in sizes:
print(f"\nBenchmarking with {size} vectors of dimension {embedding_dim}")
# Generate test data
embeddings_cpu = self.generate_embeddings(size, embedding_dim, self.device_cpu)
# CPU Naive (only for small sizes)
if size <= 1000: # Too slow for larger sizes
start_time = time.time()
_ = self.cosine_similarity_cpu_naive(embeddings_cpu)
cpu_naive_time = time.time() - start_time
results['cpu_naive'].append(cpu_naive_time)
print(f" CPU Naive: {cpu_naive_time:.3f} seconds")
else:
results['cpu_naive'].append(None)
print(f" CPU Naive: Skipped (too slow)")
# CPU Optimized
start_time = time.time()
_ = self.cosine_similarity_cpu_optimized(embeddings_cpu)
cpu_opt_time = time.time() - start_time
results['cpu_optimized'].append(cpu_opt_time)
print(f" CPU Optimized: {cpu_opt_time:.3f} seconds")
if self.gpu_available:
# GPU Standard
torch.cuda.synchronize() # Ensure GPU is ready
start_time = time.time()
_ = self.cosine_similarity_gpu(embeddings_cpu)
torch.cuda.synchronize() # Wait for GPU completion
gpu_time = time.time() - start_time
results['gpu_standard'].append(gpu_time)
print(f" GPU Standard: {gpu_time:.3f} seconds")
# GPU Batched
torch.cuda.synchronize()
start_time = time.time()
_ = self.cosine_similarity_gpu_batched(embeddings_cpu, batch_size=500)
torch.cuda.synchronize()
gpu_batched_time = time.time() - start_time
results['gpu_batched'].append(gpu_batched_time)
print(f" GPU Batched: {gpu_batched_time:.3f} seconds")
# Calculate speedup
speedup = cpu_opt_time / gpu_time
print(f" GPU Speedup: {speedup:.1f}x faster than CPU")
else:
results['gpu_standard'].append(None)
results['gpu_batched'].append(None)
print(f" GPU: Not available")
return results
class RealWorldScenarios:
"""Demonstrate real-world cosine similarity scenarios"""
def __init__(self):
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
def similarity_search_benchmark(self, num_documents: int = 100000,
num_queries: int = 1000, embedding_dim: int = 768):
"""Benchmark similarity search like in RAG systems"""
print(f"\n=== Similarity Search Benchmark ===")
print(f"Documents: {num_documents}, Queries: {num_queries}, Dimensions: {embedding_dim}")
# Generate document embeddings (normalized)
documents = torch.randn(num_documents, embedding_dim, device=self.device)
documents = torch.nn.functional.normalize(documents, p=2, dim=1)
# Generate query embeddings
queries = torch.randn(num_queries, embedding_dim, device=self.device)
queries = torch.nn.functional.normalize(queries, p=2, dim=1)
# Benchmark search
if torch.cuda.is_available():
torch.cuda.synchronize()
start_time = time.time()
# Compute similarities between all queries and documents
similarities = torch.mm(queries, documents.t()) # Shape: [num_queries, num_documents]
# Find top-k most similar documents for each query
top_k = 10
top_similarities, top_indices = torch.topk(similarities, top_k, dim=1)
if torch.cuda.is_available():
torch.cuda.synchronize()
total_time = time.time() - start_time
print(f"Time for {num_queries} queries against {num_documents} documents: {total_time:.3f} seconds")
print(f"Average time per query: {total_time/num_queries*1000:.2f} milliseconds")
print(f"Throughput: {num_queries/total_time:.1f} queries/second")
return total_time
def batch_processing_comparison(self):
"""Compare different batch processing strategies"""
print(f"\n=== Batch Processing Comparison ===")
embedding_dim = 512
num_vectors = 10000
# Generate embeddings
embeddings = torch.randn(num_vectors, embedding_dim, device=self.device)
embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)
batch_sizes = [100, 500, 1000, 2000, 5000]
times = []
for batch_size in batch_sizes:
if torch.cuda.is_available():
torch.cuda.synchronize()
start_time = time.time()
# Process in batches
total_processed = 0
for i in range(0, num_vectors, batch_size):
end_idx = min(i + batch_size, num_vectors)
batch = embeddings[i:end_idx]
# Compute similarities within batch
batch_similarities = torch.mm(batch, batch.t())
total_processed += batch.shape[0]
if torch.cuda.is_available():
torch.cuda.synchronize()
batch_time = time.time() - start_time
times.append(batch_time)
print(f"Batch size {batch_size}: {batch_time:.3f} seconds")
return batch_sizes, times
def explain_gpu_architecture():
"""Explain why GPUs are superior for cosine similarity"""
print("\n" + "="*60)
print("WHY GPUs EXCEL AT COSINE SIMILARITY")
print("="*60)
explanations = [
{
"aspect": "Parallel Processing",
"cpu": "4-16 cores, complex out-of-order execution",
"gpu": "1000s of simple cores, massive parallelism",
"benefit": "Can compute dot products for thousands of vector pairs simultaneously"
},
{
"aspect": "Memory Bandwidth",
"cpu": "~50-100 GB/s memory bandwidth",
"gpu": "500-900 GB/s memory bandwidth",
"benefit": "Faster loading of embedding matrices from memory"
},
{
"aspect": "SIMD Operations",
"cpu": "Limited SIMD width (AVX-512: 16 floats)",
"gpu": "Massive SIMD (32-64 threads per warp)",
"benefit": "Same operation applied to many data elements simultaneously"
},
{
"aspect": "Tensor Cores",
"cpu": "General purpose floating point units",
"gpu": "Specialized tensor/matrix multiplication units",
"benefit": "Hardware acceleration for matrix operations like cosine similarity"
},
{
"aspect": "Latency vs Throughput",
"cpu": "Optimized for low latency per operation",
"gpu": "Optimized for high throughput across many operations",
"benefit": "Better for processing large batches of similarity computations"
}
]
for exp in explanations:
print(f"\n{exp['aspect']}:")
print(f" CPU: {exp['cpu']}")
print(f" GPU: {exp['gpu']}")
print(f" Benefit: {exp['benefit']}")
def explain_nvidia_dominance():
"""Explain why NVIDIA dominates the AI/ML GPU market"""
print("\n" + "="*60)
print("WHY NVIDIA DOMINATES AI/ML COMPUTING")
print("="*60)
factors = [
{
"factor": "CUDA Ecosystem",
"description": "Mature, well-documented parallel computing platform",
"impact": "Easy development, extensive libraries (cuBLAS, cuDNN)",
"cosine_similarity": "Optimized BLAS operations for vector operations"
},
{
"factor": "Tensor Cores",
"description": "Hardware units specialized for mixed-precision matrix operations",
"impact": "4x faster matrix multiplication for AI workloads",
"cosine_similarity": "Accelerates the matrix multiplication in cosine similarity"
},
{
"factor": "Software Stack",
"description": "PyTorch, TensorFlow deeply integrated with CUDA",
"impact": "Seamless GPU acceleration in popular ML frameworks",
"cosine_similarity": "torch.mm() automatically uses optimized CUDA kernels"
},
{
"factor": "Memory Architecture",
"description": "HBM (High Bandwidth Memory) with 900+ GB/s bandwidth",
"impact": "Faster data movement for large tensors",
"cosine_similarity": "Faster loading of embedding matrices"
},
{
"factor": "Developer Tools",
"description": "Profilers (Nsight), debuggers, optimization tools",
"impact": "Easy to optimize and debug GPU code",
"cosine_similarity": "Can profile and optimize similarity computations"
},
{
"factor": "Market Position",
"description": "First-mover advantage in GPU computing",
"impact": "Established ecosystem, trained developers",
"cosine_similarity": "Most similarity libraries optimized for CUDA first"
}
]
for factor in factors:
print(f"\n{factor['factor']}:")
print(f" Description: {factor['description']}")
print(f" Impact: {factor['impact']}")
print(f" For Cosine Similarity: {factor['cosine_similarity']}")
def demonstrate_gpu_memory_optimization():
"""Show memory optimization techniques for large-scale cosine similarity"""
print(f"\n=== GPU Memory Optimization for Cosine Similarity ===")
if not torch.cuda.is_available():
print("GPU not available - showing concepts only")
return
# Show memory usage patterns
def get_gpu_memory():
return torch.cuda.memory_allocated() / 1e9 # GB
embedding_dim = 768
num_vectors = 50000
print(f"Computing similarities for {num_vectors} vectors of dimension {embedding_dim}")
# Method 1: Naive approach (may run out of memory)
print(f"\nMethod 1: Full similarity matrix")
initial_memory = get_gpu_memory()
try:
embeddings = torch.randn(num_vectors, embedding_dim, device='cuda')
embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)
memory_after_embeddings = get_gpu_memory()
print(f"Memory after loading embeddings: {memory_after_embeddings:.2f} GB")
# This creates a huge matrix: 50000 x 50000 = 2.5B floats = 10GB
# similarities = torch.mm(embeddings, embeddings.t()) # Might fail!
print(f"Full similarity matrix would need: {(num_vectors**2 * 4) / 1e9:.2f} GB")
except RuntimeError as e:
print(f"Failed as expected: {e}")
# Method 2: Chunked processing
print(f"\nMethod 2: Chunked processing")
chunk_size = 1000
max_similarity_per_vector = torch.zeros(num_vectors, device='cuda')
torch.cuda.empty_cache() # Clear memory
embeddings = torch.randn(num_vectors, embedding_dim, device='cuda')
embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)
for i in range(0, num_vectors, chunk_size):
end_i = min(i + chunk_size, num_vectors)
chunk = embeddings[i:end_i]
# Compute similarities for this chunk against all vectors
chunk_similarities = torch.mm(chunk, embeddings.t())
# Keep only the maximum similarity for each vector in chunk
max_similarity_per_vector[i:end_i] = chunk_similarities.max(dim=1)[0]
# Clear intermediate results
del chunk_similarities
final_memory = get_gpu_memory()
print(f"Peak memory usage with chunking: {final_memory:.2f} GB")
print(f"Successfully processed {num_vectors} vectors")
def comprehensive_gpu_demo():
"""Run comprehensive GPU vs CPU demonstration"""
print("🚀 COMPREHENSIVE GPU vs CPU COSINE SIMILARITY DEMO")
print("="*60)
# Architecture explanation
explain_gpu_architecture()
explain_nvidia_dominance()
# Performance comparison
comparator = PerformanceComparator()
# Test different sizes
test_sizes = [100, 500, 1000, 2000, 5000]
print(f"\n=== Performance Benchmarks ===")
results = comparator.benchmark_methods(test_sizes, embedding_dim=512)
# Real-world scenarios
scenarios = RealWorldScenarios()
scenarios.similarity_search_benchmark(num_documents=50000, num_queries=1000)
scenarios.batch_processing_comparison()
# Memory optimization
demonstrate_gpu_memory_optimization()
# Summary
print(f"\n=== Key Takeaways ===")
print("1. GPUs provide 10-100x speedup for cosine similarity computations")
print("2. Parallel architecture perfectly matches similarity computation patterns")
print("3. High memory bandwidth crucial for large embedding matrices")
print("4. NVIDIA's CUDA ecosystem provides mature, optimized libraries")
print("5. Tensor cores accelerate matrix operations in modern GPUs")
print("6. Memory management crucial for large-scale similarity computations")
print("7. Batching strategies balance memory usage with performance")
if __name__ == "__main__":
comprehensive_gpu_demo()
Real-World Impact
Cosine similarity enables:
- Semantic search that understands meaning, not just keywords
- Recommendation systems that find truly similar content
- Language models that maintain coherent context across long conversations
- Real-time AI applications through GPU acceleration
- Cross-lingual systems that work across different languages
The combination of cosine similarity’s mathematical properties, embedding representations, and GPU acceleration forms the foundation of modern AI systems - from search engines and chatbots to recommendation systems and content generation tools.
The Real Pioneers of AI
When you compute F.cosine_similarity(embedding1, embedding2) in PyTorch, you’re invoking:
- Cauchy and Schwarz’s inequality theory
- Cayley’s matrix algebra
- Shannon’s information theory principles
- Thousands of years of trigonometric insights (Ancient Greeks and Babylonians)
- Modern optimization techniques for GPU computation