Entity resolution

Over-combined, under-combined.

Thirteen businesses shared one identity because their domains had expired. Duke's lemur centre merged with its art museum. Half the pool had no handle at all. A day spent rebuilding entity resolution around one idea — a URL tells you who owns a page, not who a company is.

Cam Fortin · October 2026

Entity resolution has exactly two ways to fail, and they pull in opposite directions.

Over-combination is merging rows that are not the same thing. It is the expensive one: two records go in, one comes out, and the addresses, phone numbers and history of the loser are gone or wrong. Under-combination is failing to merge rows that are the same thing. It is the embarrassing one: your dataset says a company appears four times, and anyone who knows the company can see it.

Every knob you can turn trades one for the other. Loosen the matcher and you over-combine; tighten it and you under-combine. The only real way out is to stop treating it as one dial — which is most of what this post is about.

Here is where OnlyData actually stood yesterday morning, on 32,900 active business rows:

52.6%
of rows had no domain at all
0
entities · no identity layer existed
10,553
URL rows, none classified
3,987
rows sharing a name with another row

No entity layer at all. A company_domains table where every row was an unlabelled string. And a UNIQUE (domain) constraint which — I will get to this — made it structurally impossible to store the only web presence half the dataset had.

The trap gallery

These are not hypotheticals. Every one is a real collision found by asking the database a single question: which URL identities are claimed by more than one business? That question returned 187 groups. Most were real duplicates. The interesting ones were not.

Over-combination The expired-domain pile-up hugedomains.com
What the matcher saw
13 businesses, one shared URL
→ one entity

Goodfellows Bar
Sweet Memories Bakery
Freak Alley Gallery
Compass Surgical Partners
Dolly's Vintage … +8
What is actually true
hugedomains.com/domain_profile.cfm?d=…

Their domain expired.
It is parked for sale.

13 businesses with
no website, not one
business with 13 names.
They share a landlord, not an owner. A domain broker is the opposite of a website, and a matcher that reads "same URL" as "same company" turns a bakery, a gallery and a surgical practice into one record. host_kind = 'parked' now; identity NULL; never evidence.
Over-combination The university that ate its own museum duke.edu
Collapse every subdomain to its apex
lemur.duke.edu  → duke.edu
nasher.duke.edu → duke.edu
gardens.duke.edu → duke.edu
library.duke.edu → duke.edu

one entity
"Duke Lemur Center"
Keep the subdomain, record the parent
lemur.duke.edu  Lemur Center
nasher.duke.edu Nasher Museum
gardens.duke.edu Duke Gardens
library.duke.edu Libraries

4 entities, parent_apex
= duke.edu → sibling edges
Duke bought one domain, so collapsing to the registrable domain merged a primate research centre with an art museum. Same bug, bigger: 47 schools on boiseschools.org became one school. The fix is that a non-functional subdomain keeps its own identity and carries its apex as a grouping key — never as a reason to merge.
Over-combination Same shape, opposite answer wixsite.com vs amazon.com
One rule for both
abetterautorepair.wixsite.com
efcncadmin.wixsite.com
→ both are Wix

aws.amazon.com
cloud.google.com
→ both are tenants
The private-suffix distinction
abetterautorepair.wixsite.com
= one auto shop
efcncadmin.wixsite.com
= one church

aws.amazon.com → amazon.com
Amazon runs that itself.
Both are "a subdomain of a platform", and the correct answer is opposite. Wix, GitHub Pages, Shopify and Square hand each customer <them>.<suffix> — the public suffix list's private section — so the subdomain is the tenant. Amazon and Google run aws. and cloud. themselves. Nothing in the URL distinguishes these. It has to be a list.
Over-combination The district CMS nc01911451.schoolwires.net
Host is the identity
West Lake Elementary
Turner Creek Elementary
Panther Creek High
Carpenter Elementary
Green Hope Elementary
West Lake Middle … +2

→ one school
Host and path
…schoolwires.net/westlakees
…schoolwires.net/turnercreekes
…schoolwires.net/panthercreekhs
…schoolwires.net/carpenteres
…schoolwires.net/greenhopees

→ 8 schools
The subdomain is the district's account and each path is a school. Eight Wake County schools shared one identity until the path was part of it.
Under-combination The half of the dataset with no handle UNIQUE (domain)
A domain belongs to one company
Joe's Diner
→ facebook.com/joesdiner
stored ✓

The barber shop
→ facebook.com/thebarber
rejected — duplicate key

