Travel Intelligence Engine: RAG, LLMs and 8,802 Open Data POIs
I built an open source travel intelligence engine
in my spare time, starting from a very concrete question: why
do all AI trip planners out there today feel like they came from
the same mold, hallucinate restaurants that don't exist, and never
cite their sources? The Travel Intelligence Engine is my
technical answer to that question. It is a system made of
8,802 ingested points of interest (7,518 from
OpenStreetMap under ODbL, 1,284 from Wikidata under CC0), a RAG
layer built on Redis Stack 7 with an HNSW index,
sentence-transformers all-MiniLM-L6-v2 with
cross-encoder re-rank, and an LLM layer using
Cloudflare Workers AI as the primary provider
and local Ollama as a deterministic fallback when
the network fails.
In this article I walk through everything: architectural decisions, trade-offs I rejected, latency numbers measured in dev, real costs in euros per month, and the three or four times I went down the wrong road and had to back out. This is not a dataset thesis: it's an engineering case study on how to put an Italy-specific RAG travel system into production without spending more than 20 euros per month and without getting locked into OpenAI.
Technical summary in 30 seconds
- Data layer: 8,802 POIs (7,518 OSM ODbL + 1,284 Wikidata CC0) in PostgreSQL 16 + PostGIS, schema
open_data.od_pois. - Ingestion: 17 Python ingestors (Overpass, Wikidata SPARQL, Open-Meteo, Eventbrite, ISTAT, EDAN Smithsonian, MiC Cultura, Arts Council UK and more).
- RAG: Redis Stack 7 HNSW, sentence-transformers
all-MiniLM-L6-v2(384 dim) + cross-encoder re-rank. - LLM: Cloudflare Workers AI primary, local Ollama (
llama3.1:8b) as deterministic fallback. - AI endpoints:
/api/v1/open-data/travel/trip-planner,/similar-poi,/destination-match. - Publishing: CC-BY 4.0 dataset with DCAT-AP 2.1 and Zenodo DOI (pending).
- Live dev-tool: POI Finder Italy.
Article index
- Why yet another Travel Intelligence Engine
- 4-layer architecture
- Data layer: 17 ingestors and 8,802 POIs
- RAG layer: Redis HNSW + cross-encoder re-rank
- LLM layer: Cloudflare primary, Ollama fallback
- UI layer: Angular SSR + Spring Boot gateway
- Publishing layer: citable dataset with DOI
- 5 lessons I learned the hard way
- Real performance numbers and costs
- Roadmap and what I did NOT do
- Links to the live dev-tool and dataset
- Frequently asked questions (FAQ)
1. Why yet another Travel Intelligence Engine
If you try Google Trips, Airbnb Experiences or any of the dozens of AI trip planners launched after the LLM boom, you notice three recurring things.
First: almost all of them are thin wrappers around GPT-4 or Claude, with no real RAG layer. Ask "what can I do in Lecce for three days" and you get a plausible but unverifiable itinerary: does that trattoria exist? What time does it open? Is the museum open on Monday? Nobody cites sources.
Second: none of these services are self-hostable. If OpenAI doubles prices tomorrow, you are stuck. My portfolio runs on a single Hetzner VPS with a monthly AI budget of about 15 euros, so depending on a provider priced in a foreign currency was not an option.
Third: nobody publishes the underlying datasets. The value of having 8,802 clean, deduplicated Italian POIs with proper ODbL/CC0 attribution is enormous - but it stays hidden inside a closed product. I wanted the opposite: to extract the dataset as a citable artifact, publish it under DCAT-AP 2.1 with a Zenodo DOI, and let anyone fork or reuse it.
What does my engine do differently?
Four measurable things:
- Always cites sources in the response payload: every referenced POI carries
source(osmorwikidata),source_idandlicense. - Is self-hostable end-to-end: if Cloudflare goes away, fallback to Ollama on local GPU or sufficient CPU.
- Publishes the underlying dataset as CC-BY 4.0 with DCAT-AP + schema.org/Dataset metadata.
- Is Italy-scoped (for now): it does not promise to plan trips in Japan, so it doesn't hallucinate Osaka restaurants.
2. 4-layer architecture
The Travel Intelligence Engine is a four-layer system, each with a sharp boundary and a single responsibility:
| # | Layer | Technology | Responsibility | Output |
|---|---|---|---|---|
| 1 | Data | PostgreSQL 16 + PostGIS | Ingestion, deduplication, normalized storage | 8,802 rows in od_pois |
| 2 | RAG | Redis Stack 7 (HNSW) + sentence-transformers | Embedding, semantic search, cross-encoder re-rank | Top-5 relevant POIs per query |
| 3 | LLM | Cloudflare Workers AI / Ollama | Narrative generation, explanation, itinerary | Structured text (JSON or SSE stream) |
| 4 | UI / API | Spring Boot 3.4 + Angular 21 SSR | Auth, rate limit, HTTP delivery, frontend | REST API + live dev-tool |
I kept these four layers physically separated because each has a different lifecycle. The data layer changes weekly (OSM re-ingest, Wikidata refresh). The RAG layer changes when I touch the embedding model. The LLM layer changes when I move from Llama 3.1 8B to a larger model or when Cloudflare adds a new one. The UI layer changes every time I add a dev-tool.
If I had coupled them into a Python monolith, every change would require a full redeploy. With four separate layers, I can update the data layer without touching the frontend and vice versa.
Why not event-driven architecture with Kafka?
Strong temptation, then rejected. A system with 8,802 POIs and less than a thousand queries per day does not need Kafka. The operational overhead (Zookeeper, brokers, consumer group management) would kill the VPS budget. I use PostgreSQL as source of truth and Redis as cache + vector store. If one day I hit 100k queries/day I'll reconsider - but "premature Kafka" is an anti-pattern I see too often in portfolio projects.
How do the 4 layers talk to each other?
Communication is synchronous and HTTP-based across the entire user
flow. The Angular frontend calls Spring Boot, which calls the
FastAPI ml-service, which reads PostgreSQL and Redis. All
HTTP/JSON, no gRPC, no message broker. Chosen on purpose:
debugging is simpler (one curl shows you everything),
retries are clear, and the Resilience4j circuit breaker protects
every hop.
The only exception is ingestion: it runs in the background as a Python job scheduled via systemd timer (nightly at 03:00). It does not block the user flow, does not need to be synchronous. If it fails, it retries the next night. Zero extra complexity.
# 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 ingestors and 8,802 POIs
The data layer lives in ml-service/app/ingestion/.
There, an orchestrator.py module coordinates 17
different loaders, each in a separate file under
sources/:
# ml-service/app/ingestion/sources/
overpass_osm.py # OpenStreetMap via Overpass API (ODbL)
wikidata_sparql.py # Wikidata Q... entities (CC0)
open_meteo.py # historical/forecast weather (CC-BY 4.0)
eventbrite_public.py # public events
istat_tourism.py # city tourism statistics
edan_smithsonian.py # Smithsonian museum catalog (CC0)
mic_cultura.py # Italy Ministry of Culture (CC-BY 4.0)
arts_council_uk.py # Arts Council England
culture_fr.py # France Ministere de la Culture
door_eu_gi.py # EU geographical indications (DOOR)
eurostat_sdmx.py # Eurostat statistics via SDMX
jp_gi.py # Japan geographical indications
mobility_database.py # GTFS public transport feeds
omi_agenzia_entrate.py # real estate market observatory
stackoverflow_survey.py # Developer Survey
uk_gi.py # UK geographical indications
usda_gi.py # US geographical indications
Every ingestor extends the BaseIngestor class in
ingestion/base.py. The minimum schema is:
# ml-service/app/ingestion/base.py (simplified)
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)
How did I deduplicate 8,802 POIs?
90% of the data layer work is not ingestion: it is
deduplication. The same museum can exist in
OpenStreetMap as node/123456 (tag
tourism=museum) and in Wikidata as Q789012
with slightly different coordinates. If you count both, you have a
systematic +20-30% bias on counts.
My strategy is three-step:
- Explicit join via OSM
wikidatatag: many OSM POIs carry awikidata=Q...tag. For these, the match is 1:1. - Geographic + name similarity match: for the rest, I search Wikidata POIs within 100 m with name Levenshtein ratio > 0.85.
- OSM priority: when there is a match, the OSM row wins (richer in tags). Wikidata only provides the reconciliation ID.
PostgreSQL + PostGIS schema
The core is the open_data.od_pois table. I chose to
keep a single open_data schema separated from the
business schema to make the public nature of this data clear:
-- 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), -- logical FK for 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 is essential here: queries like "all museums within 3 km
of Piazza del Duomo" use ST_DWithin(geom, :center, 3000)
with a GIST index and stay under 10 ms even with 8,802 rows. I
don't use PostGIS for semantic search (that goes to Redis), but for
geographic pre-filtering it's perfect.
Why a 10 km ingestion bounding box?
In my first version I ingested all POIs within 50 km of every city centroid. Result: millions of rows, Overpass API rate-limited, slow queries and no concrete benefit because a user planning a weekend in Lecce does not want the museum of Brindisi 40 km away.
I dialed back to 10 km after measuring that past that threshold
perceived relevance collapses. The trade-off is explicit: I
lose "day trips" outside the main city (Venaria Reale from
Turin, Cinecittà from Rome). The v2 will add a
day_trip_reachable column based on GTFS, but for now
10 km is the sweet spot between coverage and ingestion cost.
How do I handle partial ingestor failures?
Every ingestion run produces an IngestionReport with
counters: POIs fetched, normalized, upserted, errors. If an
ingestor fails (Overpass 429, Wikidata SPARQL timeout, broken
network), the report flags the error but does not
rollback the other already-completed ingestors. Everything is
idempotent: the unique key (source, source_id)
guarantees that re-running the ingestor does not duplicate rows.
This partial-success design has a clear consequence: the dataset can be temporarily inconsistent across sources. I accept it because the alternative (global transaction across 17 ingestors) would force me to block everything if Wikidata has 5 minutes of downtime. I'd rather have a 99% up-to-date dataset than one stuck for 24 hours.
Per-row attribution provenance
A source column alone is not enough: you also need to
know how that data got there. For this reason I keep in
tags a sub-field _provenance with
ingestion timestamp, raw record hash, ingestor version, upstream
URL. This makes the dataset auditable: if a user
disputes a data point, I can reconstruct exactly where it came
from and at what time.
-- example POI row with full provenance
{
"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
The RAG layer is the heart of the system. It lives in
ml-service/app/core/rag_engine.py and is responsible
for answering a single question: given a user prompt
("romantic weekend in Lecce with museums and a seafood
restaurant"), which are the top-5 relevant POIs from my
dataset?
The answer to this question is built in three steps:
- Prompt embedding with sentence-transformers
all-MiniLM-L6-v2(384 dimensions). - KNN search in Redis HNSW with
k=20(over-sampling). - Re-rank with cross-encoder on the 20 candidates, return top-5.
Why Redis HNSW and not Pinecone, Weaviate or Qdrant?
I evaluated four options. The decisive criterion was: self-hosted, under my control, zero marginal cost.
| Product | Self-hostable? | Min cost | HNSW? | Chose it? |
|---|---|---|---|---|
| Pinecone | No (SaaS) | ~USD 70/mo | Proprietary | No |
| Weaviate | Yes | Free (Docker) | Yes | No (overhead) |
| Qdrant | Yes | Free (Docker) | Yes | No (extra container) |
| Redis Stack 7 | Yes | Already installed | Yes (RediSearch) | Yes |
Redis Stack 7 wins because it was already in my stack for cache and session store. Adding a RediSearch module with an HNSW index does not cost an extra container: same daemon, same connection, same ops burden. Pinecone at USD 70/mo would have burned half of my target AWS budget.
HNSW index creation
# ml-service/app/storage/redis_vectors.py (simplified)
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),
)
The M=16 and EF_CONSTRUCTION=200
parameters are the defaults and with 8,802 rows there is no point
tuning them. If I got to millions of POIs I would use
M=32 and EF_CONSTRUCTION=400, paying
more memory but better recall.
Why sentence-transformers and not OpenAI embeddings?
Dimension, cost, bias. OpenAI
text-embedding-3-small produces 1536-dimension
vectors vs the 384 of all-MiniLM-L6-v2. Four times
more memory per POI. With 8,802 rows it does not matter; with 100k
it does.
More important: OpenAI embeddings are a paid service with data
egress to US servers. For a project that publishes CC-BY 4.0
datasets with European attribution, having a local,
deterministic, no-external-call embedding model is a
consistency constraint. If OpenAI decided tomorrow to retire
text-embedding-3-small, I would have to re-embed
everything. With all-MiniLM-L6-v2 I have the
checkpoint on disk, forever.
The price I pay is slightly lower embedding quality.
all-MiniLM-L6-v2 is from 2021 and shows its age on
complex multilingual contexts. But for Italian travel queries
("Etruscan museums in Florence", "traditional Apulian
restaurants in Rome") the quality is sufficient, especially
after cross-encoder re-rank.
Why is cross-encoder re-rank indispensable?
This is the lesson I learned the latest, and it changed my
perception of RAG. Semantic search with
all-MiniLM-L6-v2 returns "relevant" top-20, but the
order of the top-5 is often wrong. The bi-encoder model computes
cosine similarity between two independent vectors, while a
cross-encoder sees the pair (query, POI) together and computes a
joint score.
# ml-service/app/core/rag_engine.py (simplified)
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) query embedding
q_vec = self.bi_encoder.encode(query, normalize_embeddings=True)
# 2) KNN in Redis (over-sample k_knn = 4 * k_final)
candidates = await self._knn_search(q_vec, k=4 * k_final)
# 3) cross-encoder re-rank
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]]
I measured the impact with an eval suite of 50 Italian queries (museums, restaurants, theatres, libraries) and human judgment on top-5: without re-rank, top-5 was "useful" in 62% of cases. With re-rank, 84%. Twenty-two percentage points, at a cost of about 80 ms extra latency for 20 candidates on CPU. Acceptable for a travel system where the user is willing to wait a few seconds for a well-crafted itinerary.
The silent normalize-embeddings bug
For two weeks my RAG returned apparently correct results but with oddly low cosine similarity scores (0.3-0.4 max). Cause: I had forgotten to normalize embeddings at index time, while normalizing them at query time. Result: cosine similarity computed on vectors of different magnitude, so half-broken without anything visibly failing.
The lesson: if you use cosine similarity, normalize both
at index and at query time. Always. No exceptions. The
normalize_embeddings=True parameter from
sentence-transformers must be passed at both points, and adding a
regression unit test is mandatory.
# ml-service/app/storage/redis_vectors.py (fix after the bug)
def index_poi(r: Redis, poi: POI) -> None:
# CRITICAL: normalize=True at both index time and query time
embedding = bi_encoder.encode(
poi.name + " " + poi.description,
normalize_embeddings=True, # <-- this was missing
)
r.hset(
f"poi:{poi.id}",
mapping={
"embedding": embedding.astype(np.float32).tobytes(),
"name": poi.name,
"category": poi.category,
"source": poi.source,
},
)
How do I handle multi-language in the embedding?
all-MiniLM-L6-v2 is trained predominantly on English.
For Italian queries it works thanks to transfer learning but with
suboptimal performance on specific terms (dialect, Italian
cultural terms). I considered switching to
paraphrase-multilingual-MiniLM-L12-v2 (384 dim,
multilingual) but the eval suite comparison yielded a modest
+3% usefulness at the cost of +40% RAM usage. Not worth the
trade.
The pragmatic solution: at index time I concatenate Italian name + English name when available from Wikidata. A POI like "Duomo di Firenze" is indexed as "Duomo di Firenze Cathedral of Florence Santa Maria del Fiore". It's not elegant, but in practice it improves recall on both Italian and English queries by 8-10%.
5. LLM layer: Cloudflare primary, Ollama fallback
The LLM layer takes the top-5 POIs from the RAG engine, passes
them as context to the language model, and generates the
narrative: day-by-day itinerary, explanation, specific answer. It
lives in ml-service/app/providers/.
Why Cloudflare Workers AI as primary?
Three reasons, in order of importance:
- Price: about USD 0.011 per 1M output tokens on
@cf/meta/llama-3.1-8b-instruct, an order of magnitude less than GPT-4o. For a portfolio with budget < 20 EUR/mo it is unbeatable. - European latency: Cloudflare PoPs in Milan, Frankfurt, Amsterdam. P50 latency measured from Bari: 180 ms to first token. Comparable with OpenAI from my PoP.
- API compatibility: Cloudflare exposes a simple HTTP endpoint, no heavy proprietary SDK. An
httpx.AsyncClientis enough.
# ml-service/app/providers/cloudflare.py (simplified)
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,
)
Why Ollama as fallback and not as primary?
I went the opposite way and then came back. For six months I had
Ollama as primary (llama3.1:8b running locally on
the VPS) and Cloudflare as fallback. The problem: the Hetzner
CX32 VPS has 8 GB of RAM and without a GPU, inference on 8B
Llama runs at 3-5 tokens/sec. A 1000-token itinerary took 3-5
minutes. Unacceptable UX.
I flipped the roles: Cloudflare primary (fast, cheap, cloud) and Ollama fallback (slow but always-available, with no per-query cost). Fallback kicks in when Cloudflare returns 429, 500+, or timeouts > 10s. In those cases I prefer a slow response to no response.
# ml-service/app/providers/fallback.py (simplified)
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)
The system prompt engineering for travel narrative
The prompt is a critical and often underestimated piece. My prompt
for /trip-planner lives in
ml-service/app/api/endpoints/trip_planner.py and is
structured like this:
SYSTEM_PROMPT = """You are an expert travel assistant, specialized in Italy.
Answer ONLY using the POIs provided in the context. If the context does not
contain sufficient information, say so explicitly instead of inventing.
STRICT RULES:
- ALWAYS cite the exact POI name as it appears in the context.
- Do not invent opening hours, prices, or reviews not present.
- If the user asks about a specific restaurant not in the context, respond:
"I do not have verified data on this restaurant in my dataset."
- Output: markdown with sections per day (Morning, Afternoon, Evening).
Available POIs context:
{context}
"""
The rule "do not invent if not in context" is crucial. An LLM left free generates plausible restaurants that do not exist - the classic travel hallucination. With this prompt I reduced measured hallucinations to zero on 50 eval queries (confirmed by manual verification on OpenStreetMap of cited POIs).
The POI context format in the prompt
How do I pass the top-5 POIs to the LLM? I tested three formats: free prose, structured JSON, YAML with clear labels. The measured winner is structured JSON, because the Llama 3.1 8B model has seen many JSON examples in pre-training and parses them better. The JSON I inject into the prompt has this shape:
# the context I pass to the LLM with the top-5 POIs
[
{
"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"
}
// ... 3 more POIs
]
Important: coordinates [lat, lon] are truncated to 4
decimal places (~11 m precision) because with higher precision the
LLM tends to miscopy them and generate invented coords
not present in the dataset. Reducing precision also reduces the
risk that the LLM "completes" the number by inventing it.
SSE streaming vs batch response
Today I only expose batch response (all text at once). My
/api/v1/open-data/travel/trip-planner waits for
Cloudflare to return complete and then returns JSON. SSE
streaming is in the TODO: it's simple on the Cloudflare side (they
support stream: true), but requires propagating it
through Spring Boot → Angular SSR, which adds complexity. For now
a loading UX hint in Angular is enough.
6. UI layer: Angular SSR + Spring Boot gateway
The entry layer is Spring Boot 3.4 in
backend/src/main/java/.../presentation/OpenDataTravelController.java.
Three REST endpoints live there:
// 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 on external URLs cited
// 3) delegate to 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) { ... }
}
Why Spring Boot in front of the Python ml-service? Three reasons:
JWT auth (the ml-service does not verify JWT on
its own), Bucket4j rate limit (6 req/min on the
heaviest AI endpoint), and Resilience4j circuit
breaker on the PythonMlClient (if the
ml-service goes down, I detect it in 30 seconds and do not
saturate threads).
The frontend dev-tool
The live POI Finder Italy is at /dev-tools/travel-intelligence/poi-finder. It is a standalone Angular component that:
- Uses signal-based state management (no NgRx).
- Fetches POIs via
BackendApiServicewith native HTTP cache. - Renders a Leaflet map with a marker per POI.
- Filters: category, distance, source (OSM/Wikidata).
The AI Trip Planner frontend is still TODO. The
/trip-planner endpoint is live and testable via
curl, but the dedicated Angular UI will come in the next sprint.
How did I structure the call from the Angular frontend?
The Angular BackendApiService exposes typed methods
via TypeScript interfaces that mirror the Java DTOs. Using
signal-based state, the dev-tool reacts reactively without
explicit BehaviorSubjects. The pattern is:
// frontend/src/app/services/backend-api.service.ts (extract)
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' } },
);
}
}
Every response carries citedPois as a separate array,
so the frontend can render them as clickable cards linked to
details. This "response contains its sources" pattern is what
makes the system radically different from a thin LLM wrapper: the
user sees where suggested POIs come from.
SSR and API fetch: pitfalls I avoided
Angular 21 SSR can server-side pre-fetch API calls during
rendering. Classic pitfall: if the server-side fetch fails (e.g.
backend down), the page does not render. Solution:
TransferState + silent fallback to "client-side load"
if the server fetch times out. A page that falls back to
client-side loading is better than a page that doesn't load at
all.
7. Publishing layer: citable dataset with DOI
The publishing layer turns the database state into a
citable artifact. The module is
ml-service/app/publishing/ and exposes a 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 POIs, OSM + Wikidata"
The CLI orchestrates four steps:
- Exporter (
publishing/exporter.py): queries PostgreSQL, producesdata.csvRFC 4180,data.json,data.geojsonwith FeatureCollection. - Metadata builder (
metadata_builder.py): generatesmetadata.jsonldin DCAT-AP 2.1 format andschema-org.jsonldfor Google Dataset Search. - Validators (
validators.py): schema validation, attribution check, SHA-256 manifest. - Publisher (
publisher.py): copies artifacts tofrontend/public/open-data/<slug>/v<semver>/+ submits to Zenodo for DOI.
Why DCAT-AP 2.1 and schema.org/Dataset?
They are two complementary standards. DCAT-AP 2.1 is the European standard for public dataset metadata (used by data.europa.eu and the Italian Data Portal). Schema.org/Dataset is what Google indexes in Dataset Search. By publishing both, the dataset is discoverable on European portals and Google alike.
ODbL attribution and license compatibility
This is the legally most delicate part. OpenStreetMap requires
ODbL ShareAlike: if you redistribute "a substantial part" you
have to license the result as ODbL, not CC-BY. My solution:
separate OSM-derived fields from self-calculated fields.
The final dataset is CC-BY 4.0 for fields I computed
myself (density, ranking, score). Raw OSM fields are
marked as ODbL in metadata.jsonld with attribution
"© OpenStreetMap contributors". Wikidata is CC0, so no issue.
This is a legal gray area and I am not a lawyer. I followed the official "OSM Licence Guidelines" that distinguishes dataset produced using OSM (ODbL share-alike mandatory) from dataset that merely references OSM (CC-BY OK with attribution). Mine falls in the second category because I publish aggregated counters, not raw OSM. But if OSMF wrote me a legal concern tomorrow, I'd correct within 24 hours.
How do I verify attribution automatically?
A dedicated validator (publishing/validators.py)
inspects every output file before publication. It checks that
metadata.jsonld contains a dct:source
field for each source, that schema-org.jsonld has a
correct creditText, and that the dataset README
explicitly cites upstream licenses. If any attribution line is
missing, publisher.py halts and does not publish.
# ml-service/app/publishing/validators.py (extract)
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 lessons I learned the hard way
Lesson 1: cross-encoder re-rank changes top-5 dramatically
The biggest lesson. Before adding the cross-encoder I thought my
RAG worked well. all-MiniLM-L6-v2 cosine similarity
returned "plausible" results. Then I ran an eval with human
judgment and saw that the top-1 was wrong in 40% of cases.
Re-rank brought that error down to 16%. If you are building a
serious RAG, never skip cross-encoder re-rank.
Lesson 2: the 10 km bounding box is an ingestion/query trade-off
Ingest too much (50 km) and you pay in storage, Overpass rate limits, and ingestion time. Ingest too little (5 km) and you lose POIs at the city border. 10 km was the sweet spot for Italy, where cities are compact. For a future EU-7 version (Paris, Berlin, Madrid) the bounding box will have to be 20 km, because those cities have more sprawl.
Lesson 3: giving OSM context to the LLM generates better itineraries than describing in words
I tried two prompt approaches. Approach A: "Generate an itinerary in Lecce for a baroque enthusiast". Approach B: same prompt + 5 OSM POIs as structured JSON with name, type, tags, coordinates. Results from approach B were dramatically more concrete. The LLM stops inventing the imaginary "Palazzo Rossi" and uses the real "Basilica di Santa Croce". Structured context >> prose context.
Lesson 4: Cloudflare primary + Ollama fallback beats both individually
Cloudflare alone → cloud-dependent, if the service goes down you
go down. Ollama alone → slow, poor UX. Combined with
FallbackProvider → 99.9% availability (measured over
30 days) with P50 latency of 1.2 seconds. The "primary + fallback
with tight timeout" pattern is my default now for any external
integration.
Lesson 5: publishing the dataset forces quality
When the dataset is private, bugs are hidden. When you publish it as CC-BY 4.0 with a Zenodo DOI and index it on Google Dataset Search, every error is public. This forced me to write stricter validators, check attributions, document methodological limits. The publishing layer is not a "nice to have": it's the forcing function that keeps data layer quality high.
9. Real performance numbers and costs
Numbers measured in development on a Hetzner CX32 VPS (4 vCPU, 8 GB RAM, no GPU), unless otherwise noted. They are not production numbers: they are my honest baseline.
| Metric | P50 | P99 | Notes |
|---|---|---|---|
| RAG search (embed + HNSW) | 38 ms | 95 ms | Embedding cache disabled |
| Cross-encoder re-rank (20 candidates) | 78 ms | 140 ms | CPU only, no batching |
| Cloudflare first token | 180 ms | 450 ms | Milan PoP |
| Trip planner full response (1000 tok) | 3.2 s | 8.5 s | Includes RAG + LLM batch |
| Ollama first token (fallback) | 1800 ms | 4200 ms | CPU only, llama3.1:8b |
Real measured costs (30 days dev + test)
- Cloudflare Workers AI: about USD 4.20 over 30 days for ~1.2M tokens. Well below the 20 EUR/mo target.
- Hetzner CX32 VPS: 16.40 EUR/mo (Ollama, PostgreSQL, Redis, Spring Boot, Angular SSR).
- Overpass API: zero (community fair-use, ~200 queries/mo).
- Wikidata SPARQL: zero (rate-limited but sufficient).
- Zenodo DOI: zero (officially free for open datasets).
- Monthly total: ~21 EUR/mo. Budget respected.
Cache hit rate
I use Redis as cache for embeddings of recurring queries (24h TTL) and for full LLM responses (1h TTL). Hit rate measured in dev over 7 days: 34% for embeddings, 18% for LLM responses. Low numbers because queries are highly heterogeneous. In production with more volume I would expect 50%+.
Memory footprint in RAM
Redis keeps the embeddings of all 8,802 POIs in memory (384 float32 * 8,802 = ~13 MB just for vectors) plus the HNSW index overhead (M=16 means ~32 MB of graph). Total: ~45 MB for the vector store. The rest of Redis space (session cache, rate limiter) occupies another ~200 MB in production. With an 8 GB VPS I stay well within budget: bottlenecks are elsewhere.
The sentence-transformers model takes ~90 MB RAM (loaded once, in
FastAPI lifespan). The cross-encoder ~80 MB.
PostgreSQL with default shared_buffers ~200 MB. Java backend with
-Xmx1G. In total the system comfortably runs in 4 GB dedicated:
the VPS has headroom even with Ollama off, allowing on-demand
Ollama start-up for fallback without saturating the machine.
10. Roadmap and what I did NOT do
The project is live but incomplete. Here is the roadmap with maximum honesty:
What's there but could be better
- Eval suite: 50 manual queries, needs to scale to 500+ and introduce automatic regression tests.
- SSE streaming: Cloudflare supports it, I'll propagate to Spring → Angular in the next sprint.
- AI Trip Planner UI: endpoint live, frontend UI to implement.
- Quality of Life Index: dev-tool in development, roadmap Q3 2026.
What I did NOT do and won't do soon
- Multi-language LLM: Italian only today. English in Q3.
- Fine-tuning: too expensive for the expected benefit with 8k POIs.
- Knowledge graph: Wikidata is already a KG, I use it as join key but do not build my own KG.
- Mobile app: the Angular SSR frontend is mobile-responsive, no need for a native app for now.
11. Links to the live dev-tool and dataset
- 📍 Dev-tool POI Finder Italy: /dev-tools/travel-intelligence/poi-finder
- 🤖 AI Trip Planner (UI coming): /dev-tools/travel-intelligence/trip-planner
- 📊 Related dataset (quality of life): /open-data/quality-of-life-italian-capitals
- 🏛️ Related dataset (museums): /open-data/museums-open-italy
- 📘 Open Data catalog: /open-data
- 📡 API endpoint:
POST /api/v1/open-data/travel/trip-planner
12. Frequently asked questions (FAQ)
Why didn't you use OpenAI?
Cost, dependency on a US provider, European data egress, no control over the model. Cloudflare Workers AI is an order of magnitude cheaper and has European PoPs. For a portfolio project with a < 20 EUR/mo budget, it was not an option.
Why not Pinecone or Weaviate?
Redis Stack 7 was already in my stack for cache and session store. Adding HNSW via RediSearch does not cost an extra container. Pinecone at USD 70/mo would have burned half my target AWS budget. Weaviate/Qdrant are excellent but add a service to manage.
How much does it cost to maintain?
About 21 EUR/mo: 16.40 Hetzner CX32 VPS + 4.20 Cloudflare Workers AI. Everything else is free (Overpass, Wikidata, Zenodo). In AWS production the cost will rise to about 130 EUR/mo with reserved instances, but the logic stays the same.
Can I fork the code?
The code is not yet published in full on GitHub (it's part of the portfolio monorepo). The published datasets are CC-BY 4.0 and reusable today. I am considering extracting the ml-service as a separate repo.
Can I use the system for other countries?
The architecture is country-agnostic. Ingestors are parametrized by bounding box. The LLM prompt is currently Italian but easy to translate. A v2.0 multi-country version (EU-7) is roadmapped for Q1 2027.
How do you handle Overpass API rate limits?
Nightly batch ingestion with configurable delay (default 2s between queries), exponential retry on 429, Redis cache on raw JSON for 7 days. No abuse of the OSM fair-use community.
What happens if Cloudflare goes down?
After 10 seconds of timeout, FallbackProvider
automatically switches to local Ollama. The user sees a slower
response (P50 ~15 s instead of ~3 s) but functionality does not
break. Logged as a warning for Grafana monitoring.
Why didn't you fine-tune the LLM?
Cost/benefit doesn't add up. Fine-tuning on 8,000 POIs would require curating 10,000+ supervised examples, each with a non-trivial annotation budget. RAG with cross-encoder re-rank already gets 84% usefulness on top-5. The remaining 16% would require fine-tuning plus more data plus model maintenance. Out of scope for a portfolio project.
Is it production-ready?
Spring Boot backend: 97% production-ready (371 tests, BUILD SUCCESS, 21 Flyway migrations, 84 endpoints). ML service: 95% (120 pytest tests). Frontend: 90% (missing Trip Planner UI). AWS deploy: D1 phase not started. Estimated time to production: 3 weeks of concentrated work.
Actionable steps for the reader
- Open the POI Finder Italy and search your city.
- If you find a wrong or missing POI, contribute to OpenStreetMap: it will be in the next ingestion snapshot.
- If you want the raw dataset of 8,802 POIs, wait for the v1.0.0 release (CC-BY 4.0 + Zenodo DOI, Q2 2026).
- If you want to discuss the architecture or propose improvements, write me: fedcal01@gmail.com.
Commenti
Caricamento commenti...
Sign in to comment