entity_resolution_pairs and an analyst either confirms, rejects, or marks them as unsure through the adjudication API before any downstream consumer treats them as the same entity.
This is the foundation for investigation case-file consolidation, sanctions screening across naming variants, and UBO chain stitching where a single owner appears under spelling, jurisdiction, or registration-number differences in upstream feeds.
When to use it
Reach for entity resolution when you need to answer “is this the same company / owner / party as that one?” across heterogeneous records — not “does this string match that string.” Typical use cases:- Collapsing duplicate companies across LEI, OpenSanctions, Equasis, and registry feeds before an investigation case file is built
- Stitching ownership chains where one party appears under multiple names, transliterations, or registration numbers
- De-duplicating permit applicants before sanctions or UBO screening
- Producing an adjudication worklist for analyst review when automated matching is not safe to act on alone
record_id — use the standard Codex join keys instead. Probabilistic resolution is for the cases where deterministic keys are missing, inconsistent, or contested.
How resolution works
For each input table, Splink streams every candidate pair through a settings-driven scorer: blocking rules narrow the candidate space, comparison vectors score the surviving pairs, and an unsupervised expectation-maximisation training step learns the per-field match weights without labelled examples.1
Snapshot input
The linker reads from a Parquet snapshot of the source table, never from live Supabase. This keeps training reproducible, lets a model version be re-trained against a fixed input, and prevents long-running linkage jobs from contending with operational reads.
2
Block candidate pairs
Blocking rules narrow the
O(n²) pair space to a tractable candidate set. The companies linker blocks on a 4-character name prefix, jurisdiction plus first letter of the name, and shared non-null LEI code.3
Score with comparison vectors
Each surviving pair is scored across the comparison vector — name (Jaro–Winkler at 0.9 and 0.7), jurisdiction (exact match with term-frequency adjustment), registration number (exact match), and legal-form code (Levenshtein at distance 1 and 3 for companies).
4
Estimate parameters
Unsupervised EM training estimates
m and u probabilities per comparison level from the data itself. The two-random-records baseline is anchored on deterministic rules (LEI match, registration-number match, jurisdiction-plus-uppercase-name match) at recall = 0.8, and u is estimated from one million random pair samples.5
Persist candidate pairs
Predictions with
match_probability >= 0.95 are upserted to entity_resolution_pairs in batches of 500, keyed on (model_version, id_l, id_r). Below 0.95 is discarded — the table is a high-confidence worklist, not a comprehensive scoreboard.6
Adjudicate
Each persisted pair starts at
adjudication_status = 'pending'. An analyst confirms (match), rejects (not_match), or defers (unsure) through the adjudication API. Only pairs marked match are safe for downstream consumers — investigation case-file merge, dedup on Aleph ingest, UBO chain stitching — to treat as the same entity.The companies linker
The first linker covers thecompanies table and runs in Splink’s dedupe_only mode — same source on both sides of the pair. Address deduplication is intentionally out of scope; address normalization is handled separately by libpostal upstream.
Cross-table
link_only jobs (gleif × opensanctions × equasis) are a follow-up — the scaffolding supports them, but no production linker for those targets has shipped yet.
Configuring a run
The linker is exposed frompackages/python-services/splink and is invoked from a Python worker, not an Edge Function. Pass the path to a Parquet snapshot of the input table:
splink_<table>_v<major>.<minor> versioning convention is enforced by the upsert key (model_version, id_l, id_r) — a new version produces a fresh set of candidate pairs alongside the old ones rather than overwriting them, so analysts can compare two versions against the same input.
When to bump versions:
- Minor — thresholds, blocking rules, or comparison settings change but the entity target is the same.
- Major — the input table contract, link type, or downstream semantics change.
The entity_resolution_pairs table
Every persisted candidate pair lands in public.entity_resolution_pairs with the standard APRS envelope (record_id, schema_version, normalization_version, acl_tier, source_system, ingested_at, modified_at, chunk_id) plus resolution-specific columns:
The table enforces a unique constraint on
(model_version, id_l, id_r) so re-running the same model against the same input is idempotent — re-runs update existing rows in place rather than inserting duplicates. Indexes on adjudication_status, match_probability DESC, (source_table_l, id_l), and (source_table_r, id_r) back the common worklist and reverse-lookup queries.
Row-level security is configured for service-role writes and authenticated reads — only the Splink workers (and other service-role callers) can persist or update candidate pairs, but any authenticated analyst can read them.
Adjudication API
The adjudication API exposes two endpoints under theentity-resolution tag for analyst tooling — an inbox endpoint and a decision endpoint.
GET /pending
Returns pending pairs ordered by descending match probability, so the strongest candidates are reviewed first.
Parameters
Response
POST /decide
Records an analyst’s decision on a pair.
Body
adjudication_status, stamps adjudicated_at and modified_at to the current UTC time, and optionally records adjudicated_by. A 404 is returned when no row matches pair_id.
Gold-set evaluation
Before promoting a model version — that is, before any downstream consumer is allowed to treat itsmatch decisions as authoritative — every model goes through a 200-row gold-set evaluation:
- Sample 100 pairs from the high-confidence band (
match_probability >= 0.95), 50 from the review band (0.80 <= match_probability < 0.95), and 50 hard negatives below0.80. - Have an analyst label each pair as
match,not_match, orunsurethrough the adjudication API. - Compute precision, recall proxy, false-positive themes, and threshold sensitivity against the 200 labels.
- Record the gold-set date, model version, input snapshot URI, and metrics before enabling downstream consumers.
What the linker doesn’t do
- No live-table linkage. All inputs are read from Parquet snapshots. Training against operational Supabase tables is explicitly disallowed.
- No address deduplication. Addresses are normalized upstream by libpostal; the companies linker doesn’t include any address comparison.
- No automatic merge. Persisted pairs are candidates, not decisions. Nothing downstream treats two records as the same entity until an analyst sets
adjudication_status = 'match'. - No cross-table link jobs yet. The scaffolding supports
link_onlymode, but no production cross-table linker (gleif×opensanctions×equasis) has shipped. - No supervised training. The linker relies on unsupervised EM and deterministic anchor rules; no labelled training set is required to produce candidate pairs.
Related references
- Investigations API — case-file consolidation that consumes confirmed matches
- Sanctions API — sanctions screening across name and jurisdiction variants
- UBO API — beneficial-ownership chains that benefit from cross-source deduplication
- Cross-dataset join keys — deterministic keys to prefer over probabilistic resolution when available