17,293 rows, no handle
Unique on the URL, indexed on the identity
UNIQUE (domain, url_path)
INDEX (identity_key)

facebook.com/joesdiner
facebook.com/thebarber
two identities, one host

145 social/directory pages
now stored and matchable
This one is my favourite because it is pure schema. UNIQUE (domain) asserts that a domain belongs to exactly one company — true for domains someone bought, and false the instant two businesses each have a Facebook page. The second one could not be saved at all. With 52.6% of the pool having no domain, that constraint was silently discarding the only identifier half the dataset had.
Under-combination The employer that lived in a sentence od_profiles.headline
A claim with no row behind it
"AI and Databases at Weaviate!"
"Engineer @ NVIDIA"
"MLE @ Hugging Face"
"Founder & CEO @ Sturdy"

od_team_links rows: 0
invisible to every join
The claim becomes a row
connor-shorten → weaviate
kaiyu-xie → nvidia
githubnemo → hugging-face
steve-hazelton → sturdy
joao-gante → deepmind-google

axiom A12 enforces it
37 people asserted an employer in prose with nothing in the link table, so none of them appeared on their employer's page. The API was part of the cause: it accepted only a company slug, which nobody could be expected to know. It now takes a name or a URL and resolves it through the same classifier the entity layer uses.

The one idea that fixed most of it

Every trap above is the same mistake wearing a different hat: treating a URL as an identity when it is really a statement about ownership.

So URLs get sorted onto a ladder. Not "is this a domain" but who controls this page — which then determines how much the URL is allowed to prove.

1
owned_primary — their front door
A domain they bought and present themselves on. The strongest evidence short of a real identifier.
acme.com · mlb.com/yankees · lemur.duke.edu
2
owned_other — theirs, but not the front door
A functional subdomain. Same company as the apex, so it collapses into it.
careers.acme.com · investor.acme.com · locations.harristeeter.com
3
owned_social — a page they control
Their Facebook page, Instagram profile, Wix site. They own the content, not the domain. The path is the identity.
facebook.com/joesdiner · instagram.com/wow_idaho · joes.wixsite.com
4
listed — true, and not theirs
Real information about them that they do not control. Still a specific page, so still matchable.
yelp.com/biz/joes-diner · google.com/maps/…
8
platform_root — identifies the platform
A bare platform host. It names the operator and nobody else. Ranked below a Yelp page, because the Yelp page is at least about them.
facebook.com · yelp.com · walmart.com · business.site
9
parked — the domain is for sale
Their domain lapsed. Evidence of absence, not identity.
hugedomains.com/… · afternic.com · sedo.com

And the correction that made it right

My first version of this said: only owned domains count as match evidence. Clean, defensible, and wrong in a way that would have thrown away half the dataset. The pushback was immediate and correct:

"kinda — BUT a facebook pathed url or yelp page or instagram profile etc need to be saved correctly and matchable"

The distinction I had flattened is the whole thing. It is never "is this a social URL". It is what exactly is shared:

Two rows share…Evidence?Why
yelp.comNoneYelp lists everyone. Matching on it merges a whole city.
yelp.com/biz/joes-dinerStrongThe same page. Two rows pointing at one listing are one business.
facebook.comNone34 businesses in the pool are "on Facebook".
facebook.com/joesdinerStrongOne page, one owner.
walmart.com (by Walmart)StrongThey are the operator. 29 Walmart store rows needed this.
walmart.com (by anyone else)NoneA seller on a marketplace is not the marketplace.

That last pair is the one a URL cannot settle on its own — the same string is a front door or a dead end depending on whose row it is. The company's name settles it, deterministically, with no model call: if the domain's label is the company's name, they are the operator.

Then the rules ran out

With the ladder in place, the clear cases were clear. 140 identities were shared by more than one business, and after the classifier fixes most were genuine duplicates. What was left was a pile of cases where no rule could be right — and this was the second correction I needed:

"we SHOULD have an 'inference' layer to settle unclear merges and look at all evidence to decide if its a merge/hierarchy/keep separate situation and store that decision etc — not all merges will be clear enough for simple rules"

Correct, and I had been avoiding it. Look at what was actually in the leftover pile:

