Laboratorio AI per Licei ed ITS

Dialogo con Gemini.

Cominciamo un altro articolo: laboratorio AI per liceo ed ITS. Direi di riprendere il computer allestito per Ollama, il precedente articolo il neurone artificiale e cominciare a costruire il laboratorio AI per le scuole…

Costruire un laboratorio di Intelligenza Artificiale per scuole superiori (Licei ed ITS) significa trasformare concetti astratti in esperimenti tangibili, senza dipendere da costose sottoscrizioni cloud e garantendo piena sovranità sui dati.

Colleghiamo i due pilastri già esplorati:

  1. L’infrastruttura locale: il PC configurato con Ollama per l’inferenza di modelli open-weight (LLM, vision, embeddings).
  2. I fondamenti teorici: il neurone artificiale (il Perceptron), la minima unità computazionale da cui scaturisce l’intero paradigma del deep learning.

Titolo di lavoro: Dall’hardware al Perceptron: allestire il Laboratorio di AI per la scuola moderna

1. Il contesto: Perché un laboratorio locale a scuola

  • Indipendenza e privacy (GDPR): Nessun invio di dati di studenti o documenti scolastici a provider terzi; tutto gira su rete locale (LAN).
  • Demistificare l’AI: Mostrare che un LLM o una rete neurale non sono “magia”, ma algebra lineare, calcolo vettoriale e pipeline software eseguibili su una workstation d’istituto.
  • Didattica multilivello:
    • Liceo (Scientifico/Scienze Applicate): focus su matematica sottostante (vettori, matrici, derivate, funzioni di attivazione) e prompt/agent engineering.
    • ITS (Tecnologico/Informatico): focus su architettura client-server, API REST, container Docker, quantizzazione dei pesi e deployment.

2. L’Architettura del Laboratorio

Il computer allestito con Ollama funge da Server AI centrale d’aula, accessibile dai computer degli studenti o dai loro portatili via rete locale.

ComponenteRuolo DidatticoStack Tecnologico
Server AI d’aulaHost dei modelli e motore di calcoloUbuntu/Debian/Windows, Ollama, driver CUDA/ROCm
Ambiente di lavoroNotebook interattivi e script per gli studentiJupyterLab / VS Code Server / Python 3.11+
Librerie CoreDalla matematica di base al machine learningNumPy, Matplotlib, PyTorch / Scikit-learn
Interfaccia e APIEsplorazione rapida e integrazione softwareOpen WebUI, endpoint REST di Ollama (localhost:11434)

3. Il Percorso Didattico: Dal Neurone Artificiale agli LLM

Il programma del laboratorio si articola in quattro tappe progressive, unendo teoria ed esecuzione pratica:

  • Modulo 1: Il Neurone da zero (Python puro & NumPy)
    • Implementare a mano il modello matematico y=f(∑wi​xi​+b).
    • Risolvere problemi linearmente separabili: le porte logiche AND e OR.
    • Dimostrare il limite storico del singolo neurone (il problema dello XOR) per motivare le reti multistrato (MLP).
  • Modulo 2: Addestramento e Discesa del Gradiente
    • Visualizzare l’errore (Loss function) e l’aggiornamento iterativo dei pesi con Matplotlib.
    • Introdurre PyTorch o micro-framework per vedere la backpropagation automatica.
  • Modulo 3: Il ponte verso i modelli generativi (Ollama in LAN)
    • Consumare l’API locale di Ollama tramite script Python (requests o libreria ufficiale ollama-python).
    • Estrazione di embedding: visualizzare nello spazio vettoriale la similarità semantica tra frasi usando PCA o t-SNE.
  • Modulo 4: Prime applicazioni concrete
    • Creazione di una pipeline RAG (Retrieval-Augmented Generation) minimale per interrogare il regolamento d’istituto o un testo di storia locale.

Proposta di scaletta per la stesura dell’articolo:

  1. Introduzione: L’AI a scuola oltre l’uso passivo di interfacce web commerciali.
  2. Setup dell’ambiente: Condividere Ollama e Jupyter in rete d’istituto (configurazione IP, firewall, accessi).
  3. Hands-on 1 (Matematica applicata): Esercitazione guidata sul neurone artificiale in Jupyter (codice commentato e visualizzazioni grafiche).
  4. Hands-on 2 (Integrazione moderna): Interrogare il modello locale Ollama via script Python per compiti di classificazione e spiegazione del codice.
  5. Scheda didattica per docenti: Obiettivi di apprendimento, prerequisiti e griglia di valutazione.

