Visualizzare gli embeddings
31 Ottobre 2025

Quando lavoriamo con sistemi RAG, gli embeddings rimangono spesso concetti astratti: vettori a 768 dimensioni che rappresentano il significato semantico dei testi. Ma cosa succederebbe se potessimo ''vedere'' questi vettori? In questo articolo esploreremo come visualizzare gli embeddings del nostro mini RAG farmaceutico esaminato nel precedente articolo, proiettandoli su un piano bidimensionale per comprendere visivamente come il sistema identifica i documenti più rilevanti.

Il Problema della Dimensionalità

Gli embeddings generati dal modello Gemini text-embedding-004 hanno 768 dimensioni. Questo significa che ogni documento viene rappresentato come un punto in uno spazio a 768 dimensioni - impossibile da visualizzare direttamente per noi umani che percepiamo al massimo tre dimensioni spaziali. Per "vedere" questi vettori, dobbiamo proiettarli su un piano bidimensionale, mantenendo però le relazioni di similarità tra i punti. È come guardare l'ombra di un oggetto tridimensionale: perdiamo informazioni, ma possiamo comunque capire molto sulla sua struttura.

Le Tecniche di Riduzione Dimensionale

Esistono diverse tecniche matematiche per proiettare vettori ad alta dimensionalità su uno spazio più piccolo. Le più comuni sono:

PCA (Principal Component Analysis)

La PCA è una tecnica lineare che trova le direzioni di massima varianza nei dati. Immaginate di avere una nuvola di punti in 3D: la PCA trova il piano che meglio "cattura" la forma di quella nuvola. È veloce, deterministica (produce sempre lo stesso risultato) e mantiene bene le distanze globali tra i punti.

t-SNE (t-Distributed Stochastic Neighbor Embedding)

t-SNE è più sofisticata: si concentra nel preservare le relazioni locali tra punti vicini, creando visualizzazioni dove i cluster sono ben separati. È perfetta per dataset grandi, ma può essere lenta e produce risultati diversi ad ogni esecuzione.

UMAP (Uniform Manifold Approximation and Projection)

UMAP è un compromesso moderno tra PCA e t-SNE: preserva sia le strutture locali che quelle globali, è più veloce di t-SNE e più stabile nei risultati.

Per il nostro mini RAG, useremo PCA perché:

  • Abbiamo pochi punti (4 documenti + 1 query)
  • Vogliamo un risultato deterministico e riproducibile
  • La velocità di calcolo è importante per un'integrazione pratica
  • Le distanze relative sono più importanti dei cluster visivi

Implementazione della Visualizzazione

Estendiamo la nostra classe EmbeddingManager con un nuovo metodo che genera una visualizzazione interattiva:


<?php
/**
 * Visualizza gli embeddings in 2D usando PCA
 * 
 * @param string $queryText Testo della query da visualizzare
 * @param string $outputFile Path del file HTML di output
 * @return array Dati della proiezione
 */
public function visualizeEmbeddings($queryText, $outputFile = 'visualization.html') {
    if (!isset($this->basePath)) {
        throw new Exception("Nessun repository caricato");
    }
    
    // Raccoglie tutti gli embeddings
    $embeddings = [];
    $labels = [];
    
    // Aggiungi gli embeddings dei documenti
    $embFiles = glob($this->embPath . '/*.emb');
    foreach ($embFiles as $embFile) {
        $embContent = file_get_contents($embFile);
        if ($embContent === false) continue;
        
        $docEmbedding = json_decode($embContent, true);
        if ($docEmbedding === null) continue;
        
        $baseName = basename($embFile, '.emb');
        $embeddings[] = $docEmbedding;
        $labels[] = $baseName;
    }
    
    // Aggiungi l'embedding della query
    $queryEmbedding = $this->calculateEmbedding($queryText);
    $embeddings[] = $queryEmbedding;
    $labels[] = 'QUERY';
    
    // Applica PCA per ridurre a 2D
    $projected = $this->pcaProjection($embeddings, 2);
    
    // Genera la visualizzazione HTML
    $this->generateVisualizationHTML($projected, $labels, $queryText, $outputFile);
    
    return [
        'points' => $projected,
        'labels' => $labels
    ];
}

/**
 * Riduzione dimensionale con PCA
 * 
 * @param array $data Matrice dei dati (righe = campioni, colonne = features)
 * @param int $dimensions Numero di dimensioni target
 * @return array Dati proiettati
 */
