A vector search has returned two hundred candidates that are all, in some sense, about the right subject. Ordering them is now a spatial problem: the document about the site next door should outrank the one about a similar site in another county, without the pipeline collapsing into a nearest-neighbour lookup that ignores what the question actually asked. This guide implements that reranking step for spatial context retrieval and reranking.
When to Use This Approach
Rerank when candidates share a region but differ in position, and when the question is about a place rather than about a policy. If every candidate sits inside a small site, distance carries no information and the reranker only adds noise; if the question is regional, distance to a focus point is actively misleading.
| Question | Distance term | Reason |
|---|---|---|
| “What is at this address” | Strong | The nearest description is almost always the right one |
| “Flood risk to this street” | Moderate | Catchment documents legitimately sit further away |
| “What does the local plan say” | Weak or none | Policy applies across an area, not at a point |
| “Compare these two sites” | None — two foci | A single distance term biases toward whichever focus it used |
The last row is worth taking seriously. Multi-focus questions break the single-distance model entirely, and the honest handling is to run the retrieval once per focus and merge, rather than to average two distances into a number that describes neither.
Implementation
The reranker takes scored candidates with geometry, computes edge distance in metres, applies an exponential decay, and returns a fused ordering with both components preserved for inspection.
import logging
import math
from dataclasses import dataclass, replace
from typing import Optional, Sequence
from shapely.geometry.base import BaseGeometry
from shapely.ops import transform
from pyproj import Transformer, CRS
log = logging.getLogger("spatial_rerank")
@dataclass(frozen=True)
class Candidate:
chunk_id: str
semantic: float # cosine similarity in [0, 1]
geom: Optional[BaseGeometry] # in EPSG:4326
distance_m: Optional[float] = None
fused: Optional[float] = None
def _metric_transformer(focus: BaseGeometry) -> Transformer:
"""Build a locally accurate metric projection centred on the query focus."""
lon, lat = focus.centroid.x, focus.centroid.y
local = CRS.from_proj4(
f"+proj=aeqd +lat_0={lat} +lon_0={lon} +datum=WGS84 +units=m +no_defs"
)
return Transformer.from_crs(CRS.from_epsg(4326), local, always_xy=True)
def _edge_distance_m(geom: BaseGeometry, focus: BaseGeometry, tf: Transformer) -> Optional[float]:
"""Distance from the focus to the candidate's nearest edge; 0 when it contains the focus."""
try:
return transform(tf.transform, geom).distance(transform(tf.transform, focus))
except Exception as exc: # unprojectable or malformed geometry
log.warning("distance failed for candidate geometry: %s", exc)
return None
def rerank(
candidates: Sequence[Candidate],
focus: Optional[BaseGeometry],
radius_m: float = 5000.0,
w_semantic: float = 0.65,
half_life_m: float = 1500.0,
) -> list[Candidate]:
"""Fuse semantic score with proximity. Falls back to semantic order without a focus."""
if focus is None or focus.is_empty:
log.info("no focus — returning semantic ordering unchanged")
return sorted(candidates, key=lambda c: c.semantic, reverse=True)
if half_life_m <= 0:
raise ValueError("half_life_m must be positive")
tf = _metric_transformer(focus)
out: list[Candidate] = []
for cand in candidates:
distance = _edge_distance_m(cand.geom, focus, tf) if cand.geom is not None else None
if distance is None:
# Deterministic fallback: unknown position keeps its semantic score,
# discounted so it never outranks a candidate known to be nearby.
fused = round(w_semantic * cand.semantic * 0.8, 6)
out.append(replace(cand, distance_m=None, fused=fused))
continue
if distance > radius_m:
continue # outside the region: not a candidate
proximity = 0.5 ** (distance / half_life_m)
fused = round(w_semantic * cand.semantic + (1.0 - w_semantic) * proximity, 6)
out.append(replace(cand, distance_m=distance, fused=fused))
return sorted(out, key=lambda c: (c.fused if c.fused is not None else 0.0), reverse=True)
The locally centred azimuthal projection is worth the two extra lines. Computing distance in degrees and multiplying by a constant is accurate at the equator and increasingly wrong toward the poles, and the error is a systematic bias in ranking rather than random noise — candidates east and west of the focus are penalised too heavily at high latitude, so northern corpora quietly favour documents to the north and south.
The unknown-distance branch discounts rather than excludes. A chunk with no geometry may still be the best answer, especially in corpora where prose and geometry live in different documents, and dropping it entirely converts a metadata gap into a retrieval gap.
Validation & Testing
from shapely.geometry import Point, box
def test_nearer_candidate_wins_at_equal_semantic_score():
focus = Point(-3.19, 55.95)
near = Candidate("near", 0.80, Point(-3.19, 55.951))
far = Candidate("far", 0.80, Point(-3.30, 55.99))
ranked = rerank([far, near], focus, radius_m=20000)
assert ranked[0].chunk_id == "near"
def test_containing_polygon_scores_zero_distance():
focus = Point(-3.19, 55.95)
catchment = Candidate("catchment", 0.60, box(-3.4, 55.8, -3.0, 56.1))
ranked = rerank([catchment], focus, radius_m=20000)
assert ranked[0].distance_m == 0.0
def test_outside_radius_is_absent_not_zero_scored():
focus = Point(-3.19, 55.95)
away = Candidate("away", 0.99, Point(2.35, 48.85))
assert rerank([away], focus, radius_m=5000) == []
def test_missing_geometry_is_ranked_but_discounted():
focus = Point(-3.19, 55.95)
known = Candidate("known", 0.70, Point(-3.191, 55.951))
unknown = Candidate("unknown", 0.70, None)
ranked = rerank([unknown, known], focus)
assert ranked[0].chunk_id == "known" and len(ranked) == 2
The first test is a monotonicity property and should ideally be run over generated inputs rather than one pair; the ordering it asserts is the entire contract of the function, and it is the assertion that fails when someone inverts a sign while tuning.
Gotchas & Edge Cases
Half-life tuned on one query. A half-life that makes a demonstration query look perfect will usually be far too short for regional questions, and the symptom — “the reranker ignores anything more than a kilometre away” — is indistinguishable from a bug. Tune against a labelled set spanning both question types, and let the region-sizing logic vary the half-life with the question class rather than fixing it globally.
Candidates whose geometry is a bounding box. If chunk geometry is stored as an extent rather than the true shape, edge distance is measured to the box, which for a sparse feature collection can be far from any actual feature. Store true geometry when the store allows it, and treat extent-derived distance as an upper bound rather than a measurement.
Degenerate focus geometry. A focus resolved to an empty geometry, or to a point at the origin because a lookup failed, produces confident nonsense. Validate the focus before use, as the function does, and prefer returning semantic order to ranking against a wrong place.
Precision loss from a global projection. Reprojecting every candidate into one global metric projection is faster than building a local one, and it reintroduces the distortion the local projection avoids. If throughput demands it, at least choose the projection from the focus’s region rather than using one for the whole world.
Ties broken by insertion order. When two candidates fuse to the same score, the sort is stable and the winner is whichever arrived first — a detail that makes results depend on the upstream query plan. Add a deterministic tiebreaker, such as the chunk identifier, so the same inputs always produce the same order.
Frequently Asked Questions
Should distance be measured to a point or to the query's full geometry?
To the full geometry when the question is about an area — a parcel, a route, a catchment — because distance from a polygon's edge is the quantity that matches how people think about "near this site". Reducing the focus to its centroid first is a simplification that costs little for compact shapes and a great deal for elongated ones such as a river reach or a road corridor.
Is exponential decay the right shape, or would a step function do?
A step function — full score inside a radius, zero outside — is defensible and easier to explain, and it fails badly at the boundary, where a candidate one metre outside loses everything. Exponential decay avoids the cliff while still expressing that the first kilometre matters most. Keep the hard radius as an eligibility filter and use the decay for ordering within it, which is what the implementation above does.
How should the weight change for a regional question?
Toward the semantic side, and possibly to the point where proximity only breaks ties. For a question about policy that applies across a district, the distance from a focus point to a document about that district is close to meaningless, and a strong proximity weight will rank the nearest irrelevant site report above the policy itself. Vary the weight with the question class rather than trying to find one value that serves both.
Does reranking need the original vector scores, or just the ranks?
The scores, if you are fusing as here; the ranks, if you are using reciprocal rank fusion instead. The distinction matters because a store that returns only ranks forces the second design. Both work; what does not work is treating a rank as if it were a score, which compresses the differences between the top few candidates and exaggerates those further down.
Related
- Up to the parent topic: Spatial Context Retrieval and Reranking
- Filtering Retrieval by Bounding Box Before Vector Search
- Related topic: Hybrid Spatial and Keyword Retrieval
- Concept: Spatial Embedding Models