n4nAI

How to choose an embedding model for your RAG pipeline

A step-by-step guide to selecting the right embedding model for your RAG pipeline, with benchmarks, code, and validation strategies.

n4n Team5 min read1,079 words

Audio narration

Coming soon — every post will get a voice note here.

Choosing an embedding model for RAG is not a one-time decision — it’s a recurring evaluation that directly determines retrieval quality, latency, and cost. Most teams pick a model based on leaderboard scores, only to discover their specific corpus, query patterns, and infrastructure constraints tell a different story. This guide walks through a repeatable process to select, validate, and monitor the right model for your production pipeline.

Step 1: Define your retrieval requirements

Before comparing models, write down the constraints that actually matter for your use case. These become your evaluation criteria.

Questions to answer:

  • Query type: Are users asking factoid questions (“What is our refund policy?”), semantic search (“Show me docs about authentication”), or multi-hop reasoning (“Compare the 2023 and 2024 pricing tiers”)?
  • Corpus characteristics: What’s the document length distribution? Code, legal contracts, and chat logs each favor different embedding behaviors.
  • Language coverage: Do you need multilingual support? If so, which languages and what’s the traffic split?
  • Latency budget: What’s your p99 retrieval latency target? This constrains model size and whether you can run inference locally.
  • Update frequency: How often does the corpus change? Frequent re-indexing favors smaller, faster models.
  • Cost ceiling: What’s your monthly embedding compute budget?

Output: A requirements document. Example:

# Retrieval Requirements - Customer Support RAG

- Query types: 70% factoid, 20% procedural, 10% comparative
- Corpus: 500k chunks, median 300 tokens, 95th percentile 1200 tokens
- Languages: English (85%), Spanish (10%), French (5%)
- Latency: p99 < 200ms end-to-end (embedding + vector search)
- Re-index: Weekly full, daily incremental
- Budget: $500/month embedding compute

Step 2: Evaluate model families and trade-offs

Embedding models fall into a few families with distinct trade-offs. Don’t treat leaderboard rankings as ground truth — they reflect academic benchmarks (BEIR, MTEB) that may not match your data distribution.

Family Examples Strengths Weaknesses
General-purpose dense text-embedding-3-large, text-embedding-3-small, voyage-3-large, voyage-3 Strong out-of-box quality, multilingual, well-supported Larger models = higher latency/cost; black-box updates
Domain-adapted voyage-code-3, voyage-law-2, voyage-finance-2 Better on specialized vocabulary without fine-tuning Narrower applicability; fewer providers
Open weights (self-hosted) bge-large-en-v1.5, bge-m3, e5-mistral-7b-instruct, nomic-embed-text-v1.5 Full control, no API costs, customizable GPU memory, ops burden, you own quality regressions
Late-interaction / ColBERT colbertv2.0, jina-colbert-v2 Higher recall on complex queries Larger index, slower search, more complex serving

Decision heuristic:

  • Start with text-embedding-3-small or voyage-3 for general English workloads — they hit the latency/cost/quality sweet spot.
  • If code, legal, or finance dominates your corpus, test the corresponding Voyage domain model first.
  • If you need data residency, zero API dependency, or plan heavy fine-tuning, go open weights (bge-m3 for multilingual, e5-mistral-7b-instruct for instruction-following).
  • Reserve late-interaction models for cases where dense retrieval demonstrably fails on your eval set.

Step 3: Benchmark on your corpus

Leaderboards lie. Build a small, representative eval set and run a head-to-head comparison.

3.1 Create a golden eval set

Sample 200–500 queries from real traffic (or synthesize realistic ones). For each, identify the ground-truth relevant chunks. This is tedious but non-negotiable.

# eval_set.jsonl format
{"query": "How do I reset my API key?", "relevant_chunk_ids": ["chunk_12", "chunk_45"]}
{"query": "What's the rate limit for the embeddings endpoint?", "relevant_chunk_ids": ["chunk_88"]}

Aim for:

  • 50% head queries (high frequency)
  • 30% tail queries (low frequency, specific)
  • 20% adversarial (ambiguous, multi-intent, negations)

3.2 Run the benchmark

import asyncio
import numpy as np
from typing import List, Dict
from dataclasses import dataclass

@dataclass
class RetrievalResult:
    model: str
    query: str
    retrieved_ids: List[str]
    relevant_ids: List[str]
    latency_ms: float

