AI & LLMs Intermediate

Choosing a Vector Database — pgvector vs Pinecone vs Weaviate vs Qdrant

When your existing Postgres is enough, when to graduate to a dedicated vector DB, and how the major options compare for Django apps.

KC Ramo · DjangoZen Team May 09, 2026 19 min read 437 views

When you build AI features that search by meaning — semantic search, retrieval-augmented generation, recommendations — you need somewhere to store and search embeddings, the numeric vectors that represent meaning. This is what a vector database does, and there are several options, from extending the PostgreSQL you already run to dedicated specialized services. Choosing among them matters for your architecture and cost, and this tutorial helps you decide by understanding what each offers and when each fits.

What a vector database does

A vector database stores embeddings — the numeric vectors that capture the meaning of text or other data — and lets you search them by similarity, finding the stored vectors closest to a query vector. This similarity search is what powers semantic features: instead of matching keywords, you match meaning. Whether for retrieval-augmented generation, semantic search, or recommendations, the core operation is the same: store many vectors and quickly find the most similar ones to a given query. Understanding that a vector database is fundamentally a store optimized for similarity search over embeddings clarifies what all the options have in common and what you are actually choosing between.

Why the choice matters

The vector database choice affects your architecture, operational complexity, and cost. Adding a dedicated vector service means another system to run, integrate, and pay for, while using an extension to your existing database keeps everything in one place. The right choice depends on your scale, your existing stack, and your needs. Because switching later involves migrating your embeddings and reworking integration, it is worth choosing deliberately. Understanding that this decision has real architectural and cost implications — and is not just picking a library — helps you weigh the options seriously rather than defaulting to whatever is most hyped, and choose what genuinely fits your situation.

pgvector: vectors in PostgreSQL

The pgvector extension adds vector storage and similarity search directly to PostgreSQL, letting you store embeddings alongside your relational data and query them with SQL. For anyone already running PostgreSQL — as most Django applications do — this is a compelling option because it adds vector capability without introducing a new system. Your embeddings live in the same database as the rest of your data, backed up together, queried together, with no separate service to operate. Understanding that pgvector brings vector search into the database you already have is key, because for many applications it is the simplest, most pragmatic choice that avoids the overhead of a dedicated vector database entirely.

The distance operators

After CREATE EXTENSION vector; (the extension is called vector in SQL), you declare a column such as embedding vector(1536) and search it with operators that return a distance, where smaller always means more similar. That convention is why every nearest-neighbour query is ORDER BY ... LIMIT k in ascending order.

OperatorDistanceIndex operator class
<->Euclidean (L2)vector_l2_ops
<=>Cosine distance (1 minus cosine similarity)vector_cosine_ops
<#>Negative inner productvector_ip_ops
<+>Taxicab (L1), HNSW onlyvector_l1_ops

<#> returns the negative inner product so that ascending order still means "most similar first"; multiply by -1 to get the similarity. Many embedding APIs return unit-length vectors, for which cosine, inner product and L2 give the same ranking. If you are not sure yours are normalized, use cosine distance, which is correct either way.

When pgvector is the right choice

pgvector fits best when you are already using PostgreSQL, your scale is moderate, and you value simplicity and keeping your stack consolidated. It lets you combine vector search with regular database queries and filtering naturally, since everything is in one place, which is genuinely powerful for features that need both. For the large category of applications whose vector search needs fit comfortably within PostgreSQL's capabilities, pgvector avoids the cost and complexity of a separate service. Recognizing that many real applications do not need a dedicated vector database, and that pgvector serves them well, is important because the dedicated services are often reached for prematurely when the existing database would suffice.

Indexing pgvector: HNSW versus IVFFlat

Without an index, pgvector performs an exact search: it computes the distance to every row and sorts. That is perfectly accurate and fine for tens of thousands of rows, but the cost grows linearly. Beyond that you add an approximate nearest neighbour (ANN) index, trading a little accuracy for a large speed-up. pgvector offers two kinds.

HNSW

HNSW builds a layered graph linking each vector to its near neighbours; a search walks the graph greedily towards the query. It has the better speed-to-recall trade-off, needs no training step and can be created on an empty table, at the cost of slower builds and a larger index.

CREATE INDEX CONCURRENTLY chunk_embedding_hnsw
    ON document_chunk
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);
  • m (default 16): maximum connections per node. Higher improves recall and grows the index.
  • ef_construction (default 64): candidate list size while building. Higher gives a better graph and a slower build.
  • hnsw.ef_search (default 40): candidate list size at query time, the main recall-versus-latency knob. Set it per transaction with SET LOCAL hnsw.ef_search = 100;.

Builds are much faster when the graph fits in maintenance_work_mem; PostgreSQL emits a notice when it no longer does. Raise the setting for the build session (within the RAM the server really has free) and use max_parallel_maintenance_workers to parallelise.

