Travel Intelligence Engine: RAG, LLM e 8.802 POI Open Data
Ho costruito un motore di travel intelligence open source
nel tempo libero, partendo da una domanda molto concreta: perché
tutti i trip planner AI oggi in circolazione sembrano uscire dallo
stesso stampo, allucinano ristoranti che non esistono e non citano mai
le fonti? Il Travel Intelligence Engine è la mia risposta
tecnica a quella domanda. È un sistema composto da 8.802
punti di interesse ingestati (7.518 da OpenStreetMap in
licenza ODbL, 1.284 da Wikidata in CC0), un layer RAG basato su
Redis Stack 7 con indice HNSW, sentence-transformers
all-MiniLM-L6-v2 con re-rank cross-encoder, e un layer
LLM che usa Cloudflare Workers AI come primario e
Ollama locale come fallback deterministico quando
la rete cade.
In questo articolo racconto tutto: le decisioni architetturali, i trade-off che ho scartato, i numeri di latency misurati in dev, i costi reali in euro/mese, le tre o quattro volte in cui ho sbagliato strada e dovuto fare marcia indietro. Non è una tesi di dataset: è un case study engineering su come mettere in produzione un sistema RAG travel specifico per l'Italia senza spendere più di 20 euro al mese e senza finire legato a OpenAI.
Sintesi tecnica in 30 secondi
- Data layer: 8.802 POI (7.518 OSM ODbL + 1.284 Wikidata CC0) in PostgreSQL 16 + PostGIS, schema
open_data.od_pois. - Ingestion: 17 ingestor Python (Overpass, Wikidata SPARQL, Open-Meteo, Eventbrite, ISTAT, EDAN Smithsonian, MiC Cultura, Arts Council UK e altri).
- RAG: Redis Stack 7 HNSW, sentence-transformers
all-MiniLM-L6-v2(384 dim) + cross-encoder re-rank. - LLM: Cloudflare Workers AI primario, Ollama locale (
llama3.1:8b) in fallback deterministico. - Endpoint AI:
/api/v1/open-data/travel/trip-planner,/similar-poi,/destination-match. - Publishing: dataset CC-BY 4.0 con DCAT-AP 2.1 e DOI Zenodo (in arrivo).
- Dev-tool live: POI Finder Italia.
Indice dell'articolo
- Perché un altro Travel Intelligence Engine
- Architettura a 4 layer
- Data layer: 17 ingestor e 8.802 POI
- RAG layer: Redis HNSW + cross-encoder re-rank
- LLM layer: Cloudflare primario, Ollama fallback
- UI layer: Angular SSR + Spring Boot gateway
- Publishing layer: dataset citabile con DOI
- 5 lezioni che ho imparato a caro prezzo
- Numeri di performance e costi reali
- Roadmap e cosa non ho fatto
- Link al dev-tool live e al dataset
- Domande frequenti (FAQ)
1. Perché un altro Travel Intelligence Engine
Se provi oggi Google Trips, Airbnb Experiences o una qualsiasi delle decine di AI trip planner lanciate dopo il boom LLM, noti tre cose ricorrenti.
La prima: quasi tutti funzionano come un wrapper thin sopra GPT-4 o Claude, senza un layer RAG serio. Chiedi "cosa faccio a Lecce per tre giorni" e ricevi un itinerario plausibile ma non verificabile: quella trattoria esiste? A che ora apre? Il museo è aperto lunedì? Nessuno cita le fonti.
La seconda: nessuno di questi servizi è auto-hostable. Se domani OpenAI raddoppia il prezzo, sei bloccato. Il mio portfolio gira su un singolo VPS Hetzner con budget AI mensile di circa 15 euro, quindi dipendere da un provider in cambio valuta variabile non era un'opzione.
La terza: nessuno pubblica dataset. Il valore di avere 8.802 POI italiani puliti, deduplicati e con attribuzione ODbL/CC0 corretta è enorme - ma resta nascosto dentro un prodotto chiuso. Io volevo l'opposto: estrarre il dataset come artefatto citabile, pubblicarlo su DCAT-AP 2.1 con DOI Zenodo, e lasciare che chiunque lo possa fork-are o riusare.
Cosa fa diversamente il mio motore?
Quattro cose misurabili:
- Cita sempre le fonti nel payload di risposta: ogni POI referenziato porta
source(osmowikidata),source_idelicense. - È auto-hostabile end-to-end: se stacchi Cloudflare, fallback Ollama su GPU locale o CPU sufficiente.
- Pubblica il dataset sottostante come CC-BY 4.0 con metadata DCAT-AP + schema.org/Dataset.
- È scopato sull'Italia (per ora): non promette di pianificare viaggi in Giappone, quindi non allucina ristoranti di Osaka.
2. Architettura a 4 layer
Il Travel Intelligence Engine è un sistema a quattro layer, ognuno con un confine netto e un'unica responsabilità:
| # | Layer | Tecnologia | Responsabilità | Output |
|---|---|---|---|---|
| 1 | Data | PostgreSQL 16 + PostGIS | Ingestion, deduplica, storage normalizzato | 8.802 righe in od_pois |
| 2 | RAG | Redis Stack 7 (HNSW) + sentence-transformers | Embedding, semantic search, cross-encoder re-rank | Top-5 POI rilevanti per query |
| 3 | LLM | Cloudflare Workers AI / Ollama | Generazione narrativa, spiegazione, itinerario | Testo strutturato (JSON o SSE stream) |
| 4 | UI / API | Spring Boot 3.4 + Angular 21 SSR | Auth, rate limit, delivery HTTP, frontend | REST API + dev-tool live |
Ho tenuto questi quattro layer fisicamente separati perché ciascuno ha un ciclo di vita diverso. Il data layer cambia settimanalmente (re-ingest OSM, re-fresh Wikidata). Il RAG layer cambia quando tocco il modello embedding. Il LLM layer cambia quando passo da Llama 3.1 8B a un modello più grande o quando Cloudflare aggiunge un modello nuovo. L'UI layer cambia ogni volta che aggiungo un dev-tool.
Se li avessi accoppiati in un monolite Python, ogni cambio avrebbe richiesto un deploy completo. Con quattro layer separati, posso aggiornare il data layer senza toccare il frontend e viceversa.
Perché non una architettura event-driven con Kafka?
Tentazione forte e poi scartata. Un sistema con 8.802 POI e meno di mille query al giorno non ha bisogno di Kafka. L'overhead operativo (Zookeeper, broker, consumer group management) ammazzerebbe il budget del VPS. Uso PostgreSQL come source of truth e Redis come cache + vector store. Se un giorno arrivo a 100k query/giorno riconsidererò - ma "premature Kafka" è un anti-pattern che vedo troppo spesso nei progetti portfolio.
Come dialogano fra loro i 4 layer?
La comunicazione è sincrona e HTTP-based in tutto il flusso utente.
Il frontend Angular chiama Spring Boot, che chiama il ml-service
FastAPI, che legge PostgreSQL e Redis. Tutte HTTP/JSON, nessun gRPC,
nessun message broker. Scelto apposta: il debugging è più semplice
(un curl vedi tutto), i retry sono chiari, e il
circuit breaker Resilience4j protegge ogni hop.
L'unica eccezione è l'ingestion: gira in background come job Python schedulato via systemd timer (ogni notte alle 03:00). Non blocca il flusso utente, non ha bisogno di essere sincrono. Se fallisce, ritenta la notte dopo. Zero complessità in più.
# infra/systemd/portfolio-ingestion.timer
[Unit]
Description=Nightly ingestion of open-data POIs
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
3. Data layer: 17 ingestor e 8.802 POI
Il data layer vive in ml-service/app/ingestion/. Qui
un modulo orchestrator.py coordina 17 loader diversi,
ciascuno in un file separato dentro sources/:
# ml-service/app/ingestion/sources/
overpass_osm.py # OpenStreetMap via Overpass API (ODbL)
wikidata_sparql.py # Wikidata entità Q... (CC0)
open_meteo.py # meteo storico/forecast (CC-BY 4.0)
eventbrite_public.py # eventi pubblici
istat_tourism.py # statistiche turismo comune
edan_smithsonian.py # catalogo museale Smithsonian (CC0)
mic_cultura.py # Ministero della Cultura (CC-BY 4.0)
arts_council_uk.py # Arts Council England
culture_fr.py # Ministere de la Culture FR
door_eu_gi.py # indicazioni geografiche EU (DOOR)
eurostat_sdmx.py # statistiche Eurostat via SDMX
jp_gi.py # indicazioni geografiche JP
mobility_database.py # GTFS feed trasporto pubblico
omi_agenzia_entrate.py # Osservatorio Mercato Immobiliare
stackoverflow_survey.py # Developer Survey
uk_gi.py # indicazioni geografiche UK
usda_gi.py # indicazioni geografiche US
Ogni ingestor estende la classe BaseIngestor in
ingestion/base.py. Lo schema minimo è:
# ml-service/app/ingestion/base.py (semplificato)
class BaseIngestor(ABC):
source_id: str
license: str # ODbL, CC0, CC-BY-4.0, ...
@abstractmethod
async def fetch(self, bbox: BoundingBox) -> list[RawRecord]:
"""Pull raw data from upstream (HTTP, SPARQL, file, API)."""
@abstractmethod
def normalize(self, raw: RawRecord) -> POIRecord:
"""Map raw upstream schema to canonical POIRecord."""
async def run(self, bbox: BoundingBox) -> IngestionReport:
raw_records = await self.fetch(bbox)
pois = [self.normalize(r) for r in raw_records]
return await self._upsert(pois) # dedup by (source, source_id)
Come ho deduplicato gli 8.802 POI?
Il 90% della fatica del data layer non è l'ingestion: è la
deduplica. Lo stesso museo può esistere in
OpenStreetMap come node/123456 (tag
tourism=museum) e in Wikidata come Q789012
con coordinate leggermente diverse. Se conti entrambi hai un bias
sistematico di +20-30% sui conteggi.
La strategia che ho adottato è in tre passi:
- Join esplicito via
wikidatatag OSM: molti POI OSM hanno un tagwikidata=Q.... Per questi, il match è 1:1. - Match geografico + name similarity: per i restanti, cerco POI Wikidata entro 100 m con nome con Levenshtein ratio > 0.85.
- Priorità OSM: quando c'è match, vince la riga OSM (più ricca di tag). Wikidata fornisce solo l'ID di riconciliazione.
Schema PostgreSQL + PostGIS
Il cuore è la tabella open_data.od_pois. Ho scelto di
tenere un unico schema open_data separato dal business
per rendere chiara la natura pubblica di questi dati:
-- backend/src/main/resources/db/migration/V030__open_data_pois.sql
CREATE SCHEMA IF NOT EXISTS open_data;
CREATE TABLE open_data.od_pois (
id BIGSERIAL PRIMARY KEY,
source VARCHAR(32) NOT NULL, -- osm | wikidata | edan | mic
source_id VARCHAR(128) NOT NULL, -- node/123, Q456, ...
name TEXT NOT NULL,
category VARCHAR(64) NOT NULL, -- museum | library | theatre
geom GEOGRAPHY(POINT, 4326) NOT NULL,
tags JSONB NOT NULL DEFAULT '{}'::jsonb,
license VARCHAR(32) NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
wikidata_qid VARCHAR(32), -- FK logico per dedup
UNIQUE (source, source_id)
);
CREATE INDEX idx_od_pois_geom ON open_data.od_pois USING GIST (geom);
CREATE INDEX idx_od_pois_category ON open_data.od_pois (category);
CREATE INDEX idx_od_pois_wikidata ON open_data.od_pois (wikidata_qid)
WHERE wikidata_qid IS NOT NULL;
PostGIS è essenziale qui: le query "tutti i musei entro 3 km da
Piazza del Duomo" usano ST_DWithin(geom, :center, 3000)
con indice GIST, e restano sotto i 10 ms anche con 8.802 righe. Non
uso PostGIS per il semantic search (quello va a Redis), ma per il
pre-filtering geografico sì.
Perché 10 km di bounding box per ingestion?
Nella mia prima versione ingerivo tutti i POI entro 50 km dal centroide di ogni città. Risultato: milioni di righe, Overpass API rate-limitata, query lente e nessun beneficio concreto perché un utente che pianifica un weekend a Lecce non vuole il museo di Brindisi a 40 km.
Sono tornato a 10 km dopo aver misurato che oltre quella soglia la
rilevanza percepita crolla. Il trade-off è esplicito: perdo i
"day trip" fuori dalla città principale (Venaria Reale da
Torino, Cinecittà da Roma). La v2 introdurrà una colonna
day_trip_reachable basata su GTFS, ma per ora 10 km è
il sweet spot fra copertura e costo ingestion.
Come gestisco i failure parziali degli ingestor?
Ogni run di ingestion produce un IngestionReport con
contatori: POI fetched, POI normalized, POI upserted, errors. Se un
ingestor fallisce (Overpass 429, Wikidata timeout SPARQL, rete
rotta), il report riporta l'errore ma non rollbacka gli
altri ingestor già completati. Tutto è idempotente: la chiave unica
(source, source_id) garantisce che ri-eseguire
l'ingestor non duplichi righe.
Questo design partial-success ha una conseguenza chiara: il dataset può essere temporaneamente incoerente fra fonti. Lo accetto perché l'alternativa (transazione globale su 17 ingestor) mi costringerebbe a bloccare tutto se Wikidata ha 5 minuti di downtime. Preferisco un dataset 99% aggiornato piuttosto che un dataset bloccato da 24 ore.
Attribution provenance per riga
Una colonna source sola non basta: serve sapere anche
come quel dato è arrivato lì. Per questo tengo in
tags un sottocampo _provenance con
timestamp ingestion, hash del raw record originale, versione
dell'ingestor, URL upstream. Questo rende il dataset audit-
able: se un utente contesta un dato, posso ricostruire
esattamente da dove veniva e a che ora.
-- esempio di riga POI con provenance completa
{
"source": "osm",
"source_id": "node/12345678",
"name": "Museo del Bargello",
"tags": {
"tourism": "museum",
"cuisine": null,
"_provenance": {
"ingested_at": "2026-04-21T03:14:22Z",
"ingestor_version": "overpass_osm.py@1.2.0",
"raw_sha256": "a3f5c8e2...",
"upstream_url": "https://overpass-api.de/api/interpreter"
}
}
}
4. RAG layer: Redis HNSW + cross-encoder re-rank
Il RAG layer è il cuore del sistema. Vive in
ml-service/app/core/rag_engine.py ed è responsabile di
rispondere a una sola domanda: dato un prompt utente ("weekend
romantico a Lecce con musei e un ristorante di pesce"), quali sono i
top-5 POI rilevanti dal mio dataset?
La risposta a questa domanda si costruisce in tre passi:
- Embedding del prompt con sentence-transformers
all-MiniLM-L6-v2(384 dimensioni). - KNN search in Redis HNSW con
k=20(sovra-campiono). - Re-rank con cross-encoder sui 20 candidati, restituisco i top-5.
Perché Redis HNSW e non Pinecone, Weaviate o Qdrant?
Ho valutato quattro opzioni. Il criterio decisivo è stato: self-hosted, sotto il mio controllo, costo marginale zero.
| Prodotto | Self-host? | Costo minimo | HNSW? | Ho scelto? |
|---|---|---|---|---|
| Pinecone | No (SaaS) | ~70 USD/mese | Proprietario | No |
| Weaviate | Si | Docker gratis | Si | No (overhead) |
| Qdrant | Si | Docker gratis | Si | No (un container in più) |
| Redis Stack 7 | Si | Già installato | Si (RediSearch) | Si |
Redis Stack 7 vince perché era già nel mio stack per cache e session store. Aggiungere un modulo RediSearch con indice HNSW non costa un container in più: è lo stesso daemon, stessa connessione, stesso ops burden. Pinecone a 70 USD/mese avrebbe bruciato metà del mio budget AWS target.
Creazione dell'indice HNSW
# ml-service/app/storage/redis_vectors.py (semplificato)
from redis import Redis
from redis.commands.search.field import VectorField, TextField, TagField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
def create_poi_index(r: Redis) -> None:
schema = (
VectorField(
"embedding",
"HNSW",
{"TYPE": "FLOAT32", "DIM": 384, "DISTANCE_METRIC": "COSINE",
"M": 16, "EF_CONSTRUCTION": 200},
),
TextField("name"),
TagField("category"),
TagField("source"),
)
r.ft("idx:poi").create_index(
schema,
definition=IndexDefinition(prefix=["poi:"], index_type=IndexType.HASH),
)
I parametri M=16 e EF_CONSTRUCTION=200
sono quelli di default e con 8.802 righe non ha senso tunarli. Se
arrivassi a milioni di POI userei M=32 e
EF_CONSTRUCTION=400, pagando più memoria ma migliore
recall.
Perché sentence-transformers e non OpenAI embeddings?
Dimensione, costo, bias. OpenAI text-embedding-3-small
produce vettori da 1536 dimensioni contro i 384 di
all-MiniLM-L6-v2. Quattro volte più memoria per ogni
POI. Su 8.802 righe non fa differenza; su 100k sì.
Più importante: OpenAI embeddings sono un servizio a pagamento con
egress di dati verso server USA. Per un progetto che pubblica
dataset CC-BY 4.0 con attribuzione europea, avere un modello
embedding locale, deterministico, senza chiamate esterne
è un vincolo di coerenza. Se domani OpenAI decidesse di retirare
text-embedding-3-small, dovrei re-embeddare tutto.
Con all-MiniLM-L6-v2 ho il checkpoint sul disco, per
sempre.
Il prezzo che pago è qualità embedding leggermente inferiore.
all-MiniLM-L6-v2 è del 2021 e mostra l'età su
contesti multi-lingua complessi. Ma per query travel italiane
("musei etruschi a Firenze", "ristoranti pugliesi
tradizionali a Roma") la qualità è sufficiente, specialmente
dopo il re-rank cross-encoder.
Perché il cross-encoder re-rank è indispensabile?
Questa è la lezione che ho imparato più tardi, e mi ha cambiato la
percezione del RAG. Il semantic search con all-MiniLM-L6-v2
restituisce i top-20 "rilevanti", ma l'ordine dei primi 5 è spesso
sbagliato. Il modello bi-encoder calcola il cosine similarity fra
due vettori indipendenti, mentre un cross-encoder vede la coppia
(query, POI) insieme e ne calcola uno score congiunto.
# ml-service/app/core/rag_engine.py (semplificato)
from sentence_transformers import SentenceTransformer, CrossEncoder
class RAGEngine:
def __init__(self, redis: Redis):
self.redis = redis
self.bi_encoder = SentenceTransformer("all-MiniLM-L6-v2")
self.cross_encoder: CrossEncoder | None = None # lazy load
def load_cross_encoder(self) -> None:
if self.cross_encoder is None:
self.cross_encoder = CrossEncoder(
"cross-encoder/ms-marco-MiniLM-L-6-v2"
)
async def search(self, query: str, k_final: int = 5) -> list[POI]:
# 1) embedding della query
q_vec = self.bi_encoder.encode(query, normalize_embeddings=True)
# 2) KNN in Redis (sovra-campiono k_knn = 4 * k_final)
candidates = await self._knn_search(q_vec, k=4 * k_final)
# 3) re-rank cross-encoder
self.load_cross_encoder()
pairs = [(query, c.name + " " + c.description) for c in candidates]
scores = self.cross_encoder.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [c for c, _ in ranked[:k_final]]
Ho misurato l'impatto con una eval suite di 50 query italiane (musei, ristoranti, teatri, biblioteche) e giudizio umano sul top-5: senza re-rank, il top-5 era "utile" nel 62% dei casi. Con re-rank, nell'84%. Due punti percentuali su tre, con latency aggiuntiva di circa 80 ms per 20 candidati su CPU. Accettabile per un sistema travel dove l'utente è disposto a aspettare qualche secondo per un itinerario ben fatto.
Il bug silenzioso del normalize embeddings
Per due settimane il mio RAG ha restituito risultati apparentemente corretti ma con score cosine similarity stranamente bassi (0.3-0.4 max). Causa: avevo dimenticato di normalizzare gli embedding al momento dell'indexing, pur normalizzandoli alla query. Risultato: cosine similarity calcolato su vettori di magnitudo diversa, quindi mezzo rotto senza che si vedesse nulla.
La lezione: se usi cosine similarity, normalizza sia in
index che in query. Sempre. Senza eccezioni. Il parametro
normalize_embeddings=True di sentence-transformers va
passato in entrambi i punti, e aggiungere un unit test
regressione è obbligatorio.
# ml-service/app/storage/redis_vectors.py (fix dopo il bug)
def index_poi(r: Redis, poi: POI) -> None:
# CRITICO: normalize=True sia in index che in query
embedding = bi_encoder.encode(
poi.name + " " + poi.description,
normalize_embeddings=True, # <-- mancava questo
)
r.hset(
f"poi:{poi.id}",
mapping={
"embedding": embedding.astype(np.float32).tobytes(),
"name": poi.name,
"category": poi.category,
"source": poi.source,
},
)
Come gestisco il multi-lingua nell'embedding?
all-MiniLM-L6-v2 è addestrato prevalentemente su
inglese. Per query italiane funziona grazie al transfer learning,
ma con performance subottimali sui termini specifici (dialetto,
termini culturali italiani). Ho considerato di passare a
paraphrase-multilingual-MiniLM-L12-v2 (384 dim,
multilingue) ma il confronto su eval suite ha dato un modesto
+3% di utilità al costo di +40% di RAM usage. Non vale lo scambio.
La soluzione pragmatica: al momento dell'indexing concateno nome italiano + nome inglese quando disponibili da Wikidata. Un POI come "Duomo di Firenze" viene indicizzato come "Duomo di Firenze Cathedral of Florence Santa Maria del Fiore". Non è elegante, ma in pratica migliora il recall su query sia italiane sia inglesi del 8-10%.
5. LLM layer: Cloudflare primario, Ollama fallback
Il LLM layer prende i top-5 POI dal RAG engine, li passa come
contesto al modello di linguaggio, e genera la narrativa: un
itinerario giorno-per-giorno, una spiegazione, una risposta a
domanda specifica. Vive in ml-service/app/providers/.
Perché Cloudflare Workers AI come primario?
Tre ragioni, in ordine di importanza:
- Prezzo: circa 0.011 USD per 1M token output su
@cf/meta/llama-3.1-8b-instruct, un ordine di grandezza meno di GPT-4o. Per un portfolio con budget < 20 euro/mese è imbattibile. - Latency europea: PoP Cloudflare a Milano, Francoforte, Amsterdam. P50 latency misurata da Bari: 180 ms al primo token. Confrontabile con OpenAI dal mio PoP.
- Compatibilità API: Cloudflare espone un endpoint HTTP semplice, senza SDK proprietario pesante. Un
httpx.AsyncClientbasta.
# ml-service/app/providers/cloudflare.py (semplificato)
import httpx
from app.providers.base import BaseLLMProvider, LLMResponse
class CloudflareProvider(BaseLLMProvider):
def __init__(self, account_id: str, api_token: str, model: str):
self.url = (
f"https://api.cloudflare.com/client/v4/accounts/"
f"{account_id}/ai/run/{model}"
)
self.headers = {"Authorization": f"Bearer {api_token}"}
async def generate(
self, system: str, user: str, max_tokens: int = 1024,
) -> LLMResponse:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
self.url,
headers=self.headers,
json={
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
},
)
resp.raise_for_status()
data = resp.json()
return LLMResponse(
text=data["result"]["response"],
provider="cloudflare",
model=self.model,
)
Perché Ollama come fallback e non come primario?
Ho fatto il giro opposto e poi tornato indietro. Per sei mesi ho
avuto Ollama come primario (llama3.1:8b in locale sul
VPS) e Cloudflare come fallback. Il problema: il VPS Hetzner CX32
ha 8 GB di RAM e senza GPU l'inferenza su llama 8B gira a 3-5
token/sec. Un itinerario di 1000 token richiedeva 3-5 minuti.
Inaccettabile per UX.
Ho invertito i ruoli: Cloudflare primario (veloce, cheap, cloud) e Ollama fallback (lento ma always-available, senza costi per query). Il fallback scatta quando Cloudflare risponde 429, 500+, o timeout > 10s. In quei casi preferisco una risposta lenta a nessuna risposta.
# ml-service/app/providers/fallback.py (semplificato)
class FallbackProvider(BaseLLMProvider):
def __init__(self, primary: BaseLLMProvider, secondary: BaseLLMProvider):
self.primary = primary
self.secondary = secondary
async def generate(self, system: str, user: str, **kwargs) -> LLMResponse:
try:
return await asyncio.wait_for(
self.primary.generate(system, user, **kwargs),
timeout=10.0,
)
except (httpx.HTTPStatusError, asyncio.TimeoutError, httpx.ConnectError) as e:
logger.warning("Primary LLM failed: %s. Falling back.", e)
return await self.secondary.generate(system, user, **kwargs)
Il system prompt engineering per la narrativa travel
Il prompt è una parte critica e spesso sottovalutata. Il mio prompt
per /trip-planner vive in
ml-service/app/api/endpoints/trip_planner.py ed è
strutturato così:
SYSTEM_PROMPT = """Sei un assistente di viaggio esperto, specializzato in Italia.
Rispondi SOLO usando i POI forniti nel contesto. Se il contesto non
contiene informazioni sufficienti, dillo esplicitamente invece di inventare.
REGOLE RIGOROSE:
- Cita SEMPRE il nome esatto del POI come appare nel contesto.
- Non inventare orari di apertura, prezzi, o recensioni non presenti.
- Se l'utente chiede un ristorante specifico non nel contesto, rispondi:
"Non ho dati verificati su questo ristorante nel mio dataset."
- Output: markdown con sezioni per giorno (Mattina, Pomeriggio, Sera).
Contesto POI disponibili:
{context}
"""
La regola "non inventare se non è nel contesto" è cruciale. Un LLM lasciato libero genera ristoranti plausibili che non esistono - il classico hallucination travel. Con questo prompt ho ridotto le hallucination a zero misurate su 50 query di eval (confermato da verifica manuale su OpenStreetMap dei POI citati).
Il formato del contesto POI nel prompt
Come passo i 5 POI top al LLM? Ho testato tre formati: prosa libera, JSON strutturato, YAML con label chiari. Il vincitore misurato è JSON strutturato, perché il modello Llama 3.1 8B ha visto molti esempi di JSON in pre-training e li parsa meglio. Il JSON che injeto nel prompt ha questa forma:
# il contesto che passo al LLM con i top-5 POI
[
{
"id": 1234,
"name": "Museo del Bargello",
"category": "museum",
"tags": {"tourism": "museum", "wheelchair": "yes"},
"coords": [43.7700, 11.2571],
"license": "ODbL",
"source": "osm"
},
{
"id": 5678,
"name": "Trattoria Sostanza",
"category": "restaurant",
"tags": {"cuisine": "tuscan", "outdoor_seating": "no"},
"coords": [43.7731, 11.2469],
"license": "ODbL",
"source": "osm"
}
// ... altri 3 POI
]
Importante: le coordinate [lat, lon] sono troncate a
4 cifre decimali (precisione ~11 m) perché con precisione maggiore
il LLM tende a copiarle male e genera coords invented non
presenti nel dataset. Ridurre la precisione riduce anche il rischio
che il LLM "completi" il numero inventandolo.
Streaming SSE vs batch response
Oggi espongo solo batch response (tutto il testo in una volta). Il
mio /api/v1/open-data/travel/trip-planner aspetta che
Cloudflare risponda completo e poi restituisce JSON. Streaming SSE
è nel TODO: è semplice lato Cloudflare (supportano
stream: true), ma richiede di propagarlo attraverso
Spring Boot → Angular SSR, che aggiunge complessità. Per ora,
un'indicazione di loading UX nell'Angular è sufficiente.
6. UI layer: Angular SSR + Spring Boot gateway
Il layer di ingresso è Spring Boot 3.4 in
backend/src/main/java/.../presentation/OpenDataTravelController.java.
Qui vivono tre endpoint REST:
// backend/src/main/java/.../OpenDataTravelController.java
@RestController
@RequestMapping("/api/v1/open-data/travel")
public class OpenDataTravelController {
@PostMapping("/trip-planner")
@RateLimited(requestsPerMinute = 6) // Bucket4j
public ResponseEntity tripPlanner(
@Valid @RequestBody TripPlanRequest request) {
// 1) input validation via Jakarta
// 2) SSRF guard su URL esterni citati
// 3) delega al ml-service via PythonMlClient
// 4) audit log
TripPlanResponse response = tripPlannerUseCase.execute(request);
return ResponseEntity.ok(response);
}
@GetMapping("/similar-poi/{id}")
@RateLimited(requestsPerMinute = 60)
public ResponseEntity similarPoi(@PathVariable long id) { ... }
@PostMapping("/destination-match")
@RateLimited(requestsPerMinute = 30)
public ResponseEntity destinationMatch(
@Valid @RequestBody DestinationMatchRequest request) { ... }
}
Perché Spring Boot davanti al ml-service Python? Tre motivi:
auth JWT (il ml-service non verifica JWT di suo),
rate limit Bucket4j (6 req/min per l'endpoint più
pesante, AI), e circuit breaker Resilience4j sul
client PythonMlClient (se il ml-service va down, lo
rilevo in 30 secondi e non saturo thread).
Il dev-tool frontend
Il POI Finder Italia live si trova su /dev-tools/travel-intelligence/poi-finder. È un componente Angular standalone che:
- Usa signal-based state management (niente NgRx).
- Fetches POI via
BackendApiServicecon cache HTTP nativa. - Renderizza mappa Leaflet con marker per ogni POI.
- Filtri: categoria, distanza, sorgente (OSM/Wikidata).
L'AI Trip Planner frontend è ancora in TODO. L'endpoint
/trip-planner è live e testabile via curl, ma la UI
Angular dedicata la implementerò nella prossima sprint.
Come ho strutturato la chiamata dal frontend Angular?
Il BackendApiService Angular espone metodi tipati
tramite TypeScript interfaces che rispecchiano i DTO Java. Usando
signal-based state, il dev-tool reagisce in modo reattivo senza
BehaviorSubject esplicite. Il pattern è:
// frontend/src/app/services/backend-api.service.ts (estratto)
export interface TripPlanRequest {
city: string;
days: number;
interests: string[];
language: 'it' | 'en';
}
export interface TripPlanResponse {
itinerary: DayPlan[];
citedPois: PoiReference[];
provider: 'cloudflare' | 'ollama';
generatedAtMs: number;
}
@Injectable({ providedIn: 'root' })
export class BackendApiService {
private http = inject(HttpClient);
private base = environment.apiBaseUrl;
planTrip(req: TripPlanRequest): Observable {
return this.http.post(
`${this.base}/open-data/travel/trip-planner`,
req,
{ headers: { 'Content-Type': 'application/json' } },
);
}
}
Ogni risposta porta citedPois come array separato,
così il frontend può renderizzarli come card cliccabili con link
ai dettagli. Questo pattern "response contiene anche le fonti" è
quello che rende il sistema radicalmente diverso da un thin
wrapper LLM: l'utente vede da dove vengono i POI
suggeriti.
SSR e API fetch: i pitfall che ho evitato
Angular 21 SSR può fare pre-fetch server-side di API chiamate
durante il rendering. Un pitfall classico: se il server-side fetch
fallisce (es. backend down), la pagina non renderizza. Soluzione:
TransferState + fallback silenzioso a "caricamento
client-side" se il server fetch va in timeout. Meglio una pagina
che carica dati lato client che una pagina che non carica affatto.
7. Publishing layer: dataset citabile con DOI
Il layer di publishing trasforma lo stato del database in un
artefatto citabile. Il modulo è
ml-service/app/publishing/ ed espone un CLI:
cd ml-service
APP_ENV=dev ./venv/bin/python -m app.publishing.cli \
--dataset=italy-regional-pois \
--version=1.0.0 \
--changelog="initial release: 8802 POI, OSM + Wikidata"
Il CLI orchestra quattro step:
- Exporter (
publishing/exporter.py): interroga PostgreSQL, producedata.csvRFC 4180,data.json,data.geojsoncon FeatureCollection. - Metadata builder (
metadata_builder.py): generametadata.jsonldin formato DCAT-AP 2.1 eschema-org.jsonldper Google Dataset Search. - Validators (
validators.py): schema validation, attribution check, SHA-256 manifest. - Publisher (
publisher.py): copia gli artefatti infrontend/public/open-data/<slug>/v<semver>/+ invia a Zenodo per DOI.
Perché DCAT-AP 2.1 e schema.org/Dataset?
Sono due standard complementari. DCAT-AP 2.1 è lo standard europeo per metadata di dataset pubblici (usato da data.europa.eu e dal Portale Dati Italiani). Schema.org/Dataset è quello per cui Google indicizza in Dataset Search. Pubblicando entrambi, il dataset è discoverable sia su portali europei sia su Google.
Attribuzione ODbL e license compatibility
Questa è la parte legalmente più delicata. OpenStreetMap richiede
ODbL ShareAlike: se redistribuisci "a substantial part" devi
licenziare il risultato come ODbL, non CC-BY. La mia soluzione:
separare i campi OSM-derivati dai campi autonomi. Il
dataset finale è CC-BY 4.0 per i campi che ho calcolato
io (densità, ranking, score). I campi raw OSM sono marcati
come ODbL nel metadata.jsonld con attribuzione "©
OpenStreetMap contributors". Wikidata è CC0, quindi nessun
problema.
Questa è una zona grigia legale e non sono un avvocato. Ho seguito la guida ufficiale "OSM Licence Guidelines" che distingue dataset produced using OSM (ODbL share-alike obbligatorio) da dataset that merely references OSM (CC-BY ok con attribution). Il mio rientra nella seconda categoria perché pubblico contatori aggregati, non il raw OSM. Ma se domani OSMF mi scrivesse una legal concern, correggerei in 24 ore.
Come verifico l'attribuzione in automatico?
Un validator dedicato (publishing/validators.py)
ispeziona ogni file di output prima della pubblicazione. Controlla
che metadata.jsonld contenga il campo
dct:source per ogni fonte, che
schema-org.jsonld abbia creditText
corretto, e che il README del dataset citi esplicitamente le
licenze upstream. Se manca una riga di attribuzione, il
publisher.py si blocca e non pubblica.
# ml-service/app/publishing/validators.py (estratto)
def validate_attribution(metadata: dict) -> list[str]:
errors = []
sources = metadata.get("dct:source", [])
if not sources:
errors.append("Missing dct:source (attribution required).")
for src in sources:
if "osm" in src.get("@id", "").lower():
if "OpenStreetMap contributors" not in src.get("dct:title", ""):
errors.append(f"OSM source missing proper attribution: {src}")
if "wikidata" in src.get("@id", "").lower():
if src.get("dct:license") != "CC0-1.0":
errors.append(f"Wikidata source license mismatch: {src}")
return errors
8. 5 lezioni che ho imparato a caro prezzo
Lezione 1: il cross-encoder re-rank cambia i top-5 in modo drammatico
La lezione più grossa. Prima di aggiungere il cross-encoder pensavo
che il mio RAG funzionasse bene. Il cosine similarity di
all-MiniLM-L6-v2 restituiva risultati "plausibili".
Poi ho fatto una eval con giudizio umano e ho visto che il top-1
era sbagliato nel 40% dei casi. Il re-rank ha portato quell'errore
al 16%. Se stai costruendo un RAG serio, non skippare mai
il re-rank cross-encoder.
Lezione 2: il bounding box a 10 km è un trade-off ingestion/query
Ingeri troppo (50 km) e paghi in storage, Overpass rate limit, e tempo ingestion. Ingeri troppo poco (5 km) e perdi POI importanti al confine cittadino. 10 km è stato il sweet spot per l'Italia, dove le città sono compatte. Per una futura versione EU-7 (Parigi, Berlino, Madrid) il bounding box dovrà essere 20 km, perché quelle città hanno sprawl maggiore.
Lezione 3: dare contesto OSM al LLM genera itinerari migliori che descrivere a parole
Ho provato due approcci al prompt. Approccio A: "Genera un itinerario a Lecce per un appassionato di barocco". Approccio B: stesso prompt + 5 POI OSM come JSON strutturato con nome, tipo, tag, coordinate. I risultati dell'approccio B sono stati drammaticamente più concreti. Il LLM smette di inventare il "Palazzo Rossi" immaginario e usa "Basilica di Santa Croce" vera. Contesto strutturato >> contesto in prosa.
Lezione 4: Cloudflare primario + Ollama fallback batte entrambi individualmente
Cloudflare solo → dipendente da cloud, se cade il servizio cade.
Ollama solo → lento, UX pessima. Combinati con
FallbackProvider → availability del 99.9% (misurato
su 30 giorni) con P50 latency di 1.2 secondi. Il pattern "primary
+ fallback con timeout stretto" è il mio default ormai per
qualsiasi integrazione esterna.
Lezione 5: pubblicare il dataset costringe alla qualità
Quando il dataset è privato, i bug sono nascosti. Quando lo pubblichi come CC-BY 4.0 con DOI Zenodo e lo indicizzi su Google Dataset Search, ogni errore è pubblico. Questo mi ha costretto a scrivere validators più severi, a controllare attribuzione, a documentare i limiti metodologici. Il publishing layer non è un "nice to have": è il forcing function che tiene alta la qualità del data layer.
9. Numeri di performance e costi reali
Numeri misurati in development su VPS Hetzner CX32 (4 vCPU, 8 GB RAM, no GPU), salvo dove diversamente indicato. Non sono numeri da production: sono il mio baseline onesto.
| Metrica | P50 | P99 | Note |
|---|---|---|---|
| RAG search (embed + HNSW) | 38 ms | 95 ms | Cache embedding disabilitata |
| Cross-encoder re-rank (20 candidati) | 78 ms | 140 ms | CPU only, no batching |
| Cloudflare first token | 180 ms | 450 ms | PoP Milano |
| Trip planner full response (1000 tok) | 3.2 s | 8.5 s | Include RAG + LLM batch |
| Ollama first token (fallback) | 1800 ms | 4200 ms | CPU only, llama3.1:8b |
Costi reali misurati (30 giorni di dev + test)
- Cloudflare Workers AI: circa 4.20 USD su 30 giorni per ~1.2M token. Molto sotto il target di 20 euro/mese.
- VPS Hetzner CX32: 16.40 EUR/mese (Ollama, PostgreSQL, Redis, Spring Boot, Angular SSR).
- Overpass API: zero (fair-use community, ~200 query/mese).
- Wikidata SPARQL: zero (rate-limited ma sufficiente).
- Zenodo DOI: zero (ufficialmente gratuito per dataset open).
- Totale mensile: ~21 EUR/mese. Budget rispettato.
Cache hit rate
Uso Redis come cache per gli embedding di query ricorrenti (TTL 24h) e per le risposte complete LLM (TTL 1h). Hit rate misurato in dev su 7 giorni: 34% per embedding, 18% per LLM response. Numeri bassi perché le query sono molto eterogenee. In production con volume maggiore aspetterei un 50%+.
Memory footprint in RAM
Redis tiene in memoria gli embedding di tutti 8.802 POI (384 float32 * 8.802 = ~13 MB solo per i vettori) più l'overhead indice HNSW (M=16 significa ~32 MB di grafo). Totale: ~45 MB per il vector store. Il resto dello spazio Redis (cache sessioni, rate limiter) occupa altri ~200 MB in produzione. Con VPS da 8 GB di RAM resto largo: i colli di bottiglia sono altrove.
Il modello sentence-transformers occupa ~90 MB in RAM (caricato
una volta, in lifespan FastAPI). Il cross-encoder
~80 MB. PostgreSQL con shared_buffers default ~200 MB. Java
backend con -Xmx1G. In totale il sistema gira comodo in 4 GB
dedicati: il VPS ha margine anche con Ollama spento, e permette
di rilanciare Ollama on-demand per fallback senza saturare la
macchina.
10. Roadmap e cosa NON ho fatto
Il progetto è live ma incompleto. Qui la roadmap con honesty massima:
Cosa c'è, ma poteva essere meglio
- Eval suite: 50 query manuali, serve scalare a 500+ e introdurre regression test automatici.
- Streaming SSE: Cloudflare lo supporta, lo propago a Spring → Angular nella prossima sprint.
- UI AI Trip Planner: endpoint live, UI frontend da implementare.
- Quality of Life Index: dev-tool in sviluppo, roadmap Q3 2026.
Cosa NON ho fatto e non farò a breve
- Multi-lingua LLM: oggi solo italiano. Inglese nel Q3.
- Fine-tuning: troppo costoso per il beneficio atteso con 8k POI.
- Grafo di conoscenza: Wikidata è già un KG, lo uso come join key ma non costruisco un KG mio.
- Mobile app: il frontend Angular SSR è mobile-responsive, non serve app nativa per ora.
11. Link al dev-tool live e al dataset
- 📍 Dev-tool POI Finder Italia: /dev-tools/travel-intelligence/poi-finder
- 🤖 AI Trip Planner (UI in arrivo): /dev-tools/travel-intelligence/trip-planner
- 📊 Dataset correlato (quality of life): /open-data/quality-of-life-italian-capitals
- 🏛️ Dataset correlato (musei): /open-data/museums-open-italy
- 📘 Catalogo Open Data: /open-data
- 📡 Endpoint API:
POST /api/v1/open-data/travel/trip-planner
12. Domande frequenti (FAQ)
Perché non hai usato OpenAI?
Costo, dipendenza da provider USA, egress di dati europei, nessun controllo sul modello. Cloudflare Workers AI è un ordine di grandezza più cheap e ha PoP in Europa. Per un progetto portfolio con budget < 20 euro/mese non era un'opzione.
Perché non Pinecone o Weaviate?
Redis Stack 7 era già nel mio stack per cache e session store. Aggiungere HNSW via RediSearch non costa un container in più. Pinecone a 70 USD/mese avrebbe bruciato metà del mio budget AWS target. Weaviate/Qdrant sono ottimi ma aggiungono un servizio da gestire.
Quanto costa mantenere il sistema?
Circa 21 EUR/mese: 16.40 VPS Hetzner CX32 + 4.20 Cloudflare Workers AI. Tutto il resto è gratis (Overpass, Wikidata, Zenodo). In production AWS il costo salirà a circa 130 EUR/mese con reserved instance, ma la logica è la stessa.
Posso fork-are il codice?
Il codice non è ancora pubblicato su GitHub in toto (è parte del monorepo del portfolio). I dataset pubblicati sono CC-BY 4.0 e riusabili oggi. Sto valutando di estrarre il ml-service come repo separato.
Posso usare il sistema per altri paesi?
L'architettura è agnostica sul paese. Gli ingestor sono parametrizzati per bounding box. Il prompt LLM è attualmente italiano ma facile da tradurre. Una v2.0 multi-paese (EU-7) è in roadmap per Q1 2027.
Come gestisci il rate limit di Overpass API?
Batch ingestion notturno con delay configurabile (default 2s fra query), retry esponenziale su 429, cache Redis sul raw JSON per 7 giorni. Nessun abuso della fair-use community OSM.
Cosa succede se Cloudflare va down?
Dopo 10 secondi di timeout, il FallbackProvider passa
automaticamente a Ollama locale. L'utente vede una risposta più
lenta (P50 ~15 secondi invece di ~3) ma la funzionalità non si
rompe. Loggato come warning per monitoring Grafana.
Perché non hai fatto fine-tuning del LLM?
Costo/beneficio non torna. Fine-tuning su 8.000 POI richiederebbe curare 10.000+ esempi supervisionati, ognuno con budget di annotazione non trascurabile. Il RAG con cross-encoder re-rank ottiene già l'84% di utilità sul top-5. Il rimanente 16% richiederebbe fine-tuning più dati più manutenzione modello. Fuori scope per un progetto portfolio.
È production-ready?
Backend Spring Boot: 97% production-ready (371 test, BUILD SUCCESS, 21 migrazioni Flyway, 84 endpoint). ML service: 95% (120 test pytest). Frontend: 90% (manca UI Trip Planner). Deploy AWS: fase D1 non avviata. Tempo stimato per production: 3 settimane di lavoro concentrato.
Passi operativi per chi legge
- Apri il POI Finder Italia e cerca la tua città.
- Se trovi un POI errato o mancante, contribuisci su OpenStreetMap: sarà nella prossima snapshot ingestion.
- Se vuoi il dataset raw dei 8.802 POI, aspetta la release v1.0.0 (CC-BY 4.0 + DOI Zenodo, Q2 2026).
- Se vuoi discutere l'architettura o proporre miglioramenti, scrivimi: fedcal01@gmail.com.

Commenti
Caricamento commenti...
Accedi per commentare