Lesson 7 — Geographic search

Lesson 7 — Geographic search #

From schema to data to queries: filter documents by where they are — a coordinate, a rectangle, an arbitrary polygon, or a full geometric shape.

Pizza has two geographic field types, and picking the right one is the whole design step:

  • geo_point — a single coordinate. Answers “where is this document?” Distance, box and polygon filters run against it.
  • geo_shape — a full geometry: point, line, polygon, multi-variant, envelope or geometry collection. Answers “what area does this document cover?” and supports the relation vocabulary (intersects, within, contains, disjoint).

A store is a geo_point; the district it serves is a geo_shape.

The use case #

Location-aware features — the store finder, the delivery-zone picker, the geofence. None of these are text questions: the filter is geometry. The questions this lesson answers:

  • “Stores within 5 km of me” — geo_point + distance filter.
  • “Everything inside this map rectangle / this city boundary” — box and polygon filters.
  • “Which delivery zones cover this address?” — geo_shape + relation filters (intersects, within, …).
  • “Does this service area overlap that one?” — the shape-relation vocabulary on full geometries.

Step 1 — declare the schema #

Create a collection whose schema declares both types up front ( create a collection):

curl -X PUT -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "schema": {
      "properties": {
        "name":     { "type": "keyword" },
        "city":     { "type": "keyword" },
        "location": { "type": "geo_point" },
        "region":   { "type": "geo_shape" }
      }
    }
  }' \
  http://localhost:28000/geo-lessons

geo_point and geo_shape fields always get a column store — exact geometry is verified from it at query time, never guessed from an index approximation. Later schema changes stay additive ( update schema).

Step 2 — index documents #

A point is {lat, lon} (or a [lat, lon] array); a shape is GeoJSON — and here is the classic trap: GeoJSON coordinates are [lon, lat], point objects are {lat, lon}. The wire order comes from the GeoJSON standard; the object keys read naturally. Watch it on every shape you write:

curl -X POST -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "beijing store",
    "city": "beijing",
    "location": { "lat": 39.9042, "lon": 116.4074 },
    "region": {
      "type": "polygon",
      "coordinates": [[
        [116.4074, 39.9542], [116.4574, 39.9042],
        [116.4074, 39.8542], [116.3574, 39.9042],
        [116.4074, 39.9542]
      ]]
    }
  }' \
  http://localhost:28000/geo-lessons/_doc

If JSON objects are not your thing, geo_shape also accepts WKT strings — the same diamond as "POLYGON ((116.4074 39.9542, 116.4574 39.9042, 116.4074 39.8542, 116.3574 39.9042, 116.4074 39.9542))".

The rest of this lesson runs against the teaching dataset’s places collection — 15 Chinese cities, each with a location point and a small diamond region — so every query below is live and has a known answer you can count by hand.

Step 3 — filter points #

geo_distance: everything within 200 km of central Beijing — 2 hits, both Beijing rows:

geo_bounding_box: a rectangle by its corners — the same two Beijing rows:

geo_polygon: any closed polygon, points as [lat, lon] pairs — a quad around Beijing, again 2:

Over HTTP the nested form ({"geo_polygon": {"location": {"points": …}}}) is accepted too — the gateway normalizes it. Polygons may be concave; they must not self-intersect.

Step 4 — query shapes #

geo_shape compares the indexed geometry against a query geometry — any GeoJSON type, or WKT. The relation defaults to intersects; the envelope below clips both Beijing diamonds, so 2:

within keeps documents whose shape lies entirely inside the query — grow the envelope to swallow the whole diamond and the same 2 remain, everything else drops:

disjoint inverts intersects — 13 of 15 cities do not touch the Beijing envelope:

A WKT string works as the query shape as well: "shape": "BBOX (116.1, 40.3, 116.7, 39.6)" — the same envelope in WKT’s minLon, maxLat, maxLon, minLat order, same answers.

Under the hood #

Every geo query runs in two phases: a coarse pass over bounding boxes in the BKD index (points are 2-dim trees; shapes index their bounding box as a 4-dim point), then exact geometry verification against the column store. The first phase only nominates candidates — the second decides — so results are exact, with no false negatives. The tradeoffs to know about: coordinates are treated as planar WGS-84 degrees (shapes wrapping the antimeridian are not supported yet), and geo_shape filtering has no dedicated scoring — combine with a match query in a bool when you need ranking.

What you learned #

  • geo_point for where a document is; geo_shape for what it covers.
  • GeoJSON is [lon, lat]; point objects and geo_polygon points are {lat, lon} / [lat, lon]. Mixing them up is the one bug everyone writes once.
  • Points filter by distance, box, polygon; shapes filter by relation — intersects, within, contains, disjoint.
  • Geo filters stay DSL-side — they are demand-gated outside the SQL M1 subset; SQL reaches geo data only through its ordinary columns (a keyword city, a name, a key).

Next: Lesson 8 — joins — one query across two collections, against a live node. Every parameter of the queries in this lesson lives in the geo reference.

Calendar October 6, 2026
Edit Edit this page