Skip to content

Content Hash sebagai Identitas Observasi dalam Pipeline Ingest

Adityo Guni Waluyo

Observasi yang sama tercatat dua kali hanya karena nama ditulis berbeda. Content hash dari tuple kanonik menutup celah duplikasi itu.

Ringkasan

Pipeline sempat ngitung dobel gara-gara nama PT ditulis beda titik sama spasi padahal perusahaannya sama. Makanya dibikin normalize_company buat beresin nama plus content_hash_for yang nge-hash tuple kanonik pakai SHA-256 biar identitasnya konsisten. Terus di database dikunci pakai UniqueConstraint dan ON CONFLICT biar duplikat nggak lolos lagi.

Dua baris untuk perusahaan yang sama

Saat memuat data uji ke database DemandScope, satu nama perusahaan muncul dua kali dalam bentuk berbeda: "PT. Contoh Sentosa" dan "PT Contoh Sentosa". Keduanya merujuk entitas yang sama, tetapi sistem mencatatnya sebagai dua fakta terpisah. Titik rawannya jelas: pipeline percaya bahwa teks yang sama pasti ditulis dengan cara yang sama.

Dugaan awal saya, deduplikasi bisa mengandalkan kolom referensi mentah dari pengirim (source_ref). Asumsi itu runtuh ketika sumber yang sama mengirim ulang observasi dengan variasi penulisan minor atau urutan berbeda. Referensi mentah hanya membawa identitas penunjuk, bukan identitas semantik dari fakta itu sendiri. Baris duplikat lolos tanpa peringatan, dan angka analitik di atasnya ikut rusak.

Identitas kanonik dibangun dari nilai

Perbaikannya ada di dua fungsi baru pada app/domain/signals.py. Pertama, normalize_company: nama diubah ke huruf besar, karakter non-alfanumerik diganti spasi, prefiks bentuk hukum (PT, CV, PKPK) dibuang berulang kali sampai habis, lalu spasi ganda diringkas. Nama asli tetap disimpan di kolom terpisah company_name_raw supaya tidak ada informasi yang hilang.

Normalisasi yang dipakai sengaja dibuat memaafkan. Prefiks bentuk hukum dibuang secara berulang, jadi kasus bersarang seperti "PT. CV Contoh Sentosa" tetap menghasilkan kunci yang sama dengan "CV Contoh Sentosa". Pemisah non-alfanumerik diperlakukan sebagai spasi, sehingga tanda titik, strip, atau koma tidak lagi punya kekuatan membedakan. Yang dipertahankan justru pembeda yang bermakna: tanggal kejadian masuk ke dalam tuple, karena putusan pada tanggal berbeda adalah dua fakta yang memang berbeda.

Kedua, content_hash_for merakit tuple kanonik lalu meng-hash-nya. Python menyediakan antarmuka standar untuk ini: modul hashlib dengan algoritma SHA-256 [5]. Hasilnya string heksadesimal 64 karakter yang menjadi identitas observasi:

canonical = "|".join([
    source_id,
    external_ref.strip(),
    company_name_normalized.strip(),
    signal_type,
    event_date.isoformat() if event_date else "",
])
content_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()

Dua observasi yang bernilai sama akan menghasilkan hash yang sama, apa pun urutan kedatangannya. Sebaliknya, perubahan kecil pada nilai yang bermakna (perusahaan berbeda, tanggal kejadian berbeda) otomatis menghasilkan identitas berbeda. Kolom details bertipe JSONB menyimpan sisanya secara verbatim, tipe sinyal dikunci lewat SignalType StrEnum, dan tabel ingest_runs mencatat setiap kali pipeline berjalan: berapa halaman dibaca, berapa item dilihat, berapa yang benar-benar baru.

Perubahan sekecil apa pun di rantai ini terlihat langsung lewat pengujian. Enam belas tes di commit ini mengunci perilaku normalisasi (prefiks berulang, tanda baca, spasi), kestabilan hash untuk input yang sama, dan constraint di level model. Tanpa itu, penggantian satu karakter di fungsi normalisasi cukup untuk melipatgandakan baris secara diam-diam, dan kebocoran seperti itu baru ketahuan saat laporan bulanan sudah salah.

Ditegakkan di database, bukan di niat baik

Identitas kanonik hanya berguna jika dilanggar itu mustahil. Migrasi 0002_signals_ingest_runs.py memasang UniqueConstraint("source_id", "content_hash") di tabel signals. Di PostgreSQL, constraint UNIQUE gabungan menjamin kombinasi nilainya unik di seluruh tabel, sementara masing-masing kolom boleh berulang; constraint tersebut otomatis membuat indeks B-tree unik [1].

Lapisan tulisnya memakai INSERT dengan klausa ON CONFLICT: jalur resmi untuk memberi aksi alternatif alih-alih membiarkan error pelanggaran keunikan terlempar [2]. Konflik berarti observasi sudah pernah ada; baris lama diperbarui sehingga waktu terakhir dilihat maju, atau dibiarkan tanpa perubahan. Klausul RETURNING kemudian melaporkan baris yang benar-benar dimasukkan atau diperbarui. Dari sinilah status created, updated, dan unchanged lahir secara jujur, bukan ditebak di sisi pengirim.

Bukan pengganti entity resolution

Ada batas yang perlu ditegaskan. Yang diselesaikan content hash adalah deduplikasi deterministik terhadap sumber yang memberi referensi stabil per fakta. Soal menyatukan entitas yang sama dari banyak sumber berbeda, OpenSanctions menjelaskan bahwa itu pekerjaan lain: profil dari ratusan sumber dilebur ke satu ID kanonik yang stabil [3], dengan ambigu pencocokan yang sangat rendah, entitas dengan nama dan kewarganegaraan sama tetap dipisah [4].

Keputusan yang saya ambil dari commit ini: jangan menggeser tanggung jawab. Hash menutup masalah duplikasi di dalam satu sumber; pencocokan lintas sumber tetap jadi topik terpisah yang butuh blocking, scoring, dan tinjauan manusia. Menyamakan keduanya justru cara termudah menghasilkan data yang kelihatan bersih padahal salah gabung.

Sumber

  1. PostgreSQL Documentation: Constraints
  2. PostgreSQL Documentation: INSERT
  3. OpenSanctions: Identifiers and deduplication
  4. OpenSanctions: How we deduplicate companies and people across data sources
  5. Python docs: hashlib

Artikel terkait