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.
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.
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.
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.