Match where it is, not how it's spelled
Here are two addresses. Tell me if they're the same place.
123 Main Street, Suite 400, Springfield IL 62704
123 Main St #400, Springfield, Illinois
Easy — yes. Now these two:
Jyllandsgade 15, 9000 Aalborg
Jyllandsgade 75, 9000 Aalborg
Also easy — no. They're 650 metres apart.
Now imagine a string-similarity matcher looking at those same four lines. The first pair, the same place, scores low: different punctuation, "Street" vs "St", a reordered unit. The second pair, different places, scores 0.96 — one character apart. The tool you'd reach for gets both backwards. This isn't a tuning problem you can threshold your way out of. It's the wrong coordinate system.
An address is a location wearing a costume
An address is not text. It is a point on the Earth that happens to be written down, and the writing is lossy, inconsistent, and adversarially formatted by a hundred different intake forms. "650 metres apart" is a fact about the world. "One character apart" is a fact about the costume.
Here is the same matching decision, scored two ways:

String-first draws its decision boundary vertically: spell it close enough and it is a match, regardless of where on Earth the two records sit. Geocode-first draws it horizontally: near is plausible, far is no, and the spelling only breaks ties between neighbours. The two traps — the same place wearing different costumes, the different places wearing nearly the same one — live in the corners where those boundaries disagree. Geocode-first gets both right. The image above is the same Fellegi-Sunter scorer with real per-field weights, run twice: once seeing only the name, once seeing distance too. Geography carries the heavier evidence by design.
So we stopped matching the costume. Mailwoman is a calibrated street-level geocoder — give it a messy line, it returns a coordinate, a resolution tier, and a calibrated uncertainty. Once you have that, entity resolution changes shape. You match on the resolved place: the coordinate, the canonical key, the admin hierarchy. The string becomes evidence, not identity.
Three things change, and they drive the rest.
Blocking becomes a partition, not a search cluster. The expensive part of matching a million records is not scoring a pair — it is finding the pairs worth scoring without comparing all 500 billion of them. The textbook answer is a search cluster: stand up Elasticsearch, index everything, query for candidates. Geocode-first replaces that infrastructure with a single pass through the data. Two records falling into the same ~5 km cell are candidates; everything else is skipped. The geocoder already did the partition. We have measured the matcher resolving half a million records in about a minute in one Node process on commodity hardware, against a baseline that requires standing up a search index — no server, no Docker, no process to keep alive. The number is what it is; the architecture is what matters.
Distance becomes a feature you can calibrate. Same building: ~30 metres. Same block: ~300 metres. The geocoder knows its own error — rooftop points are tight, interpolated points looser, centroids looser still — so distance evidence can be weighted by placement quality. A string matcher has no equivalent. It can tell you two streets are spelled similarly. It cannot tell you whether they are the same building.
Shared addresses carry different evidentiary weight by rarity. Two records at the same clinic tower agreeing on the address is weak evidence they are the same entity — fifty providers share that building. Two records agreeing on a rural address with one occupant is strong evidence. It is TF-IDF for places: weight an address agreement by how many distinct entities sit on it. On a real provider-registry deduplication, turning that on moved F1 by roughly twenty points in a single change. Most of the gain was recall — a provider's own rare address re-stitched records that name drift had split apart — rather than the precision we had expected. The data surprised us. We adjusted.
The receipts
We pointed this at real public data: the national provider registry (NPPES), the FCC's Rural Health Care funding filings, and a state nursing-facility registry. Three datasets with no shared key, each published in the fragmented shape compliance data typically arrives in.
Resolving within the provider registry, we used the registry's own entity ID as held-out ground truth. The result was an unflattering number — and then a surprise about that number, which gets its own section below. We also have detail on which levers move real errors and which backfire. Negative results are still results.
Resolving across the three datasets is where the architecture shows its purpose. With no shared ID — purely on geocoded location plus name agreement — the matcher links rural Texas hospitals across the funding program and the provider registry: Hunt Memorial Hospital District, Uvalde County Hospital Authority, Winkler County Memorial Hospital. Each is a record that says "I exist" in one dataset and a record that says "I got funding" in another, stitched because they are the same building, which is a thing you can only know if you geocode first.
Once stitched, you can ask the question the fragmented data was hiding: which entities appear in the eligibility sources but not the funding ones? That anti-join is what someone currently finds by squinting at a map. We produce it as a layer that drops onto the map directly.
A careful word on that, because it matters. We produce the reconciliation, not the conclusion. An entity in the "eligible, not enrolled" set is a candidate for review, not an allegation. A gap can mean the entity did not apply, applied under a name we did not resolve, is not actually eligible, or any number of mundane things. The tool's job is to resolve public records into an analyzable shape and surface candidates. What a correlation means is the analyst's call.
The number was lying too
To measure deduplication within the registry we needed ground truth. The registry hands you a gift: a stable, government-issued ID on every provider. Grade your clusters against it, count disagreements, done. Clean, objective, unarguable.
It is also wrong, and wrong in the direction that makes you look bad. That ID is a registration number, not an organization number — one hospital system holds a separate registration for every billing subpart. Baylor College of Medicine shows up four times at a single address. So when the matcher does the correct thing and fuses those four records into one entity, the registration-number ruler scores those pairings as over-merges. You get penalized for being right. The better the matcher, the worse the number looks.
Grade the same clusters — identical output, not a different model — against a ruler that collapses same-organization records at the same building, and the F1 climbs without touching a line of code:
We froze the hardest cases — co-located records with near-identical names that the registration ID splits apart — and adjudicated them one at a time: 120 of 120 were the same real-world organization. Zero were two unrelated outfits wrongly fused. The thing we had been logging as our biggest error class was, almost entirely, the matcher being right and the yardstick being too literal.
Believing the first number cost us work. We built corroboration features to cut that over-merge, and they moved precision not at all — because there was no over-merge to cut. We optimized against a phantom.
So the lesson: a matching F1 with no named truth-grain is unreadable. State the ruler next to the number, every single time. When the number is unflattering, check the ruler before you blame the model.
And 68.1% is still a C-grade F1. The yardstick argument contextualizes the number; it does not excuse it. The matcher still misclusters. Rural route addresses and PO boxes defeat the geocoder — a shared box at a post office reads as the same rooftop coordinate for every resident, and the distance feature becomes noise. Rooftop interpolation can place a strip-mall suite on the wrong unit. The performance table includes those rows. We are working on them.
Why pure Node matters
You could bolt a geocoder onto a record-linkage library and get some of this. What you cannot get is the integration — the same calibrated geocoder feeding the blocking, weighting the distance evidence, and running in one process with no external service.
That last part is architectural, not merely convenient. Geo-blocking is what lets candidate generation run in-process at all; a string matcher needs the search cluster precisely because it cannot partition the problem cheaply. Geocode-first removes the reason the cluster existed. The whole pipeline — parse, geocode, block, score, cluster — installs with npm install and runs as a function call. No Elasticsearch, no Docker, no server to keep alive. For a compliance team with a folder of CSVs and no infrastructure budget, that is the difference between a project and an afternoon.
That is the bet. Match the location, not the spelling, and the coordinate system does the rest.
