Dense Retrieval & Vector Search

Neural search with bi-encoders, embedding models, and vector databases

intermediate50 min
On this page

The Big Picture: From Sparse to Dense Retrieval

Sparse retrieval (BM25) excels at exact keyword matching but struggles with semantic gaps. The word "automobile" is very different from "car" to BM25, even though humans recognize they are synonymous. Dense retrieval solves this by encoding queries and passages as continuous vectors in a shared embedding space, where semantic similarity translates to geometric proximity.

The Semantic Gap Problem: BM25 sees "vehicle" and "car" as unrelated strings. Dense embeddings place them close together in vector space because their meanings are similar.

Bi-Encoder Architecture

The dominant approach in dense retrieval uses a bi-encoder architecture:

  1. Encode query: q_embed = Q_encoder(query)
  2. Encode passage: p_embed = P_encoder(passage)
  3. Rank by similarity: score = q_embed · p_embed (cosine or dot product)

Both encoders are typically BERT-based or based on modern transformer architectures. This design is efficient because you can pre-compute all passage embeddings, then at query time, encode only the query and perform fast similarity search.

Use Cases

Open-Domain QA

Find relevant passages from Wikipedia to answer questions without fine-tuning on specific domains.

Product Search

Match customer queries to product descriptions semantically, handling paraphrasing and synonyms.

Related Document Retrieval

Find similar documents, news articles, or research papers based on semantic content.

RAG Systems

Retrieve relevant context from large corpora to feed into generative models.

Diagram: BM25 vs Dense Semantic Match

Sparse vs Dense Retrieval
BM25 (Sparse) Query: "automobile" exact terms only Match: "automobile" ✓ Match: "car" ✗ Match: "vehicle" ✗ Dense (Embedding) Query: "automobile" → [0.12, -0.45, 0.78...] Similarity: "automobile" 0.98 Similarity: "car" 0.96 Similarity: "vehicle" 0.92 Semantic gap solved
Why Dense Retrieval Wins on Semantics: Embeddings capture meaning. Synonyms, paraphrases, and related concepts cluster together. Typos and morphological variations are handled naturally.

Dense Passage Retrieval (DPR)

Dense Passage Retrieval, introduced by Facebook AI, is a foundational bi-encoder approach for retrieval-augmented question answering. The key innovation is using in-batch negatives and hard negative mining during training to learn discriminative embeddings.

Architecture Overview

Query Encoder (BERT-based):
  query → [CLS] token → dense vector q_embed (768-dim)

Passage Encoder (BERT-based):
  passage → [CLS] token → dense vector p_embed (768-dim)

Similarity Score:
  score(q, p) = q_embed · p_embed  (dot product)

Training: Contrastive Learning

DPR uses contrastive learning to minimize InfoNCE loss. For each query, one passage is relevant (positive), and the rest are irrelevant (negatives).

DPR Training: Positive and Negative Pairs
Query "What is AI?" Positive (+) Relevant passage maximize Negative (-) Irrelevant passage minimize InfoNCE Loss: L = -log( exp(pos_score / τ) / sum(exp(all_scores / τ)) ) pos_score = q_embed · positive_embed (should be HIGH) negative_scores = q_embed · negative_embeds (should be LOW) τ (temperature) = 0.07 (typical hyperparameter, controls sharpness)

Hard Negative Mining

A critical ingredient for DPR training is using hard negatives — passages that are not relevant but are semantically similar or ranked highly by BM25. This prevents the model from learning trivial distinctions.

Hard Negatives: Run BM25 on your corpus. For each query, take the top-K BM25 results that are NOT the gold relevant passage. These are hard negatives because they fool the sparse retriever but should be penalized by the dense model.

Training process:

  1. Batch of N queries, each with 1 positive and ~N-1 negatives (in-batch negatives)
  2. Pre-compute hard negatives via BM25 for more challenging training
  3. Minimize InfoNCE loss: push positive score up, negative scores down
  4. Larger batch sizes help because they provide more diverse negatives

Reference: See bert_guide.html for BERT architecture details.

Training Dense Retrievers

Training an effective dense retriever requires careful data preparation, negative sampling strategies, and hyperparameter tuning. Most successful models use publicly available QA datasets and augment negatives with hard samples.

