Skip to content
← All patterns

geo

Geospatial Nearest Neighbour

Which store is actually nearest?

The same 'what is near me' question as the geohash, answered by a vector index.

A vector index is a nearest-neighbour structure over any space you can put a point in, and a map is one. Project latitude and longitude onto the unit sphere and a place becomes three numbers; the straight-line gap between two of them rises and falls exactly with the distance you would walk. Same seven places as the Store Locator, same question, and the answer comes back ranked and with no cell boundary in it.

The model

Place

A store, carried twice: as a geohash for the cell query, and as a point on the unit sphere.

pk
PLACE#<placeId>

Attributes: name (S), lat (N), lon (N), geohash (S), openUntil (S), GSI1PK (S), GSI1SK (S), position (L)

Access patterns

  • SearchVectors · by-positionThe five nearest, ranked

    The five closest places to a point, in order, with the distance as the score.

  • Query · GSI1Everything in the geohash cell

    The same question answered the usual way, by reading one geohash cell. Run it after the search and compare the two lists.

Design notes

Two dimensions do not fit in one sort key. Three fit in a vector.why

The Store Locator interleaves latitude and longitude into one string because a sort key is one dimension and a position is two. A vector index has no such limit: it takes as many dimensions as you declare. So the position does not have to be flattened at all, and once it is not flattened there is no cell, no prefix, and no boundary.

Why latitude and longitude are not the vectorwhy

Two naive attempts both fail. Cosine on a raw lat/long pair measures the angle that pair makes at the origin of a degree grid, a point off the west coast of Africa that means nothing, so two places on opposite sides of the world can score as identical. Euclidean on raw lat/long is closer to sane but distorts with latitude: a degree of longitude is 111 km at the equator and 66 km in Leeds, so a circle in degree space is an ellipse on the ground, and it tears completely at the antimeridian, where 179.9 east and 179.9 west are 22 km apart on the equator and 360 units away from each other in the grid. Neither is fixable by choosing a different distance function, because the problem is the space, not the metric.

Project to the unit sphere, and Euclidean is forcedmodelling

x = cos(lat)cos(lon), y = cos(lat)sin(lon), z = sin(lat), with both angles in radians. Every place is now a point on a sphere of radius one, and the straight-line gap between two of them, which is what EUCLIDEAN reports, is 2*sin(theta/2) for a central angle theta. That climbs without ever turning back as theta goes from zero to pi, so the order a Euclidean search returns is exactly the order of true great-circle distance. Nothing here is approximated. The score converts back: kilometres = 2 * 6371 * asin(score / 2), which on this seed agrees with haversine to within half a metre.

The cell returns the further place and misses the nearer onetrade-off

Run both patterns and read the two lists side by side. The search returns Briggate at 0.45 km, The Headrow at 0.48, Kirkgate Market at 0.53, Holbeck at 0.92 and Hyde Park at 2.24, which is the true order. The cell query returns four places and Holbeck is not among them, because Holbeck sits in gcwc while the phone sits in gcwf. It is the fourth-nearest and the cell cannot see it, while Hyde Park, two and a half times further away, comes back because it happens to share a prefix. That is the geohash's known edge case, and it is not an edge: at a 23 km cell, every query near a boundary has one.

There is no radius, and there is no page twotrade-off

A search takes a TopK and nothing else. You cannot ask for everything within five kilometres, and if the five nearest branches are all in another county you will get them anyway with no hint that they are far. Filtering by score afterwards is your application's job, and it spends from a budget of at most a hundred results with no LastEvaluatedKey to continue from. The cell query has the opposite shape: it is bounded by geometry rather than by count, so a dense city centre returns everything and the moors return nothing. Neither is the general answer, which is why both are on this table.

The index stores 32-bit copies, so the floor is about a metreoperations

A unit-sphere component sits between minus one and one, and a 32-bit float resolves a value that size to about one part in seventeen million, which is roughly 40 cm on the Earth's surface. Two doorways a few centimetres apart are the same point as far as this index is concerned. That is fine for finding a branch and wrong for anything surveying-grade, and it is a property of the vector index rather than of the projection: the base table still holds whatever precision you wrote.

Nothing here is a model, and the write path is arithmeticwhy

Three trigonometric calls turn a coordinate into this vector. There is no inference, no weights to ship, no cost per write and nothing to re-embed when a library version moves. That is the difference worth noticing: a vector index is a nearest-neighbour structure over a space, and whether that space came out of a neural network or out of cos and sin is not something the index knows or cares about.

Read it against

  • Store Locator

    The same seven places and the same question, answered twice: a geohash prefix on a sort key, and a nearest-neighbour search over the projected coordinates.

Taught in the course

geospatial-nearest-neighbourDynamoDB workbench
Ready to run
Explore an access patternSelect to load & run

The five closest places to a point, in order, with the distance as the score.

Request
Execute against the local Dynoxide engine
ReturnedFiltered outChanged
PK(pk)
GSI1PK
GSI1SK
geohash
lat
lon
name
openUntil
position
PLACE#p-101
GEO#gcwfS
gcwfhct3#p-101S
gcwfhct3S
53.79756N
-1.54169N
BriggateS
18:00S
[0.5904262, -0.0158908, 0.8069352]L
PLACE#p-102
GEO#gcwfS
gcwfhcqu#p-102S
gcwfhcquS
53.7967N
-1.53963N
Kirkgate MarketS
17:30S
[0.5904389, -0.0158699, 0.8069263]L
PLACE#p-103
GEO#gcwfS
gcwfhccw#p-103S
gcwfhccwS
53.79979N
-1.54684N
The HeadrowS
20:00S
[0.5903934, -0.0159430, 0.8069581]L
PLACE#p-104
GEO#gcwfS
gcwfh5xr#p-104S
gcwfh5xrS
53.80958N
-1.5719N
Hyde ParkS
22:00S
[0.5902485, -0.0161974, 0.8070591]L
PLACE#p-105
GEO#gcwcS
gcwcuxu6#p-105S
gcwcuxu6S
53.7882N
-1.5542N
HolbeckS
19:30S
[0.5905545, -0.0160233, 0.8068387]L
PLACE#p-201
GEO#gcx4S
gcx4znwm#p-201S
gcx4znwmS
53.95755N
-1.08988N
MicklegateS
19:00S
[0.5882780, -0.0111916, 0.8085813]L
PLACE#p-202
GEO#gcx4S
gcx4zrhg#p-202S
gcx4zrhgS
53.95995N
-1.08095N
ShamblesS
17:00S
[0.5882459, -0.0110992, 0.8086059]L
Awaiting request
Your next query starts here.

Choose an access pattern above, or build your own request. See what comes back and what it costs.

Write transactions, streams, tags and TTL are among the operations this browser build leaves out. dynoxide's native build has them.