async def benchmark_model(
    model_name: str,
    embed_fn,
    index,  # your vector index (faiss, hnswlib, pgvector, etc.)
    eval_set: List[Dict],
    k: int = 10
) -> List[RetrievalResult]:
    results = []
    for item in eval_set:
        query = item["query"]
        relevant = set(item["relevant_chunk_ids"])
        
        # Embed query
        import time
        start = time.perf_counter()
        query_vec = await embed_fn(query)
        embed_latency = (time.perf_counter() - start) * 1000
        
        # Search
        start = time.perf_counter()
        distances, indices = index.search(query_vec.reshape(1, -1), k)
        search_latency = (time.perf_counter() - start) * 1000
        
        retrieved = [index.id_map[i] for i in indices[0]]
        
        results.append(RetrievalResult(
            model=model_name,
            query=query,
            retrieved_ids=retrieved,
            relevant_ids=list(relevant),
            latency_ms=embed_latency + search_latency
        ))
    return results

def compute_metrics(results: List[RetrievalResult], k: int = 10) -> Dict:
    """Compute recall@k, MRR, latency stats."""
    recalls = []
    mrrs = []
    latencies = []
    
    for r in results:
        relevant = set(r.relevant_ids)
        retrieved = r.retrieved_ids[:k]
        
        # Recall@k
        hits = len(relevant & set(retrieved))
        recalls.append(hits / len(relevant) if relevant else 1.0)
        
        # MRR
        rr = 0.0
        for i, doc_id in enumerate(retrieved, 1):
            if doc_id in relevant:
                rr = 1.0 / i
                break
        mrrs.append(rr)
        
        latencies.append(r.latency_ms)
    
    return {
        f"recall@{k}": np.mean(recalls),
        "mrr": np.mean(mrrs),
        "latency_p50_ms": np.percentile(latencies, 50),
        "latency_p99_ms": np.percentile(latencies, 99),
        "latency_mean_ms": np.mean(latencies),
    }

3.3 Compare candidates

Run the benchmark across 3–5 models. Typical comparison:

models_to_test = [
    "text-embedding-3-small",
    "text-embedding-3-large", 
    "voyage-3",
    "voyage-3-large",
    "bge-m3",  # if self-hosting
]

# Pseudocode - adapt to your embedding provider
async def main():
    all_results = {}
    for model in models_to_test:
        embed_fn = get_embed_function(model)  # your wrapper
        results = await benchmark_model(model, embed_fn, index, eval_set)
        all_results[model] = compute_metrics(results)
        print(f"{model}: {all_results[model]}")

What to look for:

  • Recall@10 > 0.85 is a reasonable baseline for factoid workloads
  • MRR > 0.6 suggests good ranking
  • p99 latency must fit your budget from Step 1
  • Plot recall vs. latency — pick the Pareto frontier

Step 4: Consider infrastructure constraints

Model choice cascades into infrastructure decisions. Validate these before committing.

4.1 Index size and memory

Embedding dimension directly determines index footprint.

def estimate_index_size(num_chunks: int, dim: int, index_type: str = "hnsw") -> Dict:
    """Rough memory estimates in GB."""
    # Float32 vectors: 4 bytes per dimension
    vector_bytes = num_chunks * dim * 4
    
    if index_type == "hnsw":
        # HNSW overhead ~1.2-1.5x vectors
        overhead = 1.3
    elif index_type == "ivf":
        overhead = 1.1
    elif index_type == "flat":
        overhead = 1.0
    else:
        overhead = 1.5
    
    total_gb = (vector_bytes * overhead) / (1024**3)
    return {"vectors_gb": vector_bytes / (1024**3), "total_est_gb": total_gb}

# Examples
print(estimate_index_size(500_000, 1536))   # text-embedding-3-small: ~3.8 GB
print(estimate_index_size(500_000, 3072))   # text-embedding-3-large: ~7.6 GB
print(estimate_index_size(500_000, 1024))   # bge-m3: ~2.5 GB
print(estimate_index_size(500_000, 4096))   # voyage-3-large: ~10.1 GB

If you’re running on a 16 GB GPU or a small managed instance, 3072+ dimensions may force quantization or sharding.

4.2 Quantization trade-offs

Most vector databases support scalar (int8) or binary quantization. Test recall degradation:

