Come gli LLM valutano i testi
Come avevamo già visto in precedenza, l’intelligenza artificiale moderna si basa sulle relazioni tra concetti, in base al loro significato semantico. Tutto questo però non vive di testi e stringhe ma si nutre di numeri, o meglio di geometria.
Un embedding è una rappresentazione numerica di un testo in uno spazio vettoriale multidimensionale. Prendiamo queste frasi:
- “L’ape regina vive in media 5 anni, molto più di un’ape semplice che difficilmente supera i 5 mesi”.
- “La Regina Elisabetta si è spenta nel 2022 a 96 anni compiuti”
- “La rosa è considerata la regina dei fiori”
Ipotizziamo questo input da parte di un utente:
“Sei un allievo alle prese con un tema sulle api. Elabora un’introduzione.”
E’ evidente quanto sia delicato elaborare un sistema capace di individuare, automaticamente, i tre contesti nei quali appare la parola Regina. Altrimenti l'allievo rischia di scrivere un testo nel quale l'ape Regina vive 96 anni...
"Digerire" i testi: embedding
Nelle applicazioni pratiche, documenti molto lunghi vengono prima suddivisi in porzioni (chunk); ciascun chunk viene poi passato al modello di embedding, che lo trasforma in un vettore numerico. Vediamo un esempio Python
<?php
from openai import OpenAI
client = OpenAI()
response = client.embeddings.create(
model="text-embedding-3-small",
input="mi piace la pizza"
)
print(response.data[0].embedding)
Questo snippet restituirà un array vettoriale di questo tipo:
<?php
{
"object": "embedding",
"index": 0,
"embedding": [
-0.0123,
0.0456,
0.0789,
-0.1102,
0.0034,
0.2210,
-0.1987,
...
(fino a 1536 numeri)
]
}
Quindi un embedding non è un'informazione umanamente leggibile, ma piuttosto un vettore numerico in uno spazio matematico ad alta dimensionalità, nel quale la vicinanza tra vettori tende a rappresentare la similarità semantica.
Grazie ad opportune funzioni, che vedremo a breve, diventa possibile trovare testi “simili a” e da questi elaborare risposte consone alle domande.
Elaborare testi lunghi: il chunk e la giusta dimensione dei frammenti
Grazie alle coordinate possiamo capire quali testi, frasi o documenti risultano semanticamente vicini e quali lontani.
La simliarità semantica è proprio questo, ovvero una misura matematica di quanto due significati sono vicini. Il tutto non si basa solo sulle singole parole (nell’esempio di prima la parola “regina” avrebbe causato disastri) ma sul significato, sui contesti e sulla relazione tra i contesti stessi.
A questo scopo esistono precise formule matematiche, tra cui la cosine similarity è la più usata; alcuni database vettoriali utilizzano il prodotto scalare (dot product) o la distanza euclidea.
Gli embedding hanno però un grosso problema: un documento intero è troppo grande per essere trattato come un’unica unità. Nell’esempio di prima abbiamo elaborato una semplice frase, ma pensiamo alla vita di tutti i giorni, nei quali abbiamo a che fare con manuali pdf di 900 pagine, analisi aziendali altrettanto esose, log di sistema di giga, libri interi..
I documenti vanno cosi divisi in parti più piccoli, i chunk, e passati cosi all’embedding. Il problema è che come abbiamo visto, il processo di embedding definisce le coordinate delle parole non solo in base alle parole stesse (se cosi fosse, “regina” restituirebbe sempre le stesse coordinate, che si parli delle api o di Elizabeth Alexandra Mary) ma anche in base al contesto. Contesto che è determinato dal resto della frase in cui si trovano.
Quindi: gli embedding di frammenti di testo troppo lunghi perdono specificità; su testi troppo brevi perdono contesto.
A questo scopo, una buona soluzione è eseguire embedding di porzioni ben definite di testi (i paragrafi, ad esempio). Se possibile, se il testo lo consente, dividere i testi quando cambia il tema del discorso. Oppure utilizzare la tecnica dell’overlap: passare ad esempio ad un primo processo le prime 5 frasi di un testo, ad un secondo processo le frasi dalla quarta all’ottava, cosi che la quarta e la quinta possano funzionare da collante semantico.
Un esempio pratico: chunck dei Promessi Sposi
Vediamo un esempio pratico. Ipotizziamo di avere nel nostro disco un file contenente il primo capitolo dei Promessi Sposi.
Come primo step, leggiamo l’intero testo e memorizziamolo in una variabile.
<?php
with open("promessi_sposi_cap1.txt", "r", encoding="utf-8") as f:
testo = f.read()
def chunk_text(text, chunk_size=500):
words = text.split()
chunks = []
for i in range(0, len(words), chunk_size):
chunk = " ".join(words[i:i+chunk_size])
chunks.append(chunk)
return chunks
chunks = chunk_text(testo, 200)
in chunks abbiamo praticamente la lista dei pezzi del capitolo. Abbiamo deciso di impostare come parametro chunk_size il valore 200, che sta a significare “spezza il testo ogni 200 parole”.
Semplificando, 1 parola corrisponde, secondo una stima molto approssimativa, a 1.2 / 1.6 token (e viceversa un 1 token equivale piu o meno a 0.6 – 0.8 parole), questo almeno per la lingua italiana che è un po' prolissa (in inglese 1 parola in media consuma 1.3 token e 1 token equivale a 0.75 parole).
Nel caso dei Promessi Sposi non esageriamo con la grandezza perché essendo un romanzo, introduce vari argomenti (i personaggi, i luoghi, i riferimenti storici) semanticamente lontani tra loro. Se si fosse trattato di un manuale tecnico avremmo potuto azzardare frammenti maggiori.
La tecnica dell'overlap
L'overlap (sovrapposizione) è una tecnica che utilizza blocchi di testo adiacenti ottenuti con il chucking in modo che condividano una porzione di parole o token uguali. Vediamo un esempio pratico in Python:
<?php
def chunk_text(text, chunk_size=200, overlap=40):
words = text.split()
chunks = []
start = 0
while start < len(words):
chunk = words[start:start+chunk_size]
chunks.append(" ".join(chunk))
start += chunk_size - overlap
return chunks
Ultimo step, eseguiamo l’embedding dei chunk appena creati
from openai import OpenAI
import os
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
embeddings = []
for chunk in chunks:
response = client.embeddings.create(
model="text-embedding-3-small",
input=chunk
)
vector = response.data[0].embedding
embeddings.append({
"text": chunk,
"vector": vector
})
print(len(embeddings))
print(len(embeddings[0]["vector"]))
A questo punto, ipotizzando che un utente voglia conoscere qualcosa del paesaggio iniziale dei promessi sposi, basterà eseguire l’embedding della sua domanda (essendo una richiesta breve, normalmente non viene suddivisa in chunk)) e poi confrontarla con il database nel quale avremo memorizzato gli embedding di prima.
<?php
query = "descrizione del paesaggio iniziale"
query_embedding = client.embeddings.create(
model="text-embedding-3-small",
input=query
).data[0].embedding
I dati sono pronti per essere salvati in un database vettoriale, al quale dedicheremo il prossimo capitolo.