Skip to main content
/ 6 min read

Location-Aware Search with Elasticsearch Geo Queries

Combining full-text relevance with geo_distance, bounding box filtering, and spatial decay scoring

Featured image for Location-Aware Search with Elasticsearch Geo Queries - Combining full-text relevance with geo_distance, bounding box filtering, and spatial decay scoring

"Elasticsearch's geospatial capabilities let you ask questions that pure text search cannot answer: not just 'find documents about coffee' but 'find the most relevant coffee shop within 500 metres of where I am standing, weighted by recency and rating'. This post walks through how to build that kind of query from first principles."

Search and geography have an obvious relationship: most interesting searches are implicitly spatial. “Restaurants near me”, “available properties in this area”, “incidents reported this week” all combine information retrieval with location filtering. Elasticsearch handles this elegantly through its geo_point and geo_shape field types, combined with a set of spatial query clauses that integrate naturally with its text relevance model.

This post builds up a complete location-aware search system, from index mapping through to advanced relevance scoring with spatial decay.

Index Mapping: Declaring Geo Fields

The first step is telling Elasticsearch that a field contains geographic coordinates. The geo_point type stores a latitude/longitude pair and enables spatial queries and aggregations:

PUT /places
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "english"
      },
      "description": {
        "type": "text",
        "analyzer": "english"
      },
      "category": {
        "type": "keyword"
      },
      "location": {
        "type": "geo_point"
      },
      "rating": {
        "type": "float"
      },
      "created_at": {
        "type": "date"
      }
    }
  }
}

geo_point accepts several input formats. The most readable is an object with explicit lat and lon fields:

POST /places/_doc
{
  "name": "Nottingham Castle",
  "description": "Historic castle and museum on a sandstone rock overlooking the city",
  "category": "attraction",
  "rating": 4.3,
  "location": {
    "lat": 52.9530,
    "lon": -1.1491
  },
  "created_at": "2026-01-15"
}

Elasticsearch also accepts "lat,lon" strings and [lon, lat] arrays (note: GeoJSON convention reverses lat/lon order).

Basic Geo Distance Query

The geo_distance query filters documents within a given radius of a point. This is the bread-and-butter of location-aware search:

GET /places/_search
{
  "query": {
    "bool": {
      "must": {
        "match": {
          "description": "castle museum"
        }
      },
      "filter": {
        "geo_distance": {
          "distance": "2km",
          "location": {
            "lat": 52.9540,
            "lon": -1.1580
          }
        }
      }
    }
  }
}

Using filter rather than must for the geo clause is important: filter clauses do not contribute to relevance scoring, but they are cached by Elasticsearch and execute efficiently. The must clause handles text relevance; the filter clause handles “only within 2km” — a clean separation.

The distance field accepts common unit suffixes: km, mi, m, ft, and even nmi for nautical miles.

Bounding Box Queries

For map-viewport queries — “fetch all points visible in the current map view” — geo_bounding_box is more efficient than geo_distance because it works entirely with simple rectangle tests:

GET /places/_search
{
  "query": {
    "bool": {
      "filter": {
        "geo_bounding_box": {
          "location": {
            "top_left": {
              "lat": 53.02,
              "lon": -1.25
            },
            "bottom_right": {
              "lat": 52.88,
              "lon": -1.07
            }
          }
        }
      }
    }
  },
  "size": 100
}

This is the query pattern to use when a user pans a map and you need to refresh the visible markers. Pair it with sort on a combination of distance and score for a smooth experience.

Advanced: Geo Distance Decay Scoring

The most powerful capability is combining text relevance with spatial decay: the closer a result is to the query location, the higher its relevance score. Elasticsearch’s function_score query handles this through decay functions.

