Ivan Centamori

EN IT


La ricerca vettoriale in Dungeons and Dragons

Se hai mai provato a cercare qualcosa in un database SQL usando una query LIKE %query%, sai che troverai solo le righe che contengono letteralmente la sequenza di caratteri specificata in query. Se nel tuo database di oggetti magici cerchi "Lama della Fenice", ma anziché le esatte parole scrivi "Spada della Fenice" o più genericamente "spada magica che brucia", il database ti risponderà con un secco "Nessun risultato".

La ricerca semantica, o vettoriale, capisce le tue intenzioni e non solo le tue parole. Invece di cercare corrispondenze esatte di caratteri, cerca corrispondenze di significato.

In questo articolo implementeremo un sistema di ricerca vettoriale da zero in PHP, usandolo per l'unico scopo che conta davvero: trovare l'incantesimo giusto per polverizzare un esercito di goblin.

Parola chiave vs Ricerca vettoriale

Immagina di essere in una biblioteca arcana, ti trovi in mezzo a migliaia di incantesimi e non sai quale usare. Hai bisogno di un incantesimo per fermare un'orda di goblin.

L'approccio classico per parola chiave Vai dal bibliotecario (il Database) e chiedi: "Dammi qualcosa per un'esplosione di fuoco". Il bibliotecario, che è un golem molto diligente ma privo di ragionamento, scansiona i titoli.

Il vero incantesimo che ti serviva era "Palla di Fuoco", ma siccome non hai detto la parola "Palla", il bibliotecario allarga le braccia sconsolato.

L'approccio vettoriale (semantico) Ora immagina un bibliotecario diverso, un Mind Flayer amichevole capace di leggere i tuoi pensieri. Tu pensi: "Voglio fare boom e bruciare i goblin". Lui capisce il concetto astratto (Danno + Area + Fuoco) e ti porta immediatamente la pergamena di Palla di Fuoco.

Questo è il cuore della ricerca vettoriale: trasformare parole in concetti matematici e calcolare la loro vicinanza. "fare boom" e "palla di fuoco" sono rappresentazioni diverse, ma concettualmente simili. Un sistema vettoriale lo sa.

I vettori

Ma come fa un computer a capire i concetti? Semplice: trasformandoli in numeri.

Pensiamo un attimo alla Scheda del Personaggio nel nostro gioco di ruolo preferito. Come descrivi un eroe in modo che le regole possano gestirlo? Usi le statistiche.

Immaginiamo ora di avere solo 2 statistiche: Forza e Intelligenza e proviamo a descrivere tre eroi:

Se disegniamo questi valori su un piano cartesiano, assegnando il valore di forza alle X e il valore di intelligenza alle Y, ogni personaggio diventa un punto. Se ora tracciamo una freccia che parte dall'origine (0,0) del piano cartesiano e arriva al punto che rappresenta il personaggio, abbiamo definito un vettore.

La distanza è significato

Guardando il piano cartesiano appena disegnato, notiamo subito una cosa: Il punto del Mago (6, 18) è geometricamente molto vicino a quello dello Warlock (8, 16). Il punto del Barbaro (18, 6) è invece molto lontano da entrambi.

In questo spazio bidimensionale, la vicinanza geometrica rappresenta una somiglianza concettuale.

Il multiverso

Nel mondo reale però non usiamo solo 2 dimensioni (Intelligenza e Forza) ma ne usiamo migliaia. Ad esempio il modello text-embedding-3-small di OpenAI che ci aiuterà a descrivere un concetto riesce a farlo usando 1536 dimensioni.

È un po' come creare una scheda del personaggio con migliaia di statistiche: non solo Forza o Destrezza o Intelligenza, ma anche "Fuocosità", "Tendenza alla Guarigione", "Danno in Area", "Probabilità di essere un Goblin", e altre 1500+ caratteristiche, ognuna con il suo valore decimale preciso.

Quindi ogni frase che scrivi viene trasformata in questa lista di 1536 numeri float. Questo processo si chiama Embedding.

Il vettore che ne risulta non potrà essere rappresentato in un piano cartesiano, ma si troverà in un multiverso di 1536 dimensioni.

Matematicamente non avremo nessun problema, 2 dimensioni o 1536 dimensioni sono solo numeri, alla fine il vettore di "Cane" sarà vicinissimo a quello di "Cucciolo" e lontano da "Automobile".

Cosine Similarity

Come calcoliamo se due vettori sono simili? In uno spazio vettoriale ad alta dimensione, preferiamo usare la Cosine Similarity invece della semplice distanza. In pratica, misuriamo l'angolo tra i due vettori.

Usiamo l'angolo perché è stato osservato che la lunghezza del testo influisce sulla lunghezza del vettore, questo significa che un libro intero sui Draghi potrebbe avere un vettore molto più lungo rispetto a un tweet sui Draghi, ma entrambi avranno un "angolo" simile perchè parlano dello stesso argomento.

Implementazione in PHP

Proviamo a costruire un motore di ricerca per la nostra biblioteca. L'obiettivo è trovare l'incantesimo giusto partendo da una descrizione vaga.

1. Il database

Creiamo un database di incantesimi definendo un file json spells.json:

[
    {
        "nome": "Palla di Fuoco",
        "descrizione": "Una luminosa striscia balena dal tuo dito... esplode in un'esplosione di fiamme."
    },
    {
        "nome": "Guarigione",
        "descrizione": "Una creatura che tocchi recupera un numero di punti ferita pari a 1d8."
    },
    {
        "nome": "Invisibilità",
        "descrizione": "Una creatura che tocchi diventa invisibile. Tutto ciò che indossa svanisce."
    }
]

