The spatial index was already doing its job
Geuneul’s radius query combines location with the freshness of user reports. ST_DWithin looked like the obvious suspect, but repeated plans with the same parameters showed that the GiST paths reducing place candidates were already working.
- Radius search used a Bitmap Index Scan on
idx_places_geom_geography. - Nearest search used a
<->KNN Index Scan and visited only the top five candidates. - A selective small map window used
idx_places_geom. - For a dense, large window with
LIMIT 100, the planner intentionally chose an early-terminating sequential scan.
I did not force an index merely because a sequential scan appeared. Selectivity and the limit determine which plan actually performs less work.
The bottleneck was the time axis of expired reports
Scored searches join place_report_signals. The view aggregates only reports where expires_at > now(), but expired reports remain as history. The number of rows discarded by the filter therefore grows over time.
Because the place predicate could not be pushed through the grouped view far enough, reducing the radius to a few dozen places did not reduce report aggregation in the same way. I separated this from the service’s 150,000+ public POIs with a reproducible synthetic seed.
| Data | Rows |
|---|---|
| Places | 300,000 |
| Valid reports | 10,000 |
| Expired reports | 200,000 |
The seed used a 70% capital-area and 30% nationwide distribution. Both sides of the comparison used the same data and query parameters.
Why the fix is a full expires_at B-tree
Flyway V8 adds one index.
CREATE INDEX idx_reports_expires ON reports (expires_at);
A partial index with WHERE expires_at > now() looks smaller, but PostgreSQL requires immutable expressions in an index predicate. The current time changes, so now() cannot be used there. A full B-tree still supports the range scan that reads currently valid rows.
I also avoided forcing location and expiry into one composite index. They belong to different tables and selectivity patterns, and the place GiST path was already correct. The change addresses only the measured bottleneck.
Validate the plan and the load together
The before/after comparison in the same environment was:
| Target | Before | After | Plan change |
|---|---|---|---|
| Report-signal view build | 256 ms | 133 ms | reports Seq Scan → expiry Bitmap Index Scan |
| Scored 800m radius query | 361 ms | 172 ms | spatial GiST unchanged; report aggregation changed |
A k6 iteration then called radius, KNN, bounds, and recommendation paths in sequence. A warm 4-VU run passed 564 of 564 checks with a 0% error rate; radius-query p95 was 1.40 seconds.
That does not mean production search takes 1.40 seconds. The run used an AMD64 PostGIS image under QEMU on a 2-vCPU ARM64 Mac, so absolute latency is inflated. The useful evidence is the relative plan change and equivalent results. The k6 thresholds detect possible performance regression, while the separately captured EXPLAIN (ANALYZE, BUFFERS) plans confirm index use.
The condition for a larger redesign
I considered correlated LATERAL subqueries, a materialized view, and composite indexes. LATERAL would duplicate aggregation across three search queries, while a materialized view would reduce report freshness. One index removed the measured bottleneck without that cost.
I would revisit the design if the view again dominates on native staging hardware or real traffic demonstrates the limit of on-demand aggregation. Until then, ADR-0012, the captured plans, and Flyway V8 remain the reproducible evidence.