Skip to article

How we built our hotel and room mapping pipeline

Our mapping pipeline compares supplier records, checks room attributes and sends uncertain matches for review. The key is keeping the evidence behind each decision.

Two suppliers can describe the same room differently. They can also use almost the same name for rooms that should stay separate. A king bed and twin beds are not interchangeable just because the rest of the description matches.

Our hotel and room mapping pipeline handles these decisions in stages. It first resolves the property, then compares room attributes and keeps rate conditions separate. Normalisation, candidate selection, matching and human review each remove a different kind of uncertainty.

This note walks through the pipeline we built, including how we revisit a mapping when supplier data changes. The hotel mapping overview explains the product problem; this is the implementation.

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.

Match the property before matching its rooms

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 an illustrative example of three supplier records for one building. The names and values below are sample data:

[
  { "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.

Select likely matches before comparing records

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:

  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 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.

Accept clear matches and review uncertain ones

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.

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 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 this illustrative example from one hotel. Assume the supplier records confirm the bed configuration and view shown below:

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

With those attributes confirmed, the first two can share a room identity: 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.

An illustrative parsed record makes the split explicit. Occupancy and cancellation terms that are not stated remain unknown:

{
  "raw": "King Room With Ocean View, Breakfast Included",
  "room": {
    "beds": [{ "type": "king", "count": 1 }],
    "view": "sea",
    "occupancy": { "adults": null }
  },
  "rate_plan": {
    "board": "breakfast",
    "cancellation": null
  }
}

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.

Check mapping decisions as supplier data changes

Our mapping pipeline combines rules, models and human review. Each stage reduces uncertainty, and reviewers handle the cases the system cannot resolve confidently. As of July 2026, the recorded results were 2M+ properties resolved, around 97% automatically, and fewer than 0.1% surfacing as duplicates. Each merge retains its confidence score and decision history.

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.