Standard Datasets

Dataset Size Type Characteristics
MS MARCO 368K QA pairs Web search Real user queries, weak relevance labels
Natural Questions 79K QA pairs Open-domain QA Google search logs, Wikipedia passages
TriviaQA 550K QA pairs Trivia Trivia websites, web documents
SQuAD 2.0 130K QA pairs Reading comprehension Wikipedia passages, span-based answers

Negative Sampling Strategies

1. In-Batch Negatives

In a batch of N queries, the N-1 other passages serve as negatives. Simple, but works well. Requires batch size ≥ 32 for reasonable diversity.

2. BM25 Hard Negatives

Run BM25 retrieval on the corpus. For each query, take top-K BM25 results that are not the gold passage. These are challenging negatives that fool keyword matching.

3. ANCE: Approximate Nearest Neighbor Negative Contrastive Learning

Dynamically refresh negatives using the current model. Every K training steps, retrieve hard negatives using ANN search on the current model's embeddings. This adapts to the model's progress.

ANCE Algorithm:
1. Initialize encoder
2. For each training step:
   a. If step % K == 0:
      - Encode all passages with current encoder
      - Build ANN index (e.g., HNSW)
      - For each query, retrieve top-n candidates via ANN
      - Use non-relevant candidates as hard negatives
   b. Train on query + positive + hard negatives
3. Repeat until convergence

Knowledge Distillation

Use a stronger re-ranker (cross-encoder) to assign soft labels or scores to passages, then train the bi-encoder to match those scores. This transfers knowledge from expensive cross-encoder to efficient bi-encoder.

Knowledge Distillation: Train cross-encoder on your task (expensive, slow). Then, for training data, use cross-encoder scores as soft targets for bi-encoder. The bi-encoder learns to approximate the cross-encoder's ranking.

Hyperparameter Impact

Parameter Typical Value Effect
Batch Size 32-128 Larger = more negatives = better gradients, but higher memory
Temperature τ 0.05-0.1 Lower = sharper loss, higher = smoother loss
Hard Negative Ratio 10-50% Higher = more challenging training, better discrimination
Learning Rate 1e-5 to 5e-5 Typical for BERT fine-tuning
Hard Negative Mining Pipeline
Initial Model BERT-based BM25 Search Get top-K Hard Negatives Non-relevant results Train on: • Query + Positive • Hard Negatives Updated Model Iteration Cycle: 1. Retrieve with BM25 2. Identify hard negatives 3. Train model 4. Repeat K steps ANCE refreshes every K steps

Sentence Transformers & Modern Embedding Models

Sentence BERT (SBERT) revolutionized dense retrieval by adding a pooling layer to BERT, enabling efficient generation of fixed-size embeddings. Modern alternatives like E5, BGE, and GTE further improve performance across diverse retrieval tasks.

SBERT Architecture

Sentence BERT adds simple mean pooling on top of BERT's [CLS] token, combined with fine-tuning on semantic similarity tasks:

SBERT: Siamese Network with Mean Pooling
Text Input BERT Encoder Sequence Output Mean Pooling avg of all tokens Fixed-Size Embedding (768-dim) Siamese: Same BERT weights used for both query and passage

Modern Embedding Models

Model Dim Base Key Features
SBERT (sentence-transformers) 384-768 BERT Fine-tuned on NLI, STS. Pioneer model. Easy to use.
E5 (Contrastive Search) 768 BERT Trained on diverse retrieval tasks. Strong generalization.
BGE (Alibaba) 768 BERT Multilingual. Contrastive learning. Top BEIR scores.
GTE (Alibaba) 768 BERT Lightweight. Good speed/quality tradeoff.
OpenAI text-embedding-3 1536 Proprietary Closed-source. State-of-the-art on many benchmarks.

Matryoshka Representation Learning (MRL)

Recent work allows a single model to generate embeddings of multiple dimensions efficiently. A 768-dim model trained with MRL can output 256-dim, 512-dim, or 768-dim embeddings by truncating the vector. This trades a small accuracy loss for faster search.

Evaluation: BEIR Benchmark

BEIR (Benchmark for Information Retrieval) is a standard suite of 15 diverse retrieval tasks:

BEIR Tasks: TREC COVID, DBpedia, SCIFACT, TREC-NEWS, MS MARCO, NFCorpus, NQ, HotpotQA, Climate-FEVER, FiQA, Legal, Robust04, Signal, WebQuestions, TREC-COVID-dev.

Typical evaluation: compute NDCG@10, recall@100, and mean reciprocal rank (MRR) across tasks. Top models achieve NDCG@10 ≈ 0.55-0.60 on BEIR.

Reference: See bert_guide.html for BERT internals and fine-tuning.

Approximate Nearest Neighbor (ANN) Algorithms

With millions or billions of embeddings, exact nearest neighbor search (brute-force L2 or cosine similarity) becomes prohibitively slow: O(N·d) complexity. ANN algorithms sacrifice small amounts of accuracy for massive speedup, enabling real-time search.

The ANN Accuracy-Speed Tradeoff

ANN Methods: Recall vs QPS
Queries Per Second (QPS) → Recall ↑ 100 1K 10K 100K 0% 50% 100% Brute-force LSH KD-tree IVF-PQ HNSW Pareto frontier

Four Families of ANN Methods

1. Tree-Based (KD-tree, Ball Tree)

Recursively partition the space into regions. Query: traverse tree to find relevant regions.

  • Pros: Simple, interpretable, works for low-dimensional data (d ≤ 20)
  • Cons: "Curse of dimensionality" — performance degrades in high dimensions
  • Example: scikit-learn's KDTree for small datasets

2. Graph-Based (HNSW, NSG)

Build a proximity graph where edges connect similar vectors. Query: navigate graph to nearest neighbor.

  • Pros: Excellent recall/QPS tradeoff, scales to billions, no training
  • Cons: Index building is sequential, O(N log N) or O(N) time
  • Example: HNSW (Hierarchical Navigable Small World) — dominant in practice

3. Quantization-Based (IVF-PQ, Product Quantization)

Reduce dimensionality and precision. IVF: partition into clusters. PQ: compress sub-vectors to bytes.

  • Pros: Extreme memory savings (32-100× smaller), very fast on GPU
  • Cons: Training required, recall ~95% with aggressive compression
  • Example: FAISS library (Facebook), industry standard

4. Hashing-Based (LSH, Learned Hashing)

Hash vectors into buckets. Query: hash and search nearby buckets.

  • Pros: Fast hashing, very memory-efficient
  • Cons: Recall is harder to tune, collisions affect quality
  • Example: Locality Sensitive Hashing (LSH), used mainly in research
In Production: HNSW (graph-based) and IVF-PQ (quantization) dominate. HNSW for best quality and fast index building; IVF-PQ for extreme scale and GPU acceleration.

HNSW: Hierarchical Navigable Small World

HNSW is a graph-based ANN algorithm that combines small-world networks with hierarchical organization. It achieves excellent recall and query speed while being practical to implement and use in production.

Core Idea: Small-World Networks

A small-world network is a graph where most nodes are not neighbors, but any node can be reached from any other in a small number of steps. Think of social networks: you are connected to direct friends, but can reach most people through "degrees of separation."

Hierarchical Organization

HNSW extends this by creating multiple layers:

  • Top Layer: Few nodes, sparse connections, large jumps in space
  • Middle Layers: More nodes, medium density
  • Bottom Layer (L0): All nodes, dense connections, fine-grained search

Construction Algorithm

HNSW Construction:
1. For each vector v to insert:
   a. Assign level l randomly (exponentially decreasing probability)
   b. If l > max_level: update entry point
   c. For each layer from max_level down to 0:
      - Find nearest neighbors in that layer
      - Connect v to M nearest neighbors (M = degree parameter)
      - Update neighbors to point back to v
   d. Prune: keep only M nearest per layer (M = edge parameter)

Key Parameters

M (max_m): Maximum number of connections per node. Typical: 5-48. Higher M = better quality but more memory and slower construction.
ef_construction: Beam width during index construction. Controls how exhaustive the search is when finding neighbors. Higher = better quality but slower building. Typical: 200-500.
ef_search: Beam width during query. Higher = better recall but slower query. Can be adjusted per query without rebuilding.

Query Algorithm