Scrivi la sezione hands-on con il codice Python commentato per implementare il neurone artificiale e le porte logiche in classe.

Ecco la sezione hands-on dell’articolo, strutturata come guida passo-passo pronta per essere inserita in un notebook Jupyter o in uno script Python durante la lezione.

Hands-on: Costruire un neurone artificiale da zero con Python

Nel modello concettuale introdotto da Frank Rosenblatt nel 1958 (il Perceptron), un neurone artificiale calcola la combinazione lineare degli ingressi pesati, aggiunge una soglia o scostamento chiamato bias, e fa passare il risultato attraverso una funzione di attivazione (spesso una funzione a gradino o step function):

$$\hat{y} = f\left(\sum_{i=1}^n w_i x_i + b\right) = f(\mathbf{w} \cdot \mathbf{x} + b)$$

Nel nostro laboratorio useremo solo Python standard e la libreria NumPy per le operazioni vettoriali, affiancata da Matplotlib per visualizzare cosa sta accadendo nello spazio geometrico.

1. Setup dell’ambiente

Se gli studenti lavorano dal proprio PC o notebook in aula, è sufficiente installare le due dipendenze di base:

Bash

pip install numpy matplotlib

2. La classe Perceptron

Scriviamo una classe orientata agli oggetti che implementa sia la predizione sia l’algoritmo di apprendimento supervisionato basato sulla regola di Hebb / delta rule per il percettrone:

$$\Delta w_i = \eta \cdot (y – \hat{y}) \cdot x_i$$

$$\Delta b = \eta \cdot (y – \hat{y})$$

dove $\eta$ (eta) è il learning rate (tasso di apprendimento), $y$ è il valore atteso e $\hat{y}$ è l’output predetto.

Python

import numpy as np
import matplotlib.pyplot as plt

class Perceptron:
    def __init__(self, n_inputs: int, lr: float = 0.1, epochs: int = 20):
        """
        Inizializza il neurone artificiale.
        :param n_inputs: Numero di segnali di ingresso (es. 2 per le porte logiche a due variabili)
        :param lr: Learning rate (tasso di apprendimento, velocità di aggiornamento pesi)
        :param epochs: Numero massimo di passaggi completi sul dataset di training
        """
        self.lr = lr
        self.epochs = epochs
        # Inizializziamo i pesi con valori casuali piccoli e il bias a zero
        self.weights = np.random.uniform(-0.1, 0.1, size=n_inputs)
        self.bias = 0.0
        self.history_errors = []

    def activation(self, z: float) -> int:
        """Funzione di attivazione a gradino (Heaviside step function)."""
        return 1 if z >= 0 else 0

    def predict(self, x: np.ndarray) -> int:
        """
        Calcola l'uscita del neurone: f(w · x + b)
        """
        # Prodotto scalare tra pesi e input + bias
        z = np.dot(x, self.weights) + self.bias
        return self.activation(z)

    def fit(self, X: np.ndarray, y: np.ndarray):
        """
        Addestra il neurone sui dati forniti.
        :param X: Matrice delle feature di input (N campioni x M feature)
        :param y: Array delle etichette attese (valori binari 0 o 1)
        """
        for epoch in range(self.epochs):
            total_errors = 0
            
            for xi, target in zip(X, y):
                prediction = self.predict(xi)
                error = target - prediction
                
                # Se c'è errore, aggiorniamo pesi e bias
                if error != 0:
                    update = self.lr * error
                    self.weights += update * xi
                    self.bias += update
                    total_errors += abs(error)
            
            self.history_errors.append(total_errors)
            
            # Se l'errore è 0, il neurone ha imparato perfettamente la regola
            if total_errors == 0:
                print(f"Convergenza raggiunta all'epoca {epoch + 1}!")
                break

3. Esperimento 1: Imparare la porta AND e la porta OR

Definiamo i dataset per le due funzioni booleane più note:

Python

# Ingressi booleani (tavola di verità a 2 ingressi)
X = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
])

# Uscite attese per AND e OR
y_and = np.array([0, 0, 0, 1])
y_or  = np.array([0, 1, 1, 1])

# --- Addestramento porta AND ---
print("--- Addestramento Porta AND ---")
p_and = Perceptron(n_inputs=2, lr=0.1, epochs=20)
p_and.fit(X, y_and)