IVFFlat

IVFFlat clusters vectors into lists with k-means and at query time scans only the ivfflat.probes lists nearest the query. It builds faster and uses less memory, but its centroids come from the data present at build time, so create it after loading representative data and rebuild (REINDEX INDEX CONCURRENTLY) when the data has shifted substantially. The pgvector documentation suggests lists of rows/1000 up to a million rows and sqrt(rows) beyond, with probes around sqrt(lists). The default of one probe is fast but often gives disappointing recall, a frequent reason people wrongly conclude that pgvector is inaccurate.

ConcernHNSWIVFFlat
Recall at a given latencyBetterWorse, tunable with probes
Build time, memory, index sizeHigherLower
Create on an empty tableYesNo
As data changesGraph updated on insertCentroids go stale; periodic rebuilds

For a Django application with continuously arriving content, HNSW is the sensible default; IVFFlat suits large, mostly static corpora rebuilt in a batch window.

Using pgvector from Django

The pgvector Python package provides a Django model field, index classes, distance expressions and a VectorExtension migration operation, and it works with the psycopg driver Django 5.2 uses. Put operations = [VectorExtension()] in an early migration so every environment, including the test database, gets the extension.

# search/models.py
from django.db import models
from pgvector.django import HnswIndex, VectorField


class DocumentChunk(models.Model):
    tenant = models.ForeignKey("accounts.Tenant", on_delete=models.CASCADE)
    body = models.TextField()
    embedding = VectorField(dimensions=1536)
    embedding_model = models.CharField(max_length=100)

    class Meta:
        indexes = [
            HnswIndex(
                name="chunk_embedding_hnsw",
                fields=["embedding"],
                m=16,
                ef_construction=64,
                opclasses=["vector_cosine_ops"],
            ),
        ]

Recording embedding_model per row lets you re-embed in the background when you change models: vectors from different models are not comparable, and mixing them produces confident nonsense rather than an error. On a large, busy table, build the index with AddIndexConcurrently from django.contrib.postgres.operations in a migration with atomic = False, instead of the blocking AddIndex the autodetector generates.

# search/services.py
from django.db import connection, transaction
from pgvector.django import CosineDistance

from .models import DocumentChunk


def similar_chunks(tenant, query_embedding, k=10, ef_search=None):
    with transaction.atomic():
        if ef_search:
            with connection.cursor() as cursor:
                # set_config(..., true) is SET LOCAL: it ends with the transaction
                cursor.execute("SELECT set_config('hnsw.ef_search', %s, true)",
                               [str(ef_search)])
        qs = (
            DocumentChunk.objects.filter(tenant=tenant)
            .annotate(distance=CosineDistance("embedding", query_embedding))
            .order_by("distance")
        )
        return list(qs[:k])

