Pavan Rangani

HomeBlogEmbedding Models Compared: OpenAI, Cohere, and BGE for Semantic Search

Embedding Models Compared: OpenAI, Cohere, and BGE for Semantic Search

By Pavan Rangani · March 26, 2026 · AI & ML

Embedding Models Compared: OpenAI, Cohere, and BGE for Semantic Search

Embedding Models Comparison for Semantic Search

Embedding models comparison is essential for any team building semantic search, RAG systems, or recommendation engines. The choice of embedding model directly impacts retrieval quality, latency, and cost. In 2026, the landscape has matured with strong options from OpenAI (text-embedding-3), Cohere (embed-v4), and open-source BGE models that can be self-hosted for complete data control.

This guide provides a practical comparison based on real benchmarks, covering retrieval quality metrics, embedding dimensions, throughput, pricing, and deployment considerations. Whether you are building a customer-facing search engine or an internal knowledge base, this comparison will help you choose the right model — and avoid the costly mistake of locking your entire vector store to a model you later regret.

Understanding Text Embeddings

Text embeddings transform text into dense numerical vectors that capture semantic meaning. Similar concepts produce vectors that are close together in the embedding space, enabling semantic search where the exact words do not need to match. Moreover, modern embedding models handle multiple languages, code, and domain-specific terminology with impressive accuracy.

Embedding models comparison semantic vector space
Text embeddings map semantically similar content to nearby points in vector space
How Embeddings Enable Semantic Search

Query: "How do I fix memory leaks in Java?"

Keyword search matches:
  ✅ "Fixing memory leaks in Java applications"
  ❌ "JVM heap tuning and garbage collection"
  ❌ "OutOfMemoryError troubleshooting guide"

Semantic search (embeddings) matches:
  ✅ "Fixing memory leaks in Java applications"
  ✅ "JVM heap tuning and garbage collection"
  ✅ "OutOfMemoryError troubleshooting guide"
  ✅ "Java profiling with VisualVM and JFR"

Benchmark Results

The numbers below are representative results drawn from the public MTEB retrieval leaderboard plus a custom domain-specific evaluation on technical documents. Treat them as directional rather than absolute, because retrieval quality on your own corpus can diverge substantially from public benchmarks.

Embedding Model Benchmarks (March 2026)

┌─────────────────────┬──────────┬──────────┬──────────┬──────────┐
│ Metric              │ OpenAI   │ Cohere   │ BGE-M3   │ BGE-EN   │
│                     │ v3-large │ embed-v4 │ (BAAI)   │ v1.5-lg  │
├─────────────────────┼──────────┼──────────┼──────────┼──────────┤
│ MTEB Retrieval      │ 0.685    │ 0.672    │ 0.661    │ 0.638    │
│ Dimensions          │ 3072     │ 1024     │ 1024     │ 1024     │
│ Max Tokens          │ 8191     │ 512      │ 8192     │ 512      │
│ Multilingual        │ Good     │ Great    │ Excellent│ English  │
│ Latency (p50, ms)   │ 45       │ 38       │ 12*      │ 8*       │
│ Cost per 1M tokens  │ $0.13    │ $0.10    │ Free**   │ Free**   │
│ Self-Hostable        │ No       │ No       │ Yes      │ Yes      │
│ Matryoshka Support  │ Yes      │ No       │ Yes      │ No       │
└─────────────────────┴──────────┴──────────┴──────────┴──────────┘

* Self-hosted on A100 GPU
** Open-source (compute costs apply for self-hosting)

How to Read These Benchmarks Without Fooling Yourself

A two-point gap on MTEB rarely translates into a noticeable difference for end users, so resist over-indexing on the leaderboard. What matters far more is how a model performs on your queries against your documents, which is why you should always build a small golden set of 50 to 100 representative query-document pairs and measure recall and NDCG yourself. Benchmarks like MTEB average across dozens of datasets, and a model that wins overall can still lose badly on your specific domain.

Pay attention to the context window column too. A 512-token limit, as on Cohere embed-v4 and BGE-EN v1.5, silently truncates long passages, which means you must chunk aggressively before embedding. By contrast, the 8K-token windows on OpenAI and BGE-M3 let you embed larger chunks intact. Furthermore, latency in the table reflects single-call timing; throughput under batching often tells a more useful story for ingestion pipelines.

OpenAI text-embedding-3

OpenAI’s latest embedding model offers the highest retrieval quality with Matryoshka dimension support, allowing you to trade off quality for storage and speed:

from openai import OpenAI
import numpy as np

