🎉 75% of content is free forever — Unlock Premium from $10/mo →
CW
NEWSLIVESearch All Content
đŸ’ŧ Servicesâ„šī¸ Aboutâœ‰ī¸ ContactView Pricing Plansfrom $10

Building a Vector Search Engine (FAISS + HNSW + FastAPI)

AI/ML ProjectsSearch & Retrieval⭐ Premium

Advertisement

Building a Vector Search Engine

FAISS + HNSW + FastAPI | Semantic Search at Scale

Advanced12+ HoursProduction-Ready

What You'll Learn

  • 1Choose between flat, IVF, and HNSW indexes using a measured recall and latency curve
  • 2Estimate index memory and compress with product quantization when the budget binds
  • 3Fuse semantic and keyword result lists with reciprocal rank fusion and an alpha weight
  • 4Apply metadata filters without returning short or empty result lists

Project Overview

Problem Statement

Traditional keyword search fails for semantic queries. Users searching for "affordable running shoes for flat feet" need results that understand meaning, not just keyword matching. Vector search engines encode semantic meaning into dense vectors and find similar items using approximate nearest neighbor algorithms.

Objectives

  • Build a vector search engine supporting text, image, and multimodal queries
  • Implement FAISS with HNSW indexing for sub-millisecond search
  • Support hybrid search (semantic + keyword)
  • Scale to 100M+ vectors with horizontal sharding
  • Create a production API with filtering and ranking
ComponentTechnology
Vector IndexFAISS + HNSW
EmbeddingsOpenAI / Sentence Transformers
API FrameworkFastAPI
StorageRedis + PostgreSQL
ShardingFAISS Shards + Load Balancer
CachingRedis
MonitoringPrometheus + Grafana

Design Rationale

Search quality is a curve, not a setting. Three knobs - index type, ef_search or nprobe, and rerank depth - trade latency against recall, and the job is to pick a point on that curve from measurement rather than from a default:

Here is the approximate result set and is exact ground truth for query q. Flat search returns recall 1.0 at linear cost; HNSW with ef_search 100 typically holds 0.95 to 0.98 recall while visiting a logarithmic number of nodes, so a 10x larger corpus costs far less than 10x the latency.

Memory is the constraint that forces the choice. A 768 dimension float32 vector occupies 3,072 bytes:

Ten million vectors need 30.72 GB before graph or quantization overhead, which fits a large node but not the 16 GB instance the budget allows. Product quantization fixes that by splitting each vector into subvectors and replacing each with a codebook index:

With 64 subquantizers and 8 bits per code that is 64 bytes per vector, so ten million vectors fall from 30.72 GB to 640 MB, a 48x reduction, at a cost of roughly 3 to 5 recall points that a cross-encoder rerank over the top 50 usually recovers.

Hybrid search then merges two lists whose raw scores live on incompatible scales, so fusion works on ranks:

An alpha of 0.7 favors semantics; drop it toward 0.3 for catalog search where exact product codes dominate. Reciprocal rank fusion needs no normalization and no shared score range, which is exactly why it beats weighted score averaging in practice.

Architecture Diagram

Vector Search Engine ArchitectureQuery Input(Text/Image)EmbeddingServiceFAISS Index(HNSW)ResultsResponseRe-ranking(Cross-Enc)CandidateFilteringCache(Redis)HybridSearchMetadataStore (PG)

Step-by-Step Implementation

Step 1: Environment Setup

mkdir vector-search && cd vector-search
pip install faiss-cpu  # or faiss-gpu for GPU support
pip install sentence-transformers openai
pip install fastapi uvicorn redis
pip install numpy psycopg2-binary
pip install prometheus-client

Step 2: FAISS Index Builder

Build and manage FAISS indexes with HNSW for efficient approximate nearest neighbor search.

python
# src/index/faiss_index.py
import faiss
import numpy as np
import pickle
import os
from typing import List, Tuple, Optional, Dict
from dataclasses import dataclass
import logging

