n4nAI

How embeddings power semantic search

A practical guide to building semantic search with embeddings — model selection, indexing strategies, query processing, reranking, and production pitfalls.

n4n Team4 min read956 words

Audio narration

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

Embeddings for semantic search turn text into dense vectors that capture meaning, not just keywords. This guide walks through the full pipeline — from model selection to production indexing — with concrete code and the tradeoffs you’ll hit at each step.

What embeddings actually are

An embedding model maps variable-length text to a fixed-dimensional vector where semantic similarity corresponds to geometric proximity. Cosine similarity between vectors approximates semantic relatedness. The math is straightforward: normalize vectors to unit length, then compute dot product.

import numpy as np

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

But the model choice determines everything downstream. A 384-dimension MiniLM runs in 10ms on CPU. A 4096-dimension E5-mistral needs GPU and 100ms. Both return “similar” vectors for “car” and “automobile,” but the larger model distinguishes “bank loan” from “river bank” more reliably.

Choosing a model

Start with the MTEB leaderboard, but filter for your constraints. Three dimensions matter:

Latency budget: If you embed at query time, 50ms is your ceiling. That rules out most 7B+ parameter models unless you batch aggressively.

Domain match: General models (E5, BGE, GTE) handle broad queries. For legal, biomedical, or code, fine-tuned variants (Legal-BERT, BioBERT, CodeBERT) win by 10-15 nDCG points.

Multilingual needs: If you serve non-English traffic, pick a model trained on 50+ languages (multilingual-e5-large, jina-embeddings-v2-base-en). Single-language models degrade sharply on accented or transliterated text.

# Quick local benchmark
from sentence_transformers import SentenceTransformer
import time

models = [
    "sentence-transformers/all-MiniLM-L6-v2",      # 384 dim, ~10ms CPU
    "BAAI/bge-small-en-v1.5",                       # 384 dim, ~15ms CPU
    "intfloat/multilingual-e5-large",               # 1024 dim, ~80ms GPU
]

sentences = ["How do I reset my password?", "Password reset instructions"] * 100

for name in models:
    model = SentenceTransformer(name)
    start = time.perf_counter()
    _ = model.encode(sentences, batch_size=32, show_progress_bar=False)
    elapsed = (time.perf_counter() - start) * 1000 / len(sentences)
    print(f"{name}: {elapsed:.1f}ms per sentence")

Run this on your target hardware. Published benchmarks use batch sizes and hardware you don’t have.

Building the index

Vector search libraries fall into three tiers:

Library Scale Build time Recall@10 When to use
FAISS (IVF+PQ) 1M-100M Minutes 0.95+ Default choice, CPU or GPU
HNSW (hnswlib) 100K-10M Seconds 0.98+ Low latency, frequent updates
DiskANN / SPTAG 100M+ Hours 0.90+ Billion-scale, SSD-optimized

For most teams, FAISS IVF+PQ hits the sweet spot. Index build is offline; query latency is sub-millisecond on CPU.

import faiss
import numpy as np

def build_ivf_pq_index(vectors: np.ndarray, nlist: int = 1024, m: int = 16) -> faiss.Index:
    """
    vectors: (N, d) float32, already L2-normalized
    nlist: number of Voronoi cells (sqrt(N) is a good start)
    m: number of PQ subquantizers (d must be divisible by m)
    """
    d = vectors.shape[1]
    quantizer = faiss.IndexFlatIP(d)  # inner product = cosine on normalized vectors
    index = faiss.IndexIVFPQ(quantizer, d, nlist, m, 8)  # 8 bits per subquantizer
    
    index.train(vectors)
    index.add(vectors)
    index.nprobe = 32  # search 32 cells at query time
    return index

Pitfall: Forgetting to normalize before IndexFlatIP. Inner product on unnormalized vectors ranks by magnitude, not angle. Always faiss.normalize_L2(vectors) after encoding.

Pitfall: Setting nprobe too low. Default is 1. At nprobe=1, IVF recalls 60-70%. At nprobe=32, you recover 95%+ with 2-3x latency. Tune this per workload.

Query processing and retrieval

Query embedding is the same model, same preprocessing. But three practical details change results:

Instruction tuning: E5 and BGE models expect a prefix: "query: " for queries, "passage: " for documents. Omitting this drops recall 5-10%.

def embed_query(model, text: str) -> np.ndarray:
    return model.encode(f"query: {text}", normalize_embeddings=True)

def embed_passages(model, texts: list[str]) -> np.ndarray:
    return model.encode([f"passage: {t}" for t in texts], normalize_embeddings=True)

Query expansion: For short queries (“error 500”), expand with hyponyms or rewrite via LLM before embedding. A single rewrite call adds 200ms but can lift recall@10 by 15%.

# Simple synonym expansion — replace with LLM rewrite for production
SYNONYMS = {
    "error": ["exception", "failure", "bug", "issue"],
    "login": ["sign in", "authenticate", "auth"],
    "reset": ["recover", "restore", "change"],
}

def expand_query(query: str) -> list[str]:
    words = query.lower().split()
    expanded = [query]
    for w in words:
        if w in SYNONYMS:
            for syn in SYNONYMS[w]:
                expanded.append(query.replace(w, syn))
    return expanded