def test_quantization(index, query_vecs, ground_truth, k=10):
    """Compare full precision vs quantized search."""
    # Full precision
    _, fp_indices = index.search(query_vecs, k)
    
    # Enable quantization (syntax varies by DB)
    index.enable_scalar_quantization()
    _, q_indices = index.search(query_vecs, k)
    
    fp_recall = compute_recall(fp_indices, ground_truth, k)
    q_recall = compute_recall(q_indices, ground_truth, k)
    
    return {"full_precision_recall": fp_recall, "quantized_recall": q_recall, "drop": fp_recall - q_recall}

Typical drop: 1–3% recall@10 for int8, 5–15% for binary. If the drop is acceptable, you can run larger models within the same memory budget.

4.3 Batch embedding throughput

If you re-index weekly, embedding speed matters. Measure tokens/sec on your hardware:

# Quick local benchmark for open-weight models
python -c "
from sentence_transformers import SentenceTransformer
import time, numpy as np
model = SentenceTransformer('BAAI/bge-m3')
texts = ['sample text ' * 50] * 1000  # ~500 tokens each
start = time.time()
embs = model.encode(texts, batch_size=64, show_progress_bar=False)
elapsed = time.time() - start
total_tokens = sum(len(t.split()) * 1.3 for t in texts)  # rough
print(f'{total_tokens/elapsed:,.0f} tokens/sec, {len(texts)/elapsed:.1f} docs/sec')
"

For API models, check provider rate limits and batch endpoints. Some providers (including n4n.ai) expose batch embedding endpoints that reduce per-request overhead.

Step 5: Validate end-to-end quality

Retrieval metrics don’t equal answer quality. Run an end-to-end eval with your actual LLM and prompt.

5.1 Build an answer-quality eval

from dataclasses import dataclass
from typing import List
import json

@dataclass
class EvalCase:
    query: str
    expected_answer_contains: List[str]  # keywords that must appear
    expected_answer_excludes: List[str]  # hallucination guards
    min_citations: int = 1

EVAL_CASES = [
    EvalCase(
        query="How do I rotate my API key?",
        expected_answer_contains=["dashboard", "settings", "regenerate"],
        expected_answer_excludes=["delete account", "contact support"],
        min_citations=1
    ),
    # ... 50-100 cases
]

async def evaluate_rag_pipeline(
    embed_model: str,
    llm_model: str,
    eval_cases: List[EvalCase]
) -> Dict:
    """Run full RAG pipeline and grade answers."""
    from your_rag_pipeline import answer_query  # your actual pipeline
    
    results = []
    for case in eval_cases:
        response = await answer_query(
            query=case.query,
            embed_model=embed_model,
            llm_model=llm_model
        )
        
        answer = response.answer.lower()
        citations = response.citations
        
        # Keyword checks
        contains_score = sum(1 for kw in case.expected_answer_contains if kw.lower() in answer) / len(case.expected_answer_contains)
        excludes_violated = any(kw.lower() in answer for kw in case.expected_answer_excludes)
        citation_ok = len(citations) >= case.min_citations
        
        passed = contains_score >= 0.8 and not excludes_violated and citation_ok
        
        results.append({
            "query": case.query,
            "passed": passed,
            "contains_score": contains_score,
            "excludes_violated": excludes_violated,
            "citation_count": len(citations)
        })
    
    pass_rate = sum(r["passed"] for r in results) / len(results)
    return {"pass_rate": pass_rate, "details": results}

5.2 Compare embedding models in-context

embed_models = ["text-embedding-3-small", "voyage-3", "bge-m3"]
llm_model = "gpt-4o-mini"  # fixed

for emb in embed_models:
    result = await evaluate_rag_pipeline(emb, llm_model, EVAL_CASES)
    print(f"{emb}: {result['pass_rate']:.1%} pass rate")

Common finding: A model with 5% lower recall@10 can yield higher answer quality if it retrieves fewer but more precise chunks, reducing LLM distraction. Optimize for the metric that pays your bills.

Step 6: Plan for model updates and monitoring

Embedding models change. Providers deprecate versions, release updates, or silently shift behavior. Open-weight models get new checkpoints. You need a drift detection loop.

6.1 Version pinning

Always pin the model version in production:

# Good
EMBED_MODEL = "text-embedding-3-small@1"  # explicit version

