Approximate Search Algorithms, HNSW (ANN & FAISS)
Let's delve into the core algorithms that power vector databases like Elasticsearch and high-speed search libraries like FAISS.
This indexing architecture sacrifices 100% perfect accuracy to prevent server crashes, instead achieving hundreds of times faster search speeds while maintaining 99% accuracy.
- ANN (Approximate Nearest Neighbor): A general term for algorithms that quickly find approximately the K nearest vectors to a query vector in a vector space.
- HNSW (Hierarchical Navigable Small World): An algorithm that has dominated the ANN ecosystem, exploring node data connected in a multi-layered graph.
- FAISS (Facebook AI Similarity Search): An ultra-high-speed dense vector search library developed in C++ by the Meta AI research team. It allows easy use of various ANN algorithms, including HNSW.
Origin of the Names #
- Approximate: A trade-off that allows for a slight error margin to gain search performance (throughput) and memory efficiency, instead of performing an exhaustive (exact) search.
- HNSW (Hierarchical Navigable Small World):
Hierarchicalmeans it's structured in multiple layers, like apartment floors.Navigablemeans it's possible to find paths because neighboring nodes are connected by links or edges.Small Worldborrows from the small-world network theory, which states that anyone in the world can be connected through six degrees of separation, implying that even distant data can be reached with just a few jumps.
The Problem #
Exact k-NN: Traditionally, when a single query vector came in, it required a brute-force calculation of cosine similarity with each of the million product vectors in the database. If traffic surged, the CPU couldn't handle it, leading to search times exceeding 1-2 seconds, making real-world service impossible.
LSH's accuracy limitations also existed. The LSH previously used involved simply truncating vectors into short 16-bit or 32-bit strings and placing them into hash tables. While fast, it compressed precise float values crudely. For detailed items like home shopping products, this meant a critical problem: the true answer might be placed in the wrong hash bucket and never found. This resulted in an accuracy of around 70% and only slightly faster search times.
Solution #
Solved with graph-based search. HNSW doesn't just shove data into hash tables; it builds a graph that weaves together relationships between data like a spiderweb.
It uses multiple layers, from high-speed "highway" layers for long jumps to precise "alleyway" layers for detailed exploration, to find the optimal path.
Let's take an intuitive example.
Imagine you need to find a red linen shirt in a giant warehouse with a million pieces of clothing.
- Exact k-NN: This would be an exhaustive search. An employee would pull out and compare each of the million clothes from the warehouse entrance. Accuracy would be 100%, but it would take a long time.
- LSH: Similar to a postal code, clothes are thrown into bins by similar colors. The employee would search the red bin, which is fast. However, if they mistakenly put a linen shirt in the orange bin, it would never be found. This means accuracy is around 70%, and search time is only slightly shorter.
- HNSW (Navigation Graph): The employee has drawn a map of the warehouse by floor.
- 3rd floor (Highway): A big jump to the summer clothes section.
- 2nd floor (National Road): Within summer clothes, the shirt section.
- 1st floor (Alleyway): Within the shirt section, compare only 10 red clothes near me. Even comparing only 10 items, accuracy is high (98% accuracy, search time around 0.01 seconds).
Detailed Operating Principles and Structure #
HNSW's internal architecture is a multi-layer graph navigation system, divided into spatial layers.
1. Indexing Phase #
In the bottom-most layer (layer 0), all million data points are densely connected with their neighbors.
As you move up, only a sparse number of "hubs" that survived through probability are connected (layer 1, layer 2...).
2. Search Phase #
The search begins at the entry point, from the topmost layer with fewer nodes.
It performs a greedy search: moving to the node closest to the query vector within the current layer. If there are no closer neighbors, it moves down to the layer below.
Upon reaching the bottom-most layer (layer 0), it's already in a location very close to the query. It then sorts the surrounding neighbors and returns the final top-k. In this process, the complexity converges to O(log(N)).
Example #
This is the basic logic for taking vectors generated by PyTorch, inserting them into a FAISS HNSW index, and performing ultra-fast searches. The key point is that FAISS only handles numpy, float32 types.
import faiss
import numpy as np
import torch
def understand_faiss_hnsw(query_tensor, db_tensors):
"""
Inserts PyTorch vectors of dimension (d) 512 into a FAISS HNSW index and searches.
"""
# 1. Convert PyTorch Tensor -> Numpy Array (FAISS requirement)
# Move to CPU memory and cast to contiguous float32 array.
db_vectors = db_tensors.cpu().numpy().astype('float32')
query_vector = query_tensor.cpu().numpy().astype('float32')
d = db_vectors.shape[1] # Number of vector dimensions (e.g., 512)
# 2. Create HNSW index
# 32 represents the maximum number of neighbors (M) to connect per node. (Parameter tuning factor)
index = faiss.IndexHNSWFlat(d, 32)
# Set distance metric to L2 distance (dot product) which has the same effect as cosine similarity.
index.hnsw.efConstruction = 40 # Search depth during indexing (accuracy vs. build speed)
# 3. Index data (build graph in memory)
index.add(db_vectors)
print(f"Total {index.ntotal} vectors indexed in HNSW graph.")
# 4. Set search parameters and search
index.hnsw.efSearch = 64 # Search depth during search (accuracy vs. search speed)
k = 5 # Top 5
# distances: Returns distance values of nodes closest to the query
# indices: Returns array indices (IDs) of those nodes in the DB
distances, indices = index.search(query_vector, k)
print(f"Found Top-{k} IDs : {indices[0]}")
print(f"Distances for these IDs : {distances[0]}")
Let's look at another example: a wrapper class that performs pre-normalization for cosine similarity search, allowing the index to be written to disk (write_index) and loaded (read_index) without re-indexing vectors every time in a real server environment.
import faiss
import numpy as np
class HNSWSearchEngine:
"""FAISS-based vector storage/search engine used in production environments"""
def __init__(self, dimension: int = 512, m: int = 32, ef_search: int = 128):
self.dimension = dimension
# Create HNSW index based on L2 distance (for cosine similarity)
self.index = faiss.IndexHNSWFlat(dimension, m)
self.index.hnsw.efConstruction = 100
self.index.hnsw.efSearch = ef_search
def _normalize(self, vectors: np.ndarray) -> np.ndarray:
"""
[Very Important] FAISS's built-in cosine similarity search is tricky.
However, if you L2-normalize vectors (make their length 1) and then calculate L2 distance (Euclidean),
it mathematically becomes perfectly equivalent to the cosine similarity ranking.
"""
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
# Prevent division by zero
norms = np.where(norms == 0, 1e-10, norms)
return vectors / norms
def build_index(self, product_ids: list, vectors: np.ndarray, save_path: str = "hnsw.index"):
"""Normalizes all product vectors, adds them to the graph, and saves to a file."""
assert len(product_ids) == vectors.shape[0]
normalized_vecs = self._normalize(vectors).astype('float32')
self.index.add(normalized_vecs)
# In production, you need to store a separate dictionary mapping index (0, 1, 2...) to actual product IDs (item_99).
self.id_map = {i: pid for i, pid in enumerate(product_ids)}
# Dump the index to disk for 1-second recovery on restart
faiss.write_index(self.index, save_path)
print(f"✅ HNSW index saved: {save_path} (Total {self.index.ntotal} items)")
def load_index(self, load_path: str = "hnsw.index"):
"""Loads the index from disk into memory on server startup."""
self.index = faiss.read_index(load_path)
print(f"✅ HNSW index loaded (Total {self.index.ntotal} items)")
def search(self, query_vector: np.ndarray, top_k: int = 10):
"""Handles real-time search queries."""
# Query vector must also be normalized
query_normalized = self._normalize(query_vector).astype('float32')
# Perform search (takes a few milliseconds)
distances, indices = self.index.search(query_normalized, top_k)
results = []
for dist, idx in zip(distances[0], indices[0]):
if idx != -1: # -1 means not found
results.append({
"product_id": self.id_map.get(idx, "Unknown"),
# Convert L2 distance to an intuitive score (shorter distance = higher score)
"score": round(float(1 / (1 + dist)), 4)
})
return results
# --- Example of use in a production API server ---
# hnsw_db = HNSWSearchEngine(dimension=512)
# hnsw_db.load_index()
#
# @app.post("/search/vector")
# def vector_search(query: str):
# query_vec = clip_encoder.get_vector(query) # CLIP object created in Step 2
# results = hnsw_db.search(np.array([query_vec]), top_k=20)
# return {"items": results}