, ,

Vector Stores in Practice: pgvector, Chroma and Qdrant Compared (AI Engineering Series, Part 11)

pgvector, Chroma and Qdrant measured on the same 50,000 chunk corpus: build time, p95 latency, resident memory and the filtered recall collapse that default settings will hand you. With the configuration fixes that actually work.

AI Engineering Series · Part 11 of 30

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.

Who this is for: a Python developer who has followed this series through Part 10 and has 50,000 embedded chunks sitting on disk. A vector store is a database that indexes those fixed length lists of numbers so a search does not have to compare against every one of them, which vector databases explained covers from scratch. Docker and basic SQL are assumed. No prior experience with approximate nearest neighbour indexes is assumed. If connecting to Postgres from Python is unfamiliar territory, getting data into Python covers that side properly.

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.

Capabilitypgvector 0.8.2Chroma 1.5.9Qdrant 1.18
Deployment shapeExtension in your existing PostgresEmbedded library, or a single node serverStandalone service, clustered if needed
Index typesHNSW and IVFFlatHNSW single node, SPANN on CloudHNSW with payload aware graph
Vector width ceiling for an index2,000 for vector, 4,000 for halfvecNo practical ceiling at our sizesNo practical ceiling at our sizes
Metadata filteringSQL WHERE, applied after the index scanwhere and where_document clausesPayload filters folded into graph traversal
Compressionhalfvec and binary quantize, manualNone on single nodeScalar int8, product and binary, one config block
Joins to your business tablesNative, in one queryApplication side onlyApplication side only
Operational burden addedAlmost none if Postgres is already yoursNone in a single process, awkward beyond itA new service to run, monitor and back up
Store selection matrix. Versions are the ones I tested against in July 2026.
Row worth rereading: the vector width ceiling. pgvector indexes the vector type only up to 2,000 dimensions, and text-embedding-3-large at full width is 3,072. You can store the column, and the insert succeeds, but CREATE INDEX fails and every query silently falls back to a sequential scan. Casting to halfvec raises the ceiling to 4,000 at the cost of half precision. Part 10 argued for 1024 dimensions on cost grounds; this is a second, independent reason.

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.

# tested with pgvector 0.8.2 on PostgreSQL 17, psycopg 3.2.9, numpy 2.3.4, Python 3.10.12
import os, json, time
import numpy as np, psycopg
from pgvector.psycopg import register_vector

conn = psycopg.connect(os.environ['PG_DSN'], autocommit=True)
conn.execute('CREATE EXTENSION IF NOT EXISTS vector')
register_vector(conn)

conn.execute('''CREATE TABLE chunks (
  id        bigint PRIMARY KEY,
  doc_id    text,
  area      text,
  body      text,
  embedding vector(1536))''')

rows = [json.loads(l) for l in open('chunks.jsonl')]
vecs = np.load('chunk_vectors.npy')
print('rows:', len(rows), 'vectors:', vecs.shape, vecs.dtype)

with conn.cursor().copy('COPY chunks (id, doc_id, area, body, embedding) '
                        'FROM STDIN WITH (FORMAT BINARY)') as cp:
    cp.set_types(['bigint', 'text', 'text', 'text', 'vector'])
    for r, v in zip(rows, vecs):
        cp.write_row([r['id'], r['doc_id'], r['area'], r['body'], v])

conn.execute("SET maintenance_work_mem = '4GB'")
t0 = time.perf_counter()
conn.execute('CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops) '
             'WITH (m = 16, ef_construction = 64)')
print('index build seconds:', round(time.perf_counter() - t0, 1))
print(conn.execute("SELECT pg_size_pretty(pg_total_relation_size('chunks'))").fetchone())
Loading 50,000 chunks into pgvector with a binary COPY, then building an HNSW index.
rows: 50000 vectors: (50000, 1536) float32
index build seconds: 252.4
('1084 MB',)
Actual output. Raise maintenance_work_mem before the build or Postgres warns that the graph no longer fits and the build slows sharply.

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.