GET /places/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": {
            "multi_match": {
              "query": "coffee cafe",
              "fields": ["name^2", "description"]
            }
          },
          "filter": {
            "geo_distance": {
              "distance": "5km",
              "location": { "lat": 52.9540, "lon": -1.1580 }
            }
          }
        }
      },
      "functions": [
        {
          "gauss": {
            "location": {
              "origin": { "lat": 52.9540, "lon": -1.1580 },
              "scale": "500m",
              "offset": "100m",
              "decay": 0.5
            }
          },
          "weight": 3
        },
        {
          "field_value_factor": {
            "field": "rating",
            "factor": 1.2,
            "modifier": "sqrt",
            "missing": 3.0
          },
          "weight": 1
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

Breaking down the decay function parameters:

  • origin: the user’s location
  • scale: distance at which the score is multiplied by the decay value (here: 500m away = 50% of the spatial score)
  • offset: documents within this distance get the full spatial score (no decay within 100m)
  • decay: the score multiplier at scale distance (0.5 = halved at 500m)

The gauss function produces a bell-curve decay — smooth, natural, and familiar from recommendation systems. Alternatives are linear (straight-line falloff) and exp (exponential, for very steep distance sensitivity).

The second function boosts by star rating using a square-root dampening factor — so a 5-star venue gets a boost, but the difference between 4 and 5 stars is less dramatic than between 1 and 2.

Python Client Example

Using the official elasticsearch-py client:

from elasticsearch import Elasticsearch
from datetime import datetime

es = Elasticsearch("http://localhost:9200")

def search_nearby(
    query_text: str,
    lat: float,
    lon: float,
    radius_km: float = 2.0,
    size: int = 10
) -> list[dict]:
    response = es.search(
        index="places",
        body={
            "query": {
                "function_score": {
                    "query": {
                        "bool": {
                            "must": {
                                "multi_match": {
                                    "query": query_text,
                                    "fields": ["name^2", "description"]
                                }
                            },
                            "filter": {
                                "geo_distance": {
                                    "distance": f"{radius_km}km",
                                    "location": {"lat": lat, "lon": lon}
                                }
                            }
                        }
                    },
                    "functions": [
                        {
                            "gauss": {
                                "location": {
                                    "origin": {"lat": lat, "lon": lon},
                                    "scale": "500m",
                                    "offset": "100m",
                                    "decay": 0.5
                                }
                            },
                            "weight": 3
                        }
                    ],
                    "boost_mode": "multiply"
                }
            },
            "size": size,
            "_source": ["name", "category", "rating", "location"]
        }
    )

    return [
        {
            "name": hit["_source"]["name"],
            "score": hit["_score"],
            "rating": hit["_source"].get("rating"),
            "location": hit["_source"]["location"]
        }
        for hit in response["hits"]["hits"]
    ]

results = search_nearby("coffee shop", lat=52.9540, lon=-1.1580, radius_km=1.5)
for r in results:
    print(f"{r['name']} — score: {r['score']:.2f}, rating: {r['rating']}")

Geo Aggregations: Spatial Clustering

For map applications where you want to cluster dense results rather than showing thousands of individual pins, the geotile_grid and geohash_grid aggregations are invaluable:

GET /places/_search
{
  "size": 0,
  "aggs": {
    "map_clusters": {
      "geotile_grid": {
        "field": "location",
        "precision": 8
      },
      "aggs": {
        "top_rating": {
          "max": { "field": "rating" }
        }
      }
    }
  }
}

precision maps to Web Mercator zoom levels. At precision 8 (roughly city-district scale) you get clusters that correspond naturally to tile boundaries in a Leaflet or MapLibre map — making it straightforward to drive map markers directly from aggregation buckets.

geo_shape for Polygon Queries

For more complex cases — checking whether a point falls inside an administrative boundary, or finding which census areas contain a given coordinate — geo_shape extends the capability to full geometry support:

PUT /areas/_doc/1
{
  "name": "Nottingham City Centre",
  "boundary": {
    "type": "Polygon",
    "coordinates": [[
      [-1.148, 52.945],
      [-1.138, 52.945],
      [-1.138, 52.957],
      [-1.148, 52.957],
      [-1.148, 52.945]
    ]]
  }
}

And a query to find which area a point falls within:

GET /areas/_search
{
  "query": {
    "geo_shape": {
      "boundary": {
        "shape": {
          "type": "Point",
          "coordinates": [-1.143, 52.951]
        },
        "relation": "contains"
      }
    }
  }
}

When Is Elasticsearch the Right Spatial Tool?

Elasticsearch shines when you need to combine:

  • Full-text relevance (keyword matching, semantic similarity, faceted filtering)
  • Spatial filtering (radius, bounding box, polygon containment)
  • Complex scoring (distance decay + textual relevance + numeric boosts)
  • Scale (billions of indexed documents with sub-second query times)

It is not the right tool for heavy geometry operations (union, intersection, topology queries) — PostGIS handles those better. And for pure spatial analytics without a search dimension, DuckDB or PostGIS are usually simpler. But for search interfaces with a geographic component — local search, property portals, point-of-interest discovery — Elasticsearch’s combination of capabilities is difficult to replicate with any other single tool.

Further Reading

James Williams
Dr James Williams
Research Fellow

Researching the intersection of place, maps, and technology.

More about me →

Posts on this blog are refined using AI. All ideas, research, and technical content originate with the author; AI assists with drafting and editing.