pgvector vs Qdrant vs Milvus for Spatial Embeddings

A decision matrix for three common vector stores judged on what a geometry-bearing corpus needs — real spatial predicates, filtered recall, operational cost and scale.

Three stores come up in every spatial retrieval design discussion, and the comparison usually turns on throughput, which is the criterion least likely to decide the outcome. This guide compares them on the four axes a geometry-bearing corpus actually exercises, and says plainly which one each axis favours — the concrete follow-on to spatial vector store selection.

When to Use This Approach

Use this comparison when you already know the workload — corpus size, dimensionality, filter selectivity, latency budget — and need to narrow to one candidate. If those numbers are not written down, the comparison will be decided by preference rather than evidence, and no matrix helps with that.

Capability Relational store with a vector extension Dedicated engine, filter-first design Distributed engine
Real geometry predicates Native and complete Bounding box and radius only Bounding box and radius only
Filtered recall at high selectivity Strong — the filter is a normal predicate Strong — filtering is designed in Strong, with tuning
Transactional updates with source data Native Separate system Separate system
Horizontal scale beyond one machine Limited Moderate Native
Operational burden Lowest, if you already run the database Moderate Highest

The pattern in that table is consistent: the relational option wins on everything that involves geometry and correctness, the distributed option wins on scale, and the dedicated engine sits between them with the best filtered-search behaviour of the three purpose-built designs.

Which store fits which corpus size and geometry demandA two-by-two of corpus scale against how much real geometry work the workload needs, showing where a relational store, a dedicated engine and a distributed engine each fit.relational + vectorsplit: geometry apartrelational, stilldistributed enginepredicates matter, corpus fitshuge corpus, real predicatessimple filters, corpus fitshuge corpus, box filters onlygeometryheavygeometrylight
Two questions decide three-quarters of cases. Does the corpus fit one machine, and does the workload need real predicates rather than rectangles? The upper-right quadrant is the genuinely hard one and the only place a split architecture earns its complexity.

Implementation

The comparison is only meaningful against your own corpus, so the useful artefact is a harness that runs the same measurements against each candidate through one interface.

import logging
import time
from dataclasses import dataclass
from typing import Callable, Protocol, Sequence

log = logging.getLogger("store_bakeoff")


class Store(Protocol):
    def upsert(self, ids: Sequence[str], vectors, metadata) -> None: ...
    def search(self, qvec, k: int, region=None) -> Sequence[str]: ...
    def rebuild(self) -> None: ...


@dataclass(frozen=True)
class Measurement:
    name: str
    recall_loose: float
    recall_tight: float
    p95_ms: float
    rebuild_s: float
    notes: tuple[str, ...]


def measure(store: Store, name: str, queries, truth_loose, truth_tight,
            loose_region, tight_region) -> Measurement:
    """Run one candidate through the four measurements that decide the choice."""
    notes: list[str] = []

    def recall(region, truth) -> float:
        hit = tot = 0
        for q, want in zip(queries, truth):
            try:
                got = set(store.search(q, k=10, region=region))
            except Exception as exc:               # a store that errors under filter fails here
                notes.append(f"search raised under filter: {exc}")
                return 0.0
            hit += len(want & got)
            tot += len(want)
        return round(hit / tot, 4) if tot else 0.0

    latencies = []
    for q in queries:
        start = time.perf_counter()
        try:
            store.search(q, k=10, region=tight_region)
        except Exception as exc:
            notes.append(f"latency probe failed: {exc}")
            break
        latencies.append((time.perf_counter() - start) * 1000)
    latencies.sort()
    p95 = latencies[int(0.95 * (len(latencies) - 1))] if latencies else float("inf")

    start = time.perf_counter()
    try:
        store.rebuild()
        rebuild_s = time.perf_counter() - start
    except Exception as exc:                       # some stores cannot rebuild online at all
        notes.append(f"rebuild unavailable: {exc}")
        rebuild_s = float("inf")

    return Measurement(name, recall(loose_region, truth_loose),
                       recall(tight_region, truth_tight), round(p95, 1),
                       round(rebuild_s, 1), tuple(notes))

Two design choices in that harness matter more than the numbers it produces. Errors are recorded as notes rather than raised, because “this store throws when a filter removes everything” is itself a finding and should appear in the comparison rather than aborting it. And rebuild time is measured, not asked about, because it is the figure most often quoted from documentation and least often true of a real corpus on real hardware.

For the relational option specifically, the query that makes it competitive is the one that puts both predicates and the ordering in a single statement:

SELECT c.chunk_id
FROM   spatial_chunks c
WHERE  c.geom && :bbox                    -- spatial index narrows first
  AND  ST_Intersects(c.geom, :region)     -- exact predicate, no approximation
  AND  c.captured_at >= :since
ORDER  BY c.embedding <=> :qvec           -- vector index orders what survives
LIMIT  20;

No other option in this comparison can express that without a round trip, and for most spatial corpora that single fact outweighs the throughput difference.

