Choose between pgvector, Qdrant, Milvus, Weaviate, Chroma, and LanceDB.
You map the decision space onto four axes — operational model, scale ceiling, query expressiveness, and ecosystem fit — and see how they interact. Every subsequent module scores its options against these same four axes, so by module 6 you can read off a verdict.
A four-axis decision framework — operational model, scale ceiling, query expressiveness, and ecosystem fit — that structures every vector DB choice in this lesson.
Why this matters: Without a ranked set of criteria, teams pick vector DBs on benchmarks alone and hit deployment blockers or scale walls after launch.
You're building a semantic search feature. The benchmark says DB-X is fastest. You ship it, then discover it can't run inside your VPC, can't filter by tenant ID, and has no Python client your team trusts. Speed was real — it just wasn't the first question.
Every vector DB decision lives on four axes: , , , and . Each axis eliminates options before the next one applies.
Operational model is almost always the first filter. It determines whether a database can run in your environment. Scoring speed or recall against an option you can't deploy is wasted work.
The axes interact: a high scale ceiling often forces a managed or distributed operational model, which in turn shapes which query features are available.
# Naive approach: score all options on speed first candidates = ["Pinecone", "Chroma", "pgvector", "Qdrant"] for db in candidates: score = benchmark_latency(db) # fast → high score print(db, score) # Result: Pinecone wins on latency # Ship it → discover it's SaaS-only, blocked by VPC policy # Rollback cost: 3 weeks of re-integration
benchmark_latency(db)for db in candidatesThis loop scores every candidate on query speed before checking operational model — the most common mistake teams make when picking a vector DB.
The real output isn't a latency number — it's a three-week rollback when the winning option can't run in your environment.
Pinecone scores highest on latency, but it's a managed SaaS service. If your VPC policy blocks external data egress, deployment is rejected by your security review — and you restart the evaluation from scratch. The latency benchmark was real; the constraint check was skipped.
use_case = {
"deployment": "self-hosted", # operational model: hard constraint
"vectors": 8_000_000, # scale ceiling: ~8 M
"needs_hybrid": True, # query expressiveness
"stack": "python+postgres", # ecosystem fit
}
candidates = filter_by_operational_model(
all_dbs, mode=use_case["deployment"]
)
# candidates → ["pgvector", "Qdrant", "Weaviate"] (SaaS-only options gone)use_case = { ... }filter_by_operational_model(all_dbs, mode=...)"needs_hybrid": TrueThe use-case dict encodes all four axes explicitly before any scoring begins.
Filtering on operational model first shrinks the candidate set immediately — the remaining axes score only viable options.
pgvector, Qdrant, and Weaviate remain — all three can run self-hosted. Pinecone (SaaS-only) and embedded-only stores (Chroma in-memory mode, LanceDB) are eliminated because they can't satisfy the deployment constraint, regardless of their speed or feature scores.
# candidates after Stage 1: ["pgvector", "Qdrant", "Weaviate"] def score(db, use_case): s = 0 s += 2 if within_scale_ceiling(db, use_case["vectors"]) else -99 s += 2 if supports_hybrid(db, use_case["needs_hybrid"]) else 0 s += 1 if ecosystem_match(db, use_case["stack"]) else 0 return s ranked = sorted(candidates, key=lambda db: score(db, use_case), reverse=True) # ranked → ["pgvector", "Qdrant", "Weaviate"] # pgvector wins: postgres stack match + hybrid via tsvector
s += 2 if within_scale_ceiling(...) else -99sorted(..., key=lambda db: score(db, use_case), reverse=True)ecosystem_match(db, use_case["stack"])Scale ceiling gets a -99 penalty (not just 0) because exceeding it is a hard failure, not a soft miss — the same logic as the operational model filter.
Ecosystem fit scores last and lowest because it's the easiest axis to work around — you can learn a new client library; you can't un-deploy a SaaS service from a private VPC.
pgvector scores 5 (2 + 2 + 1): it handles 8 M vectors within its single-node ceiling, supports hybrid search via Postgres tsvector, and matches the postgres stack exactly. The ecosystem_match line is the tiebreaker that lifts it above Qdrant (which scores 4 — no native Postgres ecosystem point).
When you ask an AI assistant to rank vector DBs for your use case, it will produce a confident-looking table. Here's what to check before acting on it.
The next module applies these four axes to two simplest options: Chroma and LanceDB. Both run embedded, in-process, with zero server setup. You'll see exactly where their scale and query ceilings sit.
Drag to see how scale ceiling forces operational model choices — and what query features become available or locked at each tier.
You trace how Chroma and LanceDB run in-process with no server, compare their storage formats and persistence models, and identify the scale and query limits where each breaks down. The running example is a local RAG prototype that you'll extend in later modules when you need to graduate to a server.
Chroma and LanceDB are embedded vector stores that run inside your Python process with no server — this module shows how to set them up, insert vectors, and query them for a local RAG prototype.
Why this matters: If you're building a local RAG system or prototype, these two stores let you get nearest-neighbor search running in minutes with zero infrastructure — and this module tells you exactly when to move on.
Decision this forces: Chroma vs LanceDB for a local/embedded use case — and whether embedded is the right tier at all.
The four axes are , , , and . This module scores Chroma and LanceDB against all four.
Both stores run in-process with no server, port, or daemon. That's their value — and their limit.
An embedded store loads index and data directly into your Python process. No network hop, auth, or container. Chroma and LanceDB both work this way but differ in storage format and persistence model.
Chroma defaults to ephemeral mode (in-memory only). Pass a path to enable persistence. LanceDB is always persistent: it writes a Lance columnar file to disk on every write.
Chroma organises data into ; LanceDB into tables. Both attach metadata to each . LanceDB's columnar layout scans large batches faster without loading everything into RAM.
You're building a local RAG prototype that answers questions over a set of product-support documents. The corpus is ~5,000 chunks, each with a 1536-dimension and metadata fields: doc_id, source, and updated_at.
You need to: ingest the chunks, run nearest-neighbor queries at query time, and filter by source so answers don't mix docs from different product lines. You'll build this in two stages — first with Chroma, then with LanceDB — so you can feel the difference.
This same prototype is what you'll hand off to later modules when the corpus grows past the embedded tier's limits.
import chromadb client = chromadb.PersistentClient(path="./chroma_store") collection = client.get_or_create_collection("support_docs") collection.add( ids=["chunk_001", "chunk_002"], embeddings=[[0.1, 0.2, ...], [0.4, 0.1, ...]], # 1536-dim in practice metadatas=[{"source": "product_a"}, {"source": "product_b"}], documents=["Reset the device by...", "Warranty covers..."], ) results = collection.query( query_embeddings=[[0.1, 0.2, ...]], n_results=3, where={"source": "product_a"}, )
PersistentClient(path=...)get_or_create_collection(...)collection.add(ids, embeddings, metadatas, documents)where={"source": "product_a"}This snippet creates a persistent Chroma , inserts two chunks with metadata, and runs a filtered nearest-neighbor query. The where clause is Chroma's metadata filter — it narrows the ANN search to product_a chunks only.
It returns a result dict with empty lists for ids, distances, and documents — no error is raised. Silent empty results are the most common surprise: if your filter key is misspelled (e.g. "Source" instead of "source"), Chroma returns zero hits without warning.
import lancedb import pyarrow as pa db = lancedb.connect("./lance_store") schema = pa.schema([ pa.field("id", pa.string()), pa.field("vector", pa.list_(pa.float32(), 1536)), pa.field("source", pa.string()), pa.field("text", pa.string()), ]) table = db.create_table("support_docs", schema=schema, exist_ok=True) table.add([ {"id": "chunk_001", "vector": [0.1, 0.2, ...], "source": "product_a", "text": "Reset the device by..."}, ]) results = ( table.search([0.1, 0.2, ...]) # query vector .where("source = 'product_a'") .limit(3) .to_list() )
lancedb.connect(path)pa.schema([...])pa.list_(pa.float32(), 1536).search(vector).where(...).limit(n).to_list()LanceDB requires an explicit Arrow schema — the tradeoff for its columnar speed. The .where() clause is SQL-style string filtering, which is more expressive than Chroma's dict syntax and can combine multiple conditions.
LanceDB raises an ArrowInvalid error at insert time — the schema is enforced strictly. This is different from Chroma, which does not enforce dimensionality at the collection level and will silently store mismatched vectors, causing wrong distances at query time. Changed lines: the schema field pa.list_(pa.float32(), 1536) is the enforcement point — get this right once and every subsequent insert is validated for free.
| Option | Persistence default | Query expressiveness | When to choose | Cost | Complexity |
|---|---|---|---|---|---|
| Chroma | In-memory; opt-in via path | Metadata filter + ANN; no SQL | Notebook prototypes, demos, and local RAG agents where you want the simplest possible API and may not need data to survive a restart. | Free / open-source; Chroma Cloud for hosted | Minimal — one import, one path argument |
| LanceDB | Always-on Lance columnar file | ANN + SQL-style filter + full-text | Local RAG prototypes that need to survive restarts, handle larger datasets efficiently, or run fast columnar scans alongside vector search. | Free / open-source; LanceDB Cloud for hosted | Minimal — one import, one directory path |
Embedded stores break in two distinct ways, both easy to miss until they hurt.
Chroma loads its (an graph) entirely into RAM. At ~1M vectors × 1536 dims × 4 bytes, that's roughly 6 GB before Python overhead. The symptom: your process exits with no traceback or a MemoryError.
Both stores allow only one writer at a time. A second process trying to write triggers a file-lock error or silent corruption. With LanceDB: CommitConflictError: another writer committed — your write is dropped.
/tmp or ephemeral, (2) vector dimension matches your embedding model exactly, (3) metadata filter keys match what you insert — Chroma won't warn on typos, (4) no two processes share the same store path in multi-worker deployments.import lancedb db = lancedb.connect("./lance_store") table = db.open_table("support_docs") query_vector = [0.3, 0.5, ...] # your 1536-dim query embedding results = ( table.search(query_vector) # TODO: filter to source = 'product_b' AND return only 5 results .to_list() )
db.open_table(name).where("source = 'product_b'").limit(5)The table is already populated from Stage 2. Your task: add the two missing method calls that filter by product_b and cap results at 5 — the crux of a filtered nearest-neighbor query.
The completed chain is:
.search(query_vector)
.where("source = 'product_b'") # ← changed: SQL filter on the source column
.limit(5) # ← changed: cap at 5 results
.to_list()
Order matters: .where() must come before .limit() — LanceDB applies the filter first, then caps the result set. Reversing them doesn't error, but .limit() would cap before filtering, giving you fewer than 5 matching results.
You see how pgvector adds vector types, distance operators, and HNSW/IVFFlat indexes to a Postgres table you already own, and you work through the hybrid SQL+vector query pattern. You also revisit the scale ceiling from module 1 to see exactly where pgvector's single-node Postgres limits bite.
How pgvector extends Postgres with vector types, ANN indexes, and hybrid SQL+vector queries — and where its single-node scale ceiling bites.
Why this matters: If your app already runs on Postgres, pgvector lets you add semantic search without a separate service — but you need to know exactly when that trade-off stops working.
Decision this forces: Whether staying inside Postgres is worth the scale trade-off versus moving to a dedicated service.
The axis is . Chroma and LanceDB run in-process on a single machine. Their ceiling is one node's RAM and disk. pgvector also runs on a single Postgres node, sharing that ceiling — but it earns its place differently, as you'll see next.
Your RAG prototype from Module 2 worked. Now you need metadata filters, transactions, and access controls alongside vector search. pgvector is a Postgres extension that adds a vector column type, distance operators, and to any table.
Two operational advantages over dedicated vector services are concrete. You get full SQL joins between embeddings and application rows. You inherit Postgres's ACID transactions, backups, and role-based access control with zero extra infrastructure.
The trade-off is the . pgvector is single-node Postgres. There is no built-in . Once your vector corpus outgrows one machine's memory and disk, you need a different tool.
Imagine building a document retrieval service for a support team. Each article has an owner team, a last-updated timestamp, and an of its content. A user asks a question. You embed it and want the five most semantically similar articles — but only from the "billing" team and updated in the last 90 days.
With pgvector, that's one SQL query. Use cosine-distance ORDER BY on the vector column. Add a WHERE clause on metadata columns. Add LIMIT 5. No separate vector service, no data sync, no extra auth layer — the same Postgres connection your app already uses.
This is the pattern. Vector similarity ranking combines with structured SQL filters in a single query. It's pgvector's strongest argument over embedded stores that lack SQL.
-- Enable the extension once per database CREATE EXTENSION IF NOT EXISTS vector; -- Articles table: normal columns + a 1536-dim vector column CREATE TABLE articles ( id SERIAL PRIMARY KEY, team TEXT, updated DATE, content TEXT, embedding vector(1536) ); -- Insert one article with its pre-computed embedding INSERT INTO articles (team, updated, content, embedding) VALUES ('billing', '2025-06-01', 'How to update your payment method', '[0.12, -0.07, ...]'); -- 1536 floats in practice
CREATE EXTENSION IF NOT EXISTS vectorvector(1536)SERIAL PRIMARY KEY'[0.12, -0.07, ...]'This sets up the table that the rest of the module builds on. The only pgvector-specific piece is the vector(1536) column type — everything else is plain Postgres SQL.
1 — exactly one row was inserted. The vector is stored as a column value just like any other field; no separate index is needed to insert.
-- Create an HNSW index for fast approximate search CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops); -- Query: top-5 billing articles updated in the last 90 days SELECT id, team, content, 1 - (embedding <=> '[0.11, -0.06, ...]') AS similarity FROM articles WHERE team = 'billing' AND updated >= CURRENT_DATE - INTERVAL '90 days' ORDER BY embedding <=> '[0.11, -0.06, ...]' LIMIT 5;
USING hnsw (embedding vector_cosine_ops)<=>1 - (embedding <=> ...)ORDER BY embedding <=> ... LIMIT 5This is the full hybrid SQL + vector query pattern. The <=> operator computes cosine distance; the WHERE clause filters on metadata before ranking, so Postgres never scores irrelevant rows.
Results: you now rank all articles, not just billing ones updated recently — you may get unrelated teams and stale docs. Performance: without the metadata filter, Postgres must score a larger candidate set; with HNSW the index still limits the scan, but you lose the pre-filter that shrinks the candidate pool before ANN kicks in.
| Option | Recall at scale | Write / update cost | When to choose | Cost | Complexity |
|---|---|---|---|---|---|
| HNSW | High recall; graph structure navigates to near-exact neighbors efficiently | Incremental — new vectors slot into the graph without a full rebuild | When you need low-latency queries and the corpus updates frequently — no rebuild required after inserts. | More memory at query time (~graph overhead) | Higher build time and more RAM during construction |
| IVFFlat | Recall depends on nlist and nprobe settings; misconfigured nlist causes poor recall silently | Cluster assignments are fixed at build time — bulk inserts after build degrade recall until you rebuild | When the corpus is largely static and you want a lighter memory footprint; requires a VACUUM + rebuild after large bulk loads. | Lower memory than HNSW | Must choose nlist (number of clusters) at build time; wrong value hurts recall |
If the query planner decides a sequential scan is cheaper, it ignores the HNSW index. You won't see an error; queries just get slower. Run EXPLAIN (ANALYZE) SELECT ... and look for Seq Scan vs Index Scan in the output.
IVFFlat assigns each vector to a cluster at build time. Vectors inserted after build land in the wrong clusters. Queries miss them. The symptom: SELECT returns 0 rows for queries you know should match. No error, just missing results. Fix: run REINDEX INDEX <your_index>; after large bulk loads.
If you switch embedding models mid-project, every INSERT fails. Say you go from 1536-dim to 3072-dim. The error: ERROR: expected 1536 dimensions, not 3072. The column type is fixed at schema creation. You must ALTER the column or create a new table.
-- Same articles table; you need the top-3 rows -- from team = 'engineering', ordered by L2 distance -- (not cosine), updated in the last 30 days. SELECT id, team, content FROM articles WHERE team = 'engineering' AND updated >= CURRENT_DATE - INTERVAL '30 days' ORDER BY embedding <-> '[0.05, -0.12, ...]' -- TODO: add the right LIMIT ???;
<->INTERVAL '30 days'Stop — attempt the missing line before revealing. The snippet uses <-> (L2 distance) instead of <=> (cosine) — that's the intentional variation from Stage 2. Supply the one line that caps the result set.
LIMIT 3;
Changed lines vs Stage 2: LIMIT 5 → LIMIT 3 (the task asks for top-3), <=> → <-> (L2 instead of cosine), team filter is 'engineering' not 'billing', and the interval is 30 not 90 days. The crux is LIMIT — without it Postgres returns every matching row ordered by distance, which is rarely what you want in a retrieval pipeline.
You compare Qdrant's point-payload model against Weaviate's typed-object schema, run the same filtered hybrid search in both, and map each to the use cases where it wins. The running example graduates the local RAG prototype from module 2 into a server-backed service.
A side-by-side comparison of Qdrant's payload model and Weaviate's typed-object schema, with hands-on filtered and hybrid search against the running RAG corpus.
Why this matters: Choosing the right dedicated vector service — and knowing its failure modes — determines whether your RAG system stays fast and correct as data grows beyond what pgvector can handle.
Decision this forces: Qdrant vs Weaviate — and whether a dedicated service is warranted over pgvector for your workload.
Module 3 showed that pgvector shares Postgres's single-node storage and connection pool. Every vector scan competes with your transactional workload for I/O and memory.
That coupling works fine for tens of millions of vectors. It becomes the bottleneck when vector search is the primary workload — not a side feature.
This module introduces two services built exclusively for vector search: Qdrant and Weaviate.
Qdrant stores data as : each has an ID, one or more vectors, and free-form JSON . No schema required upfront.
Weaviate stores typed objects inside a with properties declared in advance. Think of it like table columns.
Both support (dense vector + keyword), but differently. Qdrant combines a dense with sparse vectors. Weaviate fuses its vector index with a built-in BM25 index.
The payload model wins with heterogeneous or evolving data. The object model wins when schema enforcement and richer per-property queries matter.
from qdrant_client import QdrantClient from qdrant_client.models import VectorParams, Distance, PointStruct client = QdrantClient(url="http://localhost:6333") client.recreate_collection( collection_name="rag_docs", vectors_config=VectorParams(size=1536, distance=Distance.COSINE), ) client.upsert( collection_name="rag_docs", points=[ PointStruct(id=1, vector=embed("Qdrant stores points"), payload={"source": "intro.md", "year": 2024}), ], )
VectorParams(size=1536, distance=Distance.COSINE)PointStruct(id=..., vector=..., payload=...)client.upsert(...)This graduates the local RAG prototype from module 2 into a server-backed Qdrant collection.
The dict is free-form — no schema declaration needed before upsert.
Qdrant returns an UpdateResult with status='completed'. If a point with id=1 already exists, it is fully replaced (upsert semantics — not merged). The old payload is overwritten, not patched.
from qdrant_client.models import Filter, FieldCondition, MatchValue results = client.search( collection_name="rag_docs", query_vector=embed("how does Qdrant store metadata?"), query_filter=Filter( must=[FieldCondition(key="year", match=MatchValue(value=2024))] ), limit=5, ) for r in results: print(r.id, r.score, r.payload["source"])
Filter(must=[...])FieldCondition(key="year", match=MatchValue(value=2024))r.scoreThis adds a payload filter to the ANN search — only points where year=2024 are candidates.
Without a payload index on "year", Qdrant scans every point's payload at query time — the failure mode covered next.
Latency grows roughly linearly with collection size because Qdrant must deserialize and check every point's payload after the ANN pass. With a payload index, the filter is applied before or alongside the ANN scan, keeping latency sub-millisecond at scale.
import weaviate client = weaviate.connect_to_local() collection = client.collections.get("RagDoc") results = collection.query.hybrid( query="how does Qdrant store metadata?", alpha=__TODO__, # ← what value balances BM25 and vector equally? limit=5, ) for obj in results.objects: print(obj.properties["source"], obj.metadata.score)
client.collections.get("RagDoc")collection.query.hybrid(query=..., alpha=..., limit=...)obj.metadata.scoreThis is the Weaviate equivalent of the Qdrant filtered search — same RAG corpus, different engine.
Stop — attempt the TODO before revealing: alpha controls the blend between BM25 (keyword) and vector similarity.
alpha=0.5
Changed line: alpha=0.5 — the midpoint gives equal weight to BM25 keyword scoring and dense vector similarity. Use alpha closer to 1.0 when semantic meaning matters more than exact keyword matches; closer to 0.0 for precise term retrieval (e.g. product codes, IDs).
| Option | Schema flexibility | Hybrid search model | Operational simplicity | When to choose | Cost | Complexity |
|---|---|---|---|---|---|---|
| Qdrant | Free-form JSON payload; no schema required | Dense + sparse vectors; requires sparse index setup | Single binary, REST + gRPC, easy Docker deploy | Your data is heterogeneous or schema-free; you need lean, fast filtered vector search with minimal ops overhead. | Open-source; Qdrant Cloud for managed | Low — single binary, no schema migrations |
| Weaviate | Typed properties; schema must be declared first | BM25 + vector fusion built in; no extra index setup | More config surface; managed cloud eases this | Your corpus has structured objects (articles, products, docs with typed fields); you want BM25+vector hybrid out of the box. | Open-source; Weaviate Cloud for managed | Medium — schema definition required; more config surface |
| pgvector (revisit) | SQL columns; rigid but familiar | Manual tsvector + vector; no native fusion | Zero new infra if Postgres already exists | Vector search is a secondary feature alongside transactional SQL; you want zero new infrastructure. | Free; runs on existing Postgres | Low — Postgres extension |
You skip create_payload_index and queries work fine in dev with 10 k points.
In production at 5 M points, filtered search takes 4–8 s instead of <10 ms — no error, just silent slowdown.
Fix: call client.create_payload_index(collection_name="rag_docs", field_name="year", field_schema="integer") before going to production.
You add a new field to your objects without updating the collection schema first.
Result: 422 Unprocessable Entity: property 'year' not found in class 'RagDoc' — the object is rejected entirely.
Fix: add the property to the schema via the API or client before upserting objects that carry it.
create_payload_index call (Qdrant) or declared property (Weaviate). This is the most common omission.alpha value in Weaviate hybrid queries is intentional, not a default. A default silently skips keyword scoring.Qdrant and Weaviate are single-node or lightly clustered services. They top out when your vector collection outgrows one machine.
Module 5 examines Milvus, which separates storage from compute. It supports multiple index families (IVF, , DiskANN) for massive scale. Qdrant would need significant sharding work at this scale.
You examine Milvus's storage-compute separation, its multiple index families (IVF, HNSW, DiskANN), and the standalone-vs-distributed deployment split. You also apply the four dimensions from module 1 to stress-test when Milvus's complexity is genuinely justified versus when Qdrant or pgvector would have been enough.
Milvus is a distributed vector database built for billion-scale similarity search, with pluggable index families and a storage-compute separation that lets each layer scale independently.
Why this matters: If your project outgrows what Qdrant or pgvector can handle on a single node, Milvus is the next step — but its infrastructure overhead means you need to know exactly when it's worth it.
The answer is . Milvus's entire design pushes that ceiling far higher than Qdrant, pgvector, or Chroma.
The other three dimensions — , , and — still apply. But scale ceiling justifies Milvus's complexity. If your project doesn't hit that ceiling, a simpler option wins.
Milvus separates storage from compute. Each layer scales independently. Query nodes handle search; data nodes handle ingestion; object storage holds segment files. You can add query capacity during traffic spikes without touching your data layer.
Milvus ships three deployment modes:
The mode you choose is an decision. Lite costs nothing. Distributed costs a Kubernetes cluster, etcd, Pulsar, and MinIO — four moving parts the other options don't require.
Milvus lets you pick the per collection. This is the main flexibility advantage over pgvector or Qdrant.
Use HNSW when your index fits in RAM and latency is critical. Switch to DiskANN when it doesn't. IVF_FLAT is the fallback when build speed or memory is the constraint.
Imagine a legal-tech platform indexing 200 million contract clauses across 40 jurisdictions, with 500 concurrent analysts running filtered searches by jurisdiction, contract type, and date range.
Qdrant Standalone hits its around 50–100 M vectors on a single node; pgvector's HNSW index saturates RAM well before 200 M. Milvus Distributed shards the across query nodes, so each node holds a manageable segment and searches run in parallel.
The team uses DiskANN (index fits on NVMe, not RAM), scalar filters on jurisdiction and date, and a reranking layer on top. The operational cost is real: etcd, Pulsar, MinIO, and Kubernetes all need monitoring. But at 200 M vectors with strict latency SLAs, no simpler option delivers.
from pymilvus import MilvusClient client = MilvusClient(uri="http://localhost:19530") # Stage 1 — create collection with schema client.create_collection( collection_name="contracts", dimension=1536, index_params={"index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200}}, ) # Stage 2 — insert vectors with scalar fields, then filtered ANN query client.insert("contracts", [ {"id": 1, "vector": [0.1] * 1536, "jurisdiction": "DE", "year": 2022}, {"id": 2, "vector": [0.9] * 1536, "jurisdiction": "FR", "year": 2023}, ]) results = client.search( collection_name="contracts", data=[[0.15] * 1536], filter="jurisdiction == 'DE' and year >= 2020", limit=5, output_fields=["jurisdiction", "year"], )
MilvusClient(uri=...)create_collection(..., dimension=1536)index_params={"index_type": "HNSW", ...}filter="jurisdiction == 'DE' and year >= 2020"output_fields=[...]This two-stage snippet creates a Milvus collection with an HNSW index, inserts contract vectors with scalar metadata, and runs a filtered ANN query — the full pattern in one runnable block.
The filter argument is Milvus's scalar pre-filter: it narrows candidates before the ANN pass, so only matching jurisdiction/year records are ranked by vector similarity.
It returns an empty list — [] — with no error. Milvus applies the scalar filter first; if zero records pass it, the ANN step has nothing to rank. No fallback occurs. This is a common silent miss: your query succeeds with status OK but returns nothing, and the only way to catch it is to check len(results[0]) > 0 before using the output.
from pymilvus import MilvusClient client = MilvusClient(uri="http://localhost:19530") # Same contract collection — but the index won't fit in RAM. # TODO: replace the index_params dict so Milvus uses DiskANN # instead of HNSW. Keep metric_type COSINE. client.create_collection( collection_name="contracts_large", dimension=1536, index_params=TODO, # <-- your change goes here ) # Insert and search calls stay identical to Stage 2.
"index_type": "DISKANN""search_list": 100Everything except index_params is identical to the Stage 1–2 example — the only change is swapping the index family to DiskANN for a collection that won't fit in RAM.
{"index_type": "DISKANN", "metric_type": "COSINE", "params": {"search_list": 100}}
Changed lines vs Stage 1:
Everything else — collection name aside — is unchanged, which is the point: Milvus's API is index-agnostic at the insert/search layer.
Drag to see which Milvus index family fits your collection size and RAM budget.
Three failure patterns account for most Milvus production incidents.
client.load_collection("contracts") gives silent empty results — status OK, zero hits, no exception.MilvusException: vector dimension mismatch — you must drop and recreate the collection.load_collection() is called after every insert batch.dimension value matches your embedding model's output.filter expression uses Milvus syntax, not SQL WHERE clauses.Module 6 scores all six databases on the same four dimensions in one comparison table.
You score all six databases on the four dimensions from module 1 in a single comparison table, walk a decision tree from use-case signals to a recommendation, and verify that recommendation against the failure modes surfaced in modules 2–5. The capstone asks you to apply the tree to a novel scenario without the table in front of you.
A head-to-head comparison of all six vector databases scored on the four decision axes, with a decision tree and migration guide.
Why this matters: This module gives you a single framework to pick the right database for any use case and know when to switch — the practical payoff of everything covered in the lesson.
Decision this forces: The final selection: which of the six databases fits your use case, and what to do when your needs change.
The four axes are (how you run it), (how far it grows), (what filters and hybrids it supports), and (what it plugs into naturally).
Every database since module 1 has been scored against these four axes. This final module collapses all six scores into one comparison. It then gives you a decision tree to pick the right one for any use case.
| Option | Scale Ceiling | Query Expressiveness | Ecosystem Fit | When to choose | Cost | Complexity |
|---|---|---|---|---|---|---|
| Chroma | Millions of vectors on one machine; no horizontal scale | Metadata filters only; no hybrid search | Python-first; LangChain/LlamaIndex native | Local RAG prototypes and notebooks; single-developer projects with no ops budget. | Free; runs in-process | None — embedded, zero-ops |
| LanceDB | Larger local datasets than Chroma via columnar Lance format; still single-node | SQL-like filters; basic hybrid via full-text index | Python/Rust; Arrow-native; good for multimodal | Local or cloud-backed prototypes needing columnar storage or multimodal vectors. | Free; storage cost only | None — embedded, zero-ops |
| pgvector | Tens of millions of vectors on a single Postgres node; HNSW degrades beyond ~50 M rows | Full SQL + vector operators; strong hybrid search | Any Postgres client; ORMs; existing SQL tooling | Teams already on Postgres who need vector search without a new service. | Postgres hosting cost; no extra service | Low — Postgres extension, existing infra |
| Qdrant | Hundreds of millions of vectors; horizontal sharding available | Rich payload filters + hybrid search; sparse+dense vectors | REST/gRPC; Python/JS/Rust clients; RAG-first design | Dedicated vector service needed; strong payload filtering is a hard requirement. | Self-host free; managed cloud paid | Medium — Docker/cloud service to operate |
| Weaviate | Hundreds of millions of vectors; multi-tenant and sharded | Semantic + keyword hybrid; GraphQL and REST; typed schema | LangChain/LlamaIndex; module ecosystem for vectorizers | Schema-driven corpora where object-level APIs and built-in hybrid retrieval matter. | Self-host free; managed cloud paid | Medium — service to operate; schema design upfront |
| Milvus | Billions of vectors; storage-compute separation; DiskANN for disk-based scale | Scalar filters; multiple ANN index families; limited hybrid vs Weaviate | Python/Java/Go; PyMilvus; strong MLOps integration | Billion-scale workloads where storage-compute separation and index variety are non-negotiable. | Self-host infra cost; Zilliz Cloud managed option | High — distributed deployment; Kubernetes typical |
Two mis-selection patterns cause most painful migrations: over-engineering and under-scaling.
You reach for Milvus or Weaviate when Chroma or pgvector would ship in a day. Your corpus is under 5 M vectors. Your team has no dedicated infra engineer. You're still validating the product idea.
Result: two weeks of Kubernetes YAML before a single query runs. The prototype never ships.
You stay on Chroma or pgvector past their . Query latency climbs past 200 ms. Index rebuilds block writes. A single-node OOM crash takes the whole service down.
pgvector HNSW index builds spike from seconds to minutes as rows cross ~50 M. Chroma returns stale results after restart if persistence wasn't configured.
def select_vector_db(corpus_size, postgres_source, needs_billion_scale, dedicated_infra): if needs_billion_scale: return "Milvus" if postgres_source and not dedicated_infra: # TODO: return the right database for this branch return ___ # ★ CHANGED → return "pgvector" if corpus_size > 50_000_000: return "Qdrant or Weaviate" return "Chroma or LanceDB" # Scenario: 8 M chunks, Postgres source of truth, team of 3, no infra eng. print(select_vector_db(8_000_000, postgres_source=True, needs_billion_scale=False, dedicated_infra=False))
corpus_size > 50_000_000postgres_source=Trueneeds_billion_scalereturn ___This function encodes the decision tree as executable logic so you can trace exactly which signal triggers which recommendation.
The TODO is the crux: the branch where Postgres is already the source of truth and the team has no dedicated infra. Fill it in from memory before revealing.
Replace ___ with "pgvector". The print statement outputs: pgvector
Changed line (the crux): return "pgvector" — the postgres_source=True + no dedicated_infra branch maps directly to the pgvector leaf in the decision tree. Corpus size (8 M) is safely under the ~50 M HNSW ceiling, so no scale concern forces a dedicated service.
Watch for: p95 query latency > 200 ms, index build blocking writes, or single-node memory pressure. Set an alert before you hit the wall, not after.
Dump your and metadata to Parquet or JSONL. Every database covered in this lesson can read those formats, so the export format is your portability layer.
Load the export into the new database while the old one stays live. Run both in shadow mode: send real queries to both and diff the top-k results to confirm parity before cutting over.
Flip the application's connection string, monitor error rates for 24 hours, then decommission the old instance. Keep the Parquet export for 30 days as a rollback artifact.
Click a query scenario to highlight the nearest databases. X = operational complexity (0 = zero-ops, 100 = full distributed); Y = scale ceiling (0 = single-node small, 100 = billion-vector).
When an AI tool recommends a vector database, run these checks before committing:
You now have the full map. The solo capstone asks you to apply the decision tree to a novel scenario. No table in front of you. Justify your pick in one sentence. That's the real test of whether the framework is yours.
Before looking at the comparison table, try to reconstruct from memory: what are the four selection dimensions, which tier does each of the six databases belong to, and what is the single strongest signal that pushes you from one tier to the next? Write it out, then check it against the module 6 table.
Apply what you learned to Vector Databases.