
RAG (Retrieval-Augmented Generation) è una tecnica che permette di recuperare informazioni rilevanti da una base di conoscenza prima di generare una risposta. Un mini RAG è una versione semplificata di questo sistema che, invece di utilizzare database vettoriali complessi, gestisce tutto attraverso il file system.
Cos'è un Mini RAG?
RAG (Retrieval-Augmented Generation) è una tecnica che permette di recuperare informazioni rilevanti da una base di conoscenza prima di generare una risposta. Un mini RAG è una versione semplificata di questo sistema che, invece di utilizzare database vettoriali complessi, gestisce tutto attraverso il file system.
Il principio è elegante: ogni documento viene convertito in un vettore numerico (embedding) che rappresenta il suo significato semantico. Quando l'utente fa una domanda, anche questa viene convertita in un vettore, e il sistema trova il documento il cui vettore è più "vicino" a quello della domanda. Non si tratta di una semplice ricerca per parole chiave, ma di una vera comprensione del significato.
La Classe EmbeddingManager: Anatomia e Funzionamento
<?php
/**
* EmbeddingManager - Gestione embeddings con Gemini API
*
* Classe per gestire repository di file di testo e relativi embeddings
* utilizzando l'API di Google Gemini per il calcolo degli embeddings.
*
* @version 1.0
* @requires PHP 7.4+
*/
class EmbeddingManager {
/**
* @var string Chiave API per Gemini
*/
private $apiKey;
/**
* @var string Path della cartella principale del repository
*/
private $basePath;
/**
* @var string Path della sottocartella per i file di testo
*/
private $txtPath;
/**
* @var string Path della sottocartella per gli embeddings
*/
private $embPath;
/**
* @var string Modello Gemini da utilizzare
*/
private $model = 'text-embedding-004';
/**
* @var int Dimensionalità dell'output
*/
private $outputDimensionality = 768;
/**
* Costruttore
*
* @param string $apiKey Chiave API di Gemini
*/
public function __construct($apiKey) {
if (empty($apiKey)) {
throw new Exception("API Key non può essere vuota");
}
$this->apiKey = $apiKey;
}
/**
* Crea un nuovo repository con la struttura di cartelle
*
* @param string $repositoryName Nome del repository da creare
* @param string $parentPath Path dove creare il repository (default: directory corrente)
* @return bool True se successo
* @throws Exception Se il repository esiste già o ci sono errori di creazione
*/
public function createRepository($repositoryName, $parentPath = '.') {
// Rimuovi caratteri non validi dal nome
$repositoryName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $repositoryName);
$this->basePath = rtrim($parentPath, '/') . '/' . $repositoryName;
$this->txtPath = $this->basePath . '/TXT';
$this->embPath = $this->basePath . '/EMB';
// Verifica se il repository esiste già
if (file_exists($this->basePath)) {
throw new Exception("Repository '$repositoryName' esiste già in $parentPath");
}
// Crea le directory
if (!mkdir($this->basePath, 0755, true)) {
throw new Exception("Impossibile creare la directory base: {$this->basePath}");
}
if (!mkdir($this->txtPath, 0755, true)) {
throw new Exception("Impossibile creare la directory TXT");
}
if (!mkdir($this->embPath, 0755, true)) {
throw new Exception("Impossibile creare la directory EMB");
}
// Crea un file .info con metadati
$info = [
'created' => date('Y-m-d H:i:s'),
'name' => $repositoryName,
'version' => '1.0'
];
file_put_contents($this->basePath . '/.info', json_encode($info, JSON_PRETTY_PRINT));
return true;
}
/**
* Carica un repository esistente
*
* @param string $repositoryPath Path completo del repository
* @return bool True se successo
* @throws Exception Se il repository non esiste o non è valido
*/
public function loadRepository($repositoryPath) {
$this->basePath = rtrim($repositoryPath, '/');
$this->txtPath = $this->basePath . '/TXT';
$this->embPath = $this->basePath . '/EMB';
if (!is_dir($this->basePath)) {
throw new Exception("Repository non trovato: {$this->basePath}");
}
if (!is_dir($this->txtPath)) {
throw new Exception("Directory TXT non trovata nel repository");
}
if (!is_dir($this->embPath)) {
throw new Exception("Directory EMB non trovata nel repository");
}
return true;
}
/**
* Aggiunge un nuovo file di testo al repository
*
* @param string $filePath Path del file da aggiungere
* @param string $customName Nome personalizzato (opzionale)
* @return array Informazioni sul file aggiunto ['id' => ..., 'filename' => ...]
* @throws Exception Se il file non esiste o ci sono errori
*/
public function addFile($filePath, $customName = null) {
if (!isset($this->basePath)) {
throw new Exception("Nessun repository caricato. Usa loadRepository() o createRepository()");
}
if (!file_exists($filePath)) {
throw new Exception("File non trovato: $filePath");
}
if (!is_readable($filePath)) {
throw new Exception("File non leggibile: $filePath");
}
// Leggi il contenuto del file
$content = file_get_contents($filePath);
if ($content === false) {
throw new Exception("Impossibile leggere il contenuto del file");
}
// Genera un ID univoco
$id = $this->generateUniqueId();
// Determina il nome del file
if ($customName !== null) {
$baseName = preg_replace('/[^a-zA-Z0-9_-]/', '_', pathinfo($customName, PATHINFO_FILENAME));
} else {
$baseName = preg_replace('/[^a-zA-Z0-9_-]/', '_', pathinfo($filePath, PATHINFO_FILENAME));
}
$fileName = $id . '_' . $baseName . '.txt';
$embFileName = $id . '_' . $baseName . '.emb';
// Salva il file di testo
$txtFilePath = $this->txtPath . '/' . $fileName;
if (file_put_contents($txtFilePath, $content) === false) {
throw new Exception("Impossibile salvare il file di testo");
}
try {
// Calcola l'embedding
$embedding = $this->calculateEmbedding($content);
// Salva l'embedding
$embFilePath = $this->embPath . '/' . $embFileName;
if (file_put_contents($embFilePath, json_encode($embedding)) === false) {
// Cleanup: rimuovi il file txt se l'embedding fallisce
unlink($txtFilePath);
throw new Exception("Impossibile salvare l'embedding");
}
} catch (Exception $e) {
// Cleanup in caso di errore
if (file_exists($txtFilePath)) {
unlink($txtFilePath);
}
throw new Exception("Errore durante il calcolo dell'embedding: " . $e->getMessage());
}
return [
'id' => $id,
'filename' => $fileName,
'embedding_file' => $embFileName,
'path' => $txtFilePath
];
}
/**
* Rimuove un file dal repository
*
* @param string $identifier ID o nome file da rimuovere
* @return bool True se successo
* @throws Exception Se il file non esiste
*/
public function removeFile($identifier) {
if (!isset($this->basePath)) {
throw new Exception("Nessun repository caricato");
}
// Cerca il file
$files = $this->findFiles($identifier);
if (empty($files)) {
throw new Exception("File non trovato: $identifier");
}
$removed = false;
foreach ($files as $file) {
$txtFile = $this->txtPath . '/' . $file['txt'];
$embFile = $this->embPath . '/' . $file['emb'];
$txtRemoved = false;
$embRemoved = false;
if (file_exists($txtFile)) {
$txtRemoved = unlink($txtFile);
}
if (file_exists($embFile)) {
$embRemoved = unlink($embFile);
}
if ($txtRemoved || $embRemoved) {
$removed = true;
}
}
if (!$removed) {
throw new Exception("Impossibile rimuovere il file");
}
return true;
}
/**
* Cerca il file più simile a un testo dato
*
* @param string $queryText Testo di ricerca
* @param int $topN Numero di risultati da restituire (default: 1)
* @return array Array di risultati ordinati per similarità
* @throws Exception Se ci sono errori durante la ricerca
*/
public function search($queryText, $topN = 1) {
if (!isset($this->basePath)) {
throw new Exception("Nessun repository caricato");
}
// Calcola l'embedding della query
$queryEmbedding = $this->calculateEmbedding($queryText);
// Ottieni tutti i file di embedding
$embFiles = glob($this->embPath . '/*.emb');
if (empty($embFiles)) {
return [];
}
$results = [];
foreach ($embFiles as $embFile) {
$embContent = file_get_contents($embFile);
if ($embContent === false) {
continue;
}
$docEmbedding = json_decode($embContent, true);
if ($docEmbedding === null) {
continue;
}
// Calcola la similarità del coseno
$similarity = $this->cosineSimilarity($queryEmbedding, $docEmbedding);
// Trova il file txt corrispondente
$baseName = basename($embFile, '.emb');
$txtFile = $this->txtPath . '/' . $baseName . '.txt';
if (file_exists($txtFile)) {
$results[] = [
'file' => $baseName . '.txt',
'path' => $txtFile,
'similarity' => $similarity,
'percentage' => $similarity * 100
];
}
}
// Ordina per similarità decrescente
usort($results, function($a, $b) {
return $b['similarity'] <=> $a['similarity'];
});
// Restituisci solo i primi N risultati
return array_slice($results, 0, $topN);
}
/**
* Ottiene il contenuto di un file dal repository
*
* @param string $filename Nome del file
* @return string Contenuto del file
* @throws Exception Se il file non esiste
*/
public function getFileContent($filename) {
$filePath = $this->txtPath . '/' . $filename;
if (!file_exists($filePath)) {
throw new Exception("File non trovato: $filename");
}
$content = file_get_contents($filePath);
if ($content === false) {
throw new Exception("Impossibile leggere il file");
}
return $content;
}
/**
* Lista tutti i file nel repository
*
* @return array Array di file con informazioni
*/
public function listFiles() {
if (!isset($this->basePath)) {
throw new Exception("Nessun repository caricato");
}
$txtFiles = glob($this->txtPath . '/*.txt');
$files = [];
foreach ($txtFiles as $txtFile) {
$basename = basename($txtFile);
$embFile = $this->embPath . '/' . str_replace('.txt', '.emb', $basename);
$files[] = [
'name' => $basename,
'path' => $txtFile,
'size' => filesize($txtFile),
'has_embedding' => file_exists($embFile),
'modified' => filemtime($txtFile)
];
}
return $files;
}
/**
* Calcola l'embedding di un testo usando Gemini API
*
* @param string $text Testo di input
* @return array Array con i valori dell'embedding
* @throws Exception Se la chiamata API fallisce
*/
private function calculateEmbedding($text) {
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:embedContent";
$data = [
'model' => "models/{$this->model}",
'content' => [
'parts' => [
['text' => $text]
]
],
'taskType' => 'SEMANTIC_SIMILARITY',
'outputDimensionality' => $this->outputDimensionality
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'x-goog-api-key: ' . $this->apiKey,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
throw new Exception("Errore cURL: $error");
}
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("Errore API (HTTP $httpCode): $response");
}
$result = json_decode($response, true);
if (!isset($result['embedding']['values'])) {
throw new Exception("Risposta API non valida: " . print_r($result, true));
}
return $result['embedding']['values'];
}
/**
* Calcola la similarità del coseno tra due vettori
*
* @param array $vec1 Primo vettore
* @param array $vec2 Secondo vettore
* @return float Valore di similarità (0-1)
*/
private function cosineSimilarity($vec1, $vec2) {
if (count($vec1) !== count($vec2)) {
throw new Exception("I vettori devono avere la stessa dimensione");
}
$dotProduct = 0.0;
$magnitude1 = 0.0;
$magnitude2 = 0.0;
$length = count($vec1);
for ($i = 0; $i < $length; $i++) {
$dotProduct += $vec1[$i] * $vec2[$i];
$magnitude1 += $vec1[$i] * $vec1[$i];
$magnitude2 += $vec2[$i] * $vec2[$i];
}
$magnitude1 = sqrt($magnitude1);
$magnitude2 = sqrt($magnitude2);
if ($magnitude1 == 0 || $magnitude2 == 0) {
return 0.0;
}
return $dotProduct / ($magnitude1 * $magnitude2);
}
/**
* Genera un ID univoco basato su timestamp e random
*
* @return string ID univoco
*/
private function generateUniqueId() {
return uniqid('', true) . '_' . mt_rand(1000, 9999);
}
/**
* Cerca file nel repository per ID o nome
*
* @param string $identifier Identificatore del file
* @return array Array di file trovati
*/
private function findFiles($identifier) {
$txtFiles = glob($this->txtPath . '/*' . $identifier . '*.txt');
$files = [];
foreach ($txtFiles as $txtFile) {
$basename = basename($txtFile, '.txt');
$embFile = $basename . '.emb';
$files[] = [
'txt' => basename($txtFile),
'emb' => $embFile
];
}
return $files;
}
}
La classe EmbeddingManager è il cuore del sistema e si appoggia alle API di Google Gemini per il calcolo degli embeddings. Vediamo i suoi metodi principali:
Gestione del Repository
Il metodo createRepository() crea una struttura organizzata con due cartelle: TXT per i documenti originali e EMB per i loro embeddings. Questa separazione mantiene il sistema ordinato e facilita la manutenzione.
Con loadRepository() è possibile caricare un repository esistente, permettendo di lavorare su basi di conoscenza già preparate.
Aggiunta e Rimozione dei Documenti
Il metodo addFile() è dove avviene la magia: legge un file di testo, genera un ID univoco, calcola l'embedding tramite l'API di Gemini e salva sia il documento che il suo vettore. Ogni file viene identificato con un ID univoco seguito dal nome originale, facilitando la tracciabilità.
Il metodo removeFile() elimina sia il documento che il suo embedding, mantenendo il repository pulito.
Ricerca Semantica
Il cuore del sistema è il metodo search(): riceve una query testuale, calcola il suo embedding, lo confronta con tutti gli embeddings salvati utilizzando la similarità del coseno, e restituisce i documenti più rilevanti ordinati per pertinenza. Il parametro topN permette di decidere quanti risultati ottenere.
Metodi di Supporto
getFileContent() recupera il contenuto di un documento specifico, mentre listFiles() fornisce una panoramica di tutti i documenti nel repository con informazioni su dimensione, data di modifica e presenza dell'embedding.
I metodi privati calculateEmbedding() e cosineSimilarity() gestiscono rispettivamente la comunicazione con l'API di Gemini e il calcolo matematico della similarità tra vettori.
L'Esempio Pratico: Un Assistente Farmaceutico
<?php
//
// Esempio di utilizzo della classe EmbeddingManager
//
// inclusioni
require_once 'EmbeddingManager.php';
// Inizializza con la tua API key
$manager = new EmbeddingManager('***la_tua_api_key***');
// 1. Crea un nuovo repository
$manager->createRepository('repository2', '/home/emanuele/Clienti/EDwareLab/chatbot/embedding/');
// 2. Aggiungi file al repository
$result = $manager->addFile('/home/emanuele/Clienti/EDwareLab/chatbot/embedding/testo1.txt');
echo "File aggiunto con ID: " . $result['id'] . "\n";
$result = $manager->addFile('/home/emanuele/Clienti/EDwareLab/chatbot/embedding/testo2.txt');
echo "File aggiunto con ID: " . $result['id'] . "\n";
$result = $manager->addFile('/home/emanuele/Clienti/EDwareLab/chatbot/embedding/testo3.txt');
echo "File aggiunto con ID: " . $result['id'] . "\n";
$result = $manager->addFile('/home/emanuele/Clienti/EDwareLab/chatbot/embedding/testo4.txt');
echo "File aggiunto con ID: " . $result['id'] . "\n";
// 3. Cerca il file più simile
$results = $manager->search('Ho gli occhi rossi e tanto prurito. In più starnutisco almeno 30 volte al giorno. Cosa posso prendere?');
if (!empty($results)) {
echo "File più simile: " . $results[0]['file'] . "\n";
echo "Similarità: " . $results[0]['percentage'] . "%\n";
// Leggi il contenuto
$content = $manager->getFileContent($results[0]['file']);
}
// Lista tutti i file
$files = $manager->listFiles();
print_r($files);
?>
Nel file test.php troviamo un caso d'uso concreto che simula un sistema di supporto farmaceutico. Ecco come funziona passo dopo passo:
Viene creato un nuovo repository chiamato "repository2" in una directory specifica. Successivamente, vengono aggiunti quattro file di testo, ciascuno contenente informazioni su una categoria di farmaci: antibiotici, antidolorifici, antistaminici e antinfiammatori.
La parte interessante arriva con la ricerca. L'utente pone una domanda in linguaggio naturale: "Ho gli occhi rossi e tanto prurito. In più starnutisco almeno 30 volte al giorno. Cosa posso prendere?"
Il sistema non cerca semplicemente parole chiave come "occhi rossi" o "prurito". Invece, comprende che questi sintomi (arrossamento oculare, prurito, starnuti frequenti) sono caratteristici di una reazione allergica. L'embedding della query viene confrontato con quelli dei quattro documenti, e il sistema identifica correttamente il file sugli antistaminici come il più rilevante, restituendo un punteggio di similarità in percentuale.
Infine, viene recuperato il contenuto completo del documento più pertinente, che può essere utilizzato per generare una risposta informativa all'utente.
Perché Scegliere Questa Soluzione
Questa implementazione offre diversi vantaggi pratici. Essendo scritta in PHP puro, è immediatamente compatibile con qualsiasi ambiente PHP, inclusi CMS come WordPress. Immaginate un plugin WordPress che permette di creare una knowledge base intelligente per un sito di e-commerce o un portale informativo, senza bisogno di infrastrutture complesse.
Per volumi contenuti (fino a 50 documenti circa), questo approccio basato su file system è sorprendentemente efficiente. Non richiede l'installazione di database vettoriali come Pinecone, Weaviate o ChromaDB, eliminando complessità architetturali e costi di hosting aggiuntivi. La ricerca lineare attraverso poche decine di vettori è praticamente istantanea su hardware moderno.
La struttura del repository è trasparente e facilmente ispezionabile: ogni file è leggibile e modificabile direttamente, rendendo il debugging e la manutenzione estremamente semplici. Inoltre, il sistema è completamente portabile: basta copiare la cartella del repository per trasferire l'intera knowledge base.
Conclusione
Questo mini RAG rappresenta un equilibrio perfetto tra semplicità e funzionalità. Non è progettato per competere con sistemi enterprise che gestiscono milioni di documenti, ma per quella vasta categoria di applicazioni dove serve una ricerca semantica intelligente senza l'overhead di soluzioni più complesse.
La compatibilità con WordPress apre scenari interessanti: chatbot per siti aziendali, sistemi di supporto clienti, knowledge base interne, FAQ intelligenti. Tutto questo utilizzando solo PHP e un'API key di Gemini, senza dipendenze complicate o server dedicati.
Per progetti di piccola-media scala, questa soluzione offre il giusto compromesso tra potenza e praticità, dimostrando che non sempre servono strumenti complessi per ottenere risultati intelligenti.