Ten rows requested. Four rows returned. No exception, no warning, nothing in the Postgres log, and the query was an ordinary nearest neighbour search with one WHERE clause on a metadata column.
That single line of output is the most expensive thing I have learned about vector stores. Which of the three you pick matters far less than whether you understand what its defaults do the moment you add a filter, and all three behave differently at exactly that point. This part runs the same corpus through pgvector, Chroma and Qdrant, measures them, and names a pick.
Key takeaways
On 50,000 chunks at 1536 dimensions, Qdrant answered filtered queries at 9 ms p95 in 290 MB resident with int8 quantization, Chroma at 21 ms in 980 MB, pgvector at 34 ms in roughly 1.1 GB.
pgvector applies metadata filters after the index has been scanned, so a filtered query with the default hnsw.ef_search of 40 gave me recall@10 of 0.41 until iterative index scans were switched on. That is a one line fix and a three week delay if nobody looks.
Chroma defaults to squared L2 distance, not cosine, and the distance function cannot be changed after a collection is created. Rebuilding is the only route back.
Verdict: Qdrant if filtering matters and the corpus will grow, pgvector if you already run Postgres and stay under roughly 200,000 chunks, and treat single node Chroma as a prototyping store you plan to leave.
Where the assistant stands, and what a vector store must now do
Last part the assistant picked an embedding model and produced a 50,000 row float32 array, 1536 wide, sitting in a numpy file next to a JSONL of chunk text and metadata. This part gives those vectors a home that survives a restart and answers a query in single digit milliseconds. Nothing about the retrieval quality changes here. What changes is whether the thing can be operated.
Four jobs define the requirement, and only the first one gets discussed in most tutorials. Approximate nearest neighbour search over 50,000 vectors is the easy job; every store here does it well. Filtering by metadata is the hard one, because our support assistant has to answer questions scoped to a product area, a document version, or a customer tier, and a filter interacts with an approximate index in ways that are not obvious. Incremental upsert and delete matter because product docs change weekly and a store that needs a full rebuild per edit is a store you will grow to resent. Durability across restarts is the fourth, and it quietly disqualifies more prototypes than anything else.
Everything measured below ran on one machine: 8 vCPU, 32 GB RAM, NVMe disk, Postgres 17 and Qdrant 1.18 in Docker, Chroma embedded in the Python process. Same 50,000 chunks, same 1536 dimension vectors, same 60 question evaluation set carried forward from Part 9. Numbers from a single box are not a benchmark of the projects; they are a benchmark of what a reader with one box will see, which is more useful at this stage.
Three stores, three different bets
Reading these as three interchangeable products is a mistake. Each one is a bet about where vector search belongs in your architecture, and that bet is what you inherit.
pgvector is a Postgres extension, so its bet is that vectors belong beside your relational data. You get transactions, joins against your existing tables, your existing backup and replication story, and your existing on call rotation. Chroma is a library first, running inside your Python process and writing to a local directory, betting that a single application does not need a separate database tier at all. Qdrant is a standalone service written in Rust, betting that search is its own concern with its own memory profile and deserves its own box.
| Capability | pgvector 0.8.2 | Chroma 1.5.9 | Qdrant 1.18 |
|---|---|---|---|
| Deployment shape | Extension in your existing Postgres | Embedded library, or a single node server | Standalone service, clustered if needed |
| Index types | HNSW and IVFFlat | HNSW single node, SPANN on Cloud | HNSW with payload aware graph |
| Vector width ceiling for an index | 2,000 for vector, 4,000 for halfvec | No practical ceiling at our sizes | No practical ceiling at our sizes |
| Metadata filtering | SQL WHERE, applied after the index scan | where and where_document clauses | Payload filters folded into graph traversal |
| Compression | halfvec and binary quantize, manual | None on single node | Scalar int8, product and binary, one config block |
| Joins to your business tables | Native, in one query | Application side only | Application side only |
| Operational burden added | Almost none if Postgres is already yours | None in a single process, awkward beyond it | A new service to run, monitor and back up |
Running the same corpus through each
All three scripts read the same two files: chunks.jsonl with id, doc_id, area and body, and chunk_vectors.npy holding a 50000 by 1536 float32 array in the same order. Credentials come from environment variables in every case, never from the file.
Chroma is the shortest script of the three, and that is genuinely its selling point. Note the explicit space setting. Leaving it out gives you squared L2 on embeddings that were trained for cosine, and no error tells you so.
Qdrant needs the most configuration and repays it. Two lines do most of the work: on_disk storage with int8 scalar quantization kept in RAM, which is what drops resident memory from about 610 MB to 290 MB, and an explicit payload index on the field you filter by. Skip that payload index and filtered queries fall back to a much slower path.
Metadata filtering, where pgvector quietly loses rows
Here is the war story. We shipped a product area filter on the assistant so that a question tagged billing only retrieved billing documentation, which is an obviously correct thing to do and took about forty minutes. Answer quality complaints started a week later, vague ones, the kind that are easy to attribute to the model rather than to retrieval. I spent two days tuning the prompt. Three weeks after the filter shipped I finally ran the evaluation set with the filter applied instead of without it, and recall@10 came back at 0.41 against 0.93 unfiltered. Same index, same embeddings, same questions. Only the WHERE clause differed.
Cause is documented and completely unintuitive. With an approximate index, pgvector scans the graph first and applies your WHERE clause to whatever came back. Default hnsw.ef_search is 40, so the scan surfaces roughly 40 candidates and then filters them. If your predicate matches ten percent of rows, about four survive. Asking for ten gets you four, and nothing anywhere says so.
Against the common advice
Standard guidance says use pgvector because you already run Postgres, and that guidance is right up until you add a metadata filter. Its defaults are tuned for unfiltered search, and the failure mode under a filter is silent row loss rather than an error or a slow query. Qdrant folds the payload filter into graph traversal instead, so a selective filter makes it faster rather than less accurate. If your retrieval is filtered by tenant, version or product area, and most real retrieval is, that architectural difference outweighs the operational convenience.
Memory, latency and cost on 50,000 chunks
Latency figures below are p50 and p95 over 2,000 filtered queries after a warm cache, each configured correctly rather than left on defaults, so pgvector here includes the iterative scan fix and pays for it.
| Measure | pgvector | Chroma | Qdrant |
|---|---|---|---|
| Load plus index build | 252 s | 168 s | 185 s |
| Filtered query p50 | 18 ms | 8 ms | 4 ms |
| Filtered query p95 | 51 ms | 21 ms | 9 ms |
| Resident memory, tuned | 1,120 MB | 980 MB | 290 MB with int8 |
| On disk footprint | 1,084 MB | 742 MB | 618 MB |
| Filtered recall@10, default config | 0.41 | 0.89 | 0.96 |
| Filtered recall@10, tuned | 0.94 | 0.93 | 0.96 |
| Smallest sane instance | 4 vCPU, 8 GB, about 95 USD a month | Rides in your app container | 2 vCPU, 4 GB, about 32 USD a month |
Two things in that table deserve attention beyond the headline. First, a 3.8x memory gap between Qdrant with int8 and pgvector is not a rounding error at scale; multiply the corpus by ten and it becomes the difference between a 4 GB instance and a 16 GB one. Second, pgvector paid 33 ms of p95 latency to recover its recall, which is a real and reasonable trade, but it is a trade nobody tells you about because the broken configuration is the fast one. Benchmarks that compare unfiltered latency are comparing pgvector at 0.41 recall against Qdrant at 0.96.
Defaults that will bite you
Keep this table somewhere you will find it again. Every row cost me or a colleague at least half a day, and none of them announces itself as an error.
| Symptom | Store | Cause | Fix |
|---|---|---|---|
| Fewer rows than LIMIT under a WHERE clause | pgvector | Filter applied after the index scan, ef_search 40 | SET hnsw.iterative_scan = strict_order and raise ef_search |
| CREATE INDEX fails, queries go sequential | pgvector | vector type indexes only to 2,000 dimensions | Cast to halfvec, or reduce dimensions at embedding time |
| Index build crawls after about 100,000 rows | pgvector | Graph outgrew maintenance_work_mem | Raise maintenance_work_mem before CREATE INDEX, watch the NOTICE |
| Plausible but consistently mediocre results | Chroma | space defaults to squared L2, not cosine | Set space explicitly at creation; a rebuild is the only later remedy |
| Query returns the query document itself with nonzero distance | Chroma | ef_search too low for the graph to find it | Raise ef_search, which is modifiable after creation |
| Filtered queries far slower than unfiltered | Qdrant | No payload index on the filtered field | create_payload_index on every field you filter by |
| 400 Bad Request, vector dimension error | Qdrant | Query embedded with a different model than the index | Pin the model name and dimension in config, assert at startup |
Migration cost, and what actually locks you in
Choosing wrong here is cheaper than it feels, and knowing that should make you decide faster rather than slower. Vectors are portable: a numpy array and a JSONL of metadata reload into any of these three in under four minutes for 50,000 chunks. Moving our assistant from Chroma to Qdrant took one afternoon, and most of that afternoon went on rewriting filter syntax, not on data movement.
What genuinely locks you in is the embedding model, because re-embedding is what costs real money and real time, and that decision was Part 10. Second is any query logic you have scattered through the codebase. Wrap retrieval behind one function that takes a query vector, a filter dictionary and a k, and returns chunk ids with scores. That interface is about fifteen lines per backend and it turns a rewrite into a swap. Chroma and Qdrant both offer an in memory or local mode, so your tests can run against the real client without a server, which is worth structuring for from the start.
Recommendation for a docs assistant at this scale
Qdrant is what I would run, and it is what our assistant runs now. Filtered retrieval is the normal case rather than the exception in any product with tenants, versions or product areas, and Qdrant is the only one of the three that treats a filter as part of the search rather than as a post processing step. Int8 quantization dropping resident memory to 290 MB means it fits on a 4 GB instance at 32 USD a month, roughly a third of what the equivalent Postgres costs. Price of admission is one more service to run.
pgvector is a defensible second choice under two conditions, both of which must hold: your corpus stays under roughly 200,000 chunks, and someone on the team will own the index configuration rather than trusting defaults. Its real advantage is one nobody else can match, which is joining retrieval results to your business tables inside a single transaction. That is worth a lot in an internal tool.
What I would avoid is inheriting single node Chroma as a production store. Not because it is bad, because it is not: it had the fastest load, the shortest script, and perfectly respectable filtered recall out of the box, and I would still start every prototype with it. Problem is that single node Chroma has no compression, no clustering, and a distance function you cannot change after creation, so the day the corpus doubles you are migrating anyway. Start there deliberately and leave on your own schedule, rather than discovering the ceiling during an incident.
One caveat worth stating plainly. Every recall number in this part came from dense vector search alone, and dense search misses exact identifiers, error codes and product names with reliability that will annoy you. Part 12 adds keyword search alongside the vectors and a reranking pass on top, which is where filtered recall of 0.96 becomes something you would put in front of customers. If retrieval augmented generation is still a fuzzy idea rather than a pipeline in your head, what RAG is is the twenty minute version, and monitoring models in production covers the drift habits that apply just as much to a retrieval index as to a classifier. Go and run that filtered evaluation this week.
References
- pgvector, open source vector similarity search for Postgres, v0.8.2 README, for HNSW parameters, dimension limits and iterative index scans.
- Configure Collections, Chroma documentation, for HNSW defaults and the space parameter.
- Qdrant Python client, v1.18.0, for create_collection, query_points and filter syntax.
- Quantization, Qdrant documentation, for scalar int8 configuration and its memory effect.


DrJha