HNSW Search (query q, ef):
1. Start from entry point (top layer)
2. For each layer from max_level down to 0:
   - Find nearest neighbor to q using greedy search
   - If not at bottom: use found neighbor as entry point for next layer
   - If at bottom: expand search beam to ef neighbors
3. Return top-K neighbors

Why HNSW Wins

Aspect Characteristic
Query Time O(log N) average, typically 10-50ms for billions of vectors
Construction O(N log N) or O(N) with good parameters, incremental insert possible
Recall 95-99% with typical ef_search settings
No Training Unlike IVF-PQ, no k-means or quantization training needed
Incremental Can add vectors one-by-one without rebuilding entire index
HNSW Hierarchical Structure
Layer 2 (top) Layer 1 Layer 0 (bottom) Entry point Mid-level Dense search

Typical HNSW Settings

Scenario M ef_construction ef_search Notes
High-Quality / Large Scale 32-48 400-500 200-400 Best quality, slower build/query
Balanced (Default) 16 200 100 Good quality/speed tradeoff
Fast / Real-Time 8 100 50 Lower quality, fast response

FAISS: IVF-PQ (Inverted File + Product Quantization)

FAISS (Facebook AI Similarity Search) is an industry-standard library for similarity search. IVF-PQ combines inverted file indexing with product quantization to achieve extreme memory efficiency and GPU acceleration while maintaining good recall.

Part 1: Inverted File (IVF)

IVF partitions the vector space using k-means clustering:

  1. Run k-means on embeddings → K cluster centers (Voronoi cells)
  2. Assign each vector to its nearest cluster center
  3. Store inverted lists: for each cluster, list of vector IDs in that cluster
  4. Query: compute query distance to K cluster centers, search only nearest nprobe clusters
IVF Trade-off: Larger K = finer partitioning, better accuracy, but more memory for cluster centers. Typical K = 65536 (2^16) for large corpora.

Part 2: Product Quantization (PQ)

Instead of storing full 768-dimensional vectors (768 × 4 bytes = 3072 bytes per vector), PQ compresses them dramatically:

Product Quantization:
1. Split each 768-dim vector into M sub-vectors (M=96, each 8-dim)
2. For each sub-vector, learn codebook of 256 codewords (8-bit)
3. Replace each sub-vector with its nearest codeword index
4. Store only M bytes per vector (instead of 3072 bytes)
   Compression: 3072 → 96 bytes = 32× reduction!

Asymmetric Distance Computation (ADC)

At query time, compute distances without decompressing vectors:

  • Query: 768-dim (full precision)
  • Corpus: PQ-compressed to bytes
  • Distance: compute distance from query sub-vectors to PQ codebook, then sum
  • Result: approximate distance, but very fast (no decompression needed)
IVF-PQ: Clustering and Quantization
IVF: K-means Clustering Cluster 1 Cluster 2 ...K clusters via K-means PQ: Quantization Vector: [0.12, -0.45, 0.78, ...] ↓ Split into M sub-vectors Sub1: [0.12, -0.45, ...]→Code: 42 Sub2: [0.78, 0.33, ...] → Code: 156 ... Result: Compact Index • Memory: 32-100× smaller • Speed: Search nprobe clusters, ADC on compressed vectors • Recall: ~95% with good parameters (vs 100% brute-force)

FAISS Index Types

Index Type Memory QPS Recall Use Case
Flat (Brute-force) High (4 bytes/dim) Low 100% Small corpora, exact search
IVF High (4 bytes/dim) Medium High (95%+) Large CPU-based search
IVF-PQ Very Low (1 byte/dim) Very High Good (90-95%) Billion-scale, GPU
HNSW (hnswlib) Medium High Excellent (98%+) Production, incremental updates
FAISS on GPU: FAISS supports GPU acceleration for IVF and IVF-PQ. On NVIDIA GPUs, IVF-PQ can achieve 100K+ QPS for billion-scale corpora.

Modern Vector Search Solutions

Beyond research implementations, production vector search requires features like metadata filtering, incremental updates, and easy deployment. Modern solutions provide these while leveraging state-of-the-art ANN algorithms.

ScaNN (Google)

