Skip to article

How we solved room mapping

The same hotel arrives from every supplier with a different name, address and pin. The same room arrives with fifty. This is the pipeline that resolves both, stage by stage.

Every hotel aggregator eventually meets the same question: is this the same thing? The same property, sent by three suppliers with three names. The same room, described by two marketing teams that never spoke. Answer wrong in one direction and travellers compare a hotel against its own duplicate. Answer wrong in the other and a couple books a sea-view king and walks into a room with two singles.

We spent years on this problem, and the honest summary is that no single model solves it. What works is a pipeline: each stage removes a class of ambiguity, and a feedback loop keeps the whole thing calibrated as suppliers change underneath it. This essay walks through that pipeline as it actually runs, from raw supplier payloads to versioned room mappings.

INGEST NORMALISE BLOCK MATCH THRESHOLD raw feeds fold + parse geo + trigram score pairs three zones ACCEPT REVIEW REJECT REVIEWER DECISIONS FEED CALIBRATION
The pipeline. Reviewer decisions on the middle band feed calibration back into matching.

The same hotel, arriving five ways

Supply arrives from bedbanks, chain distribution systems and channel managers, and each 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 here is exactly equal. The names differ in casing, abbreviation, language and suffix. One address is a street, one is 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 has a country code, the other does not. Ingest does not try to be clever. It lands every record raw, stamps it with source and time, and passes it on. Judgement comes later, and it only works if the original evidence survives.

Normalisation: make records comparable first

You cannot compare what you have not normalised, so the second stage builds a parallel, comparable view of every record. The raw fields are never overwritten; normalisation is an annotation, not an edit.

  • 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: never compare everything with everything

Our property graph holds 2M+ properties. Compared pairwise, that is on the order of trillions of candidate pairs, which is not a scaling problem you optimise your way out of; it is one you must refuse to have.

Blocking is that refusal. Each normalised record is indexed two ways:

  1. 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.
  2. 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 these 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

Each candidate pair now gets scored, and the scoring is deliberately two-layered.

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 embeds normalised names and descriptions and measures similarity. This is what catches paraphrase that no string metric sees: "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, just in different ways. The feature vector from both layers feeds a small supervised model whose output is calibrated against reviewed pairs, so that 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; only a calibrated one can drive automatic decisions.

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 below the accept threshold 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.

T-REJECT T-ACCEPT AUTO-REJECT HUMAN REVIEW AUTO-ACCEPT discard candidate queue for a person merge with provenance 0 1.0 CALIBRATED CONFIDENCE BAND NARROWS REVIEW DECISIONS RECALIBRATE BOTH THRESHOLDS
Confidence bands. The middle band goes to humans; their decisions move the thresholds.

The queue is not an admission of failure; it is the training loop. Every reviewer decision becomes a labelled pair, the calibration set grows in exactly the region where the model is least sure, and periodic recalibration moves both thresholds. Over time the review band narrows because the model has learned the hard cases from the people who resolved them. Today around 97% of records resolve automatically, and across the 2M+ property graph fewer than 0.1% of listings ever surface as duplicates.

Rooms are the harder half

Property identity gives you the building. What travellers actually buy is a room under a rate plan, and room names are marketing strings, not identifiers. 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 stringBedsViewBoardIdentity
Deluxe King Sea View1 kingseanot statedRoom A
King Room With Ocean View, Breakfast Included1 kingseabreakfastRoom A, different rate plan
Twin Sea View2 twinseanot statedRoom B

The first two strings share almost no words, yet they 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 string is nearly identical to the first, and it must never merge, because bed configuration is the attribute guests feel most when it is wrong. String similarity gets both of these backwards. Attribute identity gets both right, which is the whole argument for parsing.

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 of room identity. 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

Suppliers rename rooms constantly. A revenue manager retitles "Deluxe King Sea View" to "Premium King, Ocean Panorama" on a Tuesday, rate-plan codes reshuffle at season boundaries, and properties themselves rebrand after refurbishment. A mapping that was correct last month can be quietly wrong today, which is why every mapping we write is versioned and none is permanent.

Each mapping 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. The version chain also means downstream systems can ask what a mapping looked like on the day a booking was made, which matters when a guest disputes what they 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 middle band goes to people, and whose people make the machine better. The numbers that matter are the ones travellers feel: 2M+ properties resolved, around 97% automatically, with fewer than 0.1% surfacing as duplicates, and every merge carrying a confidence score and a paper trail. That same pipeline runs as a productised service on our hotel and room mapping page, if you would rather plug into it than build it.