Vector Databases Deep Dive: Qdrant, Pinecone, Weaviate & Milvus Compared
Everything you need to choose, deploy, and optimize a vector database for production RAG and AI applications.
Introduction
Vector databases are the backbone of modern AI applications — RAG, semantic search, recommendations, anomaly detection. But they're not all the same.
Why Vector Databases?
| Traditional DB | Vector DB |
|---|---|
| Exact match (B-tree) | Approximate Nearest Neighbor (ANN) |
| Scalar values | High-dimensional vectors (768-4096 dims) |
| SQL queries | Distance metrics (cosine, dot, L2) |
| Millions of rows | Billions of vectors |
| ACID transactions | Eventual consistency (usually) |
Core Concepts
Vector Similarity Search
# similarity_basics.py import numpy as np def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) def dot_product(a: np.ndarray, b: np.ndarray) -> float: return np.dot(a, b) def l2_distance(a: np.ndarray, b: np.ndarray) -> float: return np.linalg.norm(a - b) # Normalized vectors: cosine == dot product # For cosine, always normalize embeddings first! ANN Algorithms
| Algorithm | Principle | Build Time | Search Speed | Accuracy | Memory |
|---|---|---|---|---|---|
| HNSW | Hierarchical graph | Medium | Very Fast | High | High |
| IVF | Inverted file (clustering) | Fast | Fast | Medium | Medium |
| PQ | Product quantization | Medium | Very Fast | Medium | Low |
| DiskANN | Disk-based graph | Slow | Fast | High | Very Low |
| Flat | Brute force | None | Slow | Perfect | High |
HNSW is the default for most production use cases.
Database Comparison
Quick Comparison
| Feature | Qdrant | Pinecone | Weaviate | Milvus | Chroma |
|---|---|---|---|---|---|
| License | Apache 2.0 | Proprietary | BSD-3 | Apache 2.0 | Apache 2.0 |
| Self-hosted | ✅ | ❌ | ✅ | ✅ | ✅ |
| Managed Cloud | ✅ | ✅ | ✅ | ✅ (Zilliz) | ✅ |
| Filtering | Excellent | Good | Excellent | Excellent | Good |
| Hybrid Search | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| Multi-tenancy | ✅ | ✅ | ✅ | ✅ | ❌ |
| Replication | ✅ | Managed | ✅ | ✅ | ❌ |
| GPU Support | ✅ | ❌ | ❌ | ✅ | ❌ |
| Best For | Production, self-host | Serverless, ease of use | Graph + vector, GraphQL | Massive scale, GPU | Prototyping, local dev |
Detailed: Qdrant (Recommended for Self-Hosted)
# qdrant_complete.py from qdrant_client import QdrantClient from qdrant_client.models import ( Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue, MatchAny, SearchParams, HnswConfigDiff, QuantizationConfig, ScalarQuantization, ScalarQuantizationConfig, OptimizersConfigDiff, CollectionStatus ) from sentence_transformers import SentenceTransformer import uuid class QdrantVectorDB: def __init__(self, url: str = "http://localhost:6333", api_key: str = None): self.client = QdrantClient(url=url, api_key=api_key) self.encoder = SentenceTransformer("BAAI/bge-large-en-v1.5") def create_collection(self, name: str, vector_size: int = 1024): """Create optimized collection for production.""" self.client.create_collection( collection_name=name, vectors_config=VectorParams( size=vector_size, distance=Distance.COSINE, hnsw_config=HnswConfigDiff( m=16, # Connections per layer (16-64) ef_construct=100 # Build accuracy (100-200) ), quantization_config=QuantizationConfig( scalar=ScalarQuantization( type=ScalarQuantizationConfig( quantile=0.99, always_ram=True ) ) ), on_disk=False # Keep hot vectors in RAM ), optimizers_config=OptimizersConfigDiff( default_segment_number=2, max_segment_size=20000, memmap_threshold=20000, indexing_threshold=10000, flush_interval_sec=5 ) ) def upsert_documents(self, collection: str, documents: list[dict]): """Batch upsert with embeddings.""" texts = [d["text"] for d in documents] vectors = self.encoder.encode(texts, show_progress_bar=True) points = [ PointStruct( id=str(uuid.uuid4()), vector=vec.tolist(), payload={ "text": doc["text"], "metadata": doc.get("metadata", {}), "timestamp": doc.get("timestamp", "") } ) for vec, doc in zip(vectors, documents) ] self.client.upsert(collection_name=collection, points=points) def search( self, collection: str, query: str, limit: int = 10, filters: dict = None, score_threshold: float = 0.7 ) -> list[dict]: query_vector = self.encoder.encode([query])[0] query_filter = None if filters: conditions = [ FieldCondition(key=k, match=MatchValue(value=v)) for k, v in filters.items() ] query_filter = Filter(must=conditions) results = self.client.search( collection_name=collection, query_vector=query_vector.tolist(), limit=limit, query_filter=query_filter, score_threshold=score_threshold, search_params=SearchParams( hnsw_ef=128, # Search accuracy (higher = more accurate) exact=False ) ) return [ { "id": r.id, "score": r.score, "text": r.payload["text"], "metadata": r.payload["metadata"] } for r in results ] def hybrid_search( self, collection: str, query: str, query_vector: list[float] = None, limit: int = 10, sparse_weight: float = 0.3 ) -> list[dict]: """Hybrid dense + sparse search (requires sparse vectors).""" # Requires collection with sparse vectors enabled # This is simplified - full hybrid needs sparse vector config if query_vector is None: query_vector = self.encoder.encode([query])[0].tolist() # Use prefetch for hybrid from qdrant_client.models import Prefetch, FusionQuery results = self.client.query_points( collection_name=collection, prefetch=[ Prefetch( query=query_vector, using="dense", limit=limit * 2 ) ], query=FusionQuery(fusion="rrf"), # Reciprocal Rank Fusion limit=limit ) return [ {"id": r.id, "score": r.score, "text": r.payload["text"]} for r in results.points ] def delete_by_filter(self, collection: str, filters: dict): """Delete points matching filter.""" conditions = [ FieldCondition(key=k, match=MatchValue(value=v)) for k, v in filters.items() ] self.client.delete( collection_name=collection, points_selector=Filter(must=conditions) ) def scroll_all(self, collection: str, limit: int = 100): """Iterate all points.""" offset = None while True: points, offset = self.client.scroll( collection_name=collection, limit=limit, offset=offset, with_payload=True, with_vectors=False ) for p in points: yield p if offset is None: break Detailed: Pinecone (Serverless, Managed)
# pinecone_complete.py from pinecone import Pinecone, ServerlessSpec from sentence_transformers import SentenceTransformer import uuid class PineconeVectorDB: def __init__(self, api_key: str, environment: str = "us-east-1"): self.pc = Pinecone(api_key=api_key) self.encoder = SentenceTransformer("BAAI/bge-large-en-v1.5") self.environment = environment def create_index(self, name: str, dimension: int = 1024, metric: str = "cosine"): if name not in self.pc.list_indexes().names(): self.pc.create_index( name=name, dimension=dimension, metric=metric, spec=ServerlessSpec( cloud="aws", region=self.environment ) ) self.index = self.pc.Index(name) def upsert_documents(self, documents: list[dict], namespace: str = ""): texts = [d["text"] for d in documents] vectors = self.encoder.encode(texts) vectors_to_upsert = [ { "id": str(uuid.uuid4()), "values": vec.tolist(), "metadata": { "text": doc["text"], **doc.get("metadata", {}) } } for vec, doc in zip(vectors, documents) ] # Batch upsert (100 at a time) for i in range(0, len(vectors_to_upsert), 100): batch = vectors_to_upsert[i:i+100] self.index.upsert(vectors=batch, namespace=namespace) def search( self, query: str, limit: int = 10, namespace: str = "", filters: dict = None ) -> list[dict]: query_vector = self.encoder.encode([query])[0].tolist() filter_dict = None if filters: filter_dict = {k: {"$eq": v} for k, v in filters.items()} results = self.index.query( vector=query_vector, top_k=limit, namespace=namespace, filter=filter_dict, include_metadata=True ) return [ { "id": r.id, "score": r.score, "text": r.metadata.pop("text", ""), "metadata": r.metadata } for r in results.matches ] Detailed: Weaviate (GraphQL, Hybrid Search)
# weaviate_complete.py import weaviate from weaviate.classes.init import Auth from weaviate.classes.config import Configure, Property, DataType from sentence_transformers import SentenceTransformer class WeaviateVectorDB: def __init__(self, url: str, api_key: str = None): auth = Auth.api_key(api_key) if api_key else None self.client = weaviate.connect_to_wcs( cluster_url=url, auth_credentials=auth ) self.encoder = SentenceTransformer("BAAI/bge-large-en-v1.5") def create_collection(self, name: str): self.client.collections.create( name=name, vectorizer_config=Configure.Vectorizer.none(), # Bring your own vectors properties=[ Property(name="text", data_type=DataType.TEXT), Property(name="source", data_type=DataType.TEXT), Property(name="category", data_type=DataType.TEXT), ], vector_index_config=Configure.VectorIndex.hnsw( distance_metric=Configure.VectorIndex.DistanceMetric.COSINE ) ) def upsert_documents(self, collection: str, documents: list[dict]): coll = self.client.collections.get(collection) with coll.batch.dynamic() as batch: for doc in documents: vector = self.encoder.encode(doc["text"]) batch.add_object( properties={ "text": doc["text"], "source": doc.get("metadata", {}).get("source", ""), "category": doc.get("metadata", {}).get("category", "") }, vector=vector.tolist() ) def hybrid_search( self, collection: str, query: str, limit: int = 10, alpha: float = 0.5, # 0 = keyword, 1 = vector filters: dict = None ) -> list<...