SharedThe rowsWhat it is
cityofboise.orgTwo parks, a senior centre, a fire stationFour real places on their city's website
twilio.comTwilio, SendGrid, Twilio SegmentA company and two acquisitions
thrivepetcare.comThree differently-named vet clinicsA rollup. The parent is not a row we hold
wyndhamhotels.comA Days Inn and a La QuintaTwo brands, one parent, two states
harristeeter.comTwo supermarkets, different addressesTwo stores. Not a duplicate
make.comMake, IntegromatA rename. One entity across time

No threshold separates those. "Different addresses" splits the supermarkets but also splits the two parks, which should be split — and merges nothing useful about SendGrid. They need what a human would do: read all the evidence at once and decide.

So there is now an adjudication layer. The important part is not the inference — it is that the verdict is stored.

od_resolution_decisions

One standing verdict per candidate, with the evidence it was made from

Four verdicts: merge · hierarchy (with the relationship kind and direction) · separate · insufficient. Precedence is explicit and one-directional: human > model > rule. A human verdict is never touched by a later pass.

Without the ledger, every run re-decides from scratch, a human correction is silently overwritten by the next pass, and nothing can explain why two rows are merged. The evidence bundle is frozen at decision time on purpose — the source rows change, and a verdict you cannot audit against what was actually seen is a verdict you cannot trust.

It decides; it does not act. Applying is a separate, reversible step: hierarchy becomes a typed edge, separate becomes an explicit not_related, merge becomes dated entity membership. No business row is edited or deleted, ever.

And it is allowed to say it does not know. insufficient is a first-class verdict, because a model forced to choose will choose, and a forced merge is the expensive kind of wrong. 20 of 141 verdicts came back insufficient — including twelve that named a real employer the pool simply does not hold yet, which is now a to-create list rather than a silent miss.

What it actually caught

The headline cases are the best illustration, because my regex got four of them confidently wrong first and the reasoning is checkable:

HeadlineRegex saidAdjudicated
@pytorch @facebookemployed at PyTorchemployed at Facebook — PyTorch is the project
ex @datadog @elastic @arduinocurrent staff at Datadogseparate — "ex" is the whole meaning
Maintainer @vllm-projectemployed at vLLMseparate — maintainership, not employment
AI-driven developer @ Tokyoemployed at Tokyoseparate — that @ is a location
Getting good one line at a timeemployed at "a time"separate — names no organisation
Core maintainer of PyO3 and member of @pydanticemployed at Pydanticseparate — and he probably is; the sentence doesn't say so

That last row is the one I want to defend, because it is the one that looks like a miss. David Hewitt does work at Pydantic. The headline does not say that, and a pipeline that reads "member of" as "employee of" will be wrong about the open-source contributors it meets far more often than it is right. The honest answer is to not assert it from this evidence.

A merge is not the only answer

The deeper fix is that "are these the same?" was the wrong question to only have one answer to. Three things were being forced through one decision:

Thirty-six Starbucks in Charlotte made this concrete. They are 36 businesses with 36 addresses. "Starbucks in Charlotte" is a genuinely useful thing to look at and is not a thing that exists — it has no address, no phone, and its revenue is not any member's revenue. Collapsing the rows to get the view would delete 35 real locations; refusing the view to protect the rows makes the dataset harder to use than it needs to be. As a projection it costs nothing: every-location shows 36, brands-as-one shows 1, and neither had to destroy the other's answer.

The thing that will bite you

An aggregate is not always valid under a grouping

If A, B and C each do $1M in revenue, the group does not do $1M — and it does not necessarily do $3M either, because the rows may overlap or double-count. Count and sum survive grouping. revenue, employee_estimate and every modelled score do not: they were computed for a row, and under a different grouping they have to be computed again or withheld.

So each attribute declares how it behaves — sum · max · min · primary · distinct · remodel · never. A projection that cannot honestly aggregate something shows nothing rather than a confident wrong number.

Where the techniques actually are

Entity resolution is a 60-year-old field and it is worth saying plainly which parts of the literature we are and are not using.

What we adopted

What we deliberately have not done

The honest grade

Asked for a real one, so: this is where OnlyData's entity pipelines stand as of this evening, after a day of work on them. The "before" column is yesterday morning.

