Deduplicating Harvested Records by Fingerprint
This guide detects the same dataset arriving from more than one upstream catalogue and resolves the duplicates without destroying information. It belongs to Metadata Quality Assurance & Record Scoring, within the Metadata Catalog Automation & Ingestion Workflows framework.
Prerequisites
- Provenance on every record — source identifier, source-native identifier and retrieval timestamp.
- Normalised fields after the crosswalk, so comparisons are made on comparable values.
- A place to record cluster membership that is separate from the records themselves.
- A curator who can adjudicate the cases the automation declines to decide.
Duplicates are not one problem
“The same dataset twice” covers several situations with different correct answers, and a deduplicator that treats them identically will merge things that should stay separate.
The bottom two rows are why title similarity alone is an unsafe signal. “Parcels” from two districts and “Flood risk 2024” versus “Flood risk 2025” are both textually close and semantically distinct, and a deduplicator working on titles will merge them enthusiastically.
Step-by-step implementation
1. Build a fingerprint from several independent signals
# fingerprint.py — a fingerprint is deliberately coarse: it is a candidate
# generator, not a decision. Precision comes from the comparison in step 2.
import hashlib, re, unicodedata
def norm_text(s: str) -> str:
s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
s = re.sub(r"\b(the|of|for|and|dataset|data|layer)\b", " ", s.lower())
return re.sub(r"[^a-z0-9]+", " ", s).strip()
def extent_cell(extent: dict, precision: float = 0.1) -> str:
"""Snap the extent centroid to a coarse grid so near-identical areas collide."""
cx = (extent["minx"] + extent["maxx"]) / 2
cy = (extent["miny"] + extent["maxy"]) / 2
return f"{round(cx / precision)}:{round(cy / precision)}"
def fingerprint(record: dict) -> str:
parts = [
norm_text(record.get("title", ""))[:60],
extent_cell(record["extent"]),
str((record.get("temporal_extent") or {}).get("start", ""))[:4], # year only
]
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]
2. Compare candidates on evidence, not on the fingerprint
# compare.py — the fingerprint groups candidates; this decides.
def similarity(a: dict, b: dict) -> dict:
"""Return per-signal agreement so a decision can be explained, not just made."""
return {
"title": jaccard(norm_text(a["title"]).split(), norm_text(b["title"]).split()),
"extent": iou(a["extent"], b["extent"]), # intersection over union
"temporal": temporal_overlap(a, b), # 1.0 if identical periods
"distribution": 1.0 if shared_distribution_host(a, b) else 0.0,
"identifier": 1.0 if same_external_identifier(a, b) else 0.0, # DOI, etc.
}
def verdict(sig: dict) -> str:
# An authoritative shared identifier settles it outright.
if sig["identifier"] == 1.0:
return "duplicate"
# Different areas are never duplicates, whatever the title says.
if sig["extent"] < 0.6:
return "distinct"
# Different periods are editions of a series, not duplicates.
if sig["temporal"] < 0.5:
return "series"
if sig["title"] >= 0.8 and sig["extent"] >= 0.9:
return "duplicate"
return "review" # the honest answer for everything else
3. Keep every record; express the relationship separately
Merging destroys provenance and makes the next harvest ambiguous. Cluster instead: keep each source’s record intact, record which cluster it belongs to, and mark one as preferred for display.
CREATE TABLE record_cluster (
cluster_id uuid PRIMARY KEY,
preferred_id text NOT NULL REFERENCES record(identifier),
decided_by text NOT NULL, -- 'auto' or a curator's name
decided_at timestamptz NOT NULL DEFAULT now(),
signals jsonb NOT NULL -- why, so it can be revisited
);
CREATE TABLE record_cluster_member (
cluster_id uuid REFERENCES record_cluster(cluster_id),
identifier text REFERENCES record(identifier),
PRIMARY KEY (cluster_id, identifier)
);
4. Choose the preferred copy by a stated rule
The fourth criterion exists because of a specific failure: without a deterministic tie-break, two equally complete copies alternate as preferred on every harvest, and the catalogue’s public record identifier for that dataset changes daily. Downstream consumers that hold the identifier notice, and the cause is genuinely hard to find.
The review queue is the design, not an admission of failure
Every honest deduplicator produces a set of pairs it declines to decide, and the instinct to tune that set to zero is the wrong one. A queue of ambiguous pairs is a feature: it is where the automation says, correctly, that the evidence is insufficient.
Give the queue an owner and a cadence — twenty minutes a week clears a surprising number — and record every decision with its signals, so a later change to the thresholds can be checked against what humans actually concluded.
Verification
# 1. A re-harvest does not create a second record
python3 harvest.py --source county-highways && python3 harvest.py --source county-highways
psql -Atc "SELECT count(*) FROM record WHERE source_id='county-highways';"
# expect: unchanged between the two runs
# 2. Two districts' identically titled layers are NOT clustered
psql -Atc "
SELECT count(*) FROM record_cluster_member m
JOIN record r USING (identifier)
WHERE r.title = 'Parcels'
GROUP BY m.cluster_id HAVING count(*) > 1;"
# expect: no rows — the extents differ, so they are distinct
# 3. Two editions of a series are linked as a series, not merged
psql -Atc "SELECT relation FROM record_relation WHERE identifier='flood-risk-2025';"
# expect: 'series' referencing the 2024 record, and both still present
# 4. The preferred copy is stable across harvests
for i in 1 2 3; do psql -Atc "SELECT preferred_id FROM record_cluster WHERE cluster_id='$CID';"; done
# expect: the same identifier every time
# 5. Every automatic decision is explainable
psql -Atc "SELECT signals FROM record_cluster WHERE decided_by='auto' LIMIT 1;" | jq .
# expect: the per-signal values that produced the verdict
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Unrelated datasets are merged | Deciding on title similarity alone | Require extent agreement; treat differing extents as decisive |
| Yearly editions collapse into one record | No temporal comparison | Compare periods; classify as a series rather than as duplicates |
| The preferred record changes every night | No deterministic tie-break | Add a stable final criterion and re-verify across three harvests |
| Duplicates reappear after each harvest | Clustering computed but not persisted, or keyed on a volatile field | Persist membership; key on source-native identifiers |
| A curator’s decision is overwritten automatically | Automation not respecting manual verdicts | Record decided_by; never let ‘auto’ overwrite a curator |
| The review queue grows without bound | Thresholds set so most pairs are ambiguous | Widen the confident bands; a wrong-but-reviewable pair is worse than none |
| Provenance is lost after deduplication | Records merged rather than clustered | Keep every record; express the relationship in a separate table |
FAQ
Why not just merge duplicates into one record?
Because a merge destroys the ability to re-harvest either source cleanly, loses each publisher’s own wording, and makes it impossible to answer which organisation asserted what. Clustering keeps every source’s record intact and adds a relationship, which costs one join and preserves everything.
How aggressive should the automatic threshold be?
Conservative. A missed duplicate is a minor untidiness that a user can see through; a wrong merge presents one organisation’s data under another’s name and is discovered by somebody who relied on it. Set the confident band narrowly and let the review queue be larger than feels comfortable.
What identifiers can be trusted outright?
Persistent external identifiers — a DOI, or an authoritative national dataset identifier — where both records carry the same value. Those settle the question without any similarity comparison. Source-native identifiers cannot be compared across sources, because two catalogues will happily use the same local identifier for unrelated things.
Should deduplication run at ingestion or afterwards?
Afterwards, as a separate pass. At ingestion the comparison set is only the current batch, so duplicates arriving from different sources on different schedules are never compared. A nightly pass over the whole catalogue is cheap and sees everything.
How should clusters be presented to users?
As one result with the alternatives listed, rather than as several results or as one result with the others hidden. A user searching for a dataset wants one answer and occasionally wants to know that another organisation also publishes it — particularly when the other copy is the authoritative one and the preferred choice was wrong.
What happens when a source withdraws its copy?
The cluster loses a member and, if that member was preferred, the preference is recomputed by the same stated order. Record the change rather than performing it silently: a downstream consumer holding the previous preferred identifier needs to know it now resolves to a different record.
How should clusters behave when a source changes a record substantially?
Re-evaluate the cluster rather than assuming it still holds. A record whose extent or title changed materially may no longer belong with its cluster, and leaving it there presents two genuinely different datasets as one. Recompute membership whenever a record’s fingerprint changes, and send the pair back to review rather than silently splitting or keeping it.
Is there value in publishing the cluster relationships?
Yes, as links on the record: naming the other organisations that also publish this dataset is genuinely useful information for somebody deciding which copy to rely on, and it makes the catalogue’s aggregation visible rather than hidden. It also produces a useful pressure — organisations noticing that a neighbour’s copy is preferred tend to ask why, and the answer is usually a fixable gap in their own record.
Related
- Scoring Metadata Completeness in a CI Gate — the score used to choose a preferred copy.
- Implementing OAI-PMH Resumption Tokens — the idempotent upsert that prevents same-source duplicates.
- Spatial Data Versioning and Rollback Workflows — how editions of a series relate.
Up one level: Metadata Quality Assurance & Record Scoring.