Every team building RAG pipelines, semantic search, or recommendation systems hits the same crossroads: which vector database should you actually run in production? The options have matured enough that the "just use Chroma for everything" advice that was perfectly fine in 2023 can now get you into serious trouble.
This is a revisit. I ran all three — ChromaDB, Qdrant, and Weaviate — under production-like conditions on self-hosted infrastructure. I’m going to tell you what broke, what impressed me, and where each one belongs in a real stack. No fluff, no "all three are great in their own way" hedging.
The Contenders (Quick State of Affairs)
ChromaDB is the developer-friendly one. It started as an embedded library and gained a server mode. It’s Python-first, has a simple API, and you can go from pip install chromadb to working embeddings in under ten minutes. The tradeoff is that it still feels like a development tool that was given a server mode as an afterthought.
Qdrant (GitHub) is written in Rust, and you feel it — it’s fast, memory-efficient, and has the most feature-complete filtering story of the three. The team has been shipping at a serious pace: scalar quantization, product quantization, sparse vectors, multi-tenancy namespacing. This is the one that has clearly set its sights on production workloads.
Weaviate (GitHub) is the opinionated one. It brings a schema-first approach, a GraphQL API, and a module system that lets you plug in embedding models directly. Written in Go. It’s powerful but it demands more upfront investment in understanding its data model.
What "Production" Actually Means Here
Before diving in, let me set the baseline I used:
- ~5 million vectors, 1536 dimensions (OpenAI
text-embedding-3-smalloutput) - Mixed workload: ~200 QPS peak search, periodic batch ingestion of 50k vectors
- Single-node deployment initially, with the ability to scale
- Persistence across restarts (obviously)
- Filtering by metadata (tenant ID, date ranges, categories)
Docker Compose Deployments
Let me show you how each one looks in a production-ready Docker Compose file. These are not the "hello world" configs from the docs — they include persistence, resource limits, health checks, and sensible defaults.
Qdrant
# docker-compose.qdrant.yml
version: "3.9"
services:
qdrant:
image: qdrant/qdrant:v1.13.6
container_name: qdrant
restart: unless-stopped
ports:
- "6333:6333" # REST API
- "6334:6334" # gRPC
volumes:
- qdrant_storage:/qdrant/storage
- ./qdrant_config.yaml:/qdrant/config/production.yaml:ro
environment:
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY}
deploy:
resources:
limits:
memory: 8G
reservations:
memory: 4G
healthcheck:
test: ["CMD", "curl", "-f", "https://cd-linux.club:6333/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
volumes:
qdrant_storage:
driver: local
And the qdrant_config.yaml:
# qdrant_config.yaml
storage:
# WAL capacity before flushing — tune for your write patterns
wal_capacity_mb: 32
service:
max_request_size_mb: 32
# Enable gRPC for bulk ingestion — it's significantly faster than REST
enable_tls: false
# Telemetry is on by default — disable if you're paranoid about data leaving the host
telemetry_disabled: true
# HNSW index tuning for ~5M vectors
# ef_construct: higher = better recall, slower indexing
# m: higher = more connections per node, more RAM, better recall
# Default m=16 is fine for most cases; push to 32 if recall matters more than memory
Gotcha #1: Qdrant’s default
m=16for HNSW is conservative. If your recall@10 is lower than expected during load tests, bumpmto 32 or 64 at collection creation time — you can’t change it after the fact without rebuilding the collection.
ChromaDB
# docker-compose.chroma.yml
version: "3.9"
services:
chromadb:
image: chromadb/chroma:0.6.3
container_name: chromadb
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- chroma_data:/chroma/chroma
environment:
- CHROMA_SERVER_AUTHN_CREDENTIALS=${CHROMA_TOKEN}
- CHROMA_SERVER_AUTHN_PROVIDER=chromadb.auth.token_authn.TokenAuthenticationServerProvider
- ANONYMIZED_TELEMETRY=False
# SQLite WAL mode — helps with concurrent reads
- CHROMA_SQLITE_WAL_MODE=true
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
healthcheck:
test: ["CMD", "curl", "-f", "https://cd-linux.club:8000/api/v2/heartbeat"]
interval: 30s
timeout: 10s
retries: 3
volumes:
chroma_data:
driver: local
Gotcha #2: ChromaDB uses SQLite under the hood for metadata storage. Once you push past ~2-3 million vectors with heavy filtering, you will feel it. SQLite is not a distributed system, and Chroma’s server mode is effectively single-writer. This is not a knock — it’s just important to know before you plan your scale.
Gotcha #3: The authentication story in ChromaDB has changed across versions. The
TokenAuthenticationServerProvidershown above works for 0.6.x, but double-check the docs for your specific version — the env var names have shifted more than once.
Weaviate
# docker-compose.weaviate.yml
version: "3.9"
services:
weaviate:
image: cr.weaviate.io/semitechnologies/weaviate:1.29.1
container_name: weaviate
restart: unless-stopped
ports:
- "8080:8080" # REST / GraphQL
- "50051:50051" # gRPC
volumes:
- weaviate_data:/var/lib/weaviate
environment:
# Authentication — use API key for simpler setups
AUTHENTICATION_APIKEY_ENABLED: "true"
AUTHENTICATION_APIKEY_ALLOWED_KEYS: "${WEAVIATE_API_KEY}"
AUTHENTICATION_APIKEY_USERS: "admin"
AUTHORIZATION_ADMINLIST_ENABLED: "true"
AUTHORIZATION_ADMINLIST_USERS: "admin"
# Disable built-in modules if you're bringing your own embeddings
# This saves significant memory
ENABLE_MODULES: ""
# Persistence
PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
# HNSW tuning — same principles as Qdrant apply
# These are class-level defaults, can be overridden per collection
DEFAULT_VECTORIZER_MODULE: "none"
CLUSTER_HOSTNAME: "weaviate-node-1"
# Telemetry opt-out
DISABLE_TELEMETRY: "true"
deploy:
resources:
limits:
memory: 8G
reservations:
memory: 4G
healthcheck:
test: ["CMD", "curl", "-f", "https://cd-linux.club/v1/.well-known/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
weaviate_data:
driver: local
Gotcha #4: Weaviate’s module system is a double-edged sword. If you enable
text2vec-openaior similar, Weaviate will call the embedding API on every insert transparently. That’s convenient until your embedding provider has downtime and your ingestion pipeline silently stalls. I strongly prefer passing pre-computed embeddings and settingENABLE_MODULES: "".
Filtering: Where the Real Differences Live
Similarity search alone is a solved problem. Every vector DB does it acceptably. The real differentiator at scale is filtered vector search — find me the 10 most similar vectors where tenant_id = "acme" and created_at > 2025-01-01.
Qdrant has the best filtering story, full stop. It has a dedicated payload index system. You create indexes on specific payload fields and Qdrant uses them to pre-filter or post-filter depending on the selectivity it estimates. The filtering syntax is expressive — nested conditions, geo-filters, full-text on string fields, has_id filters for pagination. At 5M vectors with a tenant filter that narrows to ~50k results, query latency was consistently under 15ms at the 95th percentile.
Weaviate does filtering through its where filter on GraphQL queries. It’s capable but the interface is more verbose, and multi-tenant filtering requires either separate classes per tenant (expensive operationally) or a shared class with a tenant property and careful index management. In recent versions Weaviate added native multi-tenancy support — each tenant gets isolated storage partitions, which solves the cross-tenant bleed problem — but migrating existing data into that model is not trivial.
ChromaDB has basic where metadata filtering backed by SQLite. For workloads under 1M vectors or where filtering is simple and selectivity is high, it works fine. Once you start doing range queries or multi-field filters on millions of vectors, the SQLite layer becomes the bottleneck.
Quantization: Cutting RAM Without Destroying Recall
At 5M × 1536 float32 vectors, you’re looking at roughly 30 GB of raw vector data. That’s before indexes. This is where quantization becomes essential for self-hosters running on anything less than a dedicated 128 GB RAM box.
Qdrant is the winner here. It has three modes:
- Scalar quantization (SQ): float32 → int8, ~4x compression, minimal recall loss
- Product quantization (PQ): much more aggressive compression, measurable recall trade-off
- Binary quantization (BQ): only for angular/dot metrics, but 32x compression with oversampling to recover recall
For most production RAG use cases, scalar quantization with always_ram: true for the quantized vectors (while keeping the original vectors on disk) is the sweet spot. You stay under 8-10 GB RAM for 5M vectors, and recall@10 stays above 0.95.
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams,
ScalarQuantizationConfig, ScalarType,
QuantizationConfig
)
client = QdrantClient(url="https://cd-linux.club:6333", api_key="your-key")
client.create_collection(
collection_name="documents",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
quantization_config=QuantizationConfig(
scalar=ScalarQuantizationConfig(
type=ScalarType.INT8,
quantile=0.99, # clip top 1% of values — improves quantization quality
always_ram=True # keep quantized index in RAM, originals on disk
)
)
)
Weaviate has product quantization (PQ) support added in v1.23 and binary quantization since v1.25. The configuration is per-class and works well, though the documentation on tuning parameters is thinner than Qdrant’s. If you’re on Weaviate and hitting memory pressure, PQ is absolutely worth enabling.
ChromaDB has no built-in quantization. Your only option is to quantize embeddings before ingestion, which means you own the recall/compression trade-off fully and lose the ability to do oversampling at query time.
Horizontal Scaling
This is where the gap widens significantly.
Qdrant supports distributed deployment through sharding and replication. You spin up multiple nodes, they form a cluster, and collections are distributed across shards. Each shard can be replicated N times for HA. Setting this up requires a bit more operational work, but the architecture is clean. You don’t need an external orchestration layer — Qdrant handles rebalancing and leader election internally.
Weaviate also has a distributed mode with sharding and replication. The multi-tenancy model in recent versions is specifically designed for the "many customers, shared infrastructure" use case — each tenant’s data lives in isolated partitions that can be activated/deactivated individually. This is genuinely useful for SaaS platforms.
ChromaDB does not have a distributed mode as of writing. The server mode is single-node. If you need to scale out ChromaDB, your only option is application-level sharding — partition your data into multiple Chroma instances and route at the client layer. This works, but you own all the operational complexity.
The Backup Question
No one talks about this until it breaks at 2 AM.
Qdrant has snapshot support built into the API. You can trigger a collection snapshot, which creates a portable file you can copy to cold storage:
# Create snapshot
curl -X POST "https://cd-linux.club:6333/collections/documents/snapshots" \
-H "api-key: your-key"
# List snapshots and get the filename
curl "https://cd-linux.club:6333/collections/documents/snapshots" \
-H "api-key: your-key"
# Download it
curl -o documents_snapshot.snapshot \
"https://cd-linux.club:6333/collections/documents/snapshots/documents-<timestamp>.snapshot" \
-H "api-key: your-key"
Restoring is just as clean — POST the snapshot file to a new or existing collection. This integrates well into any backup script.
Weaviate has a backup module system. You configure a backup provider (S3, GCS, filesystem) and trigger backups via API. For self-hosted, the filesystem backend works fine:
# Add to docker-compose environment
ENABLE_MODULES: "backup-filesystem"
BACKUP_FILESYSTEM_PATH: "/var/lib/weaviate/backups"
curl -X POST "https://cd-linux.club/v1/backups/filesystem" \
-H "Authorization: Bearer your-key" \
-H "Content-Type: application/json" \
-d '{"id": "backup-2026-05-24", "include": ["documents"]}'
ChromaDB — you’re backing up the SQLite file and the data directory. It works, but you need to ensure the server is idle or handle the consistency concerns yourself. There’s no API-level backup mechanism.
Gotchas Roundup
A few more sharp edges I’ve hit that don’t fit neatly elsewhere:
Weaviate cold start time can be slow on large collections — HNSW index loading from disk on startup blocks queries until it’s ready. The health endpoint only returns ready when the index is loaded, so your health check start_period needs to be generous (60-90 seconds for large collections).
Qdrant’s gRPC for bulk ingestion is dramatically faster than REST. If you’re loading millions of vectors and not using the gRPC endpoint, you’re leaving a lot of throughput on the table. The Python client enables gRPC with prefer_grpc=True — it’s one flag.
ChromaDB tenant isolation is application-level. Collection names are your only namespace. If you’re building a multi-tenant app on Chroma, a bug in your routing logic means tenant A can query tenant B’s data. Qdrant’s payload filters and Weaviate’s native multi-tenancy give you a harder boundary.
All three eat RAM for HNSW graphs. The index lives in memory. A 5M vector collection with m=16 HNSW takes roughly 4-5 GB just for the graph structure, independent of the vector data. Budget for this separately.
The Verdict
Stop asking "which vector DB is best" and start asking "which one matches my operational reality."
Use ChromaDB when you’re prototyping, building an internal tool, or working at a scale where simplicity genuinely beats performance. Sub-million vectors, single developer or small team, no strict SLA. Don’t fight it — it’s excellent for what it’s designed to do.
Use Qdrant when you’re going to production. It has the best performance characteristics, the most mature quantization options, the cleanest filtering API, and a backup story that doesn’t require custom scripting. The Rust foundation means it’s predictable under memory pressure. If I had to pick one for a greenfield production deployment today, it’s Qdrant.
Use Weaviate when your use case maps specifically to its strengths: you’re building a multi-tenant SaaS platform and want strong tenant isolation at the database layer, or your team is heavily invested in GraphQL and wants to keep the query interface consistent. The module ecosystem is genuinely useful if you’re okay trading some control for convenience. It’s more complex to operate than Qdrant but the complexity buys you real capabilities.
The "revisited" part of this title matters. A year ago, Qdrant’s distributed mode was rougher, ChromaDB’s server mode was newer, and Weaviate’s multi-tenancy was a workaround rather than a first-class feature. All three have shipped significant improvements. But the fundamental positioning hasn’t changed — Qdrant is eating the production self-hosted market, and for good reason.
One More Thing: Migration Paths
Pick carefully because migrating between vector DBs is not fun. There’s no standard wire format. Your migration path is: re-embed your documents (or dump the raw vectors from the source DB), reindex into the new DB, update your application layer, run both in parallel for a validation period, cut over. Budget a week minimum for anything serious. Factor that switching cost into your initial choice.