Spatial Context Retrieval and Reranking

Retrieve candidates that are both semantically relevant and geographically right, then rerank them so proximity and topic pull in the same direction instead of fighting.

Semantic similarity does not know where anything is. A vector search for “flood risk to the primary school” will happily return the best-written flood assessment in the corpus, from a catchment two hundred kilometres away, and rank it above the terse local report that actually answers the question. Spatial context retrieval and reranking is the discipline of making place a first-class ranking signal, so that the documents reaching the model are the ones that are both about the right subject and about the right ground.

This topic sits within geospatial RAG pipelines and addresses the retrieval failure that survives every other fix: a pipeline whose chunks are perfectly formed, whose reference frames are correctly resolved, and whose top result is still about the wrong place. It depends on chunks that carry a defensible position — see chunk-boundary strategies for spatial corpora — and on a store that can filter on that position, which is one of the selection criteria in spatial vector store selection.

Semantic rank against spatial rank for one queryFour candidate documents plotted by how well they match the topic and how close they are to the query location. Only the candidate strong on both axes should reach the context window; the two single-axis winners are the ones a naive ranker promotes.keep — right topic, right placethe only quadrant worthspending context oneloquent, far awaythe ranker's favouriteoff topic and farnearby but off topica map sheet, no prosesemantic score increases to the right · distance to the query decreases upwardcloserfarther
Two rankings, one budget. A pure vector ranker fills the window from the left column; a pure distance ranker fills it from the top row. Both spend most of the budget on candidates that fail the other test, which is why fusion rather than sequencing is the right shape for this problem.

Foundational Principles

Position is a filter before it is a score. A candidate two hundred kilometres outside the area of interest is not a weak match, it is not a match, and paying to embed-compare it wastes both latency and recall. Run a bounding-box or radius filter in the store, then score what survives. The technique and its pitfalls are covered in filtering retrieval by bounding box before vector search.

Distance is a signal, not a verdict. Once candidates are inside the region of interest, closer is usually better but not always: a regional policy document that governs the site is more useful than a neighbouring site’s report. Reranking must be able to express “near and relevant beats far and relevant beats near and irrelevant” without collapsing into “nearest wins”.

Every score must be reproducible from stored data. A rank that depends on a live distance computation against a moving reference point cannot be replayed, and a retrieval bug that cannot be replayed cannot be fixed. Store the geometry each candidate was scored against, and the parameters of the fusion, alongside the result.

Step-by-Step Implementation Pipeline

1. Resolve the query’s geometry before touching the index

A spatial query has a subject and a place, and the place is frequently implicit: “the primary school” resolves to a point only if the agent knows which school. Resolve it first, from the conversation, from an explicit parameter, or from a gazetteer lookup, and fail loudly when it cannot be resolved rather than searching the whole corpus by accident.

from dataclasses import dataclass
from shapely.geometry.base import BaseGeometry
import logging

log = logging.getLogger("spatial_retrieval")

@dataclass(frozen=True)
class SpatialQuery:
    text: str
    focus: BaseGeometry | None      # resolved place, in EPSG:4326
    radius_m: float

def prepare_query(text: str, focus: BaseGeometry | None, radius_m: float = 5000.0) -> SpatialQuery:
    """Normalise a query; an unresolved place is explicit, never silently global."""
    if focus is None:
        log.info("no spatial focus for %r — falling back to text-only retrieval", text)
        return SpatialQuery(text, None, radius_m)
    if focus.is_empty:
        raise ValueError("focus geometry is empty; resolve the place or pass None")
    return SpatialQuery(text, focus, max(100.0, float(radius_m)))

The floor on the radius is not fussiness. A caller that computes a radius from a user’s zoom level will eventually pass zero, and a zero-radius filter matches nothing, which surfaces as “the corpus has no documents about this place” — a claim that is both false and very hard to distinguish from the truth.

2. Filter in the store, not in the application

The candidate set must be narrowed by the index before vectors are compared. In PostGIS this means an index-aware predicate: the bounding-box operator first, so the spatial index is used, and the exact predicate second, so the answer is right.

-- Index-aware: && uses the GiST index, ST_DWithin refines what survives.
SELECT chunk_id, embedding, geom
FROM   spatial_chunks
WHERE  geom && ST_Expand(ST_GeomFromEWKB(:focus), :radius_deg)
  AND  ST_DWithin(geom::geography, ST_GeomFromEWKB(:focus)::geography, :radius_m)
ORDER  BY embedding <=> :query_vector
LIMIT  :k;

Written the other way round — ST_DWithin alone, or the exact predicate before the box — the planner may still find the index, but it may equally scan the table, and the difference on a corpus of any size is between forty milliseconds and forty seconds. The bounding-box pre-filter is cheap enough to be unconditional and is the single most reliable performance decision in this pipeline.

3. Fuse the two scores rather than sequencing them

Sequencing — take the top fifty by vector, then sort by distance — sounds reasonable and produces the failure in the opening figure, because anything the vector stage missed is unrecoverable. Fusion scores every surviving candidate on both axes and combines them.

import math