Two details matter here. Transaction-scoped settings cannot leak into the next request that reuses a pooled or persistent connection (CONN_MAX_AGE, or Django's built-in pooling), whereas a plain session SET would. And if you want a similarity threshold, apply it in Python to the top k results; filtering on the annotated distance in SQL turns the query into a range condition the ANN index does not serve.

Dedicated vector databases

The alternatives — services like Pinecone, Weaviate, and Qdrant — are purpose-built for vector search, designed and optimized specifically for storing and searching large numbers of embeddings at high performance. They offer features tailored to vector workloads and can handle very large scale efficiently. The tradeoff is that they are a separate system to integrate, operate, and pay for, adding to your architecture's complexity. Understanding that these dedicated databases exist to handle vector search at a scale and performance level beyond what a general database extension targets — at the cost of being another moving part — frames when stepping up to one is worthwhile versus staying with an extension to your existing database.

Pinecone

Pinecone is a fully managed vector database service, meaning you do not run any infrastructure — you use it through an API and the operational burden is handled for you. This managed nature is its main appeal: you get scalable, performant vector search without operating a system yourself. The tradeoff is that it is a paid, hosted service you depend on, with the cost and external dependency that implies. Understanding that Pinecone trades self-hosting for a fully managed experience helps you weigh it: it suits teams that want vector search at scale without operational overhead and are comfortable with a managed, paid dependency, and less so those who prefer to keep everything self-hosted or in their existing stack.

Weaviate and Qdrant

Weaviate and Qdrant are vector databases that can be self-hosted as well as used as managed services, giving more flexibility in how you run them. They are open-source options with rich feature sets for vector search, suited to teams that want a dedicated vector database but prefer to host it themselves or want the option to. Each has its own strengths and characteristics worth evaluating against your needs. Understanding that these provide dedicated vector-database capability with the flexibility of self-hosting — unlike a purely managed service — positions them as choices for teams that have outgrown an extension like pgvector but want control over their infrastructure rather than a fully external dependency.

How to compare them

To choose, weigh a few practical dimensions: your scale and performance needs, whether you prefer managed or self-hosted, how it fits your existing stack, the cost, and the operational burden you are willing to take on. For a Django app already on PostgreSQL with moderate needs, pgvector is often the pragmatic answer. For very large scale or demanding performance, a dedicated database becomes worthwhile, with the managed-versus-self-hosted preference narrowing the choice further. Understanding that the decision comes down to matching these dimensions to your situation — rather than to which option is most popular — is what leads to a choice that genuinely fits, avoiding both premature complexity and outgrowing your tools.

Start simple, scale when needed

A sound general strategy is to start with the simplest option that meets your needs and move to something more specialized only when you genuinely require it. For most Django applications beginning with AI features, that means starting with pgvector, which adds vector search to your existing database with minimal new complexity, and considering a dedicated vector database later if your scale or performance demands exceed what it comfortably handles. This avoids the common mistake of adopting a complex dedicated service before you need it. Recognizing that you can start simple and migrate if necessary — and that pgvector covers a great deal — keeps your architecture as lean as your actual requirements allow.

The reality of migrating later

A reasonable concern is being locked in, but migrating from one vector store to another is manageable, because what you are moving is embeddings and the integration around them. If you start with pgvector and later need a dedicated database, you can regenerate or transfer your embeddings and adapt your search code. While not effortless, this is not a rewrite of your application either. Understanding that the migration path exists and is feasible reduces the pressure to choose the perfect long-term solution upfront, supporting the start-simple strategy: you can begin with the pragmatic choice knowing that if you outgrow it, moving to a more specialized option is a contained, achievable change rather than a trap.

Where the embeddings come from

It is worth being clear that the vector database stores embeddings but does not usually create them — the embeddings are produced by an embedding model, typically through an API, which turns your text into the numeric vectors. Your pipeline generates an embedding for each piece of content and stores it in the vector database, and at query time embeds the query the same way before searching. Understanding this division — an embedding model creates the vectors, the vector database stores and searches them — clarifies the overall architecture of a semantic feature, and it matters because the choice of embedding model and the consistency of using the same one for content and queries are as important as the database choice itself.

Combining vector search with filters

Real applications often need to combine similarity search with regular filtering — finding the most similar items that also belong to a certain category, or that a particular user is allowed to see. How well a vector store supports this combination matters, and it is an area where keeping vectors in a database like PostgreSQL alongside your relational data is genuinely advantageous, since you can filter with ordinary queries and search by similarity together. Understanding that many real features need both semantic similarity and conventional filtering — and that this favors solutions integrating the two — is an important practical consideration when choosing, beyond raw vector-search performance alone.

The post-filtering trap

With an HNSW index, PostgreSQL collects hnsw.ef_search candidates (40 by default) and then applies the WHERE clause. If the current tenant owns 10% of the rows, a top-10 query finds about four matching candidates and returns four rows. Nothing errors; results are silently short. pgvector 0.8.0 added iterative index scans, which keep scanning until enough rows pass the filter:

SET LOCAL hnsw.iterative_scan = relaxed_order;  -- or strict_order
SET LOCAL hnsw.max_scan_tuples = 20000;         -- bound the extra work

Check your version with SELECT extversion FROM pg_extension WHERE extname = 'vector';. Alternatives are partial indexes for a few fixed filter values, partitioning by tenant with an index per partition, or, for small slices, letting the planner use a B-tree on tenant_id followed by an exact sort. Whichever you pick, benchmark filtered queries separately: good overall recall can hide poor recall for your smallest tenants.

Thinking about scale honestly

A common mistake is choosing for an imagined future scale rather than your actual needs, leading to adopting a complex dedicated service when a simpler option would serve for a long time. It is worth assessing honestly how many vectors you really expect and what performance you genuinely need, because the answer often fits comfortably within simpler solutions. Understanding that scale should be assessed realistically — and that premature optimization for scale you may never reach adds needless complexity and cost — supports starting with the pragmatic option. You can move to a more specialized database if and when your real scale demands it, rather than carrying that complexity from the start.

Integration and ecosystem fit

Beyond raw capability, how a vector store fits your stack and tools matters. A solution that integrates cleanly with your existing database, your framework, and the libraries you use for building AI features reduces friction, while one that requires bridging gaps adds work. For a Django application, the smooth integration of a PostgreSQL-based approach with the rest of your stack is a real practical advantage. Understanding that ecosystem and integration fit — not just performance benchmarks — should weigh in your decision helps you choose a solution that is pleasant to build and maintain within your particular environment, which often matters more in practice than marginal differences in raw vector-search speed.

Evaluating a vector store: recall@k benchmarking

Published benchmarks use data, filters, hardware and tuning that are not yours. The numbers that should decide your choice are ones you measure on your own data at your own latency budget, and the method is the same for every candidate.

Keep two questions apart. ANN recall asks how many of the true k nearest vectors the index returned; the ground truth is an exact search, so it needs no labels and measures the index. Retrieval relevance asks whether the chunks that actually answer the question appear in the top k; it needs a labelled set of queries and measures the embedding model and chunking. Choosing a database is mostly about the first. If relevance is poor, a different database will not fix it.

For one query, recall@k is the overlap between approximate and exact top-k ids divided by k, averaged over a few hundred queries. Always report it alongside latency percentiles: any engine reaches perfect recall if allowed to scan everything, so the useful result is a curve of recall against p95 latency as you turn the effort knob (hnsw.ef_search, ivfflat.probes, or an engine's equivalent).

# search/management/commands/vector_recall.py
import statistics
import time

from django.core.management.base import BaseCommand
from django.db import connection, transaction
from pgvector.django import CosineDistance

from search.models import DocumentChunk


def top_k(embedding, k, *, exact=False, ef_search=40):
    with transaction.atomic(), connection.cursor() as cursor:
        if exact:  # no index scan: sequential scan plus exact sort
            cursor.execute("SELECT set_config('enable_indexscan', 'off', true)")
        else:
            cursor.execute("SELECT set_config('hnsw.ef_search', %s, true)", [str(ef_search)])
        qs = DocumentChunk.objects.order_by(CosineDistance("embedding", embedding))
        start = time.perf_counter()
        ids = list(qs.values_list("pk", flat=True)[:k])
        return ids, (time.perf_counter() - start) * 1000


class Command(BaseCommand):
    def handle(self, *args, **options):
        k = 10
        # Prefer embeddings of real user queries; sampled rows are a fallback.
        sample = list(DocumentChunk.objects.order_by("?").values_list("embedding", flat=True)[:300])
        truth = [set(top_k(e, k, exact=True)[0]) for e in sample]
        for ef in (40, 80, 160, 320):
            recalls, latencies = [], []
            for emb, exact_ids in zip(sample, truth):
                ids, ms = top_k(emb, k, ef_search=ef)
                recalls.append(len(exact_ids & set(ids)) / len(exact_ids))
                latencies.append(ms)
            pct = statistics.quantiles(latencies, n=100)
            self.stdout.write(f"ef_search={ef} recall@{k}={statistics.mean(recalls):.3f} "
                              f"p50={pct[49]:.1f}ms p95={pct[94]:.1f}ms")

Run it against a production-sized copy of the data on production-like hardware; a laptop with a tenth of the rows tells you little. Discard a warm-up pass so cold caches do not distort the first setting, and repeat runs to see how stable the percentiles are. Recall typically climbs steeply and then flattens while latency keeps rising, so choose the smallest ef_search that meets your recall target and set it explicitly. Add a variant that filters by tenant to catch the post-filtering effect. To compare engines, send exactly the same query sample to each, score against the same exact ground truth, and confirm every engine uses the same distance metric.

For retrieval relevance, keep a golden set of real questions labelled with the chunk ids that answer them, and track recall@k and mean reciprocal rank (the average of 1 divided by the rank of the first relevant hit) in your test suite. Rerun both benchmarks whenever you change the embedding model, chunking, index parameters or the store itself, and change one of those at a time so a regression has a single cause.

Troubleshooting pgvector

SymptomLikely causeFix
Sequential scan and sort in EXPLAINOperator does not match the opclass, no LIMIT, or a distance range filterORDER BY the matching operator ascending with LIMIT k
Fewer than k rows with a WHERE clausePost-filtering of ef_search candidatesIterative scans, higher ef_search, partial indexes or partitions
Poor IVFFlat recallOne probe, or index built before the data was loadedRaise ivfflat.probes; rebuild after bulk loads
"expected N dimensions"Embedding model changedRecord the model per row and re-embed

Summary

Choosing a vector database to store and search the embeddings behind your AI features is a real architectural decision affecting complexity and cost. The core operation is the same across all options — store embeddings and find the most similar to a query — so the choice is about fit. For the many Django applications already running PostgreSQL with moderate needs, pgvector is the pragmatic answer, adding vector search to your existing database with no new system to operate and letting you combine vector and relational queries naturally. Dedicated vector databases — Pinecone as a fully managed service, Weaviate and Qdrant as flexible open-source options that can be self-hosted or managed — are purpose-built for vector search at large scale and high performance, at the cost of being another system to integrate and pay for. Compare them on scale, performance, managed-versus-self-hosted preference, stack fit, and cost. The sound strategy is to start simple, usually with pgvector, and move to a specialized database only when you genuinely outgrow it — a migration that is feasible rather than a trap — keeping your architecture matched to your actual requirements.