How we solved room mapping
String similarity gets the two cases that matter exactly backwards: it merges the rooms that must never merge and splits the ones that must. This is the pipeline we built instead, stage by stage, including the part everyone tries to automate away.
Two strings from one hotel. "Deluxe King Sea View" and "Twin Sea View" are nearly the same text and must never be merged. "Deluxe King Sea View" and "King Room With Ocean View, Breakfast Included" barely overlap as text and are the same room. Anything built on string similarity gets both of those exactly backwards, which is why room mapping is the part of hotel supply that everyone claims to have solved and very few have.
We spent years on this, and the honest summary is that no single model solves it. Not a better distance metric, not a larger embedding, not a language model with two room names pasted into a prompt. What works is a pipeline: each stage removes one class of ambiguity, and a feedback loop keeps it calibrated while suppliers change the ground underneath. This essay walks that pipeline as it runs, from raw payloads to versioned room mappings. The product argument is in the overview essay; this is the machinery.
The same building, arriving five ways
Rooms come later. The building is already a mess. Supply arrives from bedbanks, chain distribution systems and channel managers, and every pipe describes the physical world differently. A bedbank sends a name a contracting team typed a decade ago. A chain feed shouts in uppercase with an airport code appended. A channel manager forwards whatever the property manager wrote, sometimes in French, sometimes in Cyrillic, with a PO box for an address and coordinates rounded to one decimal place.
Here is the shape of the problem, compressed to three records for one building:
[
{ "source": "bedbank_a", "name": "Seaview Palace Hotel & Spa",
"address": "12 Corniche Rd, Jumeirah", "lat": 25.2048, "lng": 55.2708,
"phone": "+971 4 555 0182" },
{ "source": "chain_crs", "name": "SEAVIEW PALACE HTL AND SPA DXB",
"address": "Corniche Road 12", "lat": 25.2050, "lng": 55.2711,
"website": "seaviewpalace.example" },
{ "source": "channel_mgr", "name": "Hôtel Seaview Palace",
"address": "P.O. Box 11432, Dubai", "lat": 25.2, "lng": 55.3,
"phone": "04 555 0182" }
]
Nothing there is exactly equal. The names differ in casing, abbreviation, language and suffix. One address is a street, one is the same street reordered, one is a mailbox. Two coordinate pairs are rooftop quality; the third is a city-scale guess that lands kilometres from the building. One phone number carries a country code and the other does not.
Ingest is deliberately stupid. It lands every record raw, stamps it with source and time, and passes it on. Every clever thing downstream depends on the original evidence still being there to argue with.
Normalisation is annotation, not correction
You cannot compare what you have not normalised, so the second stage builds a parallel comparable view of every record. The rule that matters: raw fields are never overwritten. The day a match is disputed, the evidence you want is what the supplier actually sent, not what your cleaner decided it meant.
- Unicode folding. Decompose and strip diacritics so Hôtel and Hotel meet as equals, normalise widths and exotic whitespace, lowercase with locale awareness.
- Transliteration. Non-Latin scripts get a deterministic Latin rendering so a Cyrillic listing can meet its English twin in the same index.
- Abbreviation expansion. A curated dictionary turns HTL into hotel, Rd into road, Intl into international, and drops noise suffixes like city and airport codes appended for internal routing.
- Address parsing. Free-text addresses become structured components: house number, road, unit, district, postcode. "12 Corniche Rd" and "Corniche Road 12" parse to the same components in a different order, which is exactly the equality we need. Mailbox addresses parse to almost nothing, which is also useful: it tells the matcher to lean on other evidence.
- Geo snapping. Coordinates carry an uncertainty radius, not blind trust. Full-precision points keep a tight radius. Coordinates that sit on a known city centroid, or arrive with one decimal place, get a radius of kilometres. A point is only as good as its precision, and pretending otherwise is how hotels end up matched across a bay.
Phones normalise to E.164, websites reduce to a registrable domain. By the end of this stage, every record has a fair chance of meeting its siblings.
Blocking: a scaling problem you refuse to have
Our property graph holds 2,057,510 properties, counted on 10 August 2026. Compared pairwise, that is on the order of two trillion candidate pairs, which is not a scaling problem you optimise your way out of. It is one you decline to have.
Blocking is that refusal. Each normalised record is indexed two ways:
- Geohash cells. The property's coordinates map to a geohash cell sized to a few hundred metres, and lookups scan the cell plus its neighbours so cell borders cannot split a match. A wide uncertainty radius simply widens the scan.
- Name trigrams. The normalised name breaks into three-character shingles. Records sharing enough trigrams become candidates regardless of geography, which rescues the listing whose coordinates are junk.
A record only ever meets the union of those two candidate sets: a handful of nearby properties and a handful of similarly named ones. Everything else in the graph is never touched. The two channels also hedge each other. Good geo with a strange name still surfaces through the cell scan; a good name with a mailbox address still surfaces through trigrams.
Matching: deterministic evidence first, embeddings second
The common mistake is to start here, and to start with the model. Embed both names, take a cosine, ship it. The demo is beautiful and it fails on precisely the pairs that decide the product, because what settles hotel identity is not linguistic. A matching house number, an identical phone number, two rooftop pins forty metres apart: no embedding recovers those, and no semantic score should overrule them.
So scoring is two-layered, in that order.
The deterministic layer computes features a human reviewer would recognise: distance between points weighed against their uncertainty radii, overlap of parsed address tokens with house-number agreement weighted heavily and house-number conflict close to a veto, and exact hits on normalised phone or website domain, which are rare but near-decisive when they land. Hard conflict rules sit alongside: distinct unit numbers in one building, or two known properties sharing a resort complex, block a merge no matter how similar the names look.
The semantic layer earns its place on what determinism cannot see. It embeds normalised names and descriptions and catches the paraphrase no string metric reaches: "Seaview Palace Hotel & Spa" against "Palace Spa Resort, Seaview" is a weak trigram match and a strong embedding match. It is also what keeps cross-language pairs alive after transliteration has done its rough work.
Neither layer is trusted alone, because strings lie and coordinates lie, in different directions. The feature vector from both feeds a small supervised model whose output is calibrated against reviewed pairs, so a score of 0.9 means what it says: roughly nine in ten pairs at that score are genuinely the same property. Calibration is the load-bearing word. An uncalibrated score can rank pairs and nothing more; only a calibrated one may decide alone.
Thresholds: three zones and a queue
A calibrated confidence score splits the world into three zones. Above the accept threshold, the pair merges automatically and the mapping is written with full provenance: which rule, which model version, which evidence. Below the reject threshold, the candidate is discarded. Nothing in between ever merges silently. The middle band goes to a human review queue, where a reviewer sees both records side by side with the evidence that moved the score.
The queue is the least fashionable part of this system and the most valuable. The standard advice is to drive human review toward zero; the standard result is a model that never learns the cases it is worst at. We run it the other way: the queue is the training set. Every reviewer decision is a labelled pair from exactly the region where the model is least sure, which is the only region worth buying labels in. Recalibration then moves both thresholds and the band narrows, because the model learned the hard cases from the people who resolved them. As of July 2026, around 97% of records resolve automatically, and across the 2M+ property graph fewer than 0.1% of listings ever surface as duplicates. Those are our own figures for our own graph, measured on our matching, not an industry benchmark.
Rooms are the harder half
Property identity gives you the building. What a traveller actually buys is a room under a rate plan, and room names are marketing strings, not identifiers. Nobody at the property is trying to give you a stable key. They are trying to sell a room.
So the room pipeline starts by parsing every supplier room name into structured attributes: bed configuration, occupancy, view, tier, board basis, cancellation class. A dictionary and grammar handle the regular language of hotel naming; a learned tagger handles the rest; anything unresolvable stays explicitly unknown rather than guessed.
Consider three strings from two suppliers at one hotel:
| Supplier string | Beds | View | Board | Identity |
|---|---|---|---|---|
| Deluxe King Sea View | 1 king | sea | not stated | Room A |
| King Room With Ocean View, Breakfast Included | 1 king | sea | breakfast | Room A, different rate plan |
| Twin Sea View | 2 twin | sea | not stated | Room B |
The first two barely overlap as text and must merge: sea and ocean fold to one canonical view, king is king, and breakfast is not a property of the room at all. The third is a token away from the first and must never merge, because bed configuration is the attribute a guest feels most when it is wrong. That is the pair from the opening, and it is the whole argument for parsing attributes rather than comparing names.
The parsed form makes the split explicit:
{
"raw": "King Room With Ocean View, Breakfast Included",
"room": {
"beds": [{ "type": "king", "count": 1 }],
"view": "sea",
"occupancy": { "adults": 2 }
},
"rate_plan": {
"board": "breakfast",
"cancellation": "flexible"
}
}
Room identity is decided on room attributes only, and unknowns are handled asymmetrically: a missing view can merge with a stated view when everything else agrees, but a conflicting view is a veto. Rate-plan identity then sits on top. Board basis, cancellation class, payment timing and inclusions distinguish rate plans within a room, never rooms from each other. That layering is what makes comparison honest: two suppliers selling the same king room with different boards appear as two rates under one room, and the traveller compares prices for the same physical promise.
Drift: a mapping is a version, not a fact
Most mapping projects treat a mapping as a fact established once at import. It is a claim with an expiry date, and nobody sends you the expiry date. A revenue manager retitles "Deluxe King Sea View" to "Premium King, Ocean Panorama" on a Tuesday. Rate-plan codes reshuffle at season boundaries. Properties rebrand after refurbishment. A mapping that was correct last month is quietly wrong today, and quietly is the problem.
So every mapping we write is versioned and none is permanent. Each one records a fingerprint of the exact source strings and attributes it was derived from. When an incoming feed no longer matches that fingerprint, the mapping does not silently persist; it drops into a re-verification state and re-enters the pipeline. Most renamed rooms re-parse to the same attributes and re-confirm automatically. The rest land in the same review queue as new supply, with their history attached, because knowing what a room used to be called is strong evidence about what it is now. The version chain also lets downstream systems ask what a mapping looked like on the day a booking was made, which is how you settle a dispute about what a guest actually bought.
What solved actually means
We did not solve mapping with a model. We solved it with a pipeline whose every stage narrows ambiguity, whose uncertain middle goes to people, and whose people make the machine better. The numbers that matter are the ones travellers feel, and as of July 2026 they are these: 2M+ properties resolved, around 97% automatically, fewer than 0.1% surfacing as duplicates, and every merge carrying a confidence score and a paper trail.
That is also the question to put to anyone selling you mapping. A single accuracy figure with no layer, no calibration set and no unmerge path is a number, not an answer. Our pipeline runs as a productised service on the hotel and room mapping page, if you would rather plug into it than build it.