Weeks 3 & 4 - Reworking the whole model
Honestly this was the most technically satisfying part of the project so far.
Mapping out the codebase
First thing this week, I did was go through the entire Nominatim codebase and document every place that touches class and type columns. The rule we landed on: anything touching place_classtype_* tables stays untouched for now - that’s a bigger refactor for later. Everything else that filters on class/type gets switched to query the categories column instead.
The N Tags → 1 Row rework
Last update I mentioned the round-trip problem - generating N rows then merging them back. This week I fixed it. The old model was straightforward but wasteful:
OSM Object: tourism=hotel, amenity=restaurant
-> write_place(tourism, hotel) -> INSERT #1
-> write_place(amenity, restaurant) -> INSERT #2
-> place: 2 rows
The new model does a single pass through all main tags, collects every category into an array, picks one winner for class/type (alphabetical for now), and inserts exactly one row:
OSM Object: tourism=hotel, amenity=restaurant
-> categories = [osm.amenity.restaurant, osm.tourism.hotel]
-> winner: amenity (alphabetical)
-> INSERT #1: class=amenity, type=restaurant, categories={osm.amenity.restaurant, osm.tourism.hotel}
-> place: 1 row
OLD: N tags → N rows. NEW: N tags → 1 row — all categories collected in a single Lua pass.
The ranking fix
This was the tricky one. Nominatim assigns address_rank and search_rank to every place by looking at class/type. With the old model each row had exactly one class/type so this was fine. With the new model a single row might have osm.boundary.administrative AND osm.place.city in its categories - and the ranking code didn’t know what to do with that.
Now the problem was: compute_place_rank was iterating all categories and picking the lowest address rank. osm.place.city (address_rank=16) was beating osm.boundary.administrative (address_rank=20), which was pulling rank_search down incorrectly and breaking tests.The fix ended up being simpler than I initially thought - just check categories @> 'osm.boundary.administrative' on the array directly instead of pre-scanning. I was doing a more complex pre-scan before which was… less efficient lol …
Looking back, it might sounds simple enough…but oh man, ranking.sql & placex triggers absolutely killed me
… The ranking function now had to iterate over an array of categories instead of a single class/type , pick the “best” one, handle the boundary-vs-place tiebreak, and deal with ?<@ returning an ltree value instead of a boolean (which confused me for way too long). Then there were the triggers: dozens of class/type checks that all had to be converted to ltree operators, plus the boundary-linking logic that extracts a place type from the category array and feeds it back into the ranking function…That led to multiple conversations like “wait, why is this test still failing?” moments, and at least one “I put parent_address_level in the wrong branch” incident that took forever to track down.
It took a lot of back and forth, debugging, and experimenting before we finally got this working… you should just check our crazy pr.
Switching SQL to ltree operators
All the remaining SQL filters that used to check class/type now use ltree containment operators. Before:
WHERE class = 'boundary' AND type = 'administrative'
After:
WHERE categories <@ 'osm.boundary.administrative'
And for multi-value checks like waterways, ltree alternation:
WHERE categories ~ 'osm.waterway.river|stream|canal|drain|ditch'::lquery
Much cleaner tbh.
The lazy backfill insight
This one came from sarah and saved a lot of work… My original plan was to proactively backfill every existing row during migration. She pointed out we don’t actually need to - placex_update can lazily fill the category from class/type when it encounters a row that still has categories = NULL. The only rows that need upfront backfill are places and waterways with rank_address <= 25 - those get looked up by other rows during linking and need categories to be present. That’s roughly 20% of placex.
So the final strategy:
- Lazy fill in
placex_update for ~80% of rows
- Proactive backfill only for
rank_address < 26 rows during migration