ScaNN (Scalable Nearest Neighbors) introduces anisotropic vector quantization, achieving better recall than standard PQ at the same compression level.

  • Key Innovation: Quantization is optimized for the specific query distribution, not uniform
  • Recall: ~97% with 4× compression (vs ~90% for standard PQ)
  • Use: Google uses ScaNN for YouTube and Search recommendations

NSG (Navigating Spreading-out Graph)

A graph-based approach similar to HNSW but with additional optimizations:

  • Construction: Greedily builds graph to minimize "neighborhood expansion"
  • Recall: Comparable to HNSW, ~98%+
  • Industry: Used in Alibaba, Tencent production systems

DiskANN (Microsoft)

Enables billion-scale search on a single machine with SSD storage, not requiring GPU or distributed setup.

  • Storage: Vectors on disk, index in RAM (100× smaller than vectors)
  • Scalability: Supports 100M+ vectors on single machine
  • Speed: Millisecond latency with SSD I/O optimization

Managed Vector Databases

Service Type Deployment Key Features
Pinecone Managed SaaS Cloud-only Metadata filtering, hybrid search, serverless
Weaviate Open-source + Cloud Self-hosted or Cloud GraphQL API, hybrid search, multimodal support
Qdrant Open-source Self-hosted or Cloud Rust-based, payload filtering, hybrid search
Milvus Open-source Self-hosted Distributed, FAISS backend, metadata filtering
Vespa Open-source Self-hosted or Cloud Hybrid BM25+dense, advanced filtering, ranking

When to Use Each

FAISS (Local)

Python library, no deployment overhead. Good for experimentation, research, batch processing.

HNSW (hnswlib)

Best quality/speed. Python library. Ideal for production systems, incremental indexing.

Pinecone (Managed)

Zero ops, built-in metadata filtering, API-first. Best for quick prototypes, SaaS apps.

Milvus/Qdrant (Open-source)

Self-hosted, distributed, full control. Enterprise deployments, cost-sensitive systems.

Hybrid Search: Modern systems support combining dense and sparse (BM25). Dense retrieves semantically similar items; BM25 catches exact/rare matches. Fusion at query time improves coverage.

Practical Considerations

Moving from research to production requires addressing indexing overhead, incremental updates, filtering, and monitoring. This section covers real-world challenges and solutions.

Index Building and Memory

HNSW Construction Time: O(N log N) with typical parameters. For 100M embeddings (768-dim, 768×4 = 3KB each = 230GB raw), building takes hours on a single machine.

Solution: Use GPU-accelerated FAISS (IVF-PQ) for initial indexing, then build HNSW incrementally. Or: distribute construction across machines, build local indices, then merge.

Incremental Indexing

For real-time systems (e-commerce, news, social media), new items arrive continuously.

  • HNSW: Supports incremental insert() — add new vectors one-by-one. Index remains online.
  • IVF-PQ: Requires retraining k-means periodically (offline). Can add vectors to existing clusters until retraining.
  • Buffer approach: New items go into a fast (small) index, periodically merge with main index

Filtered Search (Metadata Filtering)

Often need to filter by metadata: brand='Nike' AND price<$200 before or during ANN search.

Pre-Filter

Filter metadata first, then run ANN on the remaining vectors. Fast filtering but may discard relevant vectors.

Post-Filter

Retrieve top-K from ANN, then apply metadata filter. Simpler but may return few results if filter is strict.

Native Filtering

Integrate filtering into ANN search (e.g., HNSW with metadata tags). Qdrant, Weaviate, Pinecone support this. Best quality.

Multi-Vector Retrieval (Late Interaction)

ColBERT (Context-Aware BERT Representations for Learning-To-Rank) represents queries and documents as sets of vectors (one per token), then compute similarities at token level. More expressive than single-vector dense retrieval but more expensive.

Quantization and Accuracy Trade-offs

Quantization Memory Accuracy Loss Use Case
FP32 (no compression) 4 bytes/dim 0% Reference, small corpora
FP16 (half precision) 2 bytes/dim <0.5% GPU-friendly, minimal loss
INT8 1 byte/dim 1-2% CPU/mobile, moderate compression
PQ (1 byte/dim) 1 byte/dim 3-5% Billion-scale, GPU-friendly

Embedding Model Updates

