Salta al contenuto principale

Travel Intelligence Engine: RAG, LLM e 8.802 POI Open Data

Come ho costruito un motore di travel intelligence open source con Redis HNSW, sentence-transformers, Cloudflare Workers AI e 8.802 POI da OSM e Wikidata.

Cosa ne pensi di questo articolo?

Travel Intelligence Engine: RAG, LLM e 8.802 POI Open Data
AI EngineeringFoto:Pavel DanilyukPexels

In Breve

Come ho costruito un motore di travel intelligence open source con Redis HNSW, sentence-transformers, Cloudflare Workers AI e 8.802 POI da OSM e Wikidata.

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

  1. Perché un altro Travel Intelligence Engine
  2. Architettura a 4 layer
  3. Data layer: 17 ingestor e 8.802 POI
  4. RAG layer: Redis HNSW + cross-encoder re-rank
  5. LLM layer: Cloudflare primario, Ollama fallback
  6. UI layer: Angular SSR + Spring Boot gateway
  7. Publishing layer: dataset citabile con DOI
  8. 5 lezioni che ho imparato a caro prezzo
  9. Numeri di performance e costi reali
  10. Roadmap e cosa non ho fatto
  11. Link al dev-tool live e al dataset
  12. 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:

  1. Cita sempre le fonti nel payload di risposta: ogni POI referenziato porta source (osm o wikidata), source_id e license.
  2. È auto-hostabile end-to-end: se stacchi Cloudflare, fallback Ollama su GPU locale o CPU sufficiente.
  3. Pubblica il dataset sottostante come CC-BY 4.0 con metadata DCAT-AP + schema.org/Dataset.
  4. È 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à:

I 4 layer del Travel Intelligence Engine e le loro 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:

  1. Join esplicito via wikidata tag OSM: molti POI OSM hanno un tag wikidata=Q.... Per questi, il match è 1:1.
  2. Match geografico + name similarity: per i restanti, cerco POI Wikidata entro 100 m con nome con Levenshtein ratio > 0.85.
  3. 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:

  1. Embedding del prompt con sentence-transformers all-MiniLM-L6-v2 (384 dimensioni).
  2. KNN search in Redis HNSW con k=20 (sovra-campiono).
  3. 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.

Confronto vector DB per il Travel Intelligence Engine
Prodotto Self-host? Costo minimo HNSW? Ho scelto?
PineconeNo (SaaS)~70 USD/meseProprietarioNo
WeaviateSiDocker gratisSiNo (overhead)
QdrantSiDocker gratisSiNo (un container in più)
Redis Stack 7SiGià installatoSi (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:

  1. 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.
  2. Latency europea: PoP Cloudflare a Milano, Francoforte, Amsterdam. P50 latency misurata da Bari: 180 ms al primo token. Confrontabile con OpenAI dal mio PoP.
  3. Compatibilità API: Cloudflare espone un endpoint HTTP semplice, senza SDK proprietario pesante. Un httpx.AsyncClient basta.
# 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 BackendApiService con 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:

  1. Exporter (publishing/exporter.py): interroga PostgreSQL, produce data.csv RFC 4180, data.json, data.geojson con FeatureCollection.
  2. Metadata builder (metadata_builder.py): genera metadata.jsonld in formato DCAT-AP 2.1 e schema-org.jsonld per Google Dataset Search.
  3. Validators (validators.py): schema validation, attribution check, SHA-256 manifest.
  4. Publisher (publisher.py): copia gli artefatti in frontend/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.

Performance misurate del Travel Intelligence Engine (aprile 2026)
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.

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

  1. Apri il POI Finder Italia e cerca la tua città.
  2. Se trovi un POI errato o mancante, contribuisci su OpenStreetMap: sarà nella prossima snapshot ingestion.
  3. Se vuoi il dataset raw dei 8.802 POI, aspetta la release v1.0.0 (CC-BY 4.0 + DOI Zenodo, Q2 2026).
  4. Se vuoi discutere l'architettura o proporre miglioramenti, scrivimi: fedcal01@gmail.com.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti