Dense Retrieval: Semantic Search & Bi-Encoder Implementation › Code Implementation
Language AI HandbookAll chapters Dense Retrieval: Semantic Search & Bi-Encoder Implementation › Code ImplementationOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's implement dense retrieval using the sentence-transformers library, which provides pre-trained bi-encoder models optimized for semantic similarity. The library wraps BERT-based models with sensible defaults for pooling and normalization, which makes it straightforward to get a working retrieval system up and running.
!uv pip install sentence-transformers scikit-learn
import numpy as np
import matplotlib.pyplot as plt
from collections import Counter
from sklearn.decomposition import PCA
from sentence_transformers import SentenceTransformer
# Load a pre-trained bi-encoder model
# This model was trained on over 1 billion sentence pairs
# using contrastive learning on diverse text collections
model = SentenceTransformer('all-MiniLM-L6-v2')The all-MiniLM-L6-v2 model is a compact but effective retriever, creating 384-dimensional embeddings. It was distilled from a larger model and trained on over 1 billion sentence pairs using contrastive learning, which makes it a strong general-purpose retriever despite its small size. For production RAG systems requiring higher accuracy, larger models like msmarco-distilbert-base-v4 or all-mpnet-base-v2 provide better retrieval quality at higher computational cost.
# Sample document corpus about machine learning
documents = [
"Neural networks learn hierarchical representations of data through multiple layers.",
"Gradient descent optimizes model parameters by iteratively updating weights.",
"Transformers use self-attention to capture dependencies regardless of distance.",
"Random forests combine multiple decision trees for reliable predictions.",
"Support vector machines find optimal hyperplanes to separate classes.",
"Convolutional neural networks excel at processing grid-structured data like images.",
"Recurrent neural networks maintain hidden states to process sequential data.",
"The backpropagation algorithm computes gradients through the chain rule.",
]
# Encode all documents (this would be done offline in production)
# normalize_embeddings=True divides each vector by its L2 norm
# letting dot product to equal cosine similarity at query time
document_embeddings = model.encode(
documents, convert_to_numpy=True, normalize_embeddings=True
)Encoded 8 documents Embedding shape: (8, 384) Embedding dimension: 384 First embedding norm (should be ~1.0): 1.0000
Each document is now represented as a 384-dimensional unit vector. The pre-normalization confirms that all embeddings have L2 norm of exactly 1.0, meaning we can use dot product as a drop-in replacement for cosine similarity at query time. In a production system, these embeddings would be stored in a vector index for efficient approximate nearest neighbor search over millions of documents.
Computing Similarity Scores
Let's implement a function to retrieve documents for a query:
def dense_retrieve(query, doc_embeddings, docs, model, top_k=3):
"""
Retrieve top-k documents using dense retrieval.
Args:
query: The search query string
doc_embeddings: Pre-computed and normalized document embeddings
docs: List of document strings
model: The sentence transformer model
top_k: Number of documents to retrieve
Returns:
List of (document, score) tuples sorted by descending relevance
"""
# Encode the query (normalize to unit length for cosine similarity)
query_embedding = model.encode(
query, convert_to_numpy=True, normalize_embeddings=True
)
# Compute cosine similarity with all documents
# For normalized embeddings, dot product equals cosine similarity
# Shape of result: (num_docs,)
similarities = np.dot(doc_embeddings, query_embedding)
# Get indices of top-k highest scores (argsort returns ascending,
# so we reverse with [::-1] and take first top_k)
top_indices = np.argsort(similarities)[::-1][:top_k]
# Return documents with their similarity scores
return [(docs[i], similarities[i]) for i in top_indices]Let's test the retriever with a query that demonstrates semantic matching across vocabulary:
query = "How do neural networks learn?"
results = dense_retrieve(query, document_embeddings, documents, model, top_k=3)Query: How do neural networks learn? Top retrieved documents: 1. Score: 0.681 Neural networks learn hierarchical representations of data through multiple layers. 2. Score: 0.532 The backpropagation algorithm computes gradients through the chain rule. 3. Score: 0.437 Recurrent neural networks maintain hidden states to process sequential data.
The retriever correctly identifies documents about neural network learning mechanisms: the first result explains hierarchical learning directly, the second covers gradient descent (the mechanism by which neural networks learn), and the third covers backpropagation (how gradients are computed to drive learning). None of these documents contain the exact phrase "neural networks learn" yet they are all semantically relevant. The model understands that learning in neural networks is achieved through gradient descent and backpropagation, not just through documents that use the word "learn."
Comparing Dense and Lexical Retrieval
Let's compare dense retrieval to a simple lexical approach on a query that demonstrates vocabulary mismatch:
def bm25_score(query, doc, avg_doc_len, k1=1.5, b=0.75):
"""Compute simplified BM25 score for a single document."""
query_terms = query.lower().split()
doc_terms = doc.lower().split()
doc_len = len(doc_terms)
term_freq = Counter(doc_terms)
score = 0.0
for term in query_terms:
if term in term_freq:
tf = term_freq[term]
# BM25 term frequency normalization
numerator = tf * (k1 + 1)
denominator = tf + k1 * (1 - b + b * (doc_len / avg_doc_len))
score += numerator / denominator
return score
def lexical_retrieve(query, docs, top_k=3):
"""Retrieve using simplified BM25."""
avg_len = np.mean([len(d.split()) for d in docs])
scores = [(doc, bm25_score(query, doc, avg_len)) for doc in docs]
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]Now let's test both approaches on a query that uses different terminology than the documents:
# Query that uses different terminology than the documents
# "deep learning optimization methods" uses vocabulary not present in most docs
query_mismatch = "deep learning optimization methods"
dense_results = dense_retrieve(
query_mismatch, document_embeddings, documents, model, top_k=3
)
lexical_results = lexical_retrieve(query_mismatch, documents, top_k=3)Query: 'deep learning optimization methods' Dense Retrieval Results: 1. [0.513] Gradient descent optimizes model parameters by iteratively updating we... 2. [0.379] Neural networks learn hierarchical representations of data through mul... 3. [0.369] The backpropagation algorithm computes gradients through the chain rul... Lexical (BM25) Results: 1. [0.000] Neural networks learn hierarchical representations of data through mul... 2. [0.000] Gradient descent optimizes model parameters by iteratively updating we... 3. [0.000] Transformers use self-attention to capture dependencies regardless of ...
Dense retrieval correctly identifies gradient descent (an optimization method), backpropagation (how the optimization is computed), and neural network learning (the context of deep learning), all without those exact terms appearing in the query. The lexical system returns documents with BM25 scores of 0.0 for most candidates because the query terms "deep," "learning," "optimization," and "methods" rarely appear verbatim in the corpus. When results do appear, they are based on coincidental term matches rather than semantic relevance.
Visualizing the Embedding Space
Understanding the geometry of the embedding space helps build intuition for why dense retrieval works. Let's project the high-dimensional embeddings down to two dimensions using PCA to see how documents cluster:

The PCA projection reveals the clustering structure in the embedding space. Documents about neural network training mechanisms (gradient descent, backpropagation, hierarchical representations) cluster together in one region, while classical machine learning methods (SVMs, Random forests) cluster elsewhere, and specialized architectures (CNNs, RNNs, Transformers) form their own neighborhood. The query "How do neural networks learn?" falls naturally near the learning-mechanism cluster, which is exactly where dense retrieval would look for relevant documents.
Batch Retrieval for Efficiency
In production systems, we typically process multiple queries simultaneously. Encoding queries one at a time misses the opportunity to parallelize computation on GPU hardware. Batch encoding is one of the key practical optimizations for real retrieval systems:
def batch_retrieve(queries, doc_embeddings, docs, model, top_k=3):
"""
Efficiently retrieve for multiple queries using batched encoding
and matrix multiplication.
The key efficiency: encoding all queries simultaneously on GPU
is much faster than encoding them sequentially.
"""
# Encode all queries at once (enables GPU parallelism)
query_embeddings = model.encode(
queries, convert_to_numpy=True, normalize_embeddings=True
)
# Compute all query-document similarities with a single matrix multiply
# query_embeddings shape: (num_queries, embedding_dim)
# doc_embeddings.T shape: (embedding_dim, num_docs)
# Result shape: (num_queries, num_docs)
all_similarities = np.dot(query_embeddings, doc_embeddings.T)
results = []
for i, query in enumerate(queries):
similarities = all_similarities[i]
top_indices = np.argsort(similarities)[::-1][:top_k]
query_results = [(docs[j], similarities[j]) for j in top_indices]
results.append((query, query_results))
return results
# Test batch retrieval with three semantically diverse queries
test_queries = [
"attention mechanisms in deep learning",
"ensemble methods for classification",
"processing sequential information",
]
batch_results = batch_retrieve(
test_queries, document_embeddings, documents, model, top_k=2
)Batch Retrieval Results: Query: 'attention mechanisms in deep learning' [0.451] Neural networks learn hierarchical representations of data t... [0.376] Recurrent neural networks maintain hidden states to process ... Query: 'ensemble methods for classification' [0.513] Support vector machines find optimal hyperplanes to separate... [0.491] Random forests combine multiple decision trees for reliable ... Query: 'processing sequential information' [0.601] Recurrent neural networks maintain hidden states to process ... [0.265] Neural networks learn hierarchical representations of data t...
The matrix multiplication computes all query-document similarities simultaneously in a single operation. For a batch of queries and documents with embedding dimension , this requires operations performed in parallel on GPU, which is orders of magnitude faster than computing dot products sequentially. In practice, batching queries together is one of the simplest and most effective optimizations for production retrieval throughput.
Building a Simple Search Function
Let's wrap everything into a clean, reusable search interface that demonstrates the full retrieval pipeline:
class DenseRetriever:
"""
A simple dense retrieval system showing the full pipeline:
offline indexing and online query serving.
"""
def __init__(self, model_name="all-MiniLM-L6-v2"):
self.model = SentenceTransformer(model_name)
self.documents = []
self.embeddings = None
def index(self, documents):
"""
Offline step: encode all documents and store embeddings.
This is done once and can be cached to disk.
"""
self.documents = documents
self.embeddings = self.model.encode(
documents,
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=False,
)
return self
def search(self, query, top_k=5):
"""
Online step: encode query and find nearest neighbors.
Only the query encoding happens at search time.
"""
query_emb = self.model.encode(
query, convert_to_numpy=True, normalize_embeddings=True
)
scores = np.dot(self.embeddings, query_emb)
top_indices = np.argsort(scores)[::-1][:top_k]
return [
{
"rank": i + 1,
"score": float(scores[idx]),
"text": self.documents[idx],
}
for i, idx in enumerate(top_indices)
]# Build the index (offline step)
retriever = DenseRetriever()
retriever.index(documents)Query: 'sequence modeling with memory' Rank 1 (score: 0.542) Recurrent neural networks maintain hidden states to process sequential data. Rank 2 (score: 0.306) Gradient descent optimizes model parameters by iteratively updating weights. Rank 3 (score: 0.247) Neural networks learn hierarchical representations of data through multiple layers.
The separation between index() (offline) and search() (online) mirrors the architecture of production retrieval systems. The index step can be precomputed and cached to disk; in real systems it is typically stored in a vector database like FAISS, Pinecone, or Weaviate. The search step is the only latency-necessary path, and it reduces to a single transformer forward pass plus a matrix-vector multiply.