When you retrain your embedding model, all stored embeddings become stale. Three approaches:

  1. Batch Re-encode: Re-encode all passages with new model, rebuild index (offline, several hours)
  2. A/B Testing: Run new model alongside old. Gradual migration.
  3. Adapter Module: Keep embeddings fixed, train lightweight adapter to map old embeddings to new space

Monitoring Dense Retrieval

Key Metrics to Track:
  • Recall Drift: Monitor recall@K on a held-out test set. Alert if drops below threshold.
  • Latency Percentiles: Track p50, p95, p99 query latencies. Watch for ANN timeout/degradation.
  • Index Freshness: How old is the oldest vector in the index? Freshness SLA for new items.
  • Query Distribution: Monitor query embedding distribution. Large shifts may indicate query-doc mismatch.
  • Embedding Drift: Sample embeddings over time. If they shift, may need model retraining.

Dense vs Sparse vs Hybrid Retrieval

Each retrieval approach has strengths and weaknesses. Understanding the tradeoffs helps choose the right method for your task.

Comprehensive Comparison

Aspect BM25 (Sparse) DPR/E5 (Dense) Hybrid
Semantic Understanding Poor (keyword only) Excellent Excellent
Rare Terms / Entities Excellent (exact match) Poor (OOV, rare in training) Excellent
Typo / Morphology Requires preprocessing Handled naturally Handled naturally
Training Data Required None (unsupervised) Yes (QA pairs) Yes
Index Size Very small (inverted index) Large (embeddings) Large
Query Speed Fast (ms) Fast (ms with ANN) Fast (ms)
Training Time N/A Hours to days Hours to days
Interpretability Fully transparent (keywords) Black-box embeddings Partial (keywords + scores)

When to Use Each

BM25 Wins On:

  • Entity search: "Barack Obama", "COVID-19 vaccine"
  • Product catalogs with product IDs, SKUs
  • Legal/financial documents with specific terms and regulations
  • Systems with no training data and strict interpretability requirements

Dense Retrieval Wins On:

  • Open-domain QA: "Who won the Nobel Prize in 2023?"
  • Semantic search: "flights to Paris in the spring"
  • Paraphrase matching: "autonomous vehicles" ≈ "self-driving cars"
  • Typo tolerance: "rekursion" → "recursion"
  • Cross-lingual search with multilingual embeddings

Hybrid Retrieval Wins On:

  • E-commerce: combine semantic search with brand/category filters and entity matching
  • Enterprise search: blend semantic understanding with policy keyword compliance
  • Medical search: find semantically similar papers AND articles with specific drug names

When to Upgrade from BM25 to Dense

Consider dense retrieval if:

  • BM25 baseline has low recall on a held-out test set (<50% recall@100)
  • You have semantic failures: synonyms, paraphrases not retrieved
  • You have training data (query-passage pairs) or can leverage pre-trained models like E5
  • Index size is not a constraint (embedding storage is manageable)
  • You can afford ANN index building time (hours, not seconds)
Practical Migration: Start with BM25. Measure recall@100, NDCG@10 on your test set. If adequate, keep BM25. If not, add dense retrieval and combine via rank fusion (RRF, learned fusion).

References: See bm25_inverted_index_guide.html and hybrid_search_reranking_guide.html for detailed comparisons and hybrid approaches.

Python Implementation: Complete Dense Retrieval System

Build a practical dense retrieval system from scratch using only numpy and pandas. This demonstrates key concepts: embedding computation, similarity search, and evaluation.

Full Implementation

import numpy as np
import pandas as pd
from typing import List, Tuple
import math

