Indexing Spatial Embeddings with HNSW and Metadata Filters

Build a graph index that keeps its recall when a bounding box and a date range remove most of the corpus, and verify the filter runs during traversal rather than after it.

A graph index answers nearest-neighbour questions by walking edges toward the query. Add a filter that excludes most nodes and the walk can strand itself in a region of the graph where nothing is eligible, returning three results when it was asked for twenty. This guide builds the index and the queries so that does not happen, for the workload described in spatial vector store selection.

When to Use This Approach

Use a graph index when the corpus is large enough that exact search is too slow, and the filters are selective. For small corpora, exact search under a filter is simpler, has perfect recall, and is often fast enough — a fact worth confirming before adding an approximate index and its parameters.

Corpus after filtering Index Reason
Under ~50 000 vectors Exact search Perfect recall, no parameters, fast enough
50 000 to a few million Graph index, filtered traversal The normal case for this workload
Tens of millions Graph index plus quantization Memory becomes the binding constraint
Highly selective, small result Filter first, then exact The filter has already solved the problem

The last row is the one people miss. If a bounding box reduces two million chunks to eight hundred, an exact scan of those eight hundred is microseconds of work and the graph index contributes nothing but risk.

Why a filtered graph walk strandsA traversal that ignores the filter walks toward the query through ineligible nodes and exhausts its budget; a traversal that evaluates the filter during the walk steers toward eligible neighbourhoods and returns a full result set.filter applied after the walkwalk visits the nearest nodesmost fail the filterbudget exhausted, 3 of 20 returnedrecall collapses as the filter tightensfilter evaluated during the walkwalk prefers eligible neighboursineligible nodes still traversedbudget spent usefully, 20 of 20recall holds as the filter tightensThe difference is where the predicate is evaluated, not how good the index is
Ineligible nodes still have to be walked through. That is the subtlety: a filtered traversal does not skip them, it declines to return them while still using their edges to reach eligible regions. A design that removes them from the graph entirely disconnects it.

Implementation

The index definition carries three decisions: the distance operator, the graph connectivity, and the build-time search width. The filter columns need their own indexes, because the planner has to be able to narrow before or during the graph walk.

-- Vector column plus the metadata the filters use.
ALTER TABLE spatial_chunks
    ADD COLUMN IF NOT EXISTS embedding vector(768);

-- Graph index over cosine distance. m controls connectivity; ef_construction
-- controls how hard the builder searches while wiring each node in.
CREATE INDEX IF NOT EXISTS spatial_chunks_embedding_idx
    ON spatial_chunks USING hnsw (embedding vector_cosine_ops)
    WITH (m = 24, ef_construction = 128);

-- The filters need indexes of their own or the pre-filter is a scan.
CREATE INDEX IF NOT EXISTS spatial_chunks_geom_idx   ON spatial_chunks USING gist (geom);
CREATE INDEX IF NOT EXISTS spatial_chunks_period_idx ON spatial_chunks (captured_at);

-- Query-time search width: higher means better recall and more work.
SET hnsw.ef_search = 120;

SELECT chunk_id
FROM   spatial_chunks
WHERE  geom && :bbox
  AND  ST_Intersects(geom, :region)
  AND  captured_at >= :since
ORDER  BY embedding <=> :qvec
LIMIT  20;

The build parameters are chosen higher than the defaults on purpose. Filtered search spends part of its budget walking through ineligible nodes, so a graph with more edges per node and a wider build search gives the traversal more routes into eligible territory. The cost is memory and build time, both of which are covered in sizing HNSW parameters for spatial recall.

The application layer’s job is to raise the search width when the filter is tight, since a single global value cannot serve both loose and selective queries.

import logging
import math

log = logging.getLogger("hnsw_index")

BASE_EF = 60
MAX_EF = 500


def ef_for_selectivity(selectivity: float, k: int = 20) -> int:
    """Widen the search when the filter removes most of the corpus.

    selectivity is the estimated share of the corpus passing the filter, in (0, 1].
    """
    if not (0.0 < selectivity <= 1.0) or math.isnan(selectivity):
        log.warning("implausible selectivity %r — using the base width", selectivity)
        return BASE_EF                              # deterministic fallback
    # Roughly: to see k eligible nodes, the walk must visit k / selectivity nodes.
    needed = int(k / max(selectivity, 1e-4))
    return max(BASE_EF, min(MAX_EF, needed))


def search(conn, qvec, region, since, k: int = 20, selectivity: float = 1.0):
    ef = ef_for_selectivity(selectivity, k)
    with conn.cursor() as cur:
        try:
            cur.execute("SET LOCAL hnsw.ef_search = %s", (ef,))
        except Exception as exc:                    # older server, or a store without the knob
            log.info("could not set search width (%s) — proceeding with the default", exc)
        cur.execute(FILTERED_QUERY, {"qvec": qvec, "region": region,
                                     "since": since, "k": k})
        rows = cur.fetchall()
    if len(rows) < k:
        log.info("filtered search returned %d of %d at ef=%d", len(rows), k, ef)
    return rows

The estimate of selectivity does not need to be accurate, only roughly right in order of magnitude. Deriving it from the region’s area relative to the corpus extent, cached per region size band, is enough to distinguish a town-sized query from a national one, which is the distinction that matters.