# tested with chromadb 1.5.9, numpy 2.3.4, Python 3.10.12
import json, time
import numpy as np, chromadb

client = chromadb.PersistentClient(path='./chroma_docs')
col = client.create_collection(
    name='docs',
    configuration={'hnsw': {'space': 'cosine',
                            'ef_construction': 200,
                            'ef_search': 100,
                            'max_neighbors': 16}})

rows = [json.loads(l) for l in open('chunks.jsonl')]
vecs = np.load('chunk_vectors.npy')

t0 = time.perf_counter()
for i in range(0, len(rows), 2000):
    b = rows[i:i + 2000]
    col.add(ids=[str(r['id']) for r in b],
            embeddings=vecs[i:i + 2000].tolist(),
            documents=[r['body'] for r in b],
            metadatas=[{'doc_id': r['doc_id'], 'area': r['area']} for r in b])
print('count:', col.count(), 'load seconds:', round(time.perf_counter() - t0, 1))

q = np.load('query_vector.npy').tolist()
res = col.query(query_embeddings=[q], n_results=10, where={'area': 'billing'})
print('returned:', len(res['ids'][0]),
      'top distance:', round(res['distances'][0][0], 4))
Chroma with an explicit cosine space. The space cannot be changed later, so getting it right here is not optional.
count: 50000 load seconds: 168.3
returned: 10 top distance: 0.1832
Chroma returned a full ten rows under a filter where pgvector returned four. That difference is the subject of the next section.

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.

# tested with qdrant-client 1.18.0 against qdrant 1.18 in docker, Python 3.10.12
import os, json, time
import numpy as np
from qdrant_client import QdrantClient, models

client = QdrantClient(url=os.environ['QDRANT_URL'],
                      api_key=os.environ.get('QDRANT_API_KEY'))

client.create_collection(
    collection_name='docs',
    vectors_config=models.VectorParams(
        size=1536, distance=models.Distance.COSINE, on_disk=True),
    quantization_config=models.ScalarQuantization(
        scalar=models.ScalarQuantizationConfig(
            type=models.ScalarType.INT8, quantile=0.99, always_ram=True)),
    hnsw_config=models.HnswConfigDiff(m=16, ef_construct=200))

client.create_payload_index(
    collection_name='docs', field_name='area',
    field_schema=models.PayloadSchemaType.KEYWORD)

rows = [json.loads(l) for l in open('chunks.jsonl')]
vecs = np.load('chunk_vectors.npy')

t0 = time.perf_counter()
client.upload_collection(
    collection_name='docs',
    vectors=vecs,
    payload=[{'doc_id': r['doc_id'], 'area': r['area']} for r in rows],
    ids=[r['id'] for r in rows],
    batch_size=512)
print('load seconds:', round(time.perf_counter() - t0, 1))

q = np.load('query_vector.npy')
hits = client.query_points(
    collection_name='docs', query=q.tolist(), limit=10,
    query_filter=models.Filter(must=[models.FieldCondition(
        key='area', match=models.MatchValue(value='billing'))])).points
print('returned:', len(hits), 'top score:', round(hits[0].score, 4))
Qdrant with int8 scalar quantization and a keyword payload index on the filter field.
load seconds: 185.2
returned: 10 top score: 0.8214

# and the failure you will hit within an hour of switching embedding models:
>>> client.query_points(collection_name='docs',
...                     query=np.random.rand(768).tolist(), limit=5)
Traceback (most recent call last):
  ...
qdrant_client.http.exceptions.UnexpectedResponse: Unexpected Response: 400 (Bad Request)
Raw response content:
b'{"status":{"error":"Wrong input: Vector dimension error: expected dim: 1536, got 768"}}'
Qdrant refuses a dimension mismatch loudly, which is a feature. Chroma raises too. pgvector rejects it at insert but will happily compare a 1536 column against a 1536 vector produced by the wrong model.

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.

sql = '''SELECT id, area, 1 - (embedding <=> %s) AS score
         FROM chunks
         WHERE area = 'billing'
         ORDER BY embedding <=> %s
         LIMIT 10'''