def fuse(semantic: float, distance_m: float, radius_m: float,
         w_semantic: float = 0.65, half_life_m: float = 1500.0) -> float:
    """Combine a cosine similarity in [0,1] with a decaying proximity term."""
    if not 0.0 <= semantic <= 1.0:
        semantic = max(0.0, min(1.0, semantic))       # clamp rather than reject
    if distance_m < 0 or not math.isfinite(distance_m):
        proximity = 0.0                               # unknown position scores as far
    else:
        proximity = 0.5 ** (distance_m / half_life_m)  # 1.0 at the focus, 0.5 per half-life
    if distance_m > radius_m:
        return 0.0                                    # outside the region: not a candidate
    return round(w_semantic * semantic + (1.0 - w_semantic) * proximity, 6)

An exponential decay is the right default because it expresses the intuition that the first kilometre matters far more than the tenth. A linear decay makes a candidate at nine kilometres nearly as good as one at eight, which is true for a policy document and false for a site report — and when in doubt, the shape that punishes distance early is the one that fails safely.

4. Rerank with a model only where fusion is ambiguous

A cross-encoder rerank is expensive and improves ordering mostly among candidates whose fused scores are close. Reserve it for the ambiguous band and let the clear cases through untouched, which typically cuts reranking cost by an order of magnitude with no measurable quality loss.

def rerank_band(candidates, cross_encoder, band: float = 0.08, top_n: int = 20):
    """Cross-encode only the candidates whose fused scores are within `band` of each other."""
    ranked = sorted(candidates, key=lambda c: c.fused, reverse=True)[:top_n]
    if len(ranked) < 2:
        return ranked
    head = ranked[0].fused
    ambiguous = [c for c in ranked if head - c.fused <= band]
    if len(ambiguous) < 2:
        return ranked
    try:
        scores = cross_encoder([(c.query_text, c.text) for c in ambiguous])
    except Exception as exc:                         # model outage must not empty the result
        log.warning("cross-encoder failed, keeping fused order: %s", exc)
        return ranked
    for cand, s in zip(ambiguous, scores):
        cand.fused = round(0.5 * cand.fused + 0.5 * float(s), 6)
    return sorted(ranked, key=lambda c: c.fused, reverse=True)

The exception handler is the important line. A reranker that fails closed — returning nothing when the model is unavailable — converts a degraded answer into no answer, which is almost never the right trade for a retrieval stage that already has a defensible ordering in hand.

5. Deduplicate on place as well as on text

Spatial corpora repeat themselves: the same site appears in an assessment, an appendix, and a revision, with near-identical prose and near-identical geometry. Text-level deduplication catches some of this; geometry-level deduplication catches the rest and is what stops a context window filling with four descriptions of one field.

def dedupe_by_place(candidates, min_separation_m: float = 50.0):
    """Keep the best-scoring candidate per place; near-coincident geometries collapse."""
    kept = []
    for cand in sorted(candidates, key=lambda c: c.fused, reverse=True):
        if cand.geom is None:
            kept.append(cand)                        # nothing to compare on: keep it
            continue
        clash = any(
            k.geom is not None and cand.geom.distance(k.geom) * 111_000 < min_separation_m
            for k in kept
        )
        if not clash:
            kept.append(cand)
    return kept

The degree-to-metre factor there is a deliberate approximation and should be replaced by a projected distance in any pipeline that spans latitudes; it is written this way to make the unit conversion visible rather than buried. Getting it wrong by the cosine of the latitude is the kind of error that makes deduplication too aggressive near the poles and too lax at the equator.

6. Return the evidence, not just the ranking

The consumer of this pipeline is a model that will cite what it was given. Each returned candidate should carry its identifier, its position, both component scores, and the fused total, so the answer can be traced and so a reviewer can see whether an odd answer came from a semantic mismatch or a spatial one.

def to_evidence(cand) -> dict:
    return {
        "chunk_id": cand.chunk_id,
        "semantic": cand.semantic,
        "distance_m": round(cand.distance_m, 1),
        "fused": cand.fused,
        "epsg": cand.epsg,
        "source": cand.source_uri,
    }
How the candidate set narrows at each stageA funnel from the full corpus through the spatial filter, vector comparison, fused ranking, ambiguity-band reranking and place deduplication, showing how many candidates survive each stage and what each stage costs.corpusin regiontop by vectorfused rankdeduped2 000 0004 100200408The spatial filter does the heavy lifting — everything after it is cheapIllustrative counts for one regional query against a national corpus
Order the stages by what they cost. The filter removes 99.8% of the corpus for the price of one index lookup. Every stage after it operates on a set small enough that the expensive work — cross-encoding, geometry distance — is affordable.

7. Size the region of interest from the question, not from a constant

A single radius constant cannot serve every question. “Which trees are protected on this plot” wants tens of metres; “what is the flood risk to this street” wants a catchment; “what does the local plan say about this area” wants an administrative boundary that may be twenty kilometres across. A pipeline that hard-codes five kilometres answers the first question with noise and the third with nothing.

The practical approach is a small table of question classes, each with a radius rule, and a classifier — often a short model call, sometimes a keyword match — that assigns the class. Crucially, the classifier chooses a rule, not a number: rules that resolve to a named boundary produce far better regions than any radius, because they follow the shape of the thing being asked about.

