Design a vector similarity search service
The interviewer asks
“Design an internal vector search service (like Pinecone or Milvus) that teams use for semantic search, recommendations and RAG. It must store 1 billion 768-dimension embeddings across many collections, answer top-k queries with metadata filters in under 50 ms, and make new or updated vectors searchable within seconds.”
What a strong answer looks like
A strong answer starts from the memory math, because it drives everything: 1B × 768 × 4 bytes is about 3 TB of raw floats, so you cannot simply hold full-precision HNSW for everything in RAM. Choose the index per collection: HNSW for latency-critical, mid-size collections; IVF-PQ or a disk-based graph (DiskANN-style) with compressed vectors in memory and full vectors on SSD for re-ranking at billion scale. Architecture borrows from search engines and LSM trees: writes go to a log (Kafka), land in a small mutable segment, and are sealed into immutable indexed segments built by separate index nodes and stored in S3; query nodes load segments and scatter-gather across shards, merging top-k. Deletes are tombstones cleaned by compaction. The hard parts are filtered search (pre-, post- and in-graph filtering), recall vs latency tuning (ef_search, nprobe) measured against exact ground truth, tail latency in scatter-gather, and multi-tenancy. Senior candidates separate storage, indexing and query compute so each scales on its own.
Concepts to mention
1 Requirements
Functional
- Create collections with a fixed dimension, distance metric (cosine, dot, L2) and metadata schema
- Upsert and delete vectors by id with metadata (tenant, category, price, timestamps)
- Top-k query by vector with metadata filters (equality, ranges, set membership)
- Hybrid query support: combine with keyword scores supplied by the caller or a sparse vector
- Per-collection choice of index type and recall/latency settings
- Snapshots and restore; namespaces per tenant inside a collection
Non-functional
- 1B vectors total, largest collection 500M; 768 dims
- Query latency p99 under 50 ms at 5,000 QPS total (top-10, with filters)
- Recall@10 at least 0.95 versus exact search on the default settings
- Upserts at 10,000/s sustained; searchable within 5 s (freshness)
- Durability: no acknowledged write lost; 99.95% query availability
- Cost: serve the billion-scale tier without holding all full vectors in RAM
Out of scope (say it out loud)
- Generating embeddings (callers send vectors)
- Full-text search engine features (BM25 index, analyzers)
- Training custom embedding models
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/collections | Create a collection: name, dimension, metric, index type (hnsw|ivf_pq|disk), metadata schema, shard count, replicas. |
POST/v1/collections/{c}/upsert | Batch upsert up to 1,000 vectors with ids and metadata. Returns after the write is durable in the log (with a sequence number). |
POST/v1/collections/{c}/delete | Delete by ids or by filter (e.g. tenant_id = X). |
POST/v1/collections/{c}/query | Body: vector, top_k, filter, namespace, ef_search/nprobe override, include_metadata, min_sequence (read-your-writes). |
POST/v1/collections/{c}/snapshots | Create a point-in-time snapshot (list of segment files in S3). |
GET/v1/collections/{c}/stats | Vector count, segment count, index build lag, recall probe results. |
4 Data model
collections metadataetcd / Postgrescollection_iddimmetricindex_paramsschemashard_mapsegment list per shard with state (growing|sealed|indexed|compacted)Small, strongly consistent catalogue read by all nodes; changes via the coordinator.
write-ahead logKafka (one topic per collection, partition per shard)seqop (upsert|delete)idvectormetadataDurability point for writes. Shards are assigned by hash(id) so an id's upserts and deletes stay ordered.
growing segmentQuery node memoryflat array of recent vectors + metadatabrute-force searchedHolds the last ~1M vectors per shard so new writes are searchable within seconds before any index is built.
sealed segmentsS3 (segment files) + local NVMe cachesegment_idvectors (raw)PQ codesindex (HNSW graph or IVF lists)metadata columnsbitmap indexestombstone bitmapImmutable files, like Lucene segments. Query nodes download and memory-map them; rebuilding a node is just reloading files.
id → location mapRocksDB on the shard leadervector_id → segment_idoffsetNeeded to turn an upsert of an existing id into tombstone + new insert.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Filtered top-10 query
- 1The product-search service sends a query vector, top_k=10 and filter category='shoes' AND price < 3000 to the proxy.
- 2The proxy reads the cached shard map for the collection (16 shards, 2 replicas each) and sends the query to one replica of each shard in parallel, with a 40 ms deadline.
- 3Each query node estimates filter selectivity from its bitmap indexes: 'shoes AND price < 3000' matches 4% of vectors, so it uses in-graph filtering in HNSW (skip non-matching nodes but keep traversing), with ef_search raised for selective filters.
- 4Each node also brute-force scans its growing segment (recent writes) with the same filter, merges, and returns its local top 10 with distances.
- 5For PQ-compressed segments the node first finds top 100 by approximate distance, then re-ranks them with full vectors read from NVMe.
- 6The proxy merges 16 lists of 10 into a global top 10 and returns ids, scores and requested metadata; total ~20 ms p50, ~45 ms p99.
Upsert becomes searchable
- 1A caller upserts 500 vectors; the proxy hashes each id to a shard and appends to that Kafka partition; once Kafka acks (replicated), the API returns with the sequence number.
- 2Query nodes serving that shard tail the partition and append the vectors to the growing segment, marking older versions of the same id as deleted in their tombstone bitmap. Searchable in ~1-2 s.
- 3When the growing segment reaches 1M vectors or 60 s, it is sealed; an index builder reads it, builds HNSW or IVF-PQ, writes segment files to S3 and updates etcd.
- 4Query nodes load the new indexed segment and drop the growing copy; queries switch atomically by segment version.
7 Deep dives
Choosing the index: HNSW, IVF-PQ, disk graphs
HNSW is a layered proximity graph: search starts at the top layer and greedily moves to closer neighbours, descending layers. It gives the best recall-latency trade-off in memory (recall 0.95+ at ~1-5 ms), supports inserts incrementally, but needs full vectors plus graph links in RAM and handles deletes poorly. IVF clusters vectors with k-means into, say, 65,536 lists; a query scans only the nprobe nearest lists. Combined with product quantization (each vector compressed to 64-128 bytes, distances computed from lookup tables), it fits a billion vectors in ~100 GB of RAM, at the cost of lower raw recall, which you recover by re-ranking the top 100-200 candidates with full vectors from SSD. Disk-based graphs (DiskANN-style) keep compressed vectors in RAM and the graph plus full vectors on NVMe, giving good recall at billion scale with a few SSD reads per query. Scalar int8 quantization is a cheap middle step: 4× smaller with ~1% recall loss. So pick per collection: HNSW float or int8 for collections under ~100M with tight latency, IVF-PQ or disk graph with re-ranking for the 500M collection. Always report recall measured against exact search, never just latency.
Filtering without losing results
Real queries carry filters: tenant, category, in-stock, language. Post-filtering (search top 100, then drop non-matching) fails when the filter is selective: if only 1% match, the top 100 may contain one valid result. Pre-filtering (compute the matching id set, then search only those) is exact but HNSW cannot be restricted cheaply, so with few matches you brute-force them, which is fast for up to ~50-100k candidates. In-graph filtering checks the filter during traversal and keeps walking past non-matching nodes; it works well for moderate selectivity but the graph can become disconnected for very selective filters. A good engine has a small query planner: estimate selectivity from bitmap or inverted indexes on metadata, then choose brute-force on the filtered set (very selective), in-graph filtering (moderate), or plain search with a larger k and post-filter (filter matches almost everything). For hard partitions like tenant, do better than filtering: use namespaces or partition keys so each tenant's vectors live in their own segments. Test recall separately per filter selectivity bucket, because averages hide the failures.
Writes, deletes and freshness: an LSM-style design
Graph indexes are expensive to update in place, and concurrent inserts into one big HNSW graph limit throughput. So treat the index like an LSM tree or Lucene. Writes are appended to a durable log (Kafka) partitioned by shard; that ack is the durability guarantee. Query nodes tail the log into a small growing segment searched by brute force, which makes writes visible in seconds. Sealed segments are indexed by separate builder nodes so heavy index builds never steal CPU from queries, and the resulting immutable files go to S3. An upsert of an existing id becomes 'tombstone old + insert new'; queries skip tombstoned ids through a bitmap. Compaction merges small segments and physically drops deleted vectors, keeping segment count low (too many segments means too many searches per query). For read-your-writes, the upsert returns a sequence number and the caller can pass min_sequence on a query; the node waits until it has consumed that offset. Because segments are immutable and stored in S3, adding a replica or recovering a crashed node means downloading files, not rebuilding indexes, and snapshots are just a list of segment ids.
Sharding, scatter-gather and tail latency
With random (hash-of-id) sharding, every query must ask every shard, and the query is as slow as the slowest of 16 nodes. If each shard has p99 of 20 ms, the chance that at least one of 16 is slow is high, so tail latency grows with fan-out. Mitigations: keep shard count moderate by using bigger nodes; hedge requests (send to a second replica if the first has not answered in the p95 time); give each shard a deadline and return partial results with a flag rather than timing out; and keep segment counts low through compaction. Ask each shard for slightly more than k (e.g. 1.5k) only if results will be filtered further. Alternatively, partition by a semantic key (cluster centroids, like IVF across machines) so a query visits only the nearest few shards; this cuts fan-out but creates hot shards for popular regions of the space. For multi-tenant collections, partitioning by tenant gives the best of both: a tenant's query touches only its shards. Separate compute roles (proxy, query nodes, builders) let you scale QPS by adding query replicas without touching ingestion.
8 Trade-offs
Recall vs latency vs cost
Raising ef_search or nprobe increases recall and latency together; PQ lowers memory cost and recall. Expose these as per-collection settings with sane defaults, and publish a measured curve (recall@10 vs p99) so teams choose consciously. RAG with a reranker after retrieval can tolerate 0.90 recall; deduplication of near-identical images may need 0.99.
Build vs use pgvector, OpenSearch or a managed vector DB
Most teams under ~50M vectors are well served by pgvector or OpenSearch k-NN. A dedicated service is justified at billion scale, many teams, strict latency, or cost pressure on RAM. In an interview, say when you would not build it.
Immutable segments vs in-place index updates
Immutable segments give simple replication, snapshots and recovery, and no locking on the hot graph, at the cost of compaction work and slightly higher query cost across several segments. In-place updates give fewer segments but complex concurrency and slow recovery.
9 Bottlenecks
Re-embedding a whole collection
When a team switches embedding models, 500M vectors must be rewritten and re-indexed. Create a new collection, bulk-load through builders (bypassing the growing segment), dual-write during the switch, then swap an alias. Budget builder capacity for it.
Hot collection or tenant
One collection can take most of the QPS during a sale. Add query replicas for its shards only, and enforce per-collection QPS quotas at the proxy so it cannot starve others.
10 Failure handling
Query node crash
The proxy routes to the other replica of each shard; the coordinator assigns the shard to a new node, which downloads segment files from S3 and replays the log from the last sealed offset. Recovery takes minutes, with no data loss.
Index builder falls behind
Growing segments get larger, brute-force cost rises and latency climbs. Alert on build lag, cap growing segment size, and apply backpressure on upserts (429) before queries degrade.
Recall silently drops
A bad parameter change or quantization can cut recall without errors. A background prober runs 1,000 sample queries per collection hourly against exact search and alerts if recall@10 falls below target.
11 What impresses
- Starts with the 3 TB calculation and lets it drive index choice
- Explains why post-filtering fails for selective filters and proposes a planner
- Uses an LSM/Lucene-style segment model for fresh writes and cheap recovery
- Measures recall continuously with a prober, not just at launch
- Discusses tail latency from fan-out and uses hedged requests
- Says when a team should just use pgvector instead
12 Mistakes
- Assuming one HNSW index in RAM scales to a billion vectors cheaply
- Quoting latency without recall
- Post-filtering every query and losing results for small tenants
- Updating a huge graph in place under heavy writes
- Ignoring deletes and re-embedding
- Rebuilding indexes on the query nodes during peak traffic
13 Follow-ups
Lessons this design uses
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.