Filtering before search: If you have hard filters (tenant_id, date_range, permission), apply them after vector search, not before. Pre-filtering requires a separate index per filter combination. Post-filtering with IndexIDMap or payload filtering (in Qdrant, Weaviate, Pinecone) keeps one index.

# FAISS post-filter pattern
ids = np.arange(len(vectors), dtype=np.int64)
index = faiss.IndexIDMap(index)
index.add_with_ids(vectors, ids)

# At query time: retrieve 100, filter in Python, return top 10
D, I = index.search(query_vec, k=100)
filtered = [(d, i) for d, i in zip(D[0], I[0]) if metadata[i]["tenant_id"] == current_tenant]
results = filtered[:10]

Embeddings retrieve candidates. A cross-encoder reranker scores (query, doc) pairs with full attention. This is expensive — 50-200ms per pair — so rerank only the top 50-100.

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank(query: str, candidates: list[dict], top_k: int = 10) -> list[dict]:
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs, batch_size=32)
    for c, s in zip(candidates, scores):
        c["rerank_score"] = float(s)
    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:top_k]

Hybrid search: Combine vector scores with BM25. Lexical matching catches exact terms (error codes, product IDs) that embeddings blur. Weighted reciprocal rank fusion (RRF) is simple and effective:

def rrf_fusion(vector_results: list[dict], bm25_results: list[dict], k: int = 60) -> list[dict]:
    """
    RRF: score = sum(1 / (k + rank)) across result sets
    """
    scores = {}
    for rank, doc in enumerate(vector_results):
        scores[doc["id"]] = scores.get(doc["id"], 0) + 1.0 / (k + rank + 1)
    for rank, doc in enumerate(bm25_results):
        scores[doc["id"]] = scores.get(doc["id"], 0) + 1.0 / (k + rank + 1)
    
    # Merge and sort
    all_docs = {d["id"]: d for d in vector_results + bm25_results}
    return sorted(all_docs.values(), key=lambda d: scores[d["id"]], reverse=True)

Set k=60 as default. Lower k favors top-ranked items; higher k smooths across deeper results.

Common pitfalls

Chunking strategy: Embedding whole documents loses granularity. Embedding sentences loses context. The sweet spot: 256-512 token chunks with 50-token overlap. For code, chunk by function/class. For legal, chunk by section.

def chunk_text(text: str, tokenizer, max_tokens: int = 512, overlap: int = 50) -> list[str]:
    tokens = tokenizer.encode(text)
    chunks = []
    for i in range(0, len(tokens), max_tokens - overlap):
        chunk_tokens = tokens[i:i + max_tokens]
        chunks.append(tokenizer.decode(chunk_tokens))
    return chunks

Stale embeddings: When content updates, re-embed. A nightly batch job works for most. For real-time needs, use a write-behind queue: write to primary DB, push embedding job to queue, update vector index asynchronously. Accept eventual consistency.

Dimension mismatch: Changing models changes dimensions. Your index is tied to a specific model version. Version your index: index_v1_minilm, index_v2_bge. Keep both during migration. Route queries by model version header.

Cold start: New content has no embeddings. Either embed synchronously on write (adds latency) or accept a visibility window. For user-generated content, synchronous embed with a 384-dim model adds ~15ms — usually acceptable.

Production considerations

Monitoring: Track three metrics per query: embedding latency, vector search latency, rerank latency. Alert on p99 > 200ms total. Log recall@k via sampled human evaluation — automated metrics drift.

Capacity planning: FAISS IVF+PQ stores ~1 byte per dimension per vector (PQ compression). 10M vectors × 768 dims ≈ 7.5 GB RAM. HNSW stores full vectors: 10M × 768 × 4 bytes ≈ 30 GB. Plan for 3x headroom.

Rolling updates: To update an IVF index without downtime, build a new index, swap the reference, retire the old. HNSW supports incremental add/remove but degrades over time — rebuild weekly.

Fallback: When your vector index is unavailable, fall back to BM25 or keyword search. The degradation is graceful, not catastrophic. n4n.ai’s routing layer can redirect embedding calls to a backup provider if your primary embedding endpoint fails, but the same principle applies at the application layer: always have a lexical backup.

# Simple fallback pattern
def search(query: str, top_k: int = 10) -> list[dict]:
    try:
        return vector_search(query, top_k)
    except (TimeoutError, ConnectionError, faiss.FaissError) as e:
        logger.warning(f"Vector search failed: {e}, falling back to BM25")
        return bm25_search(query, top_k)

Scaling beyond single-node

When query volume exceeds one machine, shard by tenant or by embedding namespace. Each shard gets its own index. A router directs queries to the correct shard. For multi-tenant systems, per-tenant indexes isolate noisy neighbors but increase memory overhead. Shared index with tenant_id filtering is more efficient until a single tenant dominates traffic.

Replication: run 3+ replicas per shard behind a load balancer. Health checks must verify index responsiveness, not just process liveness — a stuck GC pause on a 30 GB HNSW index looks healthy to kubelet but returns 5s latencies.


Start small: MiniLM + FAISS IVF+PQ + BM25 fallback gets you 80% of the way. Add reranking, hybrid fusion, and cross-encoder when metrics demand it. The embedding model is the easiest component to swap; the indexing and serving infrastructure is where you invest.

Tagsembeddingssemantic-searchsearch

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 embeddings posts →