The cost of splitting geometry from vectorsA single-store query runs one round trip; a split architecture fetches identifiers from the spatial database, passes them to the vector engine as a filter, and pays a second round trip plus a consistency risk.one storefilter and order togetherone round tripone snapshot of the truthspatial databasereturns identifiersvector enginefilters on that listtwo snapshotsthat can disagree
The second round trip is the smaller cost. The larger one is on the right: two systems updated independently will eventually disagree about which chunks exist, and the resulting retrieval bugs are irreproducible by construction.

Validation & Testing

def test_every_candidate_holds_recall_under_a_tight_filter(results):
    for m in results:
        assert m.recall_tight >= m.recall_loose - 0.05, (
            f"{m.name} loses recall when the filter bites: "
            f"{m.recall_loose} -> {m.recall_tight}")


def test_rebuild_completes_within_the_iteration_budget(results):
    for m in results:
        assert m.rebuild_s < 3600, f"{m.name} rebuild takes {m.rebuild_s}s — too slow to iterate"


def test_no_candidate_errors_under_an_empty_filter(results):
    for m in results:
        assert not any("raised under filter" in n for n in m.notes), m.notes

Run the harness against a real slice of the corpus — a few hundred thousand chunks is plenty — rather than against synthetic vectors. Synthetic data is uniformly distributed and real embeddings are emphatically not, and clustered data is exactly where approximate indexes lose recall.

Gotchas & Edge Cases

Benchmarks run without filters. Published numbers almost always measure unfiltered search, which is not the workload. Treat any figure without a stated filter selectivity as unrelated to this decision.

Recall measured against the index instead of the truth. Comparing a store’s results to its own exhaustive mode measures internal consistency, not recall. Compute truth by brute force over the filtered population.

The four measurements a bake-off should produceLoose recall, tight recall, tail latency under filter and rebuild wall clock, with a note on what a poor result in each one means for the workload.recall, loose filterrecall, tight filtertail latency under filterrebuild wall clocka floor every candidate clearsrarely discriminatesthe number that decides itrarely published by anyonegate on the 95th percentilethe mean hides the complaintssets how often you dare changemeasure it, never ask
Two of these four are usually skipped. Tight-filter recall and measured rebuild time are the two that change the decision, and both take an afternoon to obtain on a real corpus slice.

A rebuild that cannot run online. Some configurations require the index to be offline while rebuilding. That is survivable for a corpus that changes quarterly and disqualifying for one that changes daily, so establish it before, not after.

Dimensionality chosen elsewhere. The embedding decision drives the memory bill and is often made by a different team on different grounds. Fix the dimensionality before running the comparison, or the winner will change when the model does.

Geometry precision quietly reduced. A store that only holds a bounding box for each record turns every containment question into an approximation. That may be acceptable — many workloads only ever ask for rectangles — but it should be an accepted trade, not a discovery.

Run the harness twice, a week apart, on the same data. Vector stores are stateful in ways that benchmarks rarely capture: caches warm, background compaction runs, and a store that looked fastest on a freshly built index may look different once it has absorbed a week of inserts. If the two runs disagree materially, that instability is itself a finding worth more than the absolute numbers.

Record the hardware, the dataset slice and the parameter settings alongside the results. A comparison whose conditions are not written down cannot be repeated when someone asks, six months later, whether the conclusion still holds — and by then the corpus has grown, the embedding has changed, and repeating it is exactly what you want to do.

Frequently Asked Questions

Is the relational option really competitive at scale?

Up to the point where the index no longer fits comfortably in memory on one machine, yes, and that point is further away than most teams assume — tens of millions of moderate-dimension vectors sit within reach of ordinary hardware. Beyond it the argument changes, because sharding a relational store for vector search is work that the distributed engines have already done. The mistake is assuming you are past that point before measuring.

What if geometry needs are simple — just bounding boxes?

Then the geometry axis stops discriminating and the decision falls to scale and operational cost, which usually favours whatever your team already runs. Be careful about predicting simplicity, though: workloads that begin with rectangles acquire containment and adjacency questions as soon as users discover the system can answer them, and retrofitting real predicates onto a store that lacks them means a migration.

How much does the embedding model constrain the choice?

Mostly through dimensionality and through whether you need multiple vectors per chunk. High dimensionality pushes toward stores with good quantization support; multi-vector retrieval — several embeddings per chunk — is supported unevenly and is worth confirming early if it is in your plans, since emulating it with duplicate records inflates the corpus and confuses deduplication.

Does the choice change if the corpus is mostly points rather than polygons?

It softens the geometry axis considerably. Point data is well served by radius filters, which every candidate offers, so the containment and intersection advantages of a relational store stop being decisive. The remaining differences are scale and operational cost, and for point-only corpora at large scale the dedicated engines become genuinely attractive. Be honest about whether the corpus will stay point-only, though — an address index that later acquires service areas has acquired polygons.

Should the decision be revisited later?

At each order of magnitude of corpus growth, and whenever the embedding changes. Those are the two events that move the answer. Revisiting on a calendar schedule mostly produces churn, because the criteria that matter here change with the workload rather than with the release notes of the candidates.

One more consideration rarely surfaces in comparisons and often decides them in practice: who else needs the data. A corpus that also feeds dashboards, exports and analytical queries benefits enormously from living in a store those consumers already speak, and moving it into a specialised engine means every one of them acquires a second data path. That is a real cost, paid by teams who were not in the selection meeting.