Press Release

How to Match Millions of Hotels Without a Whole Engineering Department and a Labeling Team

By Pavel Zavialov, a Golang engineer with 10+ years of experience

Karta is a travel-tech product that compares hotel booking options from different suppliers and uses that data to create a single offer for each search, rather than displaying a list of options. 

I’m responsible for the backend and the data platform. The heart of this system is matching: mapping millions of hotel records from different suppliers to each other and building a single catalog out of them. Until the system knows that records from suppliers A and B describe the same hotel, it can’t compare their prices, gather all offers into one card, or pick the best one. A catalog without matching is a warehouse of unconnected hotels, where the same hotel sits as many times as there are suppliers selling it.

Systems like this used to be built by entire departments of engineers, labelers, and data validators. Today the picture is different. Algorithms and ML do most of the work, while an LLM handles the hard pairs. This significantly cuts business costs. This article is about exactly that.

The problem setup

We have dozens of suppliers, each with 300 thousand to 4 million listings, tens of millions of records in total. Imports arrive daily and weekly.

There is no manual review: no labelers, no validators.

Unresolved cases are acceptable. The system itself resolves more than 90% of the pairs that reach the decision stage. This is not the share of matched hotels: many listings simply have no counterpart at other suppliers and never reach comparison. The rest is deferred: it’s better not to merge than to merge incorrectly.

Pipeline

The work goes from cheap to expensive: indexes, then algorithms and ML, and only the uncertain pairs reach the LLM. Verified LLM decisions go into training, so over time fewer and fewer pairs reach it.

What we consider one property

Imagine two listings with almost identical coordinates and the address Riverside 18:

Field Supplier A Supplier B
Name Riverside Residence Riverside Residence Apartment 18
Type Hotel Apartment
Check-in 24-hour front desk Keys from the owner, the hotel front desk does not handle check-in

By name and location, this is almost certainly a match. In reality, the second record is a private apartment in the same building with independent management. Matching would merge them into one property (we call such properties composite) and would count the apartment’s price among the offers for the hotel. The customer would get the wrong service and check-in procedure—a bad user experience.

There are two kinds of errors. A missed match leaves duplicates: several entries for the same hotel in search results. A false match combines one property’s price with another’s photos and description. That’s why a high share of matched records says nothing about quality on its own.

Hence the first question to answer before choosing a model: what counts as one property. In short, our rule is: records describe the same property if they are in the same place (close coordinates), have similar names, the same contacts, similar amenities, and the details in their descriptions don’t contradict each other. Matching coordinates and names isn’t enough: Riverside Residence and apartment 18 share an address, but the apartment’s description says it is managed separately and that check-in isn’t handled by the hotel front desk, so they are different properties.

The opposite also happens. After a rebranding, the same hotel is updated at different suppliers at different times, and we see completely different names for one real property.

As usual, the devil is in the details. Those details are exactly what we hold on to when we examine candidates closely.

Listing profile

Most of the work happens during import, before any comparison. Suppliers send updates, and on load each listing is turned into a profile: normalized name, brand, membership in a complex, address components, property type, apartment number or separate entrance, normalized phone numbers and website, and an embedding of the name and address.

Structured fields are normalized in code: phone numbers are converted to international format, countries and cities are checked against a geography reference, brands against a list of hotel chains.

It’s important not to lose distinguishing details: apartment numbers, brand affiliation, property style, and so on.

Descriptions and RAG

The name often doesn’t contain what matters. “Riverside Residence” and “Riverside Residence Apartment 18” barely differ by name, and the fact that it’s a separate apartment with its own owner is stated only in the description.

That’s why descriptions also take part in candidate search. Each description is split into chunks of one or two sentences, along sentence boundaries. Marketing phrases like “cozy rooms” and “convenient location” appear in thousands of hotels and only get in the way, so chunks without specifics — without names, numbers, landmarks — are discarded. The rest are tagged by topic: location and landmarks, building and complex, check-in, name history. Each chunk gets its own embedding and goes into the vector index.

During candidate search, the chunks of a new listing look for similar chunks of other listings nearby, and the listings found are added to the candidates. This way a hotel whose description mentions the same complex or a former name gets into the set, even if its name is completely different.

Candidate search

Comparing every record with every other — tens of millions by tens of millions — is expensive, slow, and unnecessary: a hotel in London can’t match a hotel in Lisbon. So first, candidates are selected for each record in three independent ways:

  • geo search — properties within a radius of the coordinates; helps even if the property has been renamed;
  • text search by name, address, and unit numbers — finds the record if the supplier has wrong coordinates;
  • vector search by name embeddings and description chunks — catches different languages, phrasings, and connections visible only from the description.

All indexes live in PostgreSQL: PostGIS, pg_trgm, and pgvector with HNSW.

The results are combined, not intersected, and each candidate selection method has its own quota — about ten records each, up to 30 candidates in total. After removing duplicates, no more than twenty remain, usually fewer. The quota is needed so that one method doesn’t fill up the whole set: in a dense city center, vector search could easily take all the slots, and a record with wrong coordinates that only text search would find would get lost.

The quality of this stage is measured by recall@K: how often the correct record makes it into the set of K = 20 candidates. If it isn’t there, no model downstream will find it.

Deciding on a pair

Next, each pair goes through up to three steps, from cheap to expensive.

