A five-day build to answer one question well, and to show why the retrieval pattern was chosen and not just wired.
You have 50 minutes at Zurich. The airport shows a walking time to the gate. It does not tell you that you will cross passport control, that your Bangkok arrival means a second security check, or that the lounge you're entitled to is on the wrong side of the border. You find out by walking.
Ask a chatbot with the airport's pages pasted in and you get a fluent paragraph. Ask it 25 different ways and the paragraph changes its mind about whether there is a passport control at all. That is the failure this project is about: the answer needs to be computed from a structure (which zone am I in, which zone am I going to, which rules fire on that crossing), and text retrieval does not compute.
Only one step needs a language model: reading the question. "I land at D33 from Bangkok, fly out from A63, 50 minutes, I'm Star Gold" has to become {arrival: D33, departure: A63, minutes: 50, origin: TH, status: star_gold}. That is fuzzy input and a small model (Claude Haiku 4.5) does it for a few hundred tokens.
Everything after that is deterministic. Gates map to gate areas. Gate areas have a Schengen zone. A crossing between zones is a passport control. An origin country is on the EU one-stop-security list or it isn't. The shortest route is a graph search. The verdict is arithmetic against a minimum connection time. None of that should be left to a model, and in this build none of it is: the answer text itself is a template over what the workers returned, so the model cannot introduce a fact that was not retrieved.
| Decision | Alternative | Why, and the cost |
|---|---|---|
| Zurich only | Frankfurt, the bigger hub | I live in Zurich and can check routes on my own flights. Frankfurt I can't ground-truth. Cost: one airport is a demo, not a product. |
| Hand-built graph (22 nodes, 27 edges) from public pages and OpenStreetMap | Indoor-map vendor API, or OSM routing | No public floor-plan API exists for ZRH. OSM has every gate number and level but no corridors. Cost: walking times are my estimates from gate geometry, marked as such. |
| Explicit orchestrator with four narrow workers, run once | An agent loop with tools | The question is fully specified up front, so one pass is enough and the trace stays readable. Cost: nothing re-plans; a gate change is out of scope by design. |
| Deterministic composer | LLM writes the answer from the trace | The validator can prove every route edge exists; it can't prove a paragraph. Cost: the prose is plain. |
| Same documents for all three systems | Give the baseline "fairer" documents | The comparison is the project. Cost: the baseline gets tables rendered as text, which is exactly the point. |
| Public data only, every number with a URL | Use what I know from work | I work in this industry. If I can't cite a public page it doesn't go in. Cost: the minimum connection time is the weakest source in the corpus (below). |
Ambiguous and out-of-scope questions ("gate 34 to gate 55", "will my flight be on time?") never reach a worker. The trace shows the orchestrator's reason and an empty worker list, which is the honest answer.
It is not a loop. There is no re-planning, no repeated tool call, no memory. Each worker runs at most once, in a fixed order, and the result is validated once. I organised the code this way so that "agentic orchestration" means something concrete an interviewer can point at, and so that debugging on day three meant reading a trace instead of a transcript. Where a loop would live is in the box below.
Yes, in a bounded form, and I'd rather say exactly which form. Anthropic's guide to building agents separates workflows (a model and tools run through code paths you wrote) from agents (the model directs its own process). This is a workflow, organised in the orchestrator-workers shape. Claiming more would not survive a good follow-up question; claiming less would undersell a deliberate design.
Where a loop would earn its place: live gate data and a trigger when it changes. The orchestrator would re-run the route and checkpoint workers; the workers and the validator would not change.
The two comparison systems share the prompt and the documents. Naive vector RAG: chunk the four rule documents and the two csv tables, embed with bge-small, cosine top-6, one model call. Hybrid + reranker: BM25 and dense retrieval fused by reciprocal rank, a cross-encoder reranks twelve candidates to six, same prompt. Neither has the graph.
The airport is 22 places joined by 27 walks. Gates are grouped into areas, because walking times only make sense at that scale: there is no node for A63, there is one for "Dock A gates A62 to A86". Each place has a zone, Schengen or not. Passport control is a place with a 10-minute queue, and it is the only way across the border. Security re-screening is a short loop you leave the pier for and come back from, taken only when the origin country requires it.
Two things in this picture do the work. Checkpoints are places, not labels. If a route crosses the border, the passport node is on the path and its queue is in the total; the system cannot forget a checkpoint the way a text system can, because the checkpoint is part of the geometry. B and D are one pier. Gate B34 and gate D34 are the same door, named B for a Schengen flight and D for a non-Schengen one, so the graph has two nodes for one pier with passport control between them. "Land at D34, depart from B34" is the same gate and still needs passport control. That case shaped the graph before a line of code was written.
"Which sentence in the documents looks most like this question?" No sentence says D33 to A63 crosses the border. There are thousands of gate pairs and the airport's page describes zones, not pairs. The model has to infer it, and across 25 questions it infers it differently each time.
"Which area is D33 in? Which zone? Which area is A63 in? Which zone? Do they differ?" Three lookups and a comparison. Then a shortest-path search whose answer is the same every time. The rules documents are read by section heading, so the passage cited is always the one that applies.
25 questions, 8 traps. 19 expect an answer; 6 expect the system to ask or decline.
Expected answers were derived from the graph and the rules on day one, before either system ran. Two caveats I want on the record. First, the draft questions used gate numbers from memory; most of them don't exist at Zurich (there is no A60, for instance). The planner maps any number to a gate area, so the eval still works, but the questions will be renumbered against the real gate list once I've walked it. Second, because the expected routes come from the same graph the planner searches, a perfect score proves that the question-reader extracts the right facts and that the pipeline is internally consistent. It does not prove the graph matches the building. That check happens on foot. Third, the question-reader's instructions were fixed three times against this set, which is why there is a second, held-out set below.
"Route exact" is strict on purpose. A route with the right destination that skips the security loop is wrong, because the minutes are wrong and the traveller would not be warned. All three systems return the same answer shape, so one scorer compares all three to the same expected columns.
| system | answered / asked / declined right (25) | verdict right (19) | route exactly right (19) | rules cited right (19) | all four right |
|---|---|---|---|---|---|
| naive vector RAG | 56% | 21% | 5% | 11% | 28% |
| hybrid search + reranker | 68% | 16% | 5% | 21% | 24% |
| graph + orchestrator | 100% | 100% | 100% | 100% | 100% |
The question-reader is the only learned part, and its instructions were tuned on the 25 above. So the 25 were reworded 50 times (typos, lowercase gates, "A10 -> A85", "half an hour", "LHR-ZRH-LIS") after the tuning stopped, and never shown to it before scoring.
| system | answered / asked / declined right (50) | verdict right | route exactly right | rules cited right | all four right |
|---|---|---|---|---|---|
| naive vector RAG | 50% | 16% | 8% | 8% | 24% |
| hybrid search + reranker | 54% | 18% | 8% | 16% | 24% |
| graph + orchestrator | 100% | 100% | 100% | 100% | 100% |
Two caveats stay on the record. The expected routes come from the same graph the planner searches, so both 100% rows prove internal consistency and a robust question-reader, not that the graph matches the building. And the rewordings were written by me, so they cover the phrasings I could think of, not the ones travellers will type.
| failure mode | naive | hybrid + reranker |
|---|---|---|
| Hedged: asked for the dock letter when the question already gave it ("which dock is D50 in?") | 11 | 8 |
| Route wrong or partial | 4 | 7 |
| Missed a passport control that the route crosses | 3 | 3 |
| Verdict flipped (mostly yes → no) | 4 | 8 |
| Invented node ids that don't exist | 0 | 0 |
Two things surprised me. The naive system almost never hallucinated: it never invented a node, and its favourite failure was refusing to commit, because the retrieved chunk about the shared B/D pier made a plain "D50" look ambiguous. And better retrieval made the hedging better and the reasoning worse: the reranked system answered more often, cited the right rules more often, and flipped more verdicts, because it now had enough numbers in context to do the arithmetic wrong. Retrieval quality was not the bottleneck. Computation was.
The graph system missed three questions on its first run, all in the one place a model is used. "Both non-Schengen" was read as an unknown non-EU origin (so a re-screening appeared that shouldn't); "a one-stop-security origin" was read the same way; and "Land A40, depart A45" lost its letters. Three sentences added to the extraction instructions fixed all three. The baseline was not touched.
Cost: one full run of all three systems on 25 questions is about 15 cents. The graph system uses a quarter of the input tokens of the baseline, because the model only reads the question.
Live position, turn-by-turn, reading the sign in front of you, audio. That is a mobile app on top of indoor positioning, and vendors already sell the positioning (MappedIn, MapsPeople, Navigine sell wayfinding SDKs to airports; Frankfurt has run WiFi-based indoor routing since 2015). If one of those APIs were available it would replace `edges.csv` and nothing else. The rules layer on top, will I make it and what will I hit, is the part nobody exposes to passengers, and it is the part this build is about.
One Railway service (FastAPI, Docker, embedding and reranker models baked into the image) serves the API and these pages. Haiku 4.5 at $1 per million input tokens; a graph answer costs well under a tenth of a cent, a naive answer a few tenths. Eval runs are cached per system, model and corpus hash, so re-running is free until the data changes. Limits: static data, one airport, English only, no accounts, no live anything. What breaks first: the airport renumbers a gate area, and nobody tells the csv.
Not affiliated with Zurich Airport or any airline. Nothing here is travel advice; check your boarding pass and the signs.