private function pcaProjection($data, $dimensions = 2) {
    $n = count($data);
    $m = count($data[0]);
    
    // 1. Centra i dati (sottrai la media)
    $means = array_fill(0, $m, 0);
    for ($i = 0; $i < $n; $i++) {
        for ($j = 0; $j < $m; $j++) {
            $means[$j] += $data[$i][$j];
        }
    }
    for ($j = 0; $j < $m; $j++) {
        $means[$j] /= $n;
    }
    
    $centered = [];
    for ($i = 0; $i < $n; $i++) {
        $centered[$i] = [];
        for ($j = 0; $j < $m; $j++) {
            $centered[$i][$j] = $data[$i][$j] - $means[$j];
        }
    }
    
    // 2. Calcola la matrice di covarianza (approssimazione)
    // Per semplicità, usiamo SVD troncato tramite power iteration
    $components = $this->powerIterationPCA($centered, $dimensions);
    
    // 3. Proietta i dati sulle componenti principali
    $projected = [];
    for ($i = 0; $i < $n; $i++) {
        $projected[$i] = [];
        for ($k = 0; $k < $dimensions; $k++) {
            $sum = 0;
            for ($j = 0; $j < $m; $j++) {
                $sum += $centered[$i][$j] * $components[$k][$j];
            }
            $projected[$i][$k] = $sum;
        }
    }
    
    return $projected;
}

/**
 * PCA semplificato usando power iteration
 * 
 * @param array $data Dati centrati
 * @param int $numComponents Numero di componenti principali
 * @return array Componenti principali
 */
private function powerIterationPCA($data, $numComponents) {
    $n = count($data);
    $m = count($data[0]);
    
    $components = [];
    
    for ($comp = 0; $comp < $numComponents; $comp++) {
        // Inizializza vettore casuale
        $vector = [];
        for ($j = 0; $j < $m; $j++) {
            $vector[$j] = (mt_rand() / mt_getrandmax()) * 2 - 1;
        }
        
        // Power iteration
        for ($iter = 0; $iter < 100; $iter++) {
            // Moltiplica per la matrice di covarianza
            $newVector = array_fill(0, $m, 0);
            
            for ($i = 0; $i < $n; $i++) {
                $dot = 0;
                for ($j = 0; $j < $m; $j++) {
                    $dot += $data[$i][$j] * $vector[$j];
                }
                for ($j = 0; $j < $m; $j++) {
                    $newVector[$j] += $data[$i][$j] * $dot;
                }
            }
            
            // Normalizza
            $norm = 0;
            for ($j = 0; $j < $m; $j++) {
                $norm += $newVector[$j] * $newVector[$j];
            }
            $norm = sqrt($norm);
            
            for ($j = 0; $j < $m; $j++) {
                $newVector[$j] /= $norm;
            }
            
            $vector = $newVector;
        }
        
        $components[] = $vector;
        
        // Deflazione: rimuovi questa componente dai dati
        for ($i = 0; $i < $n; $i++) {
            $dot = 0;
            for ($j = 0; $j < $m; $j++) {
                $dot += $data[$i][$j] * $vector[$j];
            }
            for ($j = 0; $j < $m; $j++) {
                $data[$i][$j] -= $dot * $vector[$j];
            }
        }
    }
    
    return $components;
}

/**
 * Genera file HTML con visualizzazione interattiva
 */