Algorithmically. If the coordinates (within a few dozen meters), the normalized name, and the phone number match, it’s one property, and the pair goes no further. If the coordinates match but the apartment numbers or property types differ, they are different properties. This is how most of the obvious pairs are resolved at the initial stage.

  1. We use a CatBoost classifier. It looks at pair features: the distance between the points, name similarity by several metrics, embedding similarity of names and descriptions, matching phones and websites, matching property type, contradictions in the data. And at neighborhood features: is there one candidate nearby, or five nearly identical apartments at the same address. Embeddings here are just another numeric feature: the cosine similarity of two texts.

Why CatBoost. Our features are tabular and heterogeneous — distances, flags, categories like supplier and property type. On data like this, gradient boosting works better than neural networks and runs fast on CPU. CatBoost handles categorical features and missing values out of the box, and that matters to us: “the supplier has no phone number” and “the phone numbers differ” are different situations, and the model has to tell them apart. It also has clear feature importance: you can see what the model bases its decision on.

CatBoost outputs a score from 0 to 1. The score by itself is not a probability, so the thresholds are tuned on a control set. Above the upper threshold — a match; below the lower one — different properties. Between the thresholds is the uncertainty zone, and those pairs go to the LLM. At the start, while CatBoost isn’t trained yet, all pairs not resolved by the rules go to the LLM.

LLM. It receives a listing together with all the candidates the rules and CatBoost are unsure about — usually two to five records from different suppliers — with everything we have on each: name, address, contacts, full description, amenities, check-in rules and other policies, name history. Plus our rule for what counts as one property. Sending them as a group lets the model compare similar records with each other, such as five apartments in one complex, and it’s one request instead of five.

For each candidate, the model returns one of three decisions — same property, different properties, or insufficient data — along with short quotes from the input that support it. For apartment 18, these would be “managed independently of the hotel” and “the hotel front desk does not handle check-in.” The code checks each quote against the input: if the phrase isn’t there, the model made it up, and the decision is discarded.

If there is no decision, the pair is deferred: the duplicate stays, but there’s no false merge. Deferred pairs are rechecked when the supplier updates its data or the pipeline changes, and the developer looks at where they cluster — by supplier, city, property type — and fixes the cause.

Training CatBoost without labelers

CatBoost learns from pairs for which it’s already known whether they are the same property or different ones. Such pairs used to be labeled by people. For us, they appear on their own as the pipeline runs, from two places:

  • Rules. Pairs the rules resolved unambiguously: coordinates, name, and phone match — same property; different apartment numbers or different property types at the same address — different. These are easy examples, but there are many of them.
  • LLM. Pairs resolved by the LLM. These are exactly the hard cases CatBoost is unsure about, and they teach the model the most.

Not all LLM decisions make it into training. We discard those whose quotes weren’t found in the input data, those with the answer “insufficient data,” and those that contradict hard facts: for example, the LLM said “different properties,” but the records share phone, website, and coordinates. Such contradictions aren’t silently thrown away — they show where a rule or the LLM instructions need to be refined.

Hard negative examples are especially important — similar but different properties: neighboring hotels of the same chain, different apartments in the same complex, a hotel and an apartment in its building. If you train the model only on obvious pairs like “a hotel in London and a resort in Bali,” it won’t learn to distinguish what it actually gets wrong. So after each retraining, we look for pairs the model considers similar, check them, and add the confirmed different ones to training. Sometimes such a pair turns out to be a real match — that’s useful too: a match the catalog missed.

The data is split into training and validation sets by real property: all records of one hotel go into only one part. Otherwise the model would recognize familiar hotels rather than learn to tell new ones apart.

A human is needed in one place: a control set of a few hundred pairs that the developer labels once according to written rules. Thresholds and new model versions are checked against it. It must not be labeled by the same LLM — otherwise CatBoost would learn to repeat the LLM’s mistakes, and we wouldn’t notice.

The more verified LLM decisions go into training, the better CatBoost handles things on its own and the fewer pairs reach the LLM. That’s the main win: the expensive model gradually leaves the main flow.

Imports and recalculation

Each import is compared with the previous version. New photos simply update the card, while a change in address, name, or apartment number triggers re-matching — for the record itself and its neighbors.

Embeddings and description parsing are computed only for changed records. LLM responses are cached: if a supplier sends the same text again, we don’t pay for the same analysis twice.

How we evaluate

We answer three questions:

  • Do we find the right candidate? That’s recall@K at the search stage.
  • Do we merge things we shouldn’t? That’s precision — the share of correct matches among automatic matches. For us it’s a hard constraint: a version that finds more matches but makes more mistakes doesn’t ship.
  • How much do we resolve on our own? That’s coverage and the share of deferred pairs.

A new version of the pipeline is run on a saved import without touching the production catalog and compared with the old one: which cards merged, which split, how many pairs were deferred. We separately look at cards with a suspiciously large number of listings — that’s usually a sign of a wrong merge.

In production, we look at all metrics per supplier: if one supplier’s share of deferred pairs suddenly jumps, it almost always means they changed their data format.

What we gained and what we paid

What we gained:

  • No labeling team and no support department: the system is built and maintained by one person instead of a whole department.
  • A new supplier is connected in a day: all it takes is a parser for its format and a run on the control set, not a new round of labeling.
  • Improving the model takes days, not months: new training examples appear automatically.
  • The cost of the LLM goes down over time, because fewer and fewer pairs reach it: right now it’s a few dollars per million listings.

We paid with incompleteness. A few percent of pairs remain unresolved, and some offers don’t come together into one card. This is a deliberate choice: a deferred decision can be fixed later, while a false merge spoils the results right away.

The developer’s work changes too: instead of labeling pairs and tweaking the model, it’s work with groups of cases. Find where the most deferred pairs are, understand which stage is responsible, fix it, and measure the result.

Author:

Related Articles

Back to top button