client = OpenAI()

def get_embeddings(texts, model="text-embedding-3-large",
                   dimensions=None):
    """Generate embeddings with optional dimension reduction."""
    params = {"input": texts, "model": model}
    if dimensions:
        params["dimensions"] = dimensions  # Matryoshka!

    response = client.embeddings.create(**params)
    return [item.embedding for item in response.data]

# Full dimensions (3072) — highest quality
full_emb = get_embeddings(["How to optimize SQL queries"],
                          dimensions=3072)

# Reduced dimensions (256) — 92% of quality, 12x less storage
compact_emb = get_embeddings(["How to optimize SQL queries"],
                             dimensions=256)

# Batch processing for efficiency
documents = ["doc1...", "doc2...", "doc3..."]
batch_size = 100
all_embeddings = []
for i in range(0, len(documents), batch_size):
    batch = documents[i:i + batch_size]
    embs = get_embeddings(batch, dimensions=1024)
    all_embeddings.extend(embs)

The Matryoshka property is the standout feature here and deserves emphasis. Because the model is trained so that the most important information concentrates in the earliest dimensions, you can truncate a 3072-dimensional vector down to 256 or 512 dimensions and simply renormalize, keeping most of the retrieval quality. That single trick cuts your vector storage and search cost by an order of magnitude, which often matters more to your bill than the raw quality score.

Cohere embed-v4

Cohere excels in multilingual scenarios and provides input-type-aware embeddings. Furthermore, their search-optimized embeddings outperform general-purpose models for retrieval tasks:

import cohere

co = cohere.Client("your-api-key")

# Cohere uses input_type for optimized embeddings
def embed_documents(texts):
    """Embed documents for storage."""
    response = co.embed(
        texts=texts,
        model="embed-v4",
        input_type="search_document",
        embedding_types=["float"]
    )
    return response.embeddings.float

def embed_query(query):
    """Embed query for search — different input_type!"""
    response = co.embed(
        texts=[query],
        model="embed-v4",
        input_type="search_query",
        embedding_types=["float"]
    )
    return response.embeddings.float[0]

# The input_type distinction improves retrieval by 3-5%
doc_embeddings = embed_documents([
    "PostgreSQL indexing strategies for large tables",
    "MySQL query optimization with EXPLAIN ANALYZE",
    "Database connection pooling with HikariCP",
])
query_embedding = embed_query("How to speed up database queries")

The input_type parameter is easy to overlook and easy to get wrong. Cohere embeds documents and queries into slightly different sub-spaces, so using search_document for both sides of the comparison quietly degrades recall. A subtle but important consequence is that you cannot mix embeddings produced with different input types in the same index and expect distances to be meaningful. Therefore, when you migrate to or from Cohere, you must re-embed your entire corpus rather than patch it incrementally.

BGE Models (Self-Hosted)

BGE models from BAAI are the leading open-source option. Consequently, they are ideal for organizations with data privacy requirements or high-volume workloads where API costs become prohibitive:

from sentence_transformers import SentenceTransformer
import torch

# Load model (downloads ~1.3GB on first run)
model = SentenceTransformer("BAAI/bge-m3",
                            device="cuda" if torch.cuda.is_available()
                            else "cpu")

# BGE models need instruction prefix for queries
def embed_for_search(query):
    """Add instruction prefix for retrieval tasks."""
    instruction = "Represent this sentence for searching: "
    return model.encode(instruction + query,
                        normalize_embeddings=True)

def embed_documents(docs):
    """Documents don't need instruction prefix."""
    return model.encode(docs,
                        normalize_embeddings=True,
                        batch_size=64,
                        show_progress_bar=True)

# Benchmark: 500 docs/second on A100
docs = ["doc1...", "doc2...", ...]
doc_embeddings = embed_documents(docs)
query_embedding = embed_for_search("database optimization")

Self-hosting BGE shifts the cost equation from per-token API fees to fixed GPU spend, which flips the economics once you cross a certain volume. At low query volumes the managed APIs are far cheaper because you pay nothing when idle; at sustained high volume a single rented GPU amortizes to a fraction of a cent per thousand embeddings. BGE-M3 is especially attractive because it produces dense, sparse, and multi-vector (ColBERT-style) representations from one model, which lets you build hybrid retrieval without running three separate systems.

Self-hosted embedding model deployment architecture
Self-hosted BGE models provide complete data control with competitive quality

The Migration Trap: Why Model Choice Is Sticky