q = np.load('query_vector.npy')

print('default settings ->', len(conn.execute(sql, (q, q)).fetchall()), 'rows')

# available from pgvector 0.8.0: keep scanning until LIMIT is satisfied
conn.execute('SET hnsw.iterative_scan = strict_order')
conn.execute('SET hnsw.ef_search = 100')
print('iterative scan  ->', len(conn.execute(sql, (q, q)).fetchall()), 'rows')
One GUC separates a broken filtered query from a correct one.
default settings -> 4 rows
iterative scan  -> 10 rows
Recall@10 across the 60 question eval set moved from 0.41 to 0.94, and p95 latency from 34 ms to 51 ms. Worth every millisecond.

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.

MeasurepgvectorChromaQdrant
Load plus index build252 s168 s185 s
Filtered query p5018 ms8 ms4 ms
Filtered query p9551 ms21 ms9 ms
Resident memory, tuned1,120 MB980 MB290 MB with int8
On disk footprint1,084 MB742 MB618 MB
Filtered recall@10, default config0.410.890.96
Filtered recall@10, tuned0.940.930.96
Smallest sane instance4 vCPU, 8 GB, about 95 USD a monthRides in your app container2 vCPU, 4 GB, about 32 USD a month
Same 50,000 chunks at 1536 dimensions, one 8 vCPU box, July 2026. Instance prices are general purpose cloud list rates, rounded.
Latency and memory on 50,000 chunks at 1536 dimensionsFiltered queries, each store tuned. Lower is better on both panels.p95 latency, ms51219pgvectorChromaQdrantResident memory, MB1120980290pgvectorChromaQdrant int8Measured on one 8 vCPU, 32 GB box, July 2026. Qdrant memory reflects int8 scalar quantization with always_ram enabled.
Quantization is doing most of the work in the right hand panel, and it costs one config block.

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.

SymptomStoreCauseFix
Fewer rows than LIMIT under a WHERE clausepgvectorFilter applied after the index scan, ef_search 40SET hnsw.iterative_scan = strict_order and raise ef_search
CREATE INDEX fails, queries go sequentialpgvectorvector type indexes only to 2,000 dimensionsCast to halfvec, or reduce dimensions at embedding time
Index build crawls after about 100,000 rowspgvectorGraph outgrew maintenance_work_memRaise maintenance_work_mem before CREATE INDEX, watch the NOTICE
Plausible but consistently mediocre resultsChromaspace defaults to squared L2, not cosineSet space explicitly at creation; a rebuild is the only later remedy
Query returns the query document itself with nonzero distanceChromaef_search too low for the graph to find itRaise ef_search, which is modifiable after creation
Filtered queries far slower than unfilteredQdrantNo payload index on the filtered fieldcreate_payload_index on every field you filter by
400 Bad Request, vector dimension errorQdrantQuery embedded with a different model than the indexPin the model name and dimension in config, assert at startup
Failure to cause lookup for the three stores. Bookmark this one.
Do this on Monday: run your existing retrieval evaluation twice, once unfiltered and once with the metadata filter your application actually sends. If the two recall numbers differ by more than a couple of points, your store configuration is the problem and not your embeddings. Most teams have never run the second version.

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.

flowchart TD A[Embedded corpus ready] --> B{Filtered retrieval needed} B -- no --> C{Corpus under 200k chunks} B -- yes --> D{Already running Postgres} C -- yes --> E[pgvector or Chroma, pick on ops] C -- no --> F[Qdrant with int8 quantization] D -- yes --> G{Can you own the tuning} D -- no --> F G -- yes --> H[pgvector with iterative scan on] G -- no --> F E --> I[Wrap behind one retrieve function] F --> I H --> I
Decision path. Every branch ends at the same place, which is the point.

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.

AI Engineering Series · Part 11 of 30
« Previous: Part 10  |  Guide  |  Next: Part 12 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure – By Dr Pranay Jha

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure - By Dr Pranay Jha

Subscribe now to keep reading and get access to the full archive.

Continue reading