class SimpleEmbedder:
    """TF-IDF proxy for embeddings (no dependencies)."""

    def __init__(self, vocab_size=100, dim=64):
        self.vocab_size = vocab_size
        self.dim = dim
        self.word_to_id = {}
        self.id_matrix = None
        self.idf = None

    def fit(self, texts: List[str]):
        """Learn vocabulary and IDF weights."""
        words = set()
        doc_freq = {}

        for text in texts:
            tokens = text.lower().split()
            words.update(tokens)
            for token in set(tokens):
                doc_freq[token] = doc_freq.get(token, 0) + 1

        # Build vocabulary (top vocab_size words)
        sorted_words = sorted(words,
            key=lambda w: doc_freq.get(w, 0), reverse=True)
        self.word_to_id = {w: i for i, w in
            enumerate(sorted_words[:self.vocab_size])}

        # Compute IDF
        N = len(texts)
        self.idf = {}
        for word in self.word_to_id:
            count = doc_freq.get(word, 0)
            self.idf[word] = math.log(N / max(1, count))

        # Initialize random projection matrix
        np.random.seed(42)
        self.id_matrix = np.random.randn(self.vocab_size,
            self.dim) / math.sqrt(self.dim)

    def embed(self, text: str) -> np.ndarray:
        """Convert text to dense vector."""
        tokens = text.lower().split()
        tfidf_vec = np.zeros(self.vocab_size)

        # Compute TF-IDF
        for token in tokens:
            if token in self.word_to_id:
                wid = self.word_to_id[token]
                idf = self.idf.get(token, 0)
                tfidf_vec[wid] += idf

        # L2 normalize
        norm = np.linalg.norm(tfidf_vec)
        if norm > 0:
            tfidf_vec /= norm

        # Project to embedding space
        embedding = tfidf_vec @ self.id_matrix
        embedding /= (np.linalg.norm(embedding) + 1e-8)
        return embedding


class DenseRetriever:
    """Bi-encoder style dense retriever."""

    def __init__(self, embedder: SimpleEmbedder):
        self.embedder = embedder
        self.passages = []
        self.embeddings = None

    def build_index(self, passages: List[str]):
        """Build index from passages."""
        self.passages = passages
        self.embeddings = np.array([
            self.embedder.embed(p) for p in passages
        ])

    def retrieve(self, query: str, top_k: int = 5
        ) -> List[Tuple[str, float]]:
        """Retrieve top-K passages for query."""
        q_embedding = self.embedder.embed(query)

        # Compute cosine similarity
        scores = self.embeddings @ q_embedding

        # Get top-K indices
        top_indices = np.argsort(scores)[::-1][:top_k]

        results = []
        for idx in top_indices:
            passage = self.passages[idx]
            score = float(scores[idx])
            results.append((passage, score))

        return results


class ANNIndex:
    """Brute-force ANN (top-K with batch processing)."""

    def __init__(self, embeddings: np.ndarray):
        self.embeddings = embeddings

    def search(self, query_embedding: np.ndarray, k: int = 10
        ) -> List[int]:
        """Return top-K indices."""
        scores = self.embeddings @ query_embedding
        return np.argsort(scores)[::-1][:k].tolist()


class DenseVsBM25Eval:
    """Evaluate dense retrieval vs BM25."""

    @staticmethod
    def bm25_score(query: str, passage: str) -> float:
        """Simple BM25-like scoring."""
        q_tokens = set(query.lower().split())
        p_tokens = passage.lower().split()
        matches = sum(1 for t in p_tokens if t in q_tokens)
        return matches / (len(p_tokens) + 1)

    @staticmethod
    def evaluate(retriever: DenseRetriever, passages: List[str],
        query: str, relevant_passages: List[str]):
        """Compare dense and BM25 retrieval."""

        # Dense retrieval
        dense_results = retriever.retrieve(query, top_k=5)
        dense_matches = sum(1 for p, _ in dense_results
            if p in relevant_passages)

        # BM25 retrieval
        bm25_scores = [(p, DenseVsBM25Eval.bm25_score(query, p))
            for p in passages]
        bm25_results = sorted(bm25_scores,
            key=lambda x: x[1], reverse=True)[:5]
        bm25_matches = sum(1 for p, _ in bm25_results
            if p in relevant_passages)

        return {
            'dense_precision@5': dense_matches / 5,
            'bm25_precision@5': bm25_matches / 5,
            'dense_results': dense_results,
            'bm25_results': bm25_results
        }