D → B
URL handling
Was: 10,553 unlabelled strings and a constraint that made half the dataset's only identifier unstorable. Now: every row carries a tier, an apex, a path and a match key; 145 social and directory pages are stored and matchable; parked and platform-root hosts are named and refused. Upgraded from the first draft of this post: the host lists are no longer purely hand-maintained — the real public suffix list is fetched, committed and diffed against them, so "a new website builder appeared and every one of its customers looks like a company" is now a reported diff rather than a silent rot. Still not an A: the 20 local additions the PSL does not cover remain mine to remember.
F → B−
Identity layer
Was: it did not exist. Zero entities, zero projections — "the row" was the only concept. Now: 70 entities with dated, reversible membership; every change logged to an event table; a protected set that automated grouping will not touch. Not better than B− because 141 live memberships against 32,900 rows means the layer is real but barely deployed.
C → A−
Relationship graph
Was: 35 edges, all hand-curated, no derivation. Now: 414 edges across 11 kinds, including 190 explicit negatives, and the vocabulary distinguishes ownership from public-sector control from sibling-with-unknown-parent. The near-A is because the direction convention is encoded in the kind name rather than the schema, and I inverted 50 edges before catching it.
— → B
Adjudication of hard cases
Was: nothing. Hard cases were left as silent non-decisions, so every pass reconsidered them from scratch. Now: 157 stored verdicts across three triggers, each with its evidence frozen, explicit precedence, a real abstain option, and a review surface at /admin/resolution so a human can actually outrank the machine. Still B, for an honest reason: no human has used that surface yet, so the precedence is proven in mechanism and untested in practice. Worth noting what the third trigger found on its own — the facebook company slug in our own data is held by a Vietnamese restaurant, which is why the adjudicator refused to treat it as Facebook.
D+
Coverage of the long tail
Unchanged, and it is the biggest remaining hole. 17,293 rows have no domain, and 11,682 are unkeyable — no usable URL, no city/state. The name-and-address path handles the chains; the single-location businesses with a Facebook page and nothing else are still mostly unreachable. This is where the next real work is.
C
Enforcement
Three axioms now, all runnable. A13 (every URL row classified) is at 0. A12 (an employer in a headline needs a link row) went 37 → 12, and the 12 are a named to-create list rather than noise. A7 (at most one primary employer per person) went 41 → 0 — seven of those were duplicate company profiles (moonshot/moonshot-ai, thinking-machines/thinkingmachines, builder-io/builderio), and the remaining 15 turned out to be real joint appointments — "MIT" and "MIT / Databricks" is one person with two genuine primary affiliations, so the axiom was wrong to call them violations and its autofix would have silently demoted half of each. Two axioms now read the decision ledger, which is what stops a permanently-red check nobody can fix.

Still a C, for one reason: A11 sits at 7,932 violations with a working autofix nobody has pulled the trigger on. Enforcement you decline to run is documentation.

What I would do next, in order

Cam read the above and said do all of it, so three of these shipped the same evening. Marked accordingly, because a plan that quietly stays a plan is the thing worth noticing.

  1. Label pairs from the ledger and fit the weights. Attempted — and it produced a better answer than the one I wanted. The ledger is a deliberately biased sample: a candidate only reaches adjudication when the rules could not settle it, so the observed merge rate is P(merge | the rules found this hard), not P(same entity | shared evidence). Installing it as a weight would make every clear case look like a coin flip. calibrate-match-weights.mjs now reports the rates with their denominators and says what each can support, and it changes no constant — a calibration you cannot justify is worse than an admitted guess, because it looks like evidence. The honest next step is labelling ~60 random pairs rather than only the hard ones, or expectation-maximisation over agreement patterns, which needs no labels at all.

    It did surface one thing worth the trip: among ambiguous shared-domain candidates, only 11% were merges and 49% were hierarchies. A shared domain is far more often a relationship signal than an identity one, which is the whole bet this rebuild made.
  2. Build the review surface. Shipped. /admin/resolution, lowest-confidence-first so the queue prioritises itself. An override stores a new row and marks the old superseded, so how a candidate was judged over time survives; a rationale is required, because an unexplained human override is worse than a model's — the model at least recorded its reasoning. "Human > model > rule" is now something a person can actually do rather than a comment in a migration.
  3. Attack the long tail with embeddings behind the ledger. Still the biggest hole, still open. 17,293 domainless rows need cheap recall. Candidate generation is the safe place to be loose, as long as every candidate lands in front of a decision rather than in a merge.
  4. Replace the hand lists with a fetched public suffix list. Shipped. The PSL already encodes the exact distinction the classifier needed — its private section is a maintained list of companies handing subdomains to customers, which is the wixsite.com-versus-aws.amazon.com problem solved by someone else. 3,152 entries against my 34. The fetched list is committed, so a network failure can never silently shrink it, and the refresh diffs against the old hand lists rather than replacing them: 20 were real tenant platforms the PSL does not carry (business.site, substack.com, and the school CMS hosts that caused the eight-schools collision), so they stay as explicit local additions. One entry — sch.uk — turned out not to be a public suffix at all; I had invented it. Thirty more live rows reclassified immediately.
  5. Correlation clustering before groups get big. Open. Today a negative edge inside a candidate group is handled by holding the group back. That stops working the moment a group is large and mostly right.