2. Gli embeddings

Per dare un significato agli incantesimi (e quindi trasformarli in vettori) usiamo l'API di OpenAI:

const OPENAI_API_KEY = '...'; // TODO: usa la tua API key generata registrandoti su OpenAI

public function getEmbedding(string $text): array {
    $ch = curl_init();

    $data = [
        'input' => $text,
        'model' => 'text-embedding-3-small'
    ];

    curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/embeddings');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Authorization: Bearer ' . OPENAI_API_KEY,
        'Content-Type: application/json'
    ]);

    $result = curl_exec($ch);
    curl_close($ch);

    $response = json_decode($result, true);

    if (!isset($response['data'][0]['embedding'])) {
        throw new Exception("Errore API: " . json_encode($response));
    }
    return $response['data'][0]['embedding'];
}

Ora che abbiamo la funzione, andiamo ad arricchire il file degli incantesimi con i vettori per ciascun elemento. Questa è una operazione costosa che deve essere fatta una volta sola e poi salvata su file.

// Legge le magie dal file json
$spells = json_decode(file_get_contents('spells.json'), true);

// Calcola gli embedding per ogni incantesimo
echo "Indicizzazione in corso...\n";
foreach ($spells as &$spell) {
    // Creiamo un embedding che combina nome e descrizione
    $text = "Incantesimo: " . $spell['nome'] . ". Effetto: " . $spell['descrizione'];
    $spell['vector'] = getEmbedding($text);
}

// Salva su file
file_put_contents('spells.json', json_encode($spells));

A questo punto il nostro file spells.json conterrà, oltre al nome e alla descrizione, anche il vettore per ogni incantesimo.

3. Calcolare la Cosine Similarity

La formula matematica per calcolare la similarità tra due vettori $A$ e $B$ è:

$$\text{similarity} = \frac{A \cdot B}{|A| |B|}$$

E' molto più facile di quel che sembra. In parole povere:

  1. Prodotto Scalare ($A \cdot B$): Sommiamo i prodotti delle componenti corrispondenti. Se entrambi i vettori hanno valori alti nelle stesse dimensioni, questo numero cresce.
  2. Magnitudo ($|A|$ e $|B|$): Calcoliamo la "lunghezza" di ogni vettore.
  3. Divisione: Dividendo il prodotto scalare per il prodotto delle lunghezze, "normalizziamo" il risultato. Questo ci permette di ignorare quanto è lungo il testo e concentrarci solo sulla direzione (il significato).
function cosineSimilarity(array $vecA, array $vecB): float {
    // I vettori DEVONO avere la stessa dimensione
    if (count($vecA) !== count($vecB)) {
        throw new Exception("Dimension mismatch");
    }

    $dotProduct = 0;
    $magnitudeA = 0;
    $magnitudeB = 0;

    foreach ($vecA as $i => $valA) {
        $valB = $vecB[$i];
        $dotProduct += $valA * $valB;
        $magnitudeA += $valA * $valA;
        $magnitudeB += $valB * $valB;
    }

    $magnitudeA = sqrt($magnitudeA);
    $magnitudeB = sqrt($magnitudeB);

    if ($magnitudeA * $magnitudeB == 0) return 0;

    return $dotProduct / ($magnitudeA * $magnitudeB);
}

4. Il Motore di Ricerca

// Legge le magie e i relativi vettori dal file json
$spells = json_decode(file_get_contents('spells.json'), true);

// Query Utente
$query = "Voglio curare il mio amico ferito";
echo "\nGiocatore cerca: \"$query\"\n";

// Embedding della query
$queryVector = getEmbedding($query);

// Ricerca (scorriamo tutti i record, non è efficiente per grandi dataset)
$results = [];
foreach ($spells as $spell) {
    $score = cosineSimilarity($queryVector, $spell['vector']);
    $results[] = [
        'nome' => $spell['nome'],
        'score' => $score
    ];
}

// Ordinamento dei risultati
usort($results, fn($a, $b) => $b['score'] <=> $a['score']);

// Output
foreach ($results as $res) {
    $perc = round($res['score'] * 100, 1);
    echo "[{$perc}%] {$res['nome']}\n";
}

Il risultato? Guarigione sarà al primo posto con uno score alto (es. 0.85), mentre Palla di Fuoco sarà in fondo, perché matematicamente "curare" e "bruciare" sono vettori che puntano in direzioni diverse.

Ma nella vita reale?

Il nostro script PHP funziona bene per pochi dati, ma confrontare il vettore della query con milioni di record risulterebbe troppo lento (un loop foreach gigante significa una complessità O(N) che cresce linearmente al crescere dei dati).

Per questo motivo, in produzione si utilizzano i Vector Database (come Qdrant, Pinecone, o pgvector su PostgreSQL). Questi sistemi non fanno un semplice ciclo su tutte le righe, ma utilizzano indici spaziali intelligenti (come HNSW) per restringere la ricerca solo ai record con significato "vicino", rendendo la query istantanea anche su milioni di documenti.

Conclusione

La ricerca vettoriale non è magia nera, è solo geometria applicata al linguaggio. Ci permette di costruire interfacce più umane, che capiscono l'intento e tollerano l'imprecisione.

Ora che il tuo grimorio è intelligente, hai molte più chance contro quei goblin.

Riferimenti