Question class Region rule Typical extent
Site condition Parcel geometry, buffered 30–100 m
Immediate impact Fixed radius around the focus 250–1000 m
Catchment or network Upstream or connected geometry 2–20 km
Policy and designation Containing administrative unit Variable
Comparative or statistical Whole study area Corpus-wide

When the classifier is unsure, widen rather than narrow. A region that is too large costs latency and dilutes ranking slightly; a region that is too small removes the correct answer from consideration entirely, and no amount of clever reranking recovers a candidate that was never retrieved. That asymmetry should govern every default in this stage.

Record the chosen region alongside the results. When someone disputes an answer, the first question is almost always “what area did you look at”, and a pipeline that cannot answer it will be assumed to have looked at the wrong one.

Failure Modes & Root Causes

The eloquent stranger. A well-written document from the wrong region outranks the terse local one. Root cause: semantic score alone, with no spatial term. Mitigation: fusion in step 3, with the region filter in step 2 as the backstop.

The empty region. A query returns nothing because the filter radius was computed from a degenerate input, or because the chunks in that area lack geometry metadata and were excluded by the filter. Root cause: treating “no geometry” as “not here”. Mitigation: keep geometry-less chunks in a separate lane that the text-only path can still reach, and floor the radius as in step 1.

Proximity tyranny. After adding a distance term, every answer becomes about the nearest feature regardless of subject. Root cause: a weight or decay tuned on a single query. Mitigation: tune against a labelled set spanning both site-specific and region-wide questions, and report both components so the imbalance is visible.

The four-times-duplicated site. The context window fills with revisions of one document. Root cause: deduplication on exact text only. Mitigation: place-level deduplication in step 5, plus a preference for the most recent revision when geometries coincide.

Production Validation Protocols

  1. Filter-first assertion. Assert in a query test that the executed plan uses the spatial index; a plan regression here is silent and catastrophic for latency.
  2. Component visibility. Assert every returned candidate carries both component scores; a result set that reports only the fused total cannot be debugged.
  3. Fusion monotonicity. For fixed semantic score, a nearer candidate must never rank below a farther one; property-test this rather than spot-checking it.
  4. Outside-radius exclusion. Assert that a candidate beyond the radius scores exactly zero and is absent from results, so the filter and the score agree.
  5. Reranker failure drill. Disable the cross-encoder in a test and assert results are still returned in fused order.
  6. Labelled retrieval set. Maintain a small set of queries with known-correct documents and track recall at eight; this is the only gate that measures the thing users care about.
Proximity weight against distance for two decay shapesBars comparing an exponential half-life decay with a linear decay at five distances. The exponential shape drops sharply over the first two kilometres and flattens; the linear shape treats near and middling distances as almost equivalent.0 km1.5 km3 km4.5 km6 kmExponential half-life against linear decayLeft bar of each pair is exponential; right bar is linear over the same radius
Pick the shape before tuning the weight. The two curves agree at the focus and at nothing else. Most disagreements about "how much should distance matter" are really disagreements about decay shape, and they are easier to settle by looking at this picture than by adjusting a scalar.

Frequently Asked Questions

What distance should a polygon candidate use — centroid or edge?

Edge distance, almost always. A large catchment polygon whose centroid is forty kilometres away may still contain the query point, and centroid distance would rank it as irrelevant. Edge distance is zero when the focus falls inside the candidate, which is the answer you want. The exception is when candidates vary wildly in size and you specifically want to prefer local documents over regional ones; then a blend of edge distance and area is more honest than pretending centroid distance means something.

Should the fusion weight be learned or hand-set?

Hand-set first, learned only once you have a labelled set large enough to trust. A learned weight fitted on a hundred queries mostly memorises the distribution of those queries' distances, and it moves whenever the corpus's spatial density changes. A hand-set weight with a published rationale is easier to defend, easier to override per query type, and usually within a few points of the learned optimum.

How do I handle queries with no place at all?

Detect them and route them to a text-only path rather than inventing a focus. A question like "what does the regulation say about flood zones" has no location, and forcing one in — from the user's last query, or from their session — produces answers that are subtly scoped to somewhere the user did not ask about. Make the absence explicit in the query object, as step 1 does, so the downstream code branches on it deliberately.

How many candidates should actually reach the model?

Fewer than the window allows. The window sets an upper bound; the useful number is set by how many genuinely distinct places and viewpoints the question needs, which is usually between four and ten. Filling the remaining space with rank-twenty candidates measurably degrades answers, because the model must now decide which of twenty documents to trust and the weakest ones are the ones most likely to contain a confidently phrased irrelevance. Cut the list where the fused score falls off, not where the token budget does.

Is reranking worth it if the fusion is already good?

Only in the ambiguous band, which is why step 4 restricts it there. When fused scores are well separated, a cross-encoder almost always agrees with the existing order and you have paid for a confirmation. When they are clustered — which happens most often on broad regional queries with many similar documents — the cross-encoder is the only signal that can tell them apart, and there it earns its cost several times over.