private function generateVisualizationHTML($points, $labels, $queryText, $outputFile) {
    $pointsJson = json_encode($points);
    $labelsJson = json_encode($labels);
    $queryTextJson = json_encode($queryText);
    
    $html = <<<HTML
<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Visualizzazione Embeddings - Mini RAG</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
            background: #f5f5f5;
        }
        .container {
            background: white;
            border-radius: 12px;
            padding: 30px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        h1 {
            color: #2c3e50;
            margin-bottom: 10px;
        }
        .subtitle {
            color: #7f8c8d;
            margin-bottom: 20px;
        }
        .query-box {
            background: #e8f4f8;
            border-left: 4px solid #3498db;
            padding: 15px;
            margin: 20px 0;
            border-radius: 4px;
        }
        .query-label {
            font-weight: bold;
            color: #2980b9;
            margin-bottom: 5px;
        }
        canvas {
            margin: 20px 0;
        }
        .legend {
            display: flex;
            flex-wrap: wrap;
            gap: 20px;
            margin-top: 20px;
            padding: 15px;
            background: #f8f9fa;
            border-radius: 8px;
        }
        .legend-item {
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .legend-color {
            width: 20px;
            height: 20px;
            border-radius: 50%;
            border: 2px solid white;
            box-shadow: 0 2px 4px rgba(0,0,0,0.2);
        }
        .legend-color.star {
            clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
            border-radius: 0;
        }
        .info-box {
            background: #fff3cd;
            border-left: 4px solid #ffc107;
            padding: 15px;
            margin-top: 20px;
            border-radius: 4px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Visualizzazione Embeddings 2D</h1>
        <p class="subtitle">Proiezione PCA da 768 dimensioni a 2 dimensioni</p>
        
        <div class="query-box">
            <div class="query-label">Query analizzata:</div>
            <div id="queryText"></div>
        </div>
        
        <canvas id="embeddingChart"></canvas>
        
        <div class="legend" id="legendContainer"></div>
        
        <div class="info-box">
            <strong>Come leggere il grafico:</strong><br>
            • Ogni punto rappresenta un documento o la query nel loro "spazio semantico"<br>
            • La distanza tra i punti indica la similarità semantica<br>
            • La query (stella rossa) appare vicina al documento più rilevante<br>
            • La proiezione PCA preserva le relazioni di distanza dell'embedding a 768D
        </div>
    </div>
    
    <script>
        const points = $pointsJson;
        const labels = $labelsJson;
        const queryText = $queryTextJson;
        
        document.getElementById('queryText').textContent = queryText;
        
        // Funzione per estrarre il tipo di documento dal nome del file
        function extractDocType(label) {
            if (label === 'QUERY') return 'QUERY';
            
            // Rimuovi l'ID univoco e l'estensione
            // Formato: "67272af30d8c46.123456789_1234_antistaminici.txt"
            const withoutExt = label.replace('.txt', '');
            const parts = withoutExt.split('_');
            
            // L'ultimo elemento dovrebbe essere il tipo di documento
            const docType = parts[parts.length - 1].toLowerCase();
            
            console.log('Label:', label, '-> DocType:', docType); // Debug
            
            return docType;
        }
        
        // Colori per i documenti
        const colors = {
            'antibiotici': '#e74c3c',
            'antidolorifici': '#3498db',
            'antistaminici': '#2ecc71',
            'antinfiammatori': '#f39c12',
            'QUERY': '#c0392b'
        };
        
        // Prepara i dataset
        const datasets = labels.map((label, idx) => {
            const isQuery = label === 'QUERY';
            const docType = extractDocType(label);
            const color = colors[docType] || '#95a5a6';
            
            console.log('Processing:', label, 'isQuery:', isQuery, 'docType:', docType, 'color:', color); // Debug
            
            return {
                label: isQuery ? '⭐ Query' : docType.charAt(0).toUpperCase() + docType.slice(1),
                data: [{
                    x: points[idx][0],
                    y: points[idx][1]
                }],
                backgroundColor: color,
                borderColor: isQuery ? '#8b0000' : 'white',
                borderWidth: isQuery ? 3 : 2,
                pointRadius: isQuery ? 15 : 10,
                pointHoverRadius: isQuery ? 18 : 13,
                pointStyle: isQuery ? 'star' : 'circle'
            };
        });
        
        console.log('Final datasets:', datasets); // Debug
        
        // Crea il grafico
        const ctx = document.getElementById('embeddingChart').getContext('2d');
        new Chart(ctx, {
            type: 'scatter',
            data: { datasets },
            options: {
                responsive: true,
                maintainAspectRatio: true,
                aspectRatio: 1.5,
                plugins: {
                    legend: {
                        display: false
                    },
                    tooltip: {
                        callbacks: {
                            label: function(context) {
                                return context.dataset.label + 
                                       ' (' + context.parsed.x.toFixed(3) + 
                                       ', ' + context.parsed.y.toFixed(3) + ')';
                            }
                        }
                    }
                },
                scales: {
                    x: {
                        title: {
                            display: true,
                            text: 'Componente Principale 1',
                            font: { size: 14, weight: 'bold' }
                        },
                        grid: {
                            color: 'rgba(0, 0, 0, 0.05)'
                        }
                    },
                    y: {
                        title: {
                            display: true,
                            text: 'Componente Principale 2',
                            font: { size: 14, weight: 'bold' }
                        },
                        grid: {
                            color: 'rgba(0, 0, 0, 0.05)'
                        }
                    }
                }
            }
        });
        
        // Genera la legenda
        const legendContainer = document.getElementById('legendContainer');
        datasets.forEach(dataset => {
            const item = document.createElement('div');
            item.className = 'legend-item';
            
            const colorBox = document.createElement('div');
            colorBox.className = 'legend-color' + (dataset.label.includes('Query') ? ' star' : '');
            colorBox.style.backgroundColor = dataset.backgroundColor;
            if (dataset.label.includes('Query')) {
                colorBox.style.border = '2px solid #8b0000';
            }
            
            const label = document.createElement('span');
            label.textContent = dataset.label;
            
            item.appendChild(colorBox);
            item.appendChild(label);
            legendContainer.appendChild(item);
        });
    </script>
</body>
</html>
HTML;
    
    file_put_contents($outputFile, $html);
}

Esempio di utilizzo

Aggiorniamo il nostro file di test per includere la visualizzazione:


<?php
require_once 'EmbeddingManager.php';

// Inizializza
$manager = new EmbeddingManager('***la_tua_api_key***');

// Carica il repository esistente
$manager->loadRepository('/home/emanuele/Clienti/EDwareLab/chatbot/embedding/repository2');

// Query dell'utente
$query = 'Ho gli occhi rossi e tanto prurito. In più starnutisco almeno 30 volte al giorno. Cosa posso prendere?';

// Genera la visualizzazione
$result = $manager->visualizeEmbeddings($query, 'embedding_visualization.html');

echo "Visualizzazione creata! Apri embedding_visualization.html nel browser.\n";

// Esegui anche la ricerca per confronto
$searchResults = $manager->search($query);
echo "\nDocumento più rilevante: " . $searchResults[0]['file'] . "\n";
echo "Similarità: " . $searchResults[0]['percentage'] . "%\n";
?>

Cosa Ci Mostra la Visualizzazione

Quando apriamo il file HTML generato, vediamo un grafico scatter interattivo dove:
- I quattro documenti (antibiotici, antidolorifici, antistaminici, antinfiammatori) appaiono come punti colorati distribuiti nel piano
- La query appare come una stella rossa
- La distanza visiva tra la query e i documenti riflette la loro similarità semantica originale nello spazio a 768 dimensioni
Nel nostro esempio farmaceutico, la stella rossa (query sui sintomi allergici) apparirà chiaramente vicina al punto verde degli antistaminici, confermando visivamente ciò che il sistema ha calcolato matematicamente.

Limitazioni e Considerazioni

È importante comprendere che questa proiezione 2D è una semplificazione. La PCA preserva circa il 20-40% della varianza originale (dipende dai dati). Questo significa che:
- Due punti vicini in 2D sono probabilmente vicini anche in 768D
- Due punti lontani in 2D potrebbero essere più vicini in 768D di quanto appaia
- Le distanze relative sono indicative ma non precise al 100%
Tuttavia, per un mini RAG con pochi documenti ben distinti semanticamente, la visualizzazione è sorprendentemente accurata nel rappresentare le relazioni principali.

Applicazioni Pratiche

Questa visualizzazione non è solo didattica, ma ha valore pratico:
- Debug del sistema: verificare se documenti simili sono effettivamente vicini nello spazio degli embeddings
- Quality assurance: identificare documenti potenzialmente mal classificati o ambigui
- Presentazioni: spiegare il funzionamento del RAG a stakeholder non tecnici
- Ottimizzazione: decidere se aggiungere o rimuovere documenti dalla knowledge base

Conclusione

Proiettare gli embeddings su un piano 2D trasforma un concetto matematico astratto in qualcosa di visivamente comprensibile. Mentre lavoriamo con vettori a 768 dimensioni, vedere i nostri documenti e query come punti su un grafico ci aiuta a comprendere intuitivamente come il sistema "ragiona" semanticamente.
Questa tecnica di visualizzazione, combinata con il mini RAG che abbiamo costruito nell'articolo precedente, ci offre uno strumento completo: non solo un sistema che funziona, ma anche la capacità di capire come e perché funziona. E questa comprensione è fondamentale per costruire applicazioni AI più robuste e affidabili.

Torna al blog