Search width against recall for three filter selectivitiesBars showing that a loose filter reaches full recall at a modest search width while a tight filter needs a much wider search to reach the same recall.Recall at twenty, by search widthef 60ef 200ef 50050% pass5% pass0.5% passtaller is better
Selectivity and search width trade against each other. A single global width is either wasteful for loose filters or inadequate for tight ones, which is why the width belongs in the query rather than in the configuration file.

Validation & Testing

def test_recall_holds_at_high_selectivity(conn):
    truth = brute_force_topk(conn, QVEC, TIGHT_REGION, k=20)
    got = {r[0] for r in search(conn, QVEC, TIGHT_REGION, SINCE, k=20, selectivity=0.005)}
    assert len(truth & got) / len(truth) >= 0.9


def test_full_result_count_under_a_tight_filter(conn):
    rows = search(conn, QVEC, TIGHT_REGION, SINCE, k=20, selectivity=0.005)
    assert len(rows) == 20, f"stranded walk: only {len(rows)} results"


def test_search_width_scales_with_selectivity():
    assert ef_for_selectivity(1.0) == 60
    assert ef_for_selectivity(0.01) > ef_for_selectivity(0.5)
    assert ef_for_selectivity(0.0) == 60          # implausible input falls back, not crashes


def test_filter_indexes_are_used(conn):
    plan = explain(conn, FILTERED_QUERY)
    assert "Seq Scan on spatial_chunks" not in plan

The second test is the one that catches stranding directly, and it is easy to omit because a short result set looks like “the corpus has little about this place”. Assert the count, not just the overlap.

Gotchas & Edge Cases

Filters on unindexed columns. A metadata predicate with no index forces a scan of whatever the graph returns, which caps throughput and, worse, changes the plan under load. Index every column a filter touches, including the ones added later for a single feature.

Insert-heavy workloads degrading connectivity. Nodes added after the graph is built are wired into the graph as it exists, which over time produces a less well-connected structure than a full rebuild would. Track recall over time and rebuild on a schedule rather than waiting for complaints.

Graph quality degrading across incremental insertsRecall measured after a full build and after successive batches of incremental inserts, showing a slow decline that a periodic rebuild restores.Filtered recall after a full build, then after months of insertsbuildmonth 1month 2month 3rebuildNothing fails; recall simply erodes until a rebuild restores it
The slowest failure in the pipeline. No alert fires, no query errors, and answers get slightly worse each month — which is why scheduled recall measurement, not a scheduled rebuild, is the control that matters.

Quantization applied before measuring. Compressing vectors before establishing a recall baseline makes it impossible to attribute a later recall problem to compression or to the filter. Measure uncompressed first, then compress and measure again.

A search width set globally and forgotten. A width tuned during a demonstration with loose filters will underserve every selective query in production. Set it per query from selectivity, as above, and log when a search returns fewer results than requested.

Deleted records left in the graph. Soft deletes that remain as graph nodes consume search budget and can strand a walk exactly like a filter does. Ensure deletions are reflected in the index, and treat a rising deleted-node fraction as a reason to rebuild.

Frequently Asked Questions

Should the geometry filter or the vector index run first?

Let the planner decide, but give it the information to decide well: an index on the geometry column, current statistics, and a query written with the bounding-box operator so the spatial index is usable. For very selective regions the planner should narrow spatially and scan; for loose ones it should walk the graph. A plan that never changes across those two cases is a sign that one of the indexes is not being considered.

How do I estimate selectivity without querying twice?

From geometry, not from data. The ratio of the query region's area to the corpus extent's area, adjusted by a constant fitted once, is accurate enough to pick a search width. Caching that estimate by region-size band avoids recomputing it and keeps the choice stable, which matters because a search width that fluctuates between queries makes latency graphs unreadable.

Does raising connectivity always improve filtered recall?

Up to a point, after which the extra edges cost memory and build time without helping, because the walk is already reaching eligible regions. The point varies with how spatially clustered the eligible set is: a filter that selects a compact region benefits more from connectivity than one selecting scattered records. Measure rather than assuming, and measure under your tightest realistic filter.

Should the index be built before or after the corpus is loaded?

After, whenever the load is a bulk operation. Building the graph as records arrive wires each node into a partial graph, and the result is measurably worse connected than one built over the finished set — the same degradation that incremental inserts cause over time, compressed into the initial load. Load the vectors, then create the index, then measure. The build takes longer as one operation and produces a better graph than the same work spread across the load.

What is a reasonable recall target under filtering?

Ninety percent at the tightest selectivity you actually serve, which is usually achievable without exotic tuning. Chasing ninety-nine costs disproportionately and rarely changes answers, because the reranking stage downstream reorders the top candidates anyway. What matters far more than the last few points is that recall does not silently fall over time, which is a monitoring question rather than a tuning one.

A final note on ordering the work: establish the recall baseline before touching any parameter. Every knob in this guide trades recall against memory or latency, and without a baseline you cannot tell whether a change helped, hurt, or merely moved the cost somewhere you were not measuring. The baseline should be recorded with the corpus size and the filter selectivity it was measured at, because both change what the number means.