Vector DB Selection
Compare popular vector databases, their features, scaling characteristics, and when to use each solution.
SPACED REPETITION Β· 12 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 12The tenant who got one answer
A B2B help-desk assistant keeps every customer's documents apart: each chunk carries a tenant_id, and every query filters on it. The team already runs Postgres, so they store the chunks there with pgvector (0.8.6 is the current release, checked 2026-09-25) and add an HNSW index with the default settings. The demo tenant is huge, and every answer looks great.
Then a user at Acme, a mid-sized customer, asks how to rotate the API key for their SSO integration. The app runs:
-- pgvector 0.8.6 on Postgres 18; HNSW index with defaults (m = 16, ef_construction = 64)
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
SELECT id
FROM chunks
WHERE tenant_id = 'acme'
ORDER BY embedding <=> $1 -- cosine distance to the query embedding
LIMIT 10;
What came back: one chunk, about SAML certificates. What should have come back: ten Acme chunks, led by Acme's "Rotating API keys" page. No error, no warning. The LLM answered from one loosely related chunk.
The story is a composite, but the numbers are not. We rebuilt the situation with pgvector 0.8.6: 60,000 synthetic 64-dimensional embeddings, Acme owning 1,759 of them (2.9%), and 200 queries.
Predict first: pgvector's HNSW scan keeps a list of 40 candidates by default (hnsw.ef_search = 40). If the WHERE clause is checked only on those candidates, how many rows do you expect per query?
Check your answer
About 40 Γ 0.029 β 1.2. We measured 1.1 rows per query on average (between 0 and 5). Not one of the 200 queries got a full page of 10. The pgvector README says it plainly: "With approximate indexes, filtering is applied after the index is scanned." The index hands back its 40 nearest neighbours from the whole table, and Postgres then drops the ones that belong to other tenants.
The first fix is one setting, added in pgvector 0.8.0 and off by default: iterative index scans, which keep scanning more of the index when too few rows survive the filter. Same 200 queries:
| Setting | Rows per query | ANN recall@10 | Time per query |
|---|---|---|---|
| defaults | 1.1 | 0.10 | 0.4 ms |
SET hnsw.iterative_scan = strict_order |
10 | 0.92 | 1.6 ms |
SET hnsw.iterative_scan = relaxed_order |
10 | 0.98 | 1.3 ms |
ANN recall@10 is the overlap between what the index returned and the exact top 10 among Acme's chunks. For comparison, unfiltered queries on the same index scored 0.92. The timings come from a laptop running Postgres in Docker, so compare the settings with each other, not with your production numbers.
Iterative scans are not unlimited, though. The README says a scan ends when enough rows are found or when it has visited about hnsw.max_scan_tuples tuples (20,000 by default); a memory limit, hnsw.scan_mem_multiplier Γ work_mem, can end it too. Acme holds 2.9% of the rows, so the cap is no problem. A tenant whose rows are rarer than about 10 in 20,000 (0.05%) still comes back short, and for such tenants a partial index, a partition or exact search over the tenant's rows is the better tool. Also, relaxed_order may return rows slightly out of distance order; strict_order keeps the exact order.
So the database was not broken. The team had simply never asked, before choosing it, "what does this engine do when a filter matches 3% of the rows?" And nobody had measured it. Most painful vector-database stories look like this. The product did what its documentation said, and the requirement that mattered was never written down.
Picking a vector database is not a contest for "the best". It is a short list of requirements that actually decide, checked against current documentation, and then proved on your own data. Each requirement has a symptom you will meet if you skip it:
| # | Requirement that decides | Symptom when you skipped it |
|---|---|---|
| 1 | Scale and memory | The RAM bill, or out-of-memory errors at 10Γ data; p95 climbs as the index spills to disk |
| 2 | Filter selectivity | Small tenants or narrow date ranges get one or two results, silently |
| 3 | Hybrid (lexical + dense) | Part numbers and error codes go missing; a second search system appears |
| 4 | Multitenancy | Thousands of collections; one giant tenant; deleting a customer becomes a project |
| 5 | Writes, freshness, consistency | New documents stay invisible; deleted ones linger; a crash means a multi-day rebuild |
| 6 | Ops model and cost | An on-call rotation nobody staffed; a per-query bill that grows with traffic |
| 7 | Ecosystem and portability | ACL checks and joins in application code; a migration that rewrites every query |
This lesson takes you through them in order. First you decide whether you need a new database at all. Then you walk the requirements with real numbers, profile the 2026 options with dated facts, and run a fair proof of concept. What a vector database is made of is covered in Vector Database Architecture. How HNSW, IVF and quantization work is covered in ANN Algorithms. Here you use those ideas to choose.
β οΈ Product facts rot quickly. Every version, default and limit below carries the date it was checked (2026-09-25) and a link. Re-check the ones your decision depends on.
Step zero: you may not need a new database
A new database is a new thing to deploy, secure, back up, monitor and keep in sync with your source of truth. It has to earn its place. Three cheaper answers come first.
Answer 1: a numpy array
Exact nearest-neighbour search is one matrix-vector product. It has perfect recall and no index to tune:
import time
import numpy as np
def exact_top_k(X, q, k=10):
scores = X @ q # cosine: rows of X and q are unit length
top = np.argpartition(-scores, k)[:k]
return top[np.argsort(-scores[top])]
rng = np.random.default_rng(1)
dim = 768
for n in (100_000, 1_000_000):
X = rng.standard_normal((n, dim), dtype=np.float32)
X /= np.linalg.norm(X, axis=1, keepdims=True)
q = X[0]
times = []
for _ in range(30):
t0 = time.perf_counter()
exact_top_k(X, q)
times.append((time.perf_counter() - t0) * 1000)
print(f"n={n:>9,} vectors {X.nbytes / 1e9:.2f} GB median {np.median(times):5.1f} ms/query")
# On an Apple M2 Max laptop shared with other jobs, one run printed:
# n= 100,000 vectors 0.31 GB median 6.7 ms/query
# n=1,000,000 vectors 3.07 GB median 56.1 ms/query
Other runs, while the laptop was busy with other jobs, were up to 4Γ slower, so measure on your own hardware. The cost is mostly reading memory: when the machine was quiet, roughly 18β25 ms per GB of float32 vectors scanned. The exercises below use about 20 ms per GB. At 100,000 chunks, exact search takes a few milliseconds, which is nothing next to an LLM call. At 1M chunks it no longer fits a 50 ms retrieval budget, though batch jobs will not mind. These are single-query latencies: batching many queries into one matrix multiply raises throughput a lot, which is why offline jobs can afford exact search at sizes an interactive service cannot.
What a bare array does not give you: durable writes (you can np.save a file, but that is not a write-ahead log), concurrent writers, one shared copy across many server processes, and fast filtering over millions of rows.
Answer 2: the database you already run
- Postgres β pgvector. Vectors sit next to your rows. You get transactions, joins, the backups and replicas you already have, and exact or approximate search in SQL.
- Elasticsearch or OpenSearch β vector fields. You already have BM25, analyzers and the operations know-how. Both engines add HNSW vector search to the same index (details in the landscape section).
- Several other general-purpose databases have added vector indexes too. Check what yours supports before you add a new system.
What you give up: vector-specific features can lag behind the dedicated engines, as the Acme story showed with filtering. Vector queries also compete with your transactional load on the same machines.
Answer 3: an embedded library or database
FAISS and hnswlib are indexing libraries: they run inside your process and can save and load index files. Chroma (with PersistentClient) and LanceDB are embedded databases that also keep documents and metadata on disk. Both kinds work well for a single process or a notebook. In embedded mode, none of them replicates your data or serves many application servers.
π‘ Rule of thumb: a dedicated vector database has to win on a requirement that the cheaper options measurably fail. "We might need to scale" is a forecast, not a measurement.
Your turn: an internal tool has 250,000 chunks of 1,024 float32 dimensions, peaks at 3 queries per second, and has a 50 ms retrieval budget. Is exact search in numpy acceptable? What would push you to an index?
Check your answer
The vectors take 250,000 Γ 1,024 Γ 4 B = 1.02 GB. At 18β25 ms per GB, one exact query costs about 19β26 ms. That is inside 50 ms, and at 3 QPS one core is busy less than 10% of the time. So yes, this is a reasonable choice: perfect recall and nothing to operate.
Three things would change the answer. 10Γ growth pushes each query to about 190β260 ms. Many server processes would each hold their own 1 GB copy, unless you memory-map one shared file. Frequent updates from several writers need a real write path. At that point, use pgvector if you already run Postgres, or a dedicated engine.
Requirement 1: scale and memory β do the arithmetic before the demo
Symptom: "It was fast in the demo." The demo had 50,000 vectors.
Take a legal-research product with 40 million chunks from a 1,024-dimensional model. Before you look at any product, work out what the vectors alone need:
def footprint_gb(n, dim, M=16):
GB = 1e9
return {
"float32 vectors": n * dim * 4 / GB,
"int8 vectors": n * dim * 1 / GB,
"binary vectors": n * dim / 8 / GB,
"HNSW links": n * 2 * M * 4 / GB, # base layer: 2*M neighbour ids of 4 bytes; an estimate
}
for part, gb in footprint_gb(40_000_000, 1024).items():
print(f"{part:16} {gb:7.2f} GB")
# float32 vectors 163.84 GB
# int8 vectors 40.96 GB
# binary vectors 5.12 GB
# HNSW links 5.12 GB
Those numbers turn into three candidate layouts:
| Layout | RAM | Disk | What it costs you |
|---|---|---|---|
| float32 vectors + graph in RAM | β 169 GB | β | Big nodes or several shards; the fastest and simplest option |
| int8 in RAM, float32 on disk for rescoring | β 46 GB | 164 GB | A small recall loss, won back by rescoring the top candidates from disk |
| binary in RAM, float32 on disk for rescoring | β 10 GB | 164 GB | Recall depends heavily on the model; rescoring is not optional |
Add payload (text, metadata), engine overhead, and a full copy for every replica. Those are estimates to check against the engine's own sizing guide and a real load test. The mechanics of quantization and rescoring are in ANN Algorithms.
Questions to ask every candidate:
- Which parts must stay in RAM, and which can live on disk (memory-mapped) or in object storage?
- Which quantization does it support, and does it rescore with the original vectors automatically?
- How does it grow at 10Γ: a bigger box, more shards, or automatic scaling you don't see?
- What is the largest dimension it can index? pgvector stores
vectorvalues of up to 16,000 dimensions, but its HNSW and IVFFlat indexes accept up to 2,000 (halfvecup to 4,000,bitup to 64,000). An Elasticsearchdense_vectorfield allows up to 4,096 dimensions (pgvector README, Elasticsearch docs, both checked 2026-09-25). A 3,072-dimensional model therefore needs a workaround before pgvector can index it: index it at half precision (halfvec), index a binary-quantized copy (bit) and re-rank the candidates with the full vectors, or index a truncated (Matryoshka-style) embedding if the model supports truncation.
Requirement 2: filter selectivity β the one that fails silently
Symptom: broad queries look fine, but queries for a small tenant, a narrow date range or a rare category return a handful of results with no error.
Engines combine a filter with approximate search in three ways:
- Search, then filter: take the top C candidates from the whole index, then drop the ones that don't match. Expect about C Γ (fraction of rows that match). Acme was this case with C = 40.
- Filter, then exact scan: fetch the matching ids from a filter index and compute exact distances for them. Recall is perfect, and the cost grows with the number of matches: 2,000 matches Γ 768 Γ 4 B = 6 MB, well under a millisecond in numpy.
- Filter during search: the graph walk skips non-matching nodes. The page fills up, but at very low match rates the walk visits many nodes of other tenants.
Good engines run a query planner that estimates how many rows match, scans exactly when there are few and filters inside the graph walk when there are many. How each strategy works and what it costs, along with filter design itself (payload schemas, access control as a filter, nulls, testing for leaks), is covered in Metadata & Filtering. To choose a database, you need a narrower answer: which of these does your engine do for your filter mix, and can you see which one it picked?
What current engines do with a filter (checked 2026-09-25):
| Engine | Behaviour | Knobs worth knowing |
|---|---|---|
| pgvector 0.8.x | Index scan first, then WHERE |
Iterative scans (0.8.0+); partial indexes; list partitioning; a B-tree on the filter column for exact search (README) |
| Elasticsearch 9.x | A filter inside the knn query is applied during the search, "to ensure that k matching documents are returned"; filters elsewhere in the query are post-filters (docs) |
Where you put the filter |
| OpenSearch 3.x | "Efficient filtering" inside the k-NN clause (Lucene HNSW, Faiss HNSW/IVF); Boolean filters and post_filter run after the search (docs) |
Where you put the filter |
| Qdrant 1.19 | Payload indexes plus a filter-aware HNSW graph; the planner scans the matching points exactly when their vectors are estimated below full_scan_threshold (10,000 KB by default) (docs) |
Create payload indexes before loading data, because the graph's extra filter links are built with it; ACORN for combined strict filters (1.16+) |
| Weaviate 1.39 | An allow-list is passed into the HNSW search; ACORN is the default strategy since 1.34; flat search below flatSearchCutoff (default 40,000) (docs) |
Filter strategy, cutoff |
| Milvus 2.6 / 3.0 | "Restricts the search scope within the entities matching the specified filtering condition", then runs the ANN search (docs) | Iterative filtering for expensive filter expressions |
| Vespa 8 | Filtered HNSW search; falls back to exact search when filters match under about 2% of documents (guide) | Tunable thresholds |
Managed services often do not document the mechanism at all. That is fine, because you are going to measure it anyway.
β οΈ Same engine, different answer. In Elasticsearch and OpenSearch, a filter in the right place is applied during the search, while the same filter one level up in the query becomes a post-filter. In pgvector, the fix is a session setting that is off by default. Read your own query plan (EXPLAIN in Postgres). In our Acme test, Postgres chose the HNSW index and post-filtered for Acme (2.9% of the rows), with or without a B-tree index on tenant_id. For a tenant with only 60 rows (0.1%), the same planner skipped the vector index. It fetched the 60 rows through the B-tree (or, without one, with a sequential scan), sorted them by exact distance and returned a perfect page. The danger zone is the middle: filters narrow enough to starve a 40-candidate list, but not narrow enough for the planner to give up on the index.
Your turn: 5,000,000 chunks of 768 dimensions; a filter matches 0.2% of them. Engine A searches first with 100 candidates and filters afterwards. Engine B's planner scans exactly when fewer than 20,000 rows match. Predict what each returns for LIMIT 10, and roughly what B's exact scan costs.
Check your answer
0.2% of 5M is 10,000 matching chunks. Engine A expects 100 Γ 0.002 = 0.2 matching rows, so most queries come back empty. Engine B has 10,000 matches, which is below its cutoff, so it scans them exactly: 10,000 Γ 768 Γ 4 B = 30.7 MB, which at the in-memory rate of about 20 ms per GB is roughly 0.6 ms, with perfect recall. (An engine that fetches each vector from a row store, as Postgres does, is many times slower per row; the knowledge-base example below measures it.) The narrow filter is the easy case for a planner and the hard case for post-filtering.
Requirement 3: hybrid retrieval β one engine or two
Symptom: the query XK-4471 overheating returns three good articles about thermal management and misses the one page that mentions XK-4471. Dense embeddings blur exact identifiers. You need a lexical signal as well (why this happens is covered in Sparse vs Dense Retrieval).
You can get it two ways.
Inside one engine. One query runs lexical (or learned-sparse) and dense retrieval and fuses the results. As of 2026-09-25:
- Postgres: pgvector plus Postgres full-text search in the same database. The pgvector README suggests fusing the two with RRF, in SQL or in your application. Both indexes change in the same transaction, so there is nothing to keep in sync.
- Elasticsearch and OpenSearch: BM25 is native. Elastic recommends its
semantic_textworkflow with RRF, but itsrrfandlinearretrievers need an Enterprise licence (checked in the Elasticsearch source, 2026-09-25). On a lower tier, you run the BM25 and kNN searches and fuse the two lists in your application. OpenSearch fuses ahybridquery with a normalization processor or an RRF score-ranker processor. - Weaviate:
hybridcombines BM25 with vector search.alpha = 1means vector only andalpha = 0means keyword only, the same direction as this course's Ξ±. The server default is 0.75. - Milvus: a built-in BM25 function turns text into sparse vectors, and hybrid search fuses the results with rankers such as RRF.
- Qdrant: named dense and sparse vectors in one collection, fused in the Query API with RRF or DBSF. An IDF modifier on the sparse vector gives BM25-style weighting.
- Vespa: rank profiles combine
bm25(...)and vectorcloseness(...)over several ranking phases. - Pinecone: full-text search went GA on 2026-09-09. It "adds BM25 keyword ranking and text-match filters to the same index as your dense vectors" (blog).
- LanceDB: vector search, full-text search and SQL over the same table.
Across two systems. A vector store plus a search engine you already run, fused in your application. With two systems you get two indexes to keep in sync, two failure modes and two latency tails. Every write, delete included, has to reach both, and until it does the two sides disagree. What you gain is freedom to pick each side on its merits.
In-engine hybrid ties you to that engine's tokenizers and analyzers, so check its language support (stemming, compound words, CJK segmentation) against your content. Measure it the same way as everything else: recall@10 on a labeled query set, reported separately for queries that contain identifiers and queries that don't. Fusion and weight tuning are covered in Hybrid Retrieval Systems.
Requirement 4: multitenancy β count tenants, then look at the biggest
Symptom: the design works for ten tenants. At 3,000 you have 3,000 collections, one tenant holds a quarter of the data, and offboarding a customer means a script that runs all night.
Three patterns, from cheapest to most separated:
| Pattern | Examples (checked 2026-09-25) | Watch for |
|---|---|---|
| Shared index + tenant filter | Qdrant: one collection, keyword index with is_tenant=true (1.11+); Milvus partition key ("millions of tenants"); pgvector with a tenant_id column |
Requirement 2: how the engine filters for tiny tenants |
| Partition per tenant | Pinecone namespaces (100 on Starter up to 1,000,000 on Enterprise per index); Weaviate one shard per tenant, with tenants ACTIVE, INACTIVE or OFFLOADED; Postgres list partitions |
Per-tenant overhead; how the largest tenants are handled |
| Collection or index per tenant | Milvus: up to 65,536 collections by default; Qdrant Cloud: 1,000 collections per cluster by default | Per-collection overhead; Qdrant's docs call this "rarely the most efficient approach" |
Sources: Qdrant multitenancy, Milvus multi-tenancy, Pinecone multitenancy, Weaviate tenant states, pgvector README.
Tenant sizes are rarely even. They usually follow a long tail: a few giants and hundreds of tiny tenants. The giants set your memory and shard layout. The tail stresses your filters, because each tiny tenant is a very selective filter. Some engines handle both ends explicitly: Qdrant 1.16 added tiered multitenancy, which keeps small tenants together in a shared shard and lets you promote a large one to a dedicated shard.
Two more questions to ask:
- Can you delete a tenant quickly and completely? Pinecone describes deleting a namespace as "a lightweight and almost instant operation". With a shared index and a filter, it is a delete-by-filter followed by background cleanup. Find out how long that takes and when the space is actually reclaimed.
- Is one tenant's traffic isolated from another's? Separate partitions do not automatically mean separate compute. Ask what is shared.
β οΈ Storage isolation is not authorization. In every one of these patterns, a query sent with the wrong tenant id returns the wrong tenant's data. Placing tenants on their own shards does not change that: in Qdrant, a query sent without a shard key runs on all shards. The tenant must come from the authenticated identity on the server, never from a request parameter the client controls. See Metadata & Filtering and, for Qdrant specifically, Advanced Qdrant.
Requirement 5: writes, freshness and consistency
Symptom: a support agent uploads a new policy and asks about it a minute later. The old policy comes back. Or a customer invokes their right to erasure, and their chunks keep showing up for a day.
Questions to ask:
- Write rate. Peak upserts per second, deletes, and the occasional full re-embed. Does heavy writing slow queries down, and for how long does a bulk load leave the index un-optimized?
- Visibility. When can a query see a write? In Postgres, when the transaction commits. Qdrant's
upsert(..., wait=True)"will be returned only when all changes are applied". Watch out: the Python client's bulkupload_collectiondefaults towait=False. Pinecone documents itself as "eventually consistent, so there can be a slight delay before new or changed records are visible to queries", and it returns log sequence numbers you can use to check (docs). - Durability. Is a write acknowledged only after it reaches a write-ahead log? pgvector rides on Postgres's WAL, which gives replication and point-in-time recovery (README FAQ). FAISS and hnswlib can save index files, but they have no write-ahead log and no replication, and sharing one index between several writing processes is your problem.
- Updates that are not vectors. A price or a permission changes, but the text does not. Can you update metadata without rewriting the vector? Qdrant's
set_payloadand Vespa's partial updates of attribute fields change it in place. In Elasticsearch and OpenSearch, an update rewrites the whole document, vector included ("The document must still be reindexed", says the Elasticsearch update API). A Milvus upsert, even a partial update, inserts the merged entity and deletes the old one.
Your vector index is derived data. You can always rebuild it from the source documents, so it is not the source of truth. But a rebuild costs time. Re-embedding 40 million chunks at an assumed 500 chunks per second takes 40,000,000 / 500 = 80,000 s, about 22 hours. At the roughly 220 chunks per second that all-MiniLM-L6-v2 managed for 180-word chunks on one laptop CPU (Apple M2 Max, September 2026), it takes about 182,000 s, or 2.1 days. So snapshots and replicas still matter. And switching embedding models is a planned migration whatever database you pick (see Vector Embeddings and Data Freshness & Lifecycle).
Requirement 6: ops model and cost
Symptom: the database was "free", but the on-call rotation, the upgrade weekends and the 3 a.m. disk-full page were not. Or the opposite: the managed bill grew in step with traffic.
Four ops models:
| Model | You run | You pay for | Typical fit |
|---|---|---|---|
| Embedded | A library in your process | Your app's machines | Notebooks, single-process services, edge |
| Self-hosted | Servers or a cluster, upgrades, backups | Machines + people | Platform teams, data that must stay in your network |
| Managed (dedicated) | Nothing below the API | Provisioned capacity by the hour | Steady load, small ops team |
| Serverless / usage-based | Nothing below the API | Reads, writes and storage | Spiky or low traffic, many small indexes |
Object storage changed the cost picture. Several 2026 services keep vectors in object storage and cache hot data on local SSD or in memory: Pinecone serverless ("All your vector data is stored in highly efficient, distributed object storage", architecture), Chroma Cloud, turbopuffer, LanceDB on S3, and Amazon S3 Vectors (GA on 2025-12-02, up to two billion vectors per index, announcement). Storage is cheap, and the price is cold-query latency. turbopuffer's architecture page reports a p50 of 874 ms for a cold query against 1M documents and 14 ms once they are cached. AWS says infrequent S3 Vectors queries return "in under one second" and frequent ones in about 100 ms. If your traffic touches many rarely-used tenants, cold start is a requirement, not a footnote.
Usage-based vs provisioned, with made-up prices. Say Plan U charges USD 0.10 per 1,000 queries, and Plan P is a provisioned node at USD 600 per month that handles your peak. Plan P costs the same at any traffic. The break-even volume is 600 / 0.10 Γ 1,000 = 6,000,000 queries per month, which is an average of 6,000,000 / 2,592,000 s β 2.3 queries per second over a 30-day month.
| Average load | Plan U per month | Plan P per month |
|---|---|---|
| 0.5 QPS | USD 129.60 | USD 600 |
| 20 QPS | USD 5,184 | USD 600 |
Sustained, high-QPS traffic favours provisioned capacity. That is why Pinecone added Dedicated Read Nodes (GA on 2026-04-15) with "fixed hourly per-node pricing" for such workloads (blog). Spiky or low traffic favours usage pricing. Real prices change often, so plug in the current price sheet and your own query mix. Then add people: a self-hosted cluster needs someone who can upgrade it, restore it and get paged for it.
Requirement 7: ecosystem and portability
Symptom: permission checks, joins with the customer table, and "only documents the user can see" all live in application code, copied into three services.
- Next to your data. If filters depend on relational data (permissions, subscriptions, org charts), a database that can join, or at least filter on fields you keep in sync, saves a lot of glue code. This is pgvector's strongest argument.
- Clients and integrations. SDKs in your languages, and connectors for your ingestion and orchestration tools. Frameworks change quickly, so treat a framework integration as a convenience, not a reason to choose.
- Lock-in is not where people think. Your vectors come from your model. Keep a copy (in object storage or next to the source documents) and they move with you. What is actually sticky: the query and filter syntax, fusion semantics, the tenant model, hosted embedding or reranking features, and operational tooling. Put a thin retrieval interface in your application (
search(query, filters, k)), so a migration touches one module.
The 2026 landscape, profiled
These profiles were checked against official docs and release pages on 2026-09-25. The groups tell you what kind of system each one is, and the "pick it when" line ties each to the requirements above. None of them is "the best".
| Option (latest checked) | What it is | Strong when | Check before you commit |
|---|---|---|---|
| pgvector 0.8.6 (2026-07-29) | Postgres extension: vector, halfvec, bit, sparsevec; HNSW and IVFFlat |
You already run Postgres; joins and transactions matter; up to millions of vectors per node | Post-filtering unless iterative scans are on; 2,000-dimension index limit for vector; vector load on your primary |
| Elasticsearch 9.5 / OpenSearch 3.8 | Search engines with vector fields. Elasticsearch quantizes float vectors by default: int8 since 8.14, BBQ for 384+ dimensions since 9.1, bbq_disk since 9.4 where the licence allows (docs) |
Strong BM25 and analyzers already in place; hybrid in one query | Where the filter goes; your Elasticsearch licence tier (the rrf and linear retrievers need Enterprise); every update reindexes the whole document; JVM and cluster operations |
| Qdrant 1.19.1 (2026-09-04) | Open-source engine written in Rust; self-hosted or Qdrant Cloud | Heavy filtering; dense + sparse hybrid in one query; many tenants in one collection | Sizing of RAM vs on-disk storage β Qdrant; its sharding model at your scale β Advanced Qdrant |
| Milvus 3.0.2 (2026-09-20; 2.6.x still maintained) and Zilliz Cloud | Distributed engine with many index types (HNSW, IVF variants, DiskANN, ScaNN, GPU indexes), BM25 full-text search | Hundreds of millions of vectors or more; many index choices; separate scaling of components | Operating a distributed system; an upsert rewrites the whole entity; 2.6 dropped the Kafka/Pulsar dependency, so read current architecture docs, not 2024 blog posts |
| Weaviate 1.39 (1.39.7, 2026-09-25) | Open-source engine; self-hosted or Weaviate Cloud; "collections"; optional model-provider integrations that embed data at import | Built-in BM25 hybrid; tenant-per-shard model with offloading of cold tenants | Filter strategy and cutoff; operating it yourself vs the cloud offering |
| Vespa 8 (8.754.14, 2026-09-21) | Open-source serving engine; self-hosted or Vespa Cloud | Complex ranking (multi-phase, BM25 + vectors + business signals) at scale; streaming search for per-user data | A steeper learning curve (schemas, rank profiles) |
| Pinecone (serverless) | Managed only; object-storage-backed; namespaces; Dedicated Read Nodes; BM25 full-text search | No ops team; many tenants as namespaces; spiky traffic | Pod-based indexes are closed to Standard and Enterprise sign-ups since 2025-08-18; eventual consistency; cost at sustained QPS |
| Chroma 1.5.9 (2026-05-05) and Chroma Cloud | Open source: embedded (Client() in memory, PersistentClient on disk) or a single-node server. Cloud (2025-08-18): distributed, on object storage, same API |
Prototypes and single-node apps; a path to a managed service without changing the API | The hybrid Search API is Cloud-only (checked 2026-09-25); single-node limits when self-hosted |
| LanceDB 0.39 (2026-09-17) | Embedded library on the Lance columnar format; local disk or s3://; Cloud and Enterprise offerings |
Data already in a lake; multimodal data; vector + full-text + SQL in-process | Concurrency and serving model once many processes write |
| FAISS / hnswlib | Libraries, not databases | Offline batch search; custom serving; research | You build persistence, updates, filtering and replication yourself |
A few notes the table cannot hold:
- pgvector's scaling story is Postgres's: "Scale horizontally with replicas", or shard with Citus or a similar tool, per the README. Read replicas scale reads; writes still go to one primary.
- Milvus 2.6 (August 2025) introduced a native write-ahead log called Woodpecker, merged the index and data nodes, and unified its coordinators (release notes). Milvus 3.0 (2026-07-29) adds "external collections" that index lake tables in place (release notes). Milvus Lite, the embedded mode, was rebuilt in pure Python for 3.0. Version 3.2.1 (2026-08-25) offers FAISS-backed HNSW and IVF indexes but no product quantization, and its README scopes it to "local development and small-scale workloads" in a single process.
- Weaviate calls data containers collections (older material says "classes"), and its managed service is Weaviate Cloud.
- Vespa streaming search skips building indexes and scans each user's own documents at query time. The docs pitch it for "personal data applications" where each query touches a small subset.
Shortlists from requirements
Selection goes like this: write the requirements as numbers, cross out the options that fail a hard one, and take two or three survivors into a proof of concept.
Worked: the internal knowledge base
Requirements: 80,000 chunks of 768 dimensions; peak 5 QPS; retrieval p95 under 100 ms; a department filter (12 departments, the largest holds 20% of the chunks and the smallest 1.5%); Postgres already in production; two engineers, no platform team.
- Scale: 80,000 Γ 768 Γ 4 B = 0.25 GB, which numpy would scan exactly in about 5 ms. Postgres is much slower per row: each 768-dimension vector (about 3 KB) is stored out of line, fetched and compared row by row. On a heavily loaded laptop (pgvector 0.8.6, Postgres 18.6 in Docker), an exact
ORDER BY embedding <=> $1 LIMIT 10over all 80,000 rows took about half a second. Over the 16,000 rows of the largest department, found through a B-tree ondepartment, it took 70β160 ms. - Filters: at 1.5%, default post-filtering expects 40 Γ 0.015 = 0.6 rows. That is the Acme failure again, so iterative scans must be on.
- Ops: no new system is the cheapest system.
Shortlist: pgvector in the existing Postgres, with HNSW and hnsw.iterative_scan = relaxed_order set for the retrieval role (ALTER ROLE β¦ SET works for it). The runner-up is exact search in SQL with no vector index: a B-tree on department narrows each query to one department. It is viable only if p95 for the largest department fits the 100 ms budget on your hardware, so measure that first. Add Postgres full-text search with RRF if users paste error codes. Measure ANN recall@10 per department against exact search, and p95 at 5 QPS. What would change it: 10Γ growth plus a strict 20 ms budget, or a primary database already near its CPU limit.
Your turn: the multitenant SaaS
Requirements: 3,000 tenants, 120 million chunks in total, 1,024 dimensions; sizes follow a long tail (the largest tenant has 25 million chunks, the median about 20,000); a tenant must be deletable within 24 hours; hybrid search is needed for product codes; one platform engineer. Which patterns survive, and what do you measure first?
Check your answer
Memory: 120M Γ 1,024 Γ 4 B = 491.52 GB in float32, 122.88 GB in int8, 15.36 GB in binary. So quantization with rescoring, or a service that tiers storage, is on the table from day one.
Tenancy: collection-per-tenant is out. 3,000 collections, and more with every new customer, means 3,000 sets of indexes, settings and schema migrations for one engineer to look after. Some services also cap the count well below that (Qdrant Cloud's default is 1,000 per cluster), while others allow far more (Milvus: 65,536 by default), so the limit is vendor-specific but the overhead is not. What survives is partition-per-tenant (Pinecone namespaces, Weaviate tenant shards, Milvus partition key) or a shared collection with a tenant index plus special handling for giants (Qdrant tiered multitenancy). With one platform engineer, prefer a managed offering.
Hybrid: you need an engine with lexical or sparse retrieval inside it. All four named above have it as of 2026-09-25.
Measure first: two buckets separately, the 25M-chunk tenant and the tail, for ANN recall@10 and p95 each. Then the time to delete a mid-sized tenant completely, and the monthly cost at projected QPS. The averages will hide exactly the tenants you are worried about.
Worked: the marketplace
Requirements: 400 million listings of 768 dimensions; 1,500 QPS at peak; retrieval p99 under 60 ms; every query filters on category, region and price range and ranks with text relevance plus business signals; 2,000 price updates per second; a six-person platform team.
- Memory: 400M Γ 768 Γ 4 B = 1.23 TB in float32, 307.2 GB in int8, 38.4 GB in binary. That means sharding across nodes, quantized vectors in RAM, and full precision on disk.
- Updates: 2,000 price changes per second must update attributes without rewriting vectors. Qdrant (
set_payload) and Vespa (partial updates of attribute fields) change attributes in place. Elasticsearch, OpenSearch and Milvus rewrite the whole record, vector included (see Requirement 5), so for them this means 2,000 vector re-insertions per second plus the merges that follow. Keep them only if a proof of concept shows that write load is affordable. - Ranking: text + vector + business signals in one pass is Vespa's home ground. Qdrant, run distributed, with sparse + dense retrieval and score boosting in the Query API, is the other candidate.
Shortlist Vespa and Qdrant, and take both into a proof of concept at production-like data volume, with the price updates running. A 1% sample can leave segments too small to get an HNSW graph at all, or put them under the planner's exact-scan threshold, and then you are benchmarking brute force.
A fair proof of concept on your own data
Symptom: the vendor chart said 2 ms. Your p99 in production is 180 ms. The chart used unfiltered queries on a public dataset with a warm cache.
A fair proof of concept is boring on purpose:
- Write the requirements as numbers: corpus size today and in 18 months, peak and sustained QPS, a p95/p99 budget, filter mix, update rate, tenants.
- Use real data at a size that exercises the production code path. At minimum, that is your largest tenant or shard. Toy samples leave segments too small for a graph, or push planners into exact scans.
- Replay real queries with their real filters: a few hundred from your logs, and label 50β100 of them for relevance (see Retrieval Metrics).
- Get two kinds of ground truth. Exact search under the same filter gives you ANN recall@k (did the index find the true neighbours?). Human labels give you retrieval recall@k (did the system find what the user needed?). A perfect index cannot fix a bad embedding model.
- Bucket the results by filter selectivity and tenant size. Averages hide tails.
- Compare at equal recall. Tune each candidate to the same ANN recall target, then compare latency and cost. One tuned candidate against one on defaults is not a comparison.
- Load it: target QPS Γ 1.5, with concurrency, for at least 30 minutes, while writing at your update rate. Record p50/p95/p99.
- Break it: restart a node, query cold, restore a backup, delete a tenant, run a version upgrade.
- Price it: cost per 1,000 queries at projected load, plus storage per month, plus the people who run it.
Here is the core of steps 4β6 as a harness. Each candidate is just a search(q, tenant, k) function, so the same queries and the same ground truth score every one of them. Three toy candidates reproduce the strategies from Requirement 2 on a skewed tenant mix. The fourth calls a real engine.
import time
import numpy as np
import hnswlib
# A stand-in for a sample of your data: 60k chunks, 100 tenants with skewed sizes
rng = np.random.default_rng(3)
n, dim = 60_000, 64
centers = rng.normal(size=(40, dim))
X = centers[rng.integers(0, 40, n)] + 0.8 * rng.normal(size=(n, dim))
X = (X / np.linalg.norm(X, axis=1, keepdims=True)).astype(np.float32)
w = 1 / np.arange(1, 101) ** 1.2
tenant = rng.choice(100, size=n, p=w / w.sum())
sizes = np.bincount(tenant, minlength=100)
index = hnswlib.Index(space="cosine", dim=dim)
index.init_index(max_elements=n, M=16, ef_construction=64)
index.add_items(X, num_threads=1) # single thread: same graph on every run
index.set_ef(40)
def search_then_filter(q, t, k): # candidate A: ANN first, WHERE afterwards
ids, _ = index.knn_query(q, k=40)
return [i for i in ids[0] if tenant[i] == t][:k]
def filter_during_search(q, t, k): # candidate B: filter-aware traversal
ids, _ = index.knn_query(q, k=k, filter=lambda i: tenant[i] == t)
return list(ids[0])
def planner(q, t, k, cutoff=1_000): # candidate C: exact scan when few rows match
if sizes[t] < cutoff:
subset = np.flatnonzero(tenant == t)
return list(subset[np.argsort(-(X[subset] @ q))[:k]])
return filter_during_search(q, t, k)
def make_queries(tenants, k=10): # same queries and ground truth for every candidate
out = []
for t in tenants:
subset = np.flatnonzero(tenant == t)
q = X[rng.choice(subset)] + 0.06 * rng.normal(size=dim)
q = (q / np.linalg.norm(q)).astype(np.float32)
out.append((q, t, set(subset[np.argsort(-(X[subset] @ q))[:k]])))
return out
def evaluate(search, queries, k=10):
rows, recall, ms = [], [], []
for q, t, truth in queries:
t0 = time.perf_counter()
got = search(q, t, k)
ms.append((time.perf_counter() - t0) * 1000)
rows.append(len(got))
recall.append(len(truth & set(got)) / k)
return f"rows {np.mean(rows):4.1f} recall@{k} {np.mean(recall):.2f} p95 {np.percentile(ms, 95):4.1f} ms"
buckets = {"big tenants ": make_queries(rng.choice(np.flatnonzero(sizes > 3_000), 100)),
"tail tenants": make_queries(rng.choice(np.flatnonzero(sizes < 300), 100))}
print("tenant sizes: largest", sizes.max(), "smallest", sizes.min())
for name, fn in [("A search, then filter ", search_then_filter),
("B filter during search", filter_during_search),
("C planner ", planner)]:
for bucket, queries in buckets.items():
print(name, bucket, evaluate(fn, queries))
# tenant sizes: largest 16605 smallest 54
# A search, then filter big tenants rows 6.1 recall@10 0.58 p95 0.3 ms
# A search, then filter tail tenants rows 1.1 recall@10 0.11 p95 0.1 ms
# B filter during search big tenants rows 10.0 recall@10 1.00 p95 0.7 ms
# B filter during search tail tenants rows 10.0 recall@10 1.00 p95 32.3 ms
# C planner big tenants rows 10.0 recall@10 1.00 p95 0.4 ms
# C planner tail tenants rows 10.0 recall@10 1.00 p95 0.1 ms
Rows and recall are the same on every run. The timings are not: over eleven runs on a shared laptop, B's tail p95 ranged from 23 to 119 ms. Read the output bucket by bucket:
- A starves the tail exactly as the Acme arithmetic predicted. The tail still gets about one row only because each test query was built near one of that tenant's own chunks. It also hurts the big tenants: the four of them hold between 5% and 28% of the chunks, so only the largest can expect 10 matches among 40 candidates.
- B always fills the page, but its tail p95 is tens of milliseconds. For a tenant with a few hundred chunks, the graph walk visits a great many nodes of other tenants before it collects 10 matches. Part of that time is this toy's Python callback per visited node. A real engine checks the filter in native code, but it still has to walk the graph.
- C is what planners in Qdrant, Weaviate and Vespa do: exact search for small match sets, filtered traversal for large ones. It gets full recall at low latency in both buckets.
Now plug in a real engine. This adapter runs against a local Qdrant server (docker run -p 6333:6333 qdrant/qdrant), in the same session as the harness:
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
name = "poc_chunks"
client.create_collection(name, vectors_config=models.VectorParams(
size=dim, distance=models.Distance.COSINE))
client.create_payload_index(name, "tenant", field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD, is_tenant=True))
client.upload_collection(name, vectors=X, ids=range(n), wait=True, # default is wait=False
payload=({"tenant": f"t{t}"} for t in tenant))
while client.get_collection(name).status != models.CollectionStatus.GREEN:
time.sleep(0.5) # at real scale: let indexing finish before timing
info = client.get_collection(name)
print("points", info.points_count, " indexed vectors", info.indexed_vectors_count)
def qdrant_search(q, t, k): # candidate D: a real engine, same harness
flt = models.Filter(must=[models.FieldCondition(
key="tenant", match=models.MatchValue(value=f"t{t}"))])
hits = client.query_points(name, query=q, query_filter=flt, limit=k).points
return [h.id for h in hits]
for bucket, queries in buckets.items():
print("D Qdrant ", bucket, evaluate(qdrant_search, queries))
client.delete_collection(name)
# points 60000 indexed vectors 0
# D Qdrant big tenants rows 10.0 recall@10 1.00 p95 5.6 ms
# D Qdrant tail tenants rows 10.0 recall@10 1.00 p95 9.8 ms
# (p95 ranged from 2 to 58 ms over eight runs against a busy shared local server)
Predict first: is Qdrant's 1.00 recall here evidence that its filtered HNSW search is exact?
Check your answer
No. The adapter printed indexed vectors 0: Qdrant never built an HNSW graph. It builds one only for a segment whose vectors exceed indexing_threshold (10,000 KB by default), and our six segments (the default count depends on the CPUs) average 10,000 Γ 64 Γ 4 B β 2.6 MB. So every query, filtered or not, was an exact scan. Even with graphs, the planner would have scanned exactly, because the largest tenant's vectors (16,605 Γ 64 Γ 4 B β 4.3 MB) are below the default full_scan_threshold of 10,000 KB. The run shows that the engine returns full, correct pages for this filter mix, and nothing about the graph path you will hit at 50 million vectors. Check indexed_vectors_count before you trust a proof-of-concept number. The p95 also includes an HTTP round trip to a server shared with other jobs, which the in-process toy candidates don't pay. That is step 2 of the protocol in action: the sample must be big enough to push queries onto the production code path, and the load test must match production's concurrency.
What goes on the scorecard for each candidate:
| Measure | On what | Pass when |
|---|---|---|
| ANN recall@10 | Exact search under the same filter, per bucket | At or above your target (say 0.95) in every bucket |
| Retrieval recall@10 or nDCG@10 | 50β100 labeled queries | No worse than the current system |
| p95 / p99 latency | Target QPS Γ 1.5, 30+ minutes, writes running | Within budget |
| Freshness | Time from acknowledged write to visible in results | Within your product's promise |
| Cold query | After a restart or scale-to-zero | Acceptable for the first user of the day |
| Ops drills | Restore, delete a tenant, add a node, upgrade | Done from a runbook, within the time you promise |
| Cost | Per 1,000 queries + storage + people, at 18-month volume | Within budget |
β οΈ Benchmark theatre is a test built to confirm a choice already made. The usual forms: vendor numbers on vendor data; unfiltered queries only; latency without recall; candidates at different recall levels; a 60-second run on a warm cache; defaults for one candidate and expert tuning for another; a sample so small that every query is an exact scan.
Final round: no label on the problem
Real incidents don't come labeled. For each one, name the requirement that was skipped and the first move.
1. A team moved from a self-hosted engine to a usage-priced serverless service to "stop paying for idle nodes". Traffic is a steady 40 QPS, day and night. The bill tripled.
Check your answer
Requirement 6, ops model and cost. Steady high traffic is where provisioned capacity wins. The break-even arithmetic from Requirement 6 applies, with the real prices. First move: price the same workload on provisioned capacity (for example, dedicated read nodes on the same service), or move back to self-hosted if you have people to run it. Then keep measuring cost per 1,000 queries every month.
2. After a migration to a new engine, a proof-of-concept run on 1% of the data showed recall 1.00 everywhere. In production, recall for large tenants dropped to 0.85.
Check your answer
The proof of concept, step 2. At 1% scale the engine searched small segments exactly (without a graph, or through the planner's exact path), so the graph path was never measured. First move: rerun at production volume and measure ANN recall per bucket, then tune the search-time parameter (hnsw_ef, ef_search, β¦) or quantization rescoring until every bucket meets the target. If the engine is Qdrant, also check that the tenant payload index existed before the data was loaded: an index added after the graph was built does not get the graph's extra filter links until the graph is rebuilt. See Metadata & Filtering for the indexing order and ANN Algorithms for tuning the search-time parameter.
3. Answers cite a policy that was replaced yesterday. The new document is in the database, and a direct lookup by id finds it.
Check your answer
Requirement 5, freshness, and possibly deletes. Either the old chunks were never deleted when the document was replaced, or queries read an index that lags behind writes (eventual consistency, a replica that is behind, a bulk upload that did not wait). First move: check that replacing a document deletes its old chunks, then measure the time from acknowledged write to visible in results. The full lifecycle story is in Data Freshness & Lifecycle.
4. A teammate proposes migrating off pgvector "because it can't filter". The table has 2 million rows, and filtered queries return 2β3 rows instead of 10.
Check your answer
Requirement 2, but migration is not the first move. Turn on iterative scans (SET hnsw.iterative_scan = relaxed_order, pgvector 0.8.0+), check EXPLAIN, and consider partial indexes or partitioning for filter values so rare that the hnsw.max_scan_tuples cap stops the scan first. Measure filtered ANN recall@10 against exact search before and after. Migrate only if a measured requirement is still unmet.
Cheat sheet: symptom β move β cost
| Symptom | Move | What it costs |
|---|---|---|
| Filtered queries return fewer than k rows | Engine-side filtering: iterative scans, filter inside the kNN clause, payload indexes, a planner with exact fallback | More work per query for narrow filters |
| RAM bill or out-of-memory as data grows | Quantize with rescoring; keep originals and graph on disk; shard | Some recall (won back by rescoring); disk latency |
| Identifiers and codes missing | Hybrid in one engine (BM25 or sparse + dense), or a second system with fusion | Analyzer tuning, or two indexes to keep in sync |
| Too many collections; one giant tenant | Shared collection with tenant index or partition-per-tenant; promote giants | Filter-performance testing; per-tenant limits |
| New or deleted data not reflected | Wait for write acknowledgement, check freshness headers, delete old chunks on replace | Slower writes; ingestion code |
| Bill grows with steady traffic | Provisioned capacity for sustained load | Paying for idle capacity at night |
| Cold first query of the day is slow | Keep hot tenants warm, pick a tier with cache guarantees | Money, or a warm-up job |
| Vendor benchmark β production | A fair proof of concept at production scale, per bucket, at equal recall | Engineer-days before the decision |
Before moving on, take a system you know (or the multitenant SaaS above). Write its seven requirements as numbers. Cross out every option that fails a hard one, and name the two survivors. Then say, in one sentence each, what you would measure in the proof of concept, on which data, and what result would change your mind. If you can do that, the product names in this lesson can change next year without changing your decision process.
Next: Qdrant takes one of these engines hands-on: collections, payload indexes, filtering and a hybrid query. Metadata & Filtering goes deeper on the requirement that bit Acme.