The most expensive lesson teams learn is that embeddings from different models are not interchangeable. A vector produced by OpenAI v3-large and a vector produced by BGE-M3 occupy entirely different geometric spaces, so a query embedded with one model will return garbage against documents embedded with another. As a result, switching embedding models is never a config flag — it requires re-embedding and re-indexing every document in your corpus.

For a corpus of tens of millions of documents, that re-embedding job can take days of compute and meaningful cost, and it must usually run as a parallel backfill while the old index keeps serving traffic. The practical takeaway is to pin the exact model version in your metadata, plan for a dual-index migration strategy from day one, and prototype on a representative sample before committing your whole pipeline. A common pattern is to keep the embedding step behind an interface so swapping providers touches one module rather than the whole codebase.

Production Deployment Patterns

from functools import lru_cache
import hashlib
import redis
import json

class EmbeddingService:
    """Production embedding service with caching."""

    def __init__(self, provider="openai", redis_url="redis://localhost"):
        self.provider = provider
        self.cache = redis.from_url(redis_url)
        self.cache_ttl = 86400 * 7  # 7 days

    def _cache_key(self, text):
        h = hashlib.sha256(text.encode()).hexdigest()[:16]
        return f"emb:{self.provider}:{h}"

    def embed(self, text):
        """Get embedding with Redis cache."""
        key = self._cache_key(text)
        cached = self.cache.get(key)
        if cached:
            return json.loads(cached)

        # Generate embedding (provider-specific)
        embedding = self._generate(text)
        self.cache.setex(key, self.cache_ttl,
                        json.dumps(embedding))
        return embedding

    def embed_batch(self, texts):
        """Batch embed with partial cache hits."""
        results = [None] * len(texts)
        uncached = []

        for i, text in enumerate(texts):
            cached = self.cache.get(self._cache_key(text))
            if cached:
                results[i] = json.loads(cached)
            else:
                uncached.append((i, text))

        if uncached:
            indices, texts_to_embed = zip(*uncached)
            new_embeddings = self._generate_batch(
                list(texts_to_embed))
            for idx, emb in zip(indices, new_embeddings):
                results[idx] = emb
                self.cache.setex(
                    self._cache_key(texts_to_embed[
                        indices.index(idx)]),
                    self.cache_ttl, json.dumps(emb))

        return results

Two production details make or break this service. First, the cache key must include the provider and the model version, because the same text embedded by different models yields incompatible vectors — a cache that ignores model version will silently serve stale, mismatched embeddings after an upgrade. Second, any call to a managed API needs retry-with-backoff and rate-limit handling, since embedding endpoints throttle aggressively under bulk ingestion. For large backfills, batching to the provider’s maximum request size and respecting token-per-minute limits is what keeps the job from stalling.

When NOT to Use Semantic Embeddings

Embeddings are not always the right tool. For exact-match lookups (product SKUs, order IDs), traditional database indexes are faster and simpler. Additionally, for highly structured queries with boolean logic and faceted filtering, Elasticsearch or similar full-text engines outperform vector search. Embeddings also struggle with numerical reasoning — a query like “products under $50” requires structured filtering, not semantic similarity. If your corpus is small (under 1,000 documents), keyword search with BM25 may provide equivalent quality at a fraction of the complexity.

It is also worth recognizing that pure semantic search underperforms on rare proper nouns, exact identifiers, and acronyms, where lexical overlap carries the signal. For that reason, the strongest production systems rarely use embeddings alone. Instead, they combine BM25 and vector scores through hybrid search and then apply a cross-encoder reranker to the top candidates, which captures both the precision of keywords and the recall of semantics.

Choosing between keyword and semantic search approaches
Match the search approach to your data characteristics and query patterns

Key Takeaways

  • OpenAI leads on raw retrieval quality, Cohere on multilingual, and BGE on cost and privacy
  • Matryoshka embeddings (OpenAI, BGE-M3) let you reduce dimensions by 12x with only about 8% quality loss
  • Cohere’s input_type distinction between documents and queries improves retrieval by 3-5%
  • Self-hosted BGE models eliminate API costs and data privacy concerns at the cost of infrastructure management
  • Cache embeddings aggressively — identical text always produces the same vector, making caching highly effective
  • Switching models requires a full re-embed, so pin the model version and plan migrations up front

Related Reading

External Resources

In conclusion, a careful embedding models comparison is an essential foundation for any modern retrieval system. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and cost-effective search experiences. Start with a golden-set evaluation on your own data, iterate on chunking and dimensionality, and continuously measure retrieval quality to ensure you are getting the most value from these models.

← Back to all articles