perf(offroute): query_radius uses k-NN <-> ordering for index-supported nearest-neighbor

The old ST_DWithin(50km) scan returned ~226k candidate points near dense areas before
sorting (~3-10s/call). Replace with PostGIS k-NN ordering: ORDER BY geom::geography <->
point LIMIT k, which the existing GiST index on (geom::geography) walks nearest-first and
stops after K rows. SELECT still computes ST_Distance AS distance_m so callers see real
meters. radius_km is kept as a Python soft cap applied after fetch (drops rows beyond it),
preserving the caller expanded-radius fallback (now effectively a no-op).

2 tests: SQL contains <-> + LIMIT and no ST_DWithin; radius_km soft cap filters beyond-cap rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-25 07:28:46 +00:00
commit c8e2568f64
2 changed files with 68 additions and 14 deletions

View file

@ -255,23 +255,31 @@ class EntryPointIndex:
limit: int = 50
) -> List[Dict]:
"""
Find entry points within radius_km of (lat, lon).
Uses PostGIS ST_DWithin with geography cast for meter-accurate distance.
Find the nearest entry points to (lat, lon), ordered by true geodesic distance.
Uses PostGIS k-NN ordering (the geography ``<->`` operator), which is
index-assisted by the GiST index on ``(geom::geography)``: it walks the index
nearest-first and stops after ``limit`` rows, instead of scanning every point
inside a radius (the old ST_DWithin approach returned ~226k candidates near
dense areas before sorting). ``radius_km`` is retained as a *soft cap* applied
in Python after the fetch rows beyond it are dropped, so callers'
expanded-radius fallback still works (though it is now effectively a no-op,
since k-NN already returns the globally nearest K regardless of radius).
"""
if not self.table_exists():
return []
conn = self._get_conn()
radius_m = radius_km * 1000
# Build query with optional highway filter
# SELECT distance_m needs the point; optional highway filter; ORDER BY <-> needs
# the point again; then LIMIT. Param order follows the placeholders top-to-bottom.
highway_filter = ""
params = [lon, lat, lon, lat, radius_m]
params = [lon, lat]
if valid_highways:
placeholders = ','.join(['%s'] * len(valid_highways))
highway_filter = f"AND highway_class IN ({placeholders})"
highway_filter = f"WHERE highway_class IN ({placeholders})"
params.extend(list(valid_highways))
params.append(limit)
params.extend([lon, lat, limit])
query = f"""
SELECT
@ -286,19 +294,18 @@ class EntryPointIndex:
ST_SetSRID(ST_Point(%s, %s), 4326)::geography
) as distance_m
FROM entry_points
WHERE ST_DWithin(
geom::geography,
ST_SetSRID(ST_Point(%s, %s), 4326)::geography,
%s
)
{highway_filter}
ORDER BY distance_m
ORDER BY geom::geography <-> ST_SetSRID(ST_Point(%s, %s), 4326)::geography
LIMIT %s
"""
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(query, params)
return [dict(row) for row in cur.fetchall()]
rows = [dict(row) for row in cur.fetchall()]
# radius_km soft cap (backward compat): drop rows beyond it.
radius_m = radius_km * 1000
return [r for r in rows if r["distance_m"] <= radius_m]
def build_index(self, osm_pbf_path: Path = None) -> Dict:
"""

View file

@ -512,3 +512,50 @@ def test_has_entry_points_empty(monkeypatch):
def test_has_entry_points_rows(monkeypatch):
idx = _bare_index(monkeypatch, table_exists=True, row=(True,))
assert idx.has_entry_points() is True
# ── EntryPointIndex.query_radius — k-NN <-> ordering + radius soft cap ─────
class _FakeCurQ:
def __init__(self, rows, capture):
self._rows, self._capture = rows, capture
def __enter__(self):
return self
def __exit__(self, *a):
return False
def execute(self, q, params=None):
self._capture["query"] = q
self._capture["params"] = params
def fetchall(self):
return self._rows
class _FakeConnQ:
def __init__(self, rows, capture):
self._rows, self._capture = rows, capture
def cursor(self, cursor_factory=None):
return _FakeCurQ(self._rows, self._capture)
def test_query_radius_uses_knn_sql(monkeypatch):
cap = {}
monkeypatch.setattr(EntryPointIndex, "table_exists", lambda self: True)
monkeypatch.setattr(EntryPointIndex, "_get_conn", lambda self: _FakeConnQ([], cap))
idx = object.__new__(EntryPointIndex)
idx.query_radius(44.1, -115.0, 50, limit=10)
assert "<->" in cap["query"]
assert "LIMIT" in cap["query"]
assert "ST_DWithin" not in cap["query"] # radius scan removed
def test_query_radius_soft_cap_filters_beyond_radius(monkeypatch):
rows = [
{"id": 1, "distance_m": 100.0},
{"id": 2, "distance_m": 50000.0},
{"id": 3, "distance_m": 200000.0}, # beyond 50km cap -> dropped
]
monkeypatch.setattr(EntryPointIndex, "table_exists", lambda self: True)
monkeypatch.setattr(EntryPointIndex, "_get_conn", lambda self: _FakeConnQ(rows, {}))
idx = object.__new__(EntryPointIndex)
out = idx.query_radius(44.1, -115.0, 50, limit=10) # 50 km = 50000 m cap
assert [r["id"] for r in out] == [1, 2]