print("Verifica predizioni AND:")
for sample in X:
    print(f"Input: {sample} -> Output calcolato: {p_and.predict(sample)}")
print(f"Pesi finali: {p_and.weights.round(2)}, Bias: {round(p_and.bias, 2)}\n")

# --- Addestramento porta OR ---
print("--- Addestramento Porta OR ---")
p_or = Perceptron(n_inputs=2, lr=0.1, epochs=20)
p_or.fit(X, y_or)

print("Verifica predizioni OR:")
for sample in X:
    print(f"Input: {sample} -> Output calcolato: {p_or.predict(sample)}")
print(f"Pesi finali: {p_or.weights.round(2)}, Bias: {round(p_or.bias, 2)}")

4. Visualizzare il confine di decisione (Decision Boundary)

Il momento didatticamente più efficace in classe è mostrare la geometria della formula:

$$w_1 x_1 + w_2 x_2 + b = 0 \implies x_2 = -\frac{w_1}{w_2}x_1 – \frac{b}{w_2}$$

Questa equazione rappresenta una retta sul piano cartesiano: da un lato della retta il neurone risponde 0, dall’altro risponde 1.

Python

def plot_decision_boundary(model: Perceptron, X: np.ndarray, y: np.ndarray, title: str):
    plt.figure(figsize=(6, 5))
    
    # Disegniamo i punti del dataset
    for xi, target in zip(X, y):
        color = 'blue' if target == 1 else 'red'
        marker = 'o' if target == 1 else 's'
        plt.scatter(xi[0], xi[1], color=color, s=120, marker=marker, edgecolors='k')
    
    # Calcolo della retta di separazione: w1*x1 + w2*x2 + b = 0
    x1_vals = np.linspace(-0.2, 1.2, 100)
    w1, w2 = model.weights
    b = model.bias
    
    if abs(w2) > 1e-5:
        x2_vals = -(w1 * x1_vals + b) / w2
        plt.plot(x1_vals, x2_vals, 'g--', label='Confine di decisione (w · x + b = 0)')
    
    plt.xlim(-0.2, 1.2)
    plt.ylim(-0.2, 1.2)
    plt.title(title)
    plt.xlabel('$x_1$')
    plt.ylabel('$x_2$')
    plt.grid(True, linestyle=':', alpha=0.6)
    plt.legend()
    plt.show()

# Esegui la visualizzazione per AND e OR
plot_decision_boundary(p_and, X, y_and, "Confine di decisione - Porta AND")
plot_decision_boundary(p_or, X, y_or, "Confine di decisione - Porta OR")

5. Il momento “Eureka” in aula: il fallimento dello XOR

Nel 1969 Marvin Minsky e Seymour Papert dimostrarono che un singolo percettrone non è in grado di risolvere problemi non linearmente separabili, portando al primo “inverno dell’AI”. Facciamo sperimentare questo limite direttamente agli studenti:

Python

# Tavola di verità dello XOR (OR esclusivo)
y_xor = np.array([0, 1, 1, 0])

print("--- Test Porta XOR (Problema non linearmente separabile) ---")
p_xor = Perceptron(n_inputs=2, lr=0.1, epochs=30)
p_xor.fit(X, y_xor)

print("\nVerifica predizioni XOR:")
for sample, target in zip(X, y_xor):
    print(f"Input: {sample} | Atteso: {target} | Ottenuto: {p_xor.predict(sample)}")

# Visualizziamo l'andamento degli errori nel tempo
plt.figure(figsize=(6, 3.5))
plt.plot(range(1, len(p_xor.history_errors) + 1), p_xor.history_errors, marker='o', color='crimson')
plt.title("Numero di errori per epoca sullo XOR")
plt.xlabel("Epoca")
plt.ylabel("Errori totali")
plt.grid(True, linestyle=':')
plt.show()

Domanda guida per gli studenti:

“Guardando il grafico dei punti dello XOR (i punti (0,1) e (1,0) hanno valore 1, mentre (0,0) e (1,1) hanno valore 0), è possibile tracciare una sola linea retta che separi i punti blu da quelli rossi?”

La risposta è no: serve combinare più neuroni in una rete (Multilayer Perceptron – MLP) o aggiungere uno strato nascosto (hidden layer). Questo ponte concettuale giustifica l’esistenza del Deep Learning e introduce in modo naturale le architetture moderne.