# Example usage
if __name__ == '__main__':
    # Sample corpus
    passages = [
        "Machine learning is a subset of artificial intelligence",
        "Deep learning uses neural networks with many layers",
        "Python is a popular programming language for data science",
        "Transfer learning improves model performance with limited data",
        "Convolutional neural networks excel at image recognition",
        "NLP focuses on processing human language",
        "Transformers use attention mechanisms for sequence modeling",
        "GPU acceleration speeds up neural network training",
        "Regularization prevents overfitting in machine learning",
        "Gradient descent optimizes neural network weights",
        "Embeddings represent words as dense vectors",
        "BERT is a pre-trained language model from Google",
        "Information retrieval finds relevant documents quickly",
        "Semantic search understands meaning beyond keywords",
        "Vector databases store and search embeddings efficiently",
        "Attention mechanisms allow models to focus on relevant parts",
        "Fine-tuning adapts pre-trained models to new tasks",
        "Batch normalization stabilizes deep network training",
        "Dropout regularization randomly deactivates neurons",
        "Cross-entropy loss measures classification error"
    ]

    # Build retriever
    embedder = SimpleEmbedder(vocab_size=50, dim=32)
    embedder.fit(passages)

    retriever = DenseRetriever(embedder)
    retriever.build_index(passages)

    # Test queries
    test_cases = [
        ("vector embeddings for search",
         ["Embeddings represent words as dense vectors",
          "Vector databases store and search embeddings efficiently"]),
        ("deep learning models",
         ["Deep learning uses neural networks with many layers",
          "Convolutional neural networks excel at image recognition"]),
        ("language processing techniques",
         ["NLP focuses on processing human language",
          "Transformers use attention mechanisms for sequence modeling"])
    ]

    print("=" * 70)
    print("DENSE RETRIEVAL vs BM25 EVALUATION")
    print("=" * 70)

    evaluator = DenseVsBM25Eval()

    for query, relevant in test_cases:
        print(f"\nQuery: '{query}'")
        print(f"Relevant passages: {len(relevant)}")

        results = evaluator.evaluate(retriever, passages,
            query, relevant)

        print(f"\nDense Precision@5: {results['dense_precision@5']:.2f}")
        print(f"BM25 Precision@5: {results['bm25_precision@5']:.2f}")

        print("\nTop-5 Dense Results:")
        for i, (p, score) in enumerate(results['dense_results'], 1):
            marker = "✓" if p in relevant else " "
            print(f"  {i}. [{marker}] {p[:60]}... (score: {score:.3f})")

        print("\nTop-5 BM25 Results:")
        for i, (p, score) in enumerate(results['bm25_results'], 1):
            marker = "✓" if p in relevant else " "
            print(f"  {i}. [{marker}] {p[:60]}... (score: {score:.3f})")

        print("-" * 70)

Key Output Example

Query: "vector embeddings for search"
Relevant passages: 2

Dense Precision@5: 0.40
BM25 Precision@5: 0.20

Top-5 Dense Results:
  1. [✓] Embeddings represent words as dense vectors... (score: 0.654)
  2. [✓] Vector databases store and search embeddings effi... (score: 0.612)
  3. [ ] Semantic search understands meaning beyond keywor... (score: 0.589)
  4. [ ] Information retrieval finds relevant documents qu... (score: 0.556)
  5. [ ] Transformers use attention mechanisms for sequence... (score: 0.478)

Top-5 BM25 Results:
  1. [✓] Vector databases store and search embeddings effi... (score: 0.400)
  2. [ ] Machine learning is a subset of artificial intell... (score: 0.200)
  3. [ ] Deep learning uses neural networks with many layers (score: 0.167)
  4. [ ] NLP focuses on processing human language (score: 0.167)
  5. [ ] Transformers use attention mechanisms for sequence... (score: 0.167)
What This Shows: Dense retrieval captures semantic similarity (embeddings + vector matching) and achieves higher precision. BM25 relies on exact keyword overlap and misses semantically relevant passages.

Extending to Production

To scale to millions of embeddings:

  • Replace embedder: Use SBERT, E5, or OpenAI embeddings instead of TF-IDF
  • Add ANN index: Use FAISS or HNSW instead of brute-force
  • Metadata filtering: Add filtering by tags, categories, dates
  • Re-ranker: Use a cross-encoder to rerank top-100 for final ranking
  • Monitoring: Track recall@K, latency, index freshness

Reference: See rag_patterns_guide.html for integrating dense retrieval into RAG pipelines.

End of Dense Retrieval & Vector Search