Three questions I am still deciding

With what I would pick, and why — the recommendations are not neutral.

Question 1

Should a chain brand be an entity, or only a projection?

Recommendation: only a projection, which is what shipped. "Starbucks in Charlotte" has no address and no revenue of its own, and the moment it becomes an entity something will aggregate to it and be wrong. The cost is that genuine brand-level facts — a franchisor relationship, a national contract — have nowhere natural to live. I would rather add Starbucks the corporation as a real entity with franchisor_of edges than make a per-city brand object.

Question 2

Should a model verdict apply automatically, or wait for review?

Recommendation: auto-apply, with a reversibility bar. It is how it shipped and I think it is right, but only because of what "apply" means here: hierarchy and separate are additive edges, and merge is dated membership that un-ends. Nothing destroys a row. If applying a merge ever meant rewriting od_businesses, I would hold every one for review — the asymmetry is entirely about whether the mistake is cheap to undo, not about how much I trust the verdict.

Question 3

Should a directory page (Yelp, Google Places) carry the same match weight as a social page?

Recommendation: no — but I now know I cannot justify a number, and that is the answer. A Facebook page is maintained by the business; a Yelp listing is maintained by Yelp and routinely survives a business closing, changing hands, or being merged into a successor. Two rows on one Yelp listing is weaker evidence than two rows on one Instagram profile, and today both score 0.8.

I went to settle it from the ledger and found there is nothing to settle it with: zero of the 141 adjudicated candidates were shared social or directory pages, because the pool currently holds only 145 such URLs in total and almost none of them collide. So splitting the constant would replace one guess with two. It stays at 0.8, the reasoning is written down, and the measurement is queued behind ingesting more social URLs — which, given 52.6% of the pool has no domain, is work worth doing for its own sake.

The two bugs worth more than the features

Both were silent, both corrupted results while reporting success, and both are the kind that make you distrust everything you measured before you found them.

Bug 1

A paginated read with no ORDER BY returns a different subset every time

The helper that read 37,000 rows used .range(from, from+999) with no sort. PostgREST has no stable order without one, so successive windows overlap and omit rows — each pass read a different subset of the table and reported nothing wrong. It surfaced as two rows flapping between owned_primary and platform_root on alternating runs, because the business-name lookup was built from a page set that sometimes contained Walmart's row and sometimes did not.

Same family as the 1,000-row cap: the read succeeds, returns fewer rows than exist, and tells you nothing. It was in three scripts, including the two that had already built the entity layer — which is why it got rebuilt.

Bug 2

An edge that reads as a sentence can assert the opposite of the truth

company_relationships stores (parent_id, child_id, kind), and the direction lives in the kind name, not the columns. "X division_of Y" means X is a unit of Y. "X jurisdictional_parent_of Y" means X has authority over Y. Opposite directions, same two columns.

I wrote 50 edges the wrong way round and the output looked perfect — "Papa Murphy's division_of Papa Murphy's Overland" is a grammatical, confident, backwards claim. No constraint can catch that; only reading it as English can. The convention is now documented at the top of the module with the live rows as worked examples.

What the day actually produced

0
unclassified URL rows
(was 10,553)
414
graph edges
(was 35)
190
explicit negatives
(was 6)
141
stored verdicts
(was no ledger)

And four projections over the same untouched rows: every-location, institutions-as-one (66 groups), brands-as-one (221 groups, 1,127 rows, nothing merged) and shared-domain-families (80 groups — the districts, the universities, the cities).

The thing I would tell anyone starting this: the schema constraint was the bug. Not the matcher, not the thresholds. UNIQUE (domain) was a perfectly reasonable sentence — a domain belongs to one company — that stopped being true the moment the dataset included businesses whose entire web presence was a Facebook page. It had been silently discarding the only identifier half the rows had, and no amount of tuning the matcher would ever have found it, because the data never arrived.

The data is public.

Entities, relationships, and the projections over them.

Browse the datasets How the axioms work