Balancing Spatial Indexing and Semantic Re-ranking for Scalable Nearby Search
16th August, 2026
Balancing Spatial Indexing and Semantic Re-ranking for Scalable Nearby Search
I faced a product brief that sounded simple: return the closest restaurants matching a user's dietary filters, refreshed hourly, and able to serve a city's midday surge. The tension was immediate — distance demands pre-computed spatial indexes for millisecond lookups, while relevance, freshness, and scraped provider data demand costly extraction, normalization, and semantic scoring. Scrapers add rate limits and selector drift. In this article I lay out a compact mental model, a concrete ingestion-to-query architecture, the parts I implemented in my Universal Scraper and ARIL work, where the design fails, and a practical checklist to keep latency low without sacrificing correctness.
I’ll walk through a mental model, a concrete architecture and data-flow pattern I use, what I’ve actually built around this idea, where it breaks, and a practical checklist you can apply.
Mental model: two orthogonal dimensions
Treat nearby-entity search as the intersection of two orthogonal problems:
- Spatial filtering (where): fast, index-friendly, typically coarse-to-fine. Techniques: geohash/H3 tiling, R-tree, or PostGIS indexes. This narrows candidates by distance quickly. - Content relevance (what): semantic or rule-based scoring over name, category, reviews, and contextual signals (user preferences, time of day). This is often heavier — embeddings, BM25, or ML models.
Query-time workflow should panic-minimize the heavy work by using precomputation and cheap heuristics. The pattern I use is: coarse spatial filter -> short-list -> semantic re-rank -> final heuristics (freshness, availability, business rules).
Architecture and data flow (example)
Below is a compact pipeline I implement when building search for scraped location data.
1. Ingestion (scraper pipeline) - Fetch pages (rate-limited, fixture-tested). Extract records in a three-stage pipeline: page -> structured fields -> normalized entity. Persist raw HTML for replay. 2. Normalization & Enrichment - Normalize addresses, geocode to lat/long, canonicalize names, detect categories. - Chunk text (reviews, descriptions), compute lightweight embeddings for semantic search. 3. Storage & Indexing - Spatial index: H3/geohash bins stored in a database column + GIST/PostGIS for accurate range filters. - Vector index: pgvector or ANN index for short-listing by semantic similarity. - Time-series metadata: last-scraped timestamp, freshness score. 4. Query time - Convert user location to tiles -> expand to neighbor tiles (coarse filter). - Short-list: union of spatial candidates + top-K semantic results from vector store. - Re-rank: combine distance decay, embedding similarity, freshness, business rules. - Cache: hot tiles cached with TTL and background refresh.
Sample component responsibilities
| Component | Responsibility | |---|---| | Scraper pipeline | Durable raw captures; three-stage extraction and fixture tests for selector stability | | Normalizer | Geocoding, category mapping, embedding generation | | Spatial index | Fast candidate selection (H3/geohash + PostGIS) | | Vector store | Semantic short-listing (pgvector/ANN) | | Ranker | Weighted scoring, freshness, and personalization | | Cache & API | Tile-level caches, rate-limits, and API surface |
What I built around this idea
I applied these ideas while building the Universal Scraper: a three-stage extraction pipeline with fixture tests and safe live runs, which I use to populate structured records and text chunks that feed embeddings and indexes [1]. Around that, my ARIL work introduced tenant-scoped knowledge-bases and pgvector retrieval for multi-tenant semantic lookup; those retrieval primitives are useful for re-ranking and contextual personalization [2].
Implemented vs gates
- Implemented: three-stage scraper pipeline, fixture tests, chunking, embedding generation, pgvector-based retrieval, basic spatial indexing strategy in dev, short-listing and re-ranking logic. (Evidence: Universal Scraper and ARIL monorepo [1][2].) - Still gated / operational considerations: large-scale Postgres production migrations (real production Postgres migrations are an environment gate), scraping is subject to robots, provider rate limits and blocking, and selector drift requires periodic maintenance. Live sending or external pushes (e.g., message sends through scraped flows) are review-only and not executed [1].
Where this breaks (four+ failure modes and mitigations)
1) Provider blocking / rate limits - Symptom: scrapers get throttled or blocked; ingestion slows. - Mitigation: respect robots and rate limits, implement exponential backoff, rotate sources, keep raw captures and replay, implement canary scraping and fixture tests to detect blocking quickly.
2) Selector drift / extraction regressions - Symptom: extraction yields broken fields or missing coordinates. - Mitigation: fixture tests for pages, raw-HTML retention for replay, alert on schema-change rates, human-in-the-loop review for high-value selectors.
3) Hotspots and uneven query load - Symptom: certain tiles (downtown) become traffic hotspots; cache misses cause latency spikes. - Mitigation: tile-based caching with adaptive TTLs, pre-compute hot tiles during known peaks, backpressure API, and circuit-breakers.
4) Incorrect coordinates / geocoding errors - Symptom: business appears far away despite correct address. - Mitigation: multi-source geocoding, fuzzy address normalization, confidence scores, and fallbacks to textual distance heuristics.
5) Semantic drift in embeddings - Symptom: embedding models anchor to stale language (menu terms, new categories). - Mitigation: scheduled re-embedding, A/B evaluation of embedding models, and serving-weighted freshness.
A practical checklist (5–8 checks)
- Verify coordinate normalization: lat/long exist and fall within expected bounding boxes. - Choose hybrid index: implement coarse tile + PostGIS or R-tree for accurate distance ordering. - Limit short-list size: ensure short-listing returns < 500 candidates before re-rank to bound latency. - Cache hot tiles with TTL and background refresh; monitor cache hit-rate. - Maintain raw HTML and fixture tests to detect selector drift quickly. - Implement rate-limit aware scrapers with exponential backoff and replay capability. - Add freshness metadata and include it in the ranker; surface stale flags in UI. - Run synthetic queries emulating peak-hour hotspots to verify SLOs.
Conclusion
Nearby-entity search scales by separating cheap spatial narrowing from heavier semantic relevance, precomputing as much as possible, and protecting the pipeline with caching and robust scraping hygiene. The trade-offs are operational: the scraper/indexing layers need constant care (fixtures, replay capture, rate-limit handling) while the query path must be engineered for predictable short-lists and low tail latency.
What I shipped in code is the extraction and retrieval foundation (Universal Scraper and tenant-scoped retrieval primitives); what remains is the operational gating for continuous large-scale scraping and production migrations described above [1][2]. If you want, I can sketch a runnable reference architecture (Docker-compose + small Postgres/pgvector + H3) you can deploy locally to experiment.
References
[1] Universal Scraper and portfolio code: https://github.com/deepanshuvermaa/my-portfolio
[2] ARIL local monorepo (tenant-scoped KBs, embeddings, pgvector retrieval): https://github.com/deepanshuvermaa/my-portfolio
[3] Model Context Protocol (inspires structured context handling at query time): https://modelcontextprotocol.io/introduction
[4] Example research on scalable retrieval and evaluation patterns: https://arxiv.org/abs/2005.11401
[5] Work on retrieval and grounding evaluation: https://arxiv.org/abs/2307.03172