# Bad
EMBED_MODEL = "text-embedding-3-small"    # floats with provider changes

For open weights, pin the commit hash or release tag:

model = SentenceTransformer("BAAI/bge-m3", revision="aaaa123")  # specific commit

6.2 Shadow evaluation on new versions

When a provider announces an update (or you want to test a new open-weight release), run a shadow eval:

async def shadow_eval(new_model: str, current_model: str, sample_queries: List[str]):
    """Compare new vs current on live traffic sample."""
    results = {"new": [], "current": []}
    
    for query in sample_queries:
        # Run both embeddings
        new_vec = await embed(new_model, query)
        cur_vec = await embed(current_model, query)
        
        # Search with both
        new_results = index.search(new_vec, k=10)
        cur_results = index.search(cur_vec, k=10)
        
        results["new"].append(new_results)
        results["current"].append(cur_results)
    
    # Compare overlap, rank correlation, latency
    return analyze_shadow_results(results)

Deploy the new model only if shadow metrics meet your thresholds.

6.3 Production monitoring

Track these metrics continuously:

# Log per-request for downstream analysis
def log_retrieval_metrics(
    query: str,
    model: str,
    retrieved_ids: List[str],
    latency_ms: float,
    user_feedback: str = None  # thumbs up/down, explicit rating
):
    import logging
    logging.info(json.dumps({
        "event": "retrieval",
        "model": model,
        "query_hash": hash(query) % 1_000_000,  # privacy-safe
        "num_results": len(retrieved_ids),
        "latency_ms": latency_ms,
        "user_feedback": user_feedback
    }))

Dashboards to build:

  1. Retrieval latency p50/p99 by model — catch regressions
  2. Recall proxy: click-through rate on citations — if users click cited sources, retrieval worked
  3. Embedding API error rate and latency — provider health
  4. Re-index duration and success rate — operational health
  5. Cost per 1M tokens embedded — budget tracking

Set alerts on:

  • p99 latency > 2x baseline
  • Error rate > 1%
  • Re-index failure
  • Daily embedding cost > 1.5x forecast

Step 7: Document the decision

Write a one-page ADR (Architecture Decision Record) so future you (or your replacement) knows why this model was chosen.

# ADR-0042: Embedding Model Selection for Support RAG

## Status
Accepted

## Context
Weekly re-index of 500k chunks, p99 latency budget 200ms, $500/mo budget.

## Decision
Use `text-embedding-3-small` (1536 dim) via API with int8 quantization in pgvector.

## Alternatives Considered
| Model | Recall@10 | p99 Latency | Monthly Cost | Verdict |
|-------|-----------|-------------|--------------|---------|
| text-embedding-3-small | 0.87 | 145ms | $180 | **Selected** |
| text-embedding-3-large | 0.91 | 280ms | $720 | Over budget, latency fail |
| voyage-3 | 0.89 | 160ms | $220 | Close 2nd, vendor lock-in |
| bge-m3 (self-host, A10G) | 0.85 | 190ms | $350 (infra) | Ops burden, no clear win |

## Consequences
- Accept 4% recall gap vs large model for 2x cost savings and latency headroom
- Vendor dependency on OpenAI embedding API; mitigate with fallback to voyage-3
- Quantization adds ~1% recall drop; validated in shadow eval

## Review Date
2025-06-15 (or when corpus grows >1M chunks)

Verification checklist

Before declaring the model selection complete, confirm:

  • Requirements doc exists and is signed off by stakeholders
  • Eval set covers head, tail, and adversarial queries (≥200 cases)
  • Benchmark run on ≥3 candidate models with recall@10, MRR, p99 latency
  • Index memory footprint fits target infrastructure with headroom
  • Quantization impact measured and accepted
  • End-to-end answer quality eval passes threshold (≥80% pass rate)
  • Model version pinned in config/deployment
  • Shadow eval process documented for future updates
  • Production dashboards and alerts deployed
  • ADR written and linked in repo

The model you choose today will be wrong in six months — either because your corpus grew, your query distribution shifted, or a better model dropped. The goal isn’t perfection; it’s a process that lets you swap models confidently when the data demands it. Build the eval harness once, and every future migration becomes a measured decision instead of a guess.

Tagsembedding-modelsrag-architecturehow-tollm

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All rag architecture & pipeline design posts →