logger = logging.getLogger(__name__)


@dataclass
class IndexConfig:
    dimension: int = 384
    index_type: str = "hnsw"  # flat, ivf, hnsw
    hnsw_m: int = 32
    hnsw_ef_construction: int = 200
    hnsw_ef_search: int = 100
    nprobe: int = 10  # for IVF


class FAISSIndexBuilder:
    def __init__(self, config: IndexConfig = None):
        self.config = config or IndexConfig()
        self.index = None
        self.id_map = {}  # internal_id -> external_id
        self.reverse_map = {}  # external_id -> internal_id
        self.next_id = 0

    def build_index(self, embeddings: np.ndarray, ids: List[str]):
        """Build index from scratch with pre-computed embeddings."""
        n_vectors = len(embeddings)
        dim = embeddings.shape[1]

        if self.config.index_type == "flat":
            self.index = faiss.IndexFlatIP(dim)  # Inner product for cosine similarity
        elif self.config.index_type == "hnsw":
            self.index = faiss.IndexHNSWFlat(dim, self.config.hnsw_m)
            self.index.hnsw.efConstruction = self.config.hnsw_ef_construction
            self.index.hnsw.efSearch = self.config.hnsw_ef_search
        elif self.config.index_type == "ivf":
            quantizer = faiss.IndexFlatIP(dim)
            self.index = faiss.IndexIVFFlat(quantizer, dim, n_vectors // 10)
            self.index.nprobe = self.config.nprobe
            self.index.train(embeddings.astype(np.float32))

        # Normalize for cosine similarity
        faiss.normalize_L2(embeddings.astype(np.float32))

        self.index.add(embeddings.astype(np.float32))

        # Build ID maps
        for i, ext_id in enumerate(ids):
            self.id_map[i] = ext_id
            self.reverse_map[ext_id] = i
        self.next_id = len(ids)

        logger.info(f"Built {self.config.index_type} index with {n_vectors} vectors, dim={dim}")

    def search(
        self, query: np.ndarray, top_k: int = 10,
        filter_ids: Optional[List[str]] = None
    ) -> List[Tuple[str, float]]:
        """Search for nearest neighbors."""
        query = query.reshape(1, -1).astype(np.float32)
        faiss.normalize_L2(query)

        # Search more if filtering
        search_k = top_k * 3 if filter_ids else top_k
        scores, indices = self.index.search(query, min(search_k, self.index.ntotal))

        results = []
        for score, idx in zip(scores[0], indices[0]):
            if idx == -1:
                continue
            ext_id = self.id_map.get(idx)
            if ext_id is None:
                continue
            if filter_ids and ext_id not in filter_ids:
                continue
            results.append((ext_id, float(score)))
            if len(results) >= top_k:
                break

        return results

    def add_vectors(self, embeddings: np.ndarray, ids: List[str]):
        """Add new vectors to the index."""
        faiss.normalize_L2(embeddings.astype(np.float32))
        self.index.add(embeddings.astype(np.float32))
        for ext_id in ids:
            self.id_map[self.next_id] = ext_id
            self.reverse_map[ext_id] = self.next_id
            self.next_id += 1

    def save(self, path: str):
        """Save index and mappings to disk."""
        os.makedirs(path, exist_ok=True)
        faiss.write_index(self.index, os.path.join(path, "index.faiss"))
        with open(os.path.join(path, "mappings.pkl"), "wb") as f:
            pickle.dump({
                "id_map": self.id_map,
                "reverse_map": self.reverse_map,
                "next_id": self.next_id,
                "config": self.config,
            }, f)
        logger.info(f"Saved index to {path}")

    def load(self, path: str):
        """Load index and mappings from disk."""
        self.index = faiss.read_index(os.path.join(path, "index.faiss"))
        with open(os.path.join(path, "mappings.pkl"), "rb") as f:
            data = pickle.load(f)
            self.id_map = data["id_map"]
            self.reverse_map = data["reverse_map"]
            self.next_id = data["next_id"]
            self.config = data["config"]
        logger.info(f"Loaded index from {path} with {self.index.ntotal} vectors")

Step 3: Embedding Service

Generate embeddings for documents and queries using various embedding models.

python
# src/embeddings/embedding_service.py
import numpy as np
from typing import List, Union
from sentence_transformers import SentenceTransformer
import openai
import hashlib
import json


class EmbeddingService:
    def __init__(self, model_type: str = "sentence-transformer", model_name: str = None):
        self.model_type = model_type
        if model_type == "sentence-transformer":
            self.model = SentenceTransformer(model_name or "all-MiniLM-L6-v2")
            self.dimension = self.model.get_sentence_embedding_dimension()
        elif model_type == "openai":
            self.model = None
            self.dimension = 1536

    def embed_texts(self, texts: List[str], batch_size: int = 256) -> np.ndarray:
        if self.model_type == "sentence-transformer":
            return self.model.encode(texts, batch_size=batch_size, show_progress_bar=True)
        elif self.model_type == "openai":
            embeddings = []
            for i in range(0, len(texts), 2048):
                batch = texts[i:i+2048]
                response = openai.embeddings.create(input=batch, model="text-embedding-3-small")
                embeddings.extend([e.embedding for e in response.data])
            return np.array(embeddings)

    def embed_query(self, query: str) -> np.ndarray:
        return self.embed_texts([query])[0]

    def embed_image(self, image_path: str) -> np.ndarray:
        # Placeholder for CLIP-based image embedding
        # In production, use open_clip or CLIP model
        raise NotImplementedError("Image embedding not yet implemented")

Step 4: Hybrid Search Engine

Combine semantic vector search with keyword-based BM25 for optimal retrieval quality.

python
# src/search/hybrid_search.py
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
import numpy as np
from rank_bm25 import BM25Okapi
import jieba


@dataclass
class SearchResult:
    id: str
    score: float
    content: str
    metadata: Dict
    search_type: str  # semantic, keyword, hybrid


class HybridSearchEngine:
    def __init__(self, alpha: float = 0.7):
        """alpha: weight for semantic search (1-alpha for keyword)."""
        self.alpha = alpha
        self.vector_index = None
        self.bm25 = None
        self.documents = {}
        self.corpus_tokens = []

    def index_documents(self, documents: List[Dict]):
        """Index documents for both semantic and keyword search."""
        self.documents = {doc["id"]: doc for doc in documents}

        # Build BM25 index
        self.corpus_tokens = [
            list(jieba.cut(doc["content"])) for doc in documents
        ]
        self.bm25 = BM25Okapi(self.corpus_tokens)

    def search(
        self, query: str, query_embedding: np.ndarray,
        top_k: int = 10, filter_fn=None
    ) -> List[SearchResult]:
        """Hybrid search combining semantic and keyword results."""

        # Semantic search
        semantic_results = self.vector_index.search(query_embedding, top_k=top_k * 2)

        # Keyword search
        query_tokens = list(jieba.cut(query))
        bm25_scores = self.bm25.get_scores(query_tokens)
        bm25_top_idx = np.argsort(bm25_scores)[::-1][:top_k * 2]
        keyword_results = [
            (list(self.documents.keys())[idx], bm25_scores[idx]) for idx in bm25_top_idx
        ]

        # Merge results with Reciprocal Rank Fusion
        merged = {}
        k = 60  # RRF constant

        for rank, (doc_id, score) in enumerate(semantic_results):
            merged[doc_id] = merged.get(doc_id, 0) + self.alpha / (k + rank + 1)

        for rank, (doc_id, score) in enumerate(keyword_results):
            merged[doc_id] = merged.get(doc_id, 0) + (1 - self.alpha) / (k + rank + 1)

        # Sort and return top_k
        sorted_results = sorted(merged.items(), key=lambda x: x[1], reverse=True)[:top_k]

        return [
            SearchResult(
                id=doc_id, score=score,
                content=self.documents[doc_id]["content"],
                metadata=self.documents[doc_id].get("metadata", {}),
                search_type="hybrid",
            )
            for doc_id, score in sorted_results
        ]

Step 5: FastAPI Serving Layer

python
# src/api/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional, Dict
from contextlib import asynccontextmanager
import numpy as np
import time


@asynccontextmanager
async def lifespan(app: FastAPI):
    from src.index.faiss_index import FAISSIndexBuilder
    from src.embeddings.embedding_service import EmbeddingService
    from src.search.hybrid_search import HybridSearchEngine

    app.state.embedder = EmbeddingService("sentence-transformer")
    app.state.search_engine = HybridSearchEngine(alpha=0.7)
    # Load pre-built index...
    yield


app = FastAPI(title="Vector Search API", version="1.0.0", lifespan=lifespan)


class SearchRequest(BaseModel):
    query: str = Field(..., min_length=1, max_length=1000)
    top_k: int = Field(10, ge=1, le=100)
    filters: Optional[Dict] = None
    search_mode: str = Field("hybrid", regex="^(semantic|keyword|hybrid)$")


class SearchResponse(BaseModel):
    results: List[Dict]
    total_results: int
    latency_ms: float


@app.post("/api/v1/search", response_model=SearchResponse)
async def search(request: SearchRequest):
    start = time.time()

    # Generate query embedding
    query_emb = app.state.embedder.embed_query(request.query)

    # Search
    results = app.state.search_engine.search(
        query=request.query,
        query_embedding=query_emb,
        top_k=request.top_k,
    )

    latency = (time.time() - start) * 1000

    return SearchResponse(
        results=[{"id": r.id, "score": r.score, "content": r.content[:200]} for r in results],
        total_results=len(results),
        latency_ms=latency,
    )


@app.post("/api/v1/index")
async def index_documents(documents: List[Dict]):
    app.state.search_engine.index_documents(documents)
    return {"indexed": len(documents)}

â„šī¸

Use IVF-PQ (Inverted File Product Quantization) for very large indexes (100M+ vectors). It provides 10-50x compression with minimal recall loss.

💡

Cache frequently queried embeddings in Redis. Embedding computation is expensive, and many queries repeat across users.

Performance Metrics

MetricTargetDescription
Search Latency< 5msP99 latency
Recall@10> 0.95vs exact NN
Index Build Time< 30min10M vectors
Memory Usage< 2GBPer 10M vectors
Throughput> 1K QPSPer instance

What Breaks in Production

Vector search fails in three characteristic ways. The first is delete staleness: FAISS has no cheap deletion, so tombstones accumulate, recall drifts down, and memory climbs while ntotal stays frozen. Rebuild nightly from the source of truth and compact whenever deleted ids exceed 10 percent of the index.

The second is a dimension or normalization mismatch. A query vector produced by a different embedding model, or one that skipped the L2 normalization the index was built with, silently returns neighbors that are merely large rather than similar, and nothing errors. Assert dimension and norm at startup and record the embedding model version alongside the index.

Third, filters applied after retrieval. Post-filtering ten results against a category constraint can leave three, and the user sees a short page. Over-fetch with search_k equal to three times top_k as this code does, and when a constraint is highly selective - under 1 percent of the corpus - invert the order and scan the filtered subset directly.

Latency also drifts as the corpus grows. A flat index answers a 1 million vector corpus in about 3 ms and 10 million in about 30 ms on the same hardware, because scan cost is linear. Track p99 latency against index size and schedule the move to IVF or HNSW before p99 crosses 50 ms, not after the first outage.

Interview Talking Points

  1. HNSW vs IVF: HNSW provides better recall with lower latency but uses more memory. IVF-PQ provides compression for large-scale deployment.
  2. Cosine vs L2: Use cosine similarity for text embeddings (magnitude-invariant). Use L2 for image embeddings where magnitude matters.
  3. Hybrid Search RRF: Reciprocal Rank Fusion combines results from different retrieval methods without requiring score normalization.
  4. Scaling: Horizontal sharding with consistent hashing distributes vectors across multiple FAISS indexes.
  5. Caching: Multi-level caching (query -> embedding -> results) significantly reduces latency for repeated queries.
  6. Evaluation: Use standard IR metrics (NDCG, MAP, Recall@K) with human-annotated relevance judgments.

âš ī¸

HNSW indexes consume significant memory. Monitor memory usage and implement index compression (PQ) or sharding for large-scale deployments.

â„šī¸

For multimodal search (text + images), consider using CLIP embeddings that project both modalities into the same vector space.

Common Mistakes

Post-filtering the final top-k list

Fix: Over-fetch by 3x, or pre-filter the candidate set when the constraint selects under 1 percent of the corpus.

Mixing vectors from two embedding models in one index

Fix: Store the model version with the index and reject queries encoded by any other model.

Building the index once and never rebuilding after deletes

Fix: Rebuild nightly from source of truth and compact when tombstones exceed 10 percent of the index.

Best Practices

  • Assert vector dimension and L2 norm at startup so mismatches fail fast
  • Measure recall@10 against exact search weekly on a sample of live queries
  • Cache query embeddings keyed on normalized query text with a short TTL

Key Takeaways

  • Flat is exact and linear, HNSW holds 0.95+ recall at logarithmic cost, IVF-PQ trades recall for 48x memory savings
  • Ten million 768 dimension float32 vectors need 30.72 GB, and PQ with 64 subquantizers needs 640 MB
  • Reciprocal rank fusion merges lists on ranks with k = 60 so raw scores never need normalization
  • Filters must run inside the search or on an over-fetched candidate set, never on the final top-k

Frequently Asked Questions

HNSW or IVF-PQ for a 10 million vector corpus?

HNSW if you have the RAM: 30 GB buys 0.97 recall at single-digit millisecond latency. IVF-PQ if you do not: 640 MB with recall near 0.92 that a cross-encoder rerank over 50 candidates typically lifts back above 0.95. Choose by memory budget first, then tune ef_search or nprobe against a recall target.

Cosine similarity or L2 distance?

For text embeddings, cosine, because magnitude carries no meaning and normalizing removes it from the comparison. For image or audio embeddings where magnitude can carry signal, use raw L2. The rule is to match whatever the embedding model was trained with, since mixing them reorders your neighbors.

How often should you rebuild the index?

Rebuild when tombstones pass 10 percent of the index, when you change the embedding model, or on a fixed weekly schedule, whichever comes first. Rebuilds are cheap if you shard: build the new index under a versioned name and swap an alias, so the live queries never see a partially built index.

Interview Questions

Q1: How do you pick a point on the recall and latency curve?

Sample 1,000 live queries, compute exact ground truth with flat search, then sweep ef_search or nprobe to build a recall versus p99 table. Pick the cheapest setting that clears the recall target, usually 0.95 for consumer search, and re-run the sweep weekly.

Q2: Explain product quantization in your own words.

Split the 768 dimension vector into 64 blocks of 12 dimensions, cluster each block into 256 centroids at build time, then store only the centroid index per block: one byte each, 64 bytes total versus 3,072. Distance is approximated by summing lookups, which costs recall but cuts memory 48x.

Q3: Why fuse semantic and keyword results with ranks rather than scores?

Cosine lives in 0 to 1 while BM25 is unbounded and corpus-dependent, so any weighted sum depends on an arbitrary normalization choice. RRF uses only rank positions with a shared constant, needs no tuning beyond alpha, and degrades gracefully when one list is noisy.

🔒

Premium Content

Building a Vector Search Engine (FAISS + HNSW + FastAPI)

You've previewed the first section. Unlock this full lesson and 900+ advanced tutorials with a Premium plan.

đŸŽ¯End-to-end Projects
đŸ’ŧInterview Prep
📜Certificates
🤝Community Access

Already a member? Log in

—
☆☆☆☆☆
0 ratings

Rate & Feedback

Advertisement