Ecco la continuazione organica dell’articolo, divisa nei due step successivi: prima la risoluzione concettuale e matematica dello XOR tramite una rete neurale a due strati con PyTorch, poi il passaggio al calcolo moderno interrogando il server locale Ollama per compiti generativi ed embedding vettoriali.

Parte 2: Superare il limite del singolo neurone con PyTorch (MLP)

Il fallimento del percettrone singolo sullo XOR evidenzia una necessità strutturale: per separare regioni non connesse o configurazioni non lineari servono più iperpiani di decisione combinati, ovvero uno strato nascosto (hidden layer).

Per mostrare questo salto senza appesantire la didattica riscrivendo a mano la backpropagation con derivate parziali a catena, introduciamo PyTorch, standard industriale ideale per mostrare l’autodifferenziazione (autograd).

Bash

pip install torch

Il modello: 2 ingressi $\rightarrow$ 2 neuroni nascosti $\rightarrow$ 1 neurone di uscita

Python

import torch
import torch.nn as nn
import torch.optim as optim

# 1. Dataset XOR convertito in tensori PyTorch (float32)
X_xor = torch.tensor([[0.0, 0.0],
                      [0.0, 1.0],
                      [1.0, 0.0],
                      [1.0, 1.0]])

y_xor = torch.tensor([[0.0],
                      [1.0],
                      [1.0],
                      [0.0]])

# 2. Definizione del Multilayer Perceptron (MLP)
class XORNet(nn.Module):
    def __init__(self):
        super().__init__()
        # Strato nascosto: 2 ingressi -> 2 neuroni nascosti
        self.hidden = nn.Linear(in_features=2, out_features=2)
        # Funzione di attivazione non lineare (Sigmoide)
        self.activation = nn.Sigmoid()
        # Strato di uscita: 2 neuroni nascosti -> 1 uscita
        self.output = nn.Linear(in_features=2, out_features=1)
        self.out_activation = nn.Sigmoid()

    def forward(self, x):
        # Passaggio in avanti (Forward pass)
        h = self.activation(self.hidden(x))
        out = self.out_activation(self.output(h))
        return out

model = XORNet()

# 3. Funzione di perdita (Binary Cross Entropy) e Ottimizzatore (SGD con momentum o Adam)
criterion = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.05)

# 4. Loop di addestramento
epochs = 1500
for epoch in range(1, epochs + 1):
    # Forward pass
    predictions = model(X_xor)
    loss = criterion(predictions, y_xor)
    
    # Backward pass & ottimizzazione
    optimizer.zero_grad()  # Azzera i gradienti accumulati
    loss.backward()        # Calcola dLoss/dw con backpropagation
    optimizer.step()        # Aggiorna i pesi
    
    if epoch % 300 == 0:
        print(f"Epoca {epoch:4d} | Loss: {loss.item():.5f}")

# 5. Verifica dei risultati
print("\n--- Risultati predizioni XOR con MLP ---")
with torch.no_grad():
    test_preds = model(X_xor)
    for inp, pred, target in zip(X_xor, test_preds, y_xor):
        rounded = int(pred.item() >= 0.5)
        print(f"Input: {inp.tolist()} | Probabilità: {pred.item():.4f} -> Predetto: {rounded} | Target: {int(target.item())}")

Spunto didattico per la classe

Facciamo notare agli studenti cosa ha fatto la rete:

  1. I due neuroni nascosti tracciano ciascuno una retta distinta nel piano originale $(x_1, x_2)$.
  2. La funzione Sigmoide trasforma e piega lo spazio delle feature.
  3. Il neurone di uscita separa linearmente i punti nel nuovo spazio bidimensionale generato dai neuroni nascosti.

Parte 3: Dai pesi artificiali ai modelli linguistici (Ollama in LAN)

Una volta compreso che un’architettura neurale complessa (come un Transformer) non è altro che miliardi di queste operazioni matriciali affiancate da meccanismi di attenzione, passiamo all’utilizzo pratico del server locale.

Sul computer host del laboratorio è attivo Ollama, che espone un endpoint HTTP REST standard (di default sulla porta 11434).

Prerequisito sul Server del Laboratorio

Per consentire agli studenti di accedere al server dall’aula, assicurarsi che Ollama sia in ascolto su tutte le interfacce di rete.

Su Linux/Ubuntu, impostare la variabile d’ambiente nel servizio o nella shell:

Bash

export OLLAMA_HOST="0.0.0.0:11434"
ollama serve

