Ho Costruito un Sistema RAG Locale per la Mia Squadra di Pallavolo. Ecco Com'e' Andata.
Costruire un chatbot che risponde a domande su risultati, classifiche e calendari -- con Ollama, ChromaDB e FastAPI, tutto in locale.
Perche’ l’Ho Fatto
Aiuto a gestire una societa’ di pallavolo — RM Volley, con sede a Piacenza. Abbiamo diverse squadre per categoria: Serie D femminile, Under 18, Under 16, Under 14, Seconda Divisione. I dati delle partite arrivano dalla FIPAV come fogli Excel. Le classifiche sono scraping dal loro sito in formato JSON. Calendari, risultati, parziali, impianti — c’e’ tutto, ma sparso in file che nessuno ha voglia di aprire.
La domanda che tornava sempre: “Quando gioca la prossima la Under 18?” oppure “Com’e’ andata la RM Volley Piacenza sabato?” Domande semplici, ma per rispondere bisognava aprire l’Excel, trovare le righe giuste, e decifrare le sigle della federazione.
Cosi’ ho costruito un chatbot. Fai una domanda in italiano, ottieni una risposta con i dati reali. Nessuna API cloud, nessun abbonamento — solo Ollama in locale e un backend Python.
L’Architettura
Il sistema e’ un’applicazione FastAPI con ChromaDB per la ricerca vettoriale e Ollama per la generazione di testo.
Il flusso di una richiesta:
- L’utente fa una domanda via
POST /ask - L’API rileva l’intento: classifica? Risultati passati? Prossime partite? Quale squadra?
- Il retriever cerca in ChromaDB i chunk rilevanti
- Il prompt builder assembla istruzioni di sistema + contesto recuperato + domanda
- Ollama genera la risposta
Client --> FastAPI --> Rilevamento Intento --> Retriever (ChromaDB)
|
v
Prompt Builder
|
v
Ollama (LLM)
|
v
Risposta
La parte LLM ha richiesto forse due giorni. La logica di retrieval, il chunking e il routing degli intenti hanno richiesto settimane.
Ingestion: Trasformare Dati Sportivi in Chunk Cercabili
Prima che il sistema possa rispondere a qualsiasi cosa, i dati delle partite e le classifiche devono diventare qualcosa su cui un database vettoriale puo’ fare ricerca. Le mie fonti dati sono specifiche:
Gare.xls— Export Excel dalla FIPAV con ogni partita: date, squadre, punteggi, parziali, impianti, campionati, stato della garaclassifica.json— Classifiche scraping con punti, vittorie, sconfitte, rapporto set
La strategia di chunking e’ specifica per il dominio. Niente sliding window, niente splitting generico del testo.
Un Chunk per Partita
Ogni riga dell’Excel diventa un singolo chunk semantico, scritto come testo italiano naturale:
def create_match_chunk(self, match):
"""Converte un record partita in un chunk semantico."""
chunks = []
chunks.append(f"Partita del {data}: {squadra_casa} vs {squadra_ospite} (Squadra {categoria})")
if ha_risultato:
chunks.append(f"Risultato finale: {risultato}")
chunks.append(f"{squadra_rm} ha vinto {risultato} contro {avversario}")
if parziali:
chunks.append(f"Parziali: {parziali}")
chunks.append(f"Impianto: {impianto}")
chunks.append(f"Campionato: {campionato}")
text = ". ".join(chunks) + "."
return {"id": f"match_{match_id}", "text": text, "metadata": metadata}
L’intuizione chiave: ogni chunk porta metadata ricchi — nomi squadre, data, campionato, risultato, se RM Volley giocava in casa o fuori, la categoria (Under 18, Serie D, ecc.). Questi metadata sono fondamentali per il filtraggio successivo.
Un Chunk per Classifica di Campionato
Per le classifiche, un’intera tabella di campionato diventa un chunk, con un preambolo a forma di domanda per migliorare il matching semantico:
def create_league_standing_chunk(self, league_name, teams):
"""Crea un chunk classifica con preambolo semantico."""
preamble = (
f"Qual e' la classifica della {league_name}? "
f"Ecco la classifica aggiornata della {league_name}:\n"
)
for i, team in enumerate(teams, 1):
preamble += f"{i}. {team.name} - {team.points} punti "
preamble += f"({team.wins} vittorie, {team.losses} sconfitte)\n"
return {"id": f"standing_{league_name}", "text": preamble, "metadata": {...}}
Quel trucco del preambolo-domanda (“Qual e’ la classifica della…?”) e’ stato intenzionale. Quando un utente chiede della classifica, l’embedding della domanda corrisponde naturalmente all’embedding del preambolo. Una tecnica semplice che ha migliorato sensibilmente il retrieval per questo tipo di query.
Modello di Embedding
Uso intfloat/multilingual-e5-small — un modello multilingue a 384 dimensioni. Una scelta deliberata rispetto al piu’ comune all-MiniLM-L6-v2, perche’ tutti i miei dati e le query sono in italiano. Il modello multilingue gestisce il vocabolario e la struttura delle frasi italiane significativamente meglio.
I documenti vengono embedati in batch da 32 usando sentence-transformers e salvati in ChromaDB con i loro metadata.
La Parte Intelligente: Routing delle Query Basato sull’Intento
Questa e’ probabilmente la parte piu’ interessante del sistema, e quella che ha fatto la differenza piu’ grande sulla qualita’ delle risposte.
Non tutte le domande devono interrogare il database vettoriale allo stesso modo. “Qual e’ la classifica?” e “Quando gioca la prossima la Under 18?” richiedono strategie di retrieval completamente diverse.
L’endpoint /ask rileva l’intento usando keyword matching in italiano:
standings_keywords = ["classifica", "posizione", "punti", "graduatoria"]
past_keywords = ["recente", "giocato", "risultat", "vinto", "perso", "com'e' andata"]
future_keywords = ["prossima", "prossime", "calendario", "quando gioca"]
stats_keywords = ["statistiche", "bilancio", "andamento", "forma", "stagione"]
E poi instrada di conseguenza:
- Query classifica — recupera solo documenti di tipo standing
- Query partita passata per una squadra — recupera partite con risultato, ordinate dalla piu’ recente
- Query partita futura — recupera partite senza risultato, ordinate dalla piu’ vicina
- Query statistiche — combina risultati passati E prossima partita per la squadra rilevata
- Query generica — ricerca standard per similarita’ vettoriale
C’e’ anche il rilevamento squadra via regex (RM\s*VOLLEY\s*#?(\d+), RM\s*VOLLEY\s*PIACENZA) per limitare le query a una squadra specifica.
Un dettaglio piccolo ma utile: il sistema rileva singolare vs. plurale. “La prossima partita” restituisce un risultato. “Le prossime partite” ne restituisce diversi. Questo tipo di routing consapevole della lingua conta quando il dominio e’ cosi’ specifico.
Retrieval: Dove Vive la Qualita’
ChromaDB gestisce la ricerca vettoriale con similarita’ del coseno su indice HNSW. Il retrieval base e’ semplice:
def retrieve(self, query, n_results=5, filter_metadata=None):
query_embedding = self.embedder.embed_query(query)
query_params = {
"query_embeddings": [query_embedding],
"n_results": n_results,
}
if filter_metadata:
query_params["where"] = filter_metadata # es. {"type": "match"}
results = self.collection.query(**query_params)
return results
Ma il retrieval per squadra specifica e’ dove le cose si fanno interessanti. Recupera un set ampio di risultati, poi filtra in Python:
- Tiene solo documenti partita (non classifiche)
- Filtra per nome squadra (case-insensitive, gestendo variazioni di spaziatura)
- Parsa le date e filtra per passato/futuro
- Ordina per data (piu’ recente prima per il passato, piu’ vicina prima per il futuro)
- Tronca al numero richiesto
Questo approccio ibrido — ricerca vettoriale per il recall iniziale, poi filtraggio deterministico per la precisione — funziona bene quando i dati hanno attributi strutturati chiari. La ricerca semantica pura confonderebbe squadre con nomi simili o restituirebbe partite future quando hai chiesto del passato.
Cosa Ho Imparato su n_results
Ho iniziato con n_results=10 pensando che piu’ contesto fosse meglio. Per le query sulle partite a volte lo era, ma per classifiche e domande specifiche, il LLM si confondeva con chunk poco rilevanti che contraddicevano quelli piu’ rilevanti. Il default e’ finito a 5 per query generiche, con aggiustamento dinamico basato sul tipo di query (1 per “prossima partita,” di piu’ per statistiche).
Design del Prompt: Vincolare il Modello in Italiano
L’intero prompt e’ in italiano. Il system prompt e’ dettagliato e difensivo:
Sei un assistente di statistiche di pallavolo per RM Volley.
DATA ODIERNA: {data di oggi}
CRITICO - DISTINZIONE TEMPORALE:
- Se lo stato della partita e' "da giocare" → la partita e' nel FUTURO
- Se la partita ha un risultato (es. "3-1") → la partita e' gia' stata giocata
- NON inventare risultati per partite future
CRITICO - DISTINZIONE SQUADRE (NON CONFONDERLE MAI):
- "RM VOLLEY PIACENZA" = Serie D Femminile
- "RMVOLLEY#18" = Under 18 Femminile
- "RMVOLLEY#16" = Under 16 Femminile
(queste sono squadre DIVERSE!)
Due cose che ho imparato a mie spese:
-
Iniettare la data odierna. Senza di essa, il modello non ha concetto di “passato” vs. “futuro”. Una partita della prossima settimana sembra uguale a una del mese scorso se non gli dici che giorno e’.
-
Disambiguazione esplicita delle squadre. All’inizio il modello confondeva continuamente RM VOLLEY PIACENZA (Serie D adulte) con RMVOLLEY#18 (giovanili). Nomi simili. Il system prompt ora elenca ogni squadra con la sua categoria, e il prompt utente rinforza “NON confondere queste.”
Il prompt utente avvolge il contesto recuperato e aggiunge istruzioni rigorose su come gestire classifiche (copia l’ordine esatto, non riordinare) e partite (primo elemento = piu’ rilevante, non confondere passato e futuro).
La temperatura e’ impostata a 0.5 — un po’ di flessibilita’ per un italiano dal suono naturale, ma abbastanza bassa per mantenere le risposte ancorate al contesto.
Ollama in Locale
Il sistema supporta due backend LLM tramite una semplice astrazione:
- Ollama (locale) — il default, con
mistral:7bsu hardware locale - Groq (cloud) — opzionale, con
llama-3.3-70b-versatilevia API compatibile OpenAI
L’integrazione Ollama e’ minimale:
def generate(self, prompt, system_prompt=None, temperature=0.5, max_tokens=400):
payload = {
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
if system_prompt:
payload["system"] = system_prompt
response = requests.post(
f"{self.base_url}/api/generate",
json=payload,
timeout=60
)
return response.json()["response"]
Niente streaming — la risposta completa arriva tutta insieme. Per un tool interno dove le risposte sono brevi (risultati partita, classifiche), l’attesa e’ accettabile. Lo streaming e’ nella lista delle cose da fare ma non e’ stato necessario finora.
Scelta del Modello
Mi sono stabilizzato su mistral:7b per l’uso quotidiano. Gestisce bene l’italiano e segue le istruzioni strutturate in modo affidabile. llama3.2:3b e’ il default di fallback nel codice — piu’ veloce ma notevolmente peggiore nel seguire le regole di disambiguazione squadre. L’opzione Groq con llama-3.3-70b-versatile e’ li’ per quando voglio qualita’ superiore senza comprare hardware GPU.
La conclusione onesta: quando il retrieval fornisce al modello il contesto giusto, anche un modello 7B da’ risposte solide. Il gap di qualita’ tra modelli si riduce drasticamente quando l’input e’ buono.
Il Frontend
Un’interfaccia chat in HTML/JS vanilla. Tema scuro, nessun framework, nessun build step. Manda richieste POST a /ask e mostra la risposta. Niente di sofisticato, ma funziona.
Il frontend manda temperature: 0.5 e n_results: 10 come default, con la possibilita’ di filtrare per tipo di documento (partite vs. classifiche).
La Conclusione
Il RAG e’ una pipeline backend con un modello linguistico come componente. Il LLM e’ la parte facile. Le parti difficili sono:
- Trasformare dati disordinati in chunk buoni con metadata ricchi
- Instradare le query verso la giusta strategia di retrieval
- Scrivere prompt che vincolino il modello al contesto recuperato
Per un tool specifico per dominio come questo, dove i dati sono strutturati e le domande prevedibili, il routing basato sull’intento e il filtraggio metadata hanno contato molto piu’ della dimensione del modello. Un modello 7B che risponde dal contesto giusto batte un modello 70B che tira a indovinare.
Il sistema funziona. Risponde a domande su risultati partita, calendari e classifiche correttamente, in italiano, da dati locali, senza chiamare nessuna API esterna. Quando non ha le informazioni, lo dice.
Quest’ultima parte e’ tutto il trucco.