Pada sistem rekomendasi dan pencarian berbasis vektor, kalkulasi similarity dilakukan jutaan kali per detik. Masalah muncul ketika kalkulasi cosine similarity menghasilkan nilai NaN (Not a Number) atau nilai di luar batas teoretis seperti 1.0000002. Dalam pipeline produksi backend berpresisi tunggal (float32), anomali ini memicu kegagalan serialisasi JSON, rusaknya urutan sorting ranking, hingga Internal Server Error (HTTP 500).

Gejala Bug pada Pipeline Rekomendasi

Bug numerik pada cosine similarity umumnya bermanifestasi dalam dua bentuk:

  • Nilai NaN: Terjadi saat salah satu vektor memiliki magnitudo nol (vektor kosong atau hasil masking padding). Operasi pembagian dot product dengan magnitudo menghasilkan 0.0 / 0.0, yang langsung dievaluasi menjadi NaN oleh spesifikasi IEEE 754.
  • Skor di Luar Domain [-1.0, 1.0]: Dua vektor identik yang diproses dengan presisi float32 dapat menghasilkan nilai dot product dibagi magnitudo sebesar 1.0000001 atau -1.0000002. Jika nilai ini diteruskan ke fungsi hilir seperti numpy.arccos untuk menghitung sudut jarak, runtime akan melempar invalid value warning dan menghasilkan NaN.

Analisis Root Cause

Formulasi standar cosine similarity didefinisikan sebagai:

similarity = (A · B) / (||A|| * ||B||)

1. Division-by-Zero Tanpa Batas Toleransi

Jika teks input kosong atau diisi karakter di luar kosakata tokenizer, model embedding dapat menghasilkan vektor nol (semua elemen bernilai 0.0). Magnitude ||A|| bernilai 0.0. Menguji ||A|| == 0.0 secara eksak dalam floating-point sering gagal mendeteksi vektor dengan magnitude sangat kecil (misalnya 1e-39 pada float32) yang mengalami underflow saat dikalikan dengan ||B|| di penyebut.

2. Akumulasi Error dan Catastrophic Cancellation

Format IEEE 754 float32 hanya menyediakan 24 bit presisi signifikan (~7 digit desimal). Saat melakukan dot product pada vektor berdimensi tinggi (misalnya 768 atau 1536 dimensi), akumulasi pembulatan (rounding error) pada penjumlahan berulang dapat membuat hasil pembagian melampaui batas batas matematis 1.0.

Solusi Rekayasa

Untuk memastikan stabilitas numerik tanpa mengorbankan performa, terapkan tiga proteksi berikut:

  1. Validasi Zero-Norm dengan Machine Epsilon: Tetapkan nilai ambang batas bawah (floor tolerance) berbasis numpy.finfo(np.float32).eps atau batas minimum operasional (misalnya 1e-12). Jika norm berada di bawah batas ini, kembalikan skor default (misalnya 0.0).
  2. Normalisasi Vektor ke Unit Vector Lebih Awal: Bagi vektor dengan magnitudonya sebelum menghitung dot product. Mengubah kalkulasi menjadi dot(normalize(A), normalize(B)) membatalkan kebutuhan perkalian norm di penyebut dan memangkas kompleksitas komputasi saat inferensi berulang.
  3. Clamping Intervensi: Gunakan fungsi pemotongan nilai ke dalam interval [-1.0, 1.0] untuk mengeliminasi deviasi bit pembulatan.

Implementasi Kode

Berikut adalah implementasi Python mandiri yang menangani kasus tepi zero-vector, magnitude sangat kecil, dan rounding drift:

import numpy as np

def safe_cosine_similarity(a: np.ndarray, b: np.ndarray, eps: float = 1e-9) -> float:
    # ponytail: validasi bentuk 1D, tingkatkan ke 2D batching jika throughput naik
    if a.shape != b.shape:
        raise ValueError(f"Dimensi tidak cocok: {a.shape} vs {b.shape}")

    norm_a = float(np.linalg.norm(a))
    norm_b = float(np.linalg.norm(b))

    # Guard: Zero-norm check
    if norm_a < eps or norm_b < eps:
        return 0.0

    # L2-normalization awal
    unit_a = a / norm_a
    unit_b = b / norm_b

    # Dot product pada unit vector
    dot_product = float(np.dot(unit_a, unit_b))

    # Machine epsilon clamping ke interval matematis [-1.0, 1.0]
    return max(-1.0, min(1.0, dot_product))


# ==========================================
# Runnable Unit Checks
# ==========================================
if __name__ == "__main__":
    # 1. Test Zero Vector (Mencegah NaN)
    v_zero = np.zeros(128, dtype=np.float32)
    v_random = np.random.randn(128).astype(np.float32)
    sim_zero = safe_cosine_similarity(v_zero, v_random)
    assert sim_zero == 0.0, f"Expected 0.0, got {sim_zero}"

    # 2. Test Identical Vector Floating Drift (Mencegah > 1.0)
    # Membuat vektor bernilai besar yang rentan akumulasi drift pada float32
    v_drift = np.full(768, 1.234567, dtype=np.float32)
    sim_drift = safe_cosine_similarity(v_drift, v_drift)
    assert -1.0 <= sim_drift <= 1.0, f"Value out of bounds: {sim_drift}"
    assert np.isclose(sim_drift, 1.0, atol=1e-6), f"Expected ~1.0, got {sim_drift}"

    # 3. Test Opposing Vector (Mencegah < -1.0)
    sim_opposing = safe_cosine_similarity(v_drift, -v_drift)
    assert -1.0 <= sim_opposing <= 1.0, f"Value out of bounds: {sim_opposing}"
    assert np.isclose(sim_opposing, -1.0, atol=1e-6), f"Expected ~-1.0, got {sim_opposing}"

    print("Semua pengujian stabilitas floating-point lolos.")

Rekomendasi Produksi: Lakukan unit normalization (L2) satu kali pada tahap ingestion data vektor ke database. Saat vektor disimpan dalam bentuk unit-length, operasi cosine similarity tereduksi secara komputasi menjadi perkalian dot product murni yang jauh lebih cepat dan minim resiko instabilitas.