E scaricare un modello leggero ed efficiente per la didattica (es. Qwen o Llama):

Bash

ollama pull llama3.2:3b
ollama pull nomic-embed-text

Sulle macchine degli studenti installiamo la libreria ufficiale Python:

Bash

pip install ollama requests

Esercizio 3.1: Inferenza guidata e Zero-Shot Classification

Mostriamo come consumare il modello in locale via Python per classificare del testo o svolgere compiti logici strutturati, senza inviare dati all’esterno dell’istituto.

Python

import ollama

# Indirizzo IP del computer server allestito in aula (es. 192.168.1.50)
# Se si esegue direttamente sulla stessa macchina, usare 'http://localhost:11434'
SERVER_HOST = "http://192.168.1.50:11434"
client = ollama.Client(host=SERVER_HOST)

prompt_didattico = """
Analizza la seguente frase e classifica il tipo di ragionamento logico 
(Deduttivo, Induttivo, Abduttivo). Rispondi in formato JSON con chiavi 'tipo' e 'spiegazione'.

Frase: "Tutti i cigni osservati finora nel lago sono bianchi, quindi tutti i cigni sono bianchi."
"""

response = client.generate(
    model="llama3.2:3b",
    prompt=prompt_didattico,
    options={"temperature": 0.1} # Bassa temperatura per risposte deterministiche
)

print("Risposta dal server locale:")
print(response["response"])

Esercizio 3.2: Vettori ed Embeddings semantici (Matematica in azione)

Colleghiamo direttamente il concetto di coordinate e vettori visto con il Perceptron al funzionamento degli LLM: trasformiamo frasi in vettori multidimensionali tramite il modello nomic-embed-text e calcoliamo la Cosine Similarity (prodotto scalare normalizzato).

$$\text{Cosine Similarity}(\mathbf{u}, \mathbf{v}) = \frac{\mathbf{u} \cdot \mathbf{v}}{\Vert{}\mathbf{u}\Vert{} \Vert{}\mathbf{v}\Vert{}}$$

Python

import numpy as np
import ollama

SERVER_HOST = "http://192.168.1.50:11434"
client = ollama.Client(host=SERVER_HOST)

def get_embedding(text: str) -> np.ndarray:
    res = client.embeddings(model="nomic-embed-text", prompt=text)
    return np.array(res["embedding"])

def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float:
    # Prodotto scalare diviso per il prodotto delle norme euclidee
    dot_product = np.dot(v1, v2)
    norm_v1 = np.linalg.norm(v1)
    norm_v2 = np.linalg.norm(v2)
    return dot_product / (norm_v1 * norm_v2)

# Frasi di test per gli studenti
frasi = [
    "Il professore spiega la legge di gravitazione universale.",
    "Isaac Newton formulò le equazioni del moto dei pianeti.",
    "La pizza margherita si prepara con pomodoro e mozzarella fresca."
]

print("Calcolo dei vettori semantici in corso...")
emb_fisica_1 = get_embedding(frasi[0])
emb_fisica_2 = get_embedding(frasi[1])
emb_cucina   = get_embedding(frasi[2])

print(f"Dimensione del vettore embedding generato: {len(emb_fisica_1)} componenti\n")

sim_fisica = cosine_similarity(emb_fisica_1, emb_fisica_2)
sim_mista  = cosine_similarity(emb_fisica_1, emb_cucina)

print(f"Similarità tra Frase 1 e Frase 2 (entrambe Fisica): {sim_fisica:.4f}")
print(f"Similarità tra Frase 1 e Frase 3 (Fisica vs Cucina): {sim_mista:.4f}")

Sintesi didattica per gli studenti

  • Il percettrone calcola un prodotto scalare $\mathbf{w} \cdot \mathbf{x}$ in uno spazio a 2 dimensioni per separare categorie.
  • Il motore di embedding proietta concetti umani in uno spazio vettoriale a centinaia o migliaia di dimensioni (es. 768), dove la vicinanza angolare (prodotto scalare normalizzato) misura la parentela semantica dei concetti.

Scrivi il codice per un mini-progetto RAG (Retrieval-Augmented Generation) in locale per interrogare un documento di testo scolastico.

Ecco la sezione dell’articolo dedicata al mini-progetto RAG didattico.

L’obiettivo per gli studenti è capire che un sistema RAG non è altro che una catena composta da:

  1. Chunking: suddividere un testo lungo in segmenti gestibili.
  2. Embedding: proiettare ogni segmento nello spazio vettoriale.
  3. Retrieval (Ricerca Semantica): trovare i segmenti più vicini alla domanda tramite prodotto scalare / similarità del coseno.
  4. Augmented Generation: passare i segmenti pertinenti all’LLM come contesto nel prompt.

Tutto il codice è scritto in Python puro con NumPy e Ollama, senza librerie complesse o database vettoriali esterni (come Chroma o Pinecone), così che ogni passaggio matematico e algoritmico rimanga trasparente e ispezionabile dagli studenti.

Mini-Progetto Didattico: Costruire un motore RAG da zero in rete locale

Prerequisiti

Sul server Ollama del laboratorio devono essere presenti i due modelli:

Bash

ollama pull nomic-embed-text
ollama pull llama3.2:3b

Sulle postazioni di lavoro occorre solo ollama e numpy:

Bash

pip install ollama numpy

1. Il documento di esempio: Regolamento Scolastico

Creiamo uno scenario reale: un file di testo o una stringa contenente estratti del regolamento d’istituto (giustificazioni, uso degli smartphone, uscite anticipate, criteri di valutazione).

Python

import numpy as np
import ollama

# Indirizzo del server Ollama di laboratorio (modificare con l'IP locale d'aula se separato)
OLLAMA_SERVER = "http://localhost:11434"
client = ollama.Client(host=OLLAMA_SERVER)

EMBEDDING_MODEL = "nomic-embed-text"
LLM_MODEL = "llama3.2:3b"

# Documento d'istituto di prova
DOCUMENTO_SCOLASTICO = """
Articolo 1 - Ingresso e ritardi: Le lezioni iniziano alle ore 08:00. Gli studenti che arrivano 
con un ritardo inferiore a 10 minuti sono ammessi in classe con annotazione sul registro. 
Oltre i 10 minuti di ritardo, l'ingresso è consentito solo all'inizio della seconda ora, 
previa giustificazione scritta da parte del genitore sul libretto elettronico.

Articolo 2 - Utilizzo dei dispositivi elettronici: Durante le ore di lezione è severamente 
vietato l'uso di smartphone, tablet e cuffie per scopi non didattici. Gli smartphone devono 
essere riposti nello zaino o nell'apposita cassetta all'inizio dell'ora. L'uso è consentito 
esclusivamente su espressa autorizzazione del docente per attività didattiche o di ricerca.

Articolo 3 - Uscite anticipate e permessi: Le uscite anticipate sono consentite solo a partire 
dalle ore 12:00 per motivi di salute o gravi necessità documentate. Lo studente minorenne può 
lasciare l'istituto solo se prelevato direttamente da un genitore o da un delegato maggiorenne 
formalmente autorizzato all'inizio dell'anno scolastico.

Articolo 4 - Valutazione e verifiche: In ciascun periodo didattico ogni docente deve svolgere 
un numero congruo di verifiche, pari ad almeno due prove scritte e due orali/pratiche. 
Le verifiche corrette devono essere riconsegnate agli studenti entro e non oltre 15 giorni 
dallo svolgimento, garantendo una motivazione chiara del voto attribuito.
"""

2. Chunking e indicizzazione nello spazio vettoriale

In un’applicazione didattica, suddividiamo il testo per paragrafi o articoli. Per ciascun blocco (chunk) calcoliamo il vettore di embedding tramite il modello nomic-embed-text.

Python

def chunk_text(text: str) -> list[str]:
    """Divide il testo in paragrafi non vuoti."""
    chunks = [chunk.strip() for chunk in text.split("\n\n") if chunk.strip()]
    return chunks

def get_embedding(text: str) -> np.ndarray:
    """Calcola il vettore semantico di una stringa."""
    res = client.embeddings(model=EMBEDDING_MODEL, prompt=text)
    return np.array(res["embedding"], dtype=np.float32)

# 1. Suddivisione in blocchi
chunks = chunk_text(DOCUMENTO_SCOLASTICO)
print(f"Estratti {len(chunks)} blocchi dal regolamento.")

# 2. Creazione dell'indice vettoriale (in-memory)
vector_database = []
print("Calcolo degli embedding dei blocchi in corso...")

for idx, chunk in enumerate(chunks):
    vector = get_embedding(chunk)
    vector_database.append({
        "id": idx,
        "content": chunk,
        "vector": vector
    })

print(f"Indicizzazione completata! Ogni blocco è rappresentato da un vettore di {len(vector_database[0]['vector'])} dimensioni.\n")

3. Retrieval semantico tramite Similarità del Coseno

Quando l’utente pone una domanda:

  1. Calcoliamo l’embedding della domanda.
  2. Misuriamo la similarità del coseno tra la domanda e ogni chunk memorizzato:Cosine Similarity(q,ci​)=∥q∥∥ci​∥q⋅ci​​
  3. Ordiniamo i chunk e selezioniamo i primi k (i più pertinenti).

Python

def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float:
    """Calcola l'angolo semantico normalizzato tra due vettori."""
    norm_product = np.linalg.norm(v1) * np.linalg.norm(v2)
    if norm_product == 0:
        return 0.0
    return float(np.dot(v1, v2) / norm_product)

def search_relevant_chunks(query: str, db: list[dict], top_k: int = 1) -> list[dict]:
    """Estrae i k blocchi con il più alto punteggio di similarità semantica."""
    query_vec = get_embedding(query)
    
    scored_chunks = []
    for item in db:
        score = cosine_similarity(query_vec, item["vector"])
        scored_chunks.append({
            "content": item["content"],
            "score": score
        })
    
    # Ordiniamo in ordine decrescente di pertinenza
    scored_chunks.sort(key=lambda x: x["score"], reverse=True)
    return scored_chunks[:top_k]

4. Augmented Generation: costringere l’LLM ad attenersi al contesto

In questa fase componiamo il prompt finale. L’LLM riceve sia il contesto estratto sia la domanda originale, con una chiara istruzione di sistema (system prompt) per impedire “allucinazioni”.

Python

def ask_rag(query: str, db: list[dict]) -> str:
    # 1. Recupero delle informazioni rilevanti
    top_results = search_relevant_chunks(query, db, top_k=1)
    retrieved_chunk = top_results[0]
    
    print(f"--- [DEBUG DIDATTICO] ---")
    print(f"Domanda: \"{query}\"")
    print(f"Blocco selezionato (Punteggio di similarità: {retrieved_chunk['score']:.4f}):")
    print(f"{retrieved_chunk['content'][:120]}...\n-------------------------")
    
    # 2. Composizione del prompt aumentato
    system_prompt = (
        "Sei l'assistente ufficiale della scuola. Rispondi alla domanda dello studente "
        "basandoti ESCLUSIVAMENTE sulle informazioni presenti nel CONTESTO fornito. "
        "Se l'informazione non è presente nel testo, dichiara chiaramente: 'L'informazione non è specificata nel regolamento'."
    )
    
    augmented_prompt = f"""
CONTESTO UFFICIALE:
\"\"\"
{retrieved_chunk['content']}
\"\"\"

DOMANDA:
{query}

RISPOSTA:
"""

    # 3. Chiamata al modello generativo locale
    response = client.generate(
        model=LLM_MODEL,
        prompt=augmented_prompt,
        system=system_prompt,
        options={"temperature": 0.1}
    )
    
    return response["response"]

5. Test pratico in classe

Eseguiamo due interrogazioni: una la cui risposta è esplicitamente nel regolamento, e una non prevista, per verificare il comportamento del modello:

Python

# Test 1: Domanda coperta dal regolamento
domanda_1 = "Entro quanti giorni il professore deve restituire una verifica scritta?"
risposta_1 = ask_rag(domanda_1, vector_database)
print(f"Risposta del modello:\n{risposta_1}\n")

# Test 2: Domanda su cui il regolamento tace (test allucinazioni)
domanda_2 = "Posso uscire dalla scuola in bicicletta durante l'intervallo?"
risposta_2 = ask_rag(domanda_2, vector_database)
print(f"Risposta del modello:\n{risposta_2}")

Sintesi del percorso pedagogico per la lezione

FaseConcetto matematico / informaticoCosa apprende lo studente
ChunkingTokenizzazione e parsing di stringheCome dividere la memoria a lungo termine in unità discrete
EmbeddingSpazi vettoriali ad alta dimensione (R768)Come le macchine trasformano il significato delle parole in geometria
RetrievalProdotto scalare e metrica del cosenoLa ricerca non è per parole chiave (SQL LIKE), ma per vicinanza concettuale
GenerationPrompt conditioning e groundingCome impedire le allucinazioni ancorando il modello a fonti certe locali

Gemini è un’AI e può fare errori, anche in merito a persone.