Behind the desk
How this page works.
The voyage desk is not a search box with a chat wrapper. It decides what to look up, looks it up across four separate systems, and assembles one answer — or tells you it cannot. Here is that process, first in plain terms and then in detail.
General Overview
In plain terms
Ask a question the way you would ask a person — “can four of us get a verandah room on the Treasure next August, and what would it cost?” — and the desk works out what it needs to know before it answers.
That question alone needs four separate lookups: which sailings exist, what is still unsold, what a party of four actually pays, and what deposit secures it. Nobody wrote a script for that particular question. The desk reads it, picks the tools it needs, and chains them, carrying the sailing it found in the first step into the three that follow.
Where answers come from
Nothing is answered from memory. Every price, cabin count and policy detail in a reply came from a lookup made while you waited, against one of four systems — the sailing inventory, the pricing engine, guest records, and a library of policy and shipboard documents.
Those systems are deliberately kept apart, and none of them can query another. Joining inventory to pricing to your booking history is the desk’s job, not the database’s, which is exactly how it would work on a real reservations platform.
What it will not do
The most useful thing the desk does is stop. Ask about something the documents do not cover — onboard casinos, mobile roaming charges — and it is built to say so rather than reach for the nearest loosely related page and improvise.
That sounds obvious and is surprisingly hard. A search system will always return something; its five best results for an unanswerable question are just its five least-bad ones. Recognising that they are all bad, and returning nothing at all, is a specific piece of engineering described in the next section.
Technical Description
In detail
The request path
A question travels from the browser to a Next.js route handler, which streams it through to a FastAPI service over Server-Sent Events. That service runs a Google ADK agent driving Gemini, which holds fourteen tools: eleven reached over the Model Context Protocol, and three in-process retrieval functions.
browser
└─ POST /api/chat Next.js route, streams SSE straight through
└─ POST /chat/stream FastAPI
└─ ADK Runner the model decides which tools to call, and when
├─ McpToolset ──────────► FastMCP server :8011
│ ├─ inventory :5443
│ ├─ pricing :5444
│ └─ guests :5445
└─ retrieval tools ───────► pgvector :5442Tool activity is streamed to the browser as it happens, which is why the panel names each lookup while the answer is still being written.
Agentic retrieval, not a retrieval pipeline
There is no unconditional “search, then answer” step. An availability question goes straight to SQL and never touches the document store; a dress-code question goes only to the documents; a “what should we do in Nassau and what will it cost” question does both and joins the results. The model chooses, and that choice is what makes this agentic rather than a pipeline.
The four databases
| Database | Holds |
|---|---|
inventory | Ships, stateroom categories, sailings, day-by-day itineraries, live cabin counts |
pricing | Nightly rate basis, seasonal multipliers, age and berth brackets, fees, add-ons, deposit rules |
guests | Profiles, preferences, travelling parties, past reservations |
voyage_vectors | 58 policy, excursion and ship-experience documents, stored as embeddings |
Four separate Postgres instances, not four schemas in one. No tool can join across them, so composition has to happen in the agent — a constraint kept on purpose, because it is the constraint a production deployment would have.
Why the SQL sits behind a protocol
The three transactional databases are reached only through a FastMCP server speaking the Model Context Protocol. That indirection buys a lifecycle: the data plane can move to its own service without the agent changing. Document retrieval deliberately does not go through it, because retrieval is part of the model’s reasoning loop rather than a system of record.
Retrieval runs two searches at once
Document search combines a dense vector search with Postgres full-text search. They fail differently, which is the point: embeddings catch paraphrase, where a guest says “somewhere quiet and indulgent” and the document says “adults-exclusive”. Keyword search catches the exact rare term — a venue name, “passport” — that embeddings blur into its neighbours.
The two result sets merge by Reciprocal Rank Fusion, which combines rankings rather than scores and so needs no calibration between two systems whose numbers mean different things.
How it decides to return nothing
Each arm applies relevance floors before fusion, and getting these right was the substantial work.
- Keyword floors are based on inverse document frequency: matching one rare word clears them, matching one ubiquitous word like “deck” does not. Two thresholds are OR’d, because an absolute score is unreachable for a one-word question while a coverage ratio is too harsh on a long one.
- Distance alone cannot work. Measured on this corpus, the nearest document to an unanswerable question sits about as close as the right answer to a real one — the two distributions overlap almost entirely. Tightening a distance threshold trades recall for abstention roughly one for one, and never separates them.
- The shape of the neighbourhood does work. When the corpus can answer a question, the best match stands out from the rest. When it cannot, every candidate is equally mediocre. Comparing the closest document against the fifth-closest more than doubled the abstention rate at no cost to the answers.
An optional last pass sends the surviving passages to a small model and asks whether each one actually answers the question, rather than merely being about the subject. That is the only thing that reliably rejects a question sitting one hop from a real document — roaming charges next to the wifi policy — because one hop away is, geometrically, very close.
Money is never the model’s arithmetic
Fares are not linear: a seasonal multiplier, then age brackets crossed with berth slots where the first two guests pay full fare, then per-guest fees and taxes. All of it is computed in exact decimal arithmetic with half-up rounding, and the pricing tool returns a fully itemised breakdown so the model has nothing left to calculate. The agent is instructed never to do fare maths itself.
A question, end to end
The real chain of calls
This is the actual sequence for “can 4 adults get a verandah room on the America Treasure next August, and what would it cost?” — each step feeding the next.
find_sailings ship + an August date range → TRE-2027-08-12 list_stateroom_categories which categories sleep four check_stateroom_availability what is left on that sailing → 4 verandah rooms quote_cruise_fare the sailing, category and party → itemised total get_booking_rules the fare → deposit and final payment date
Five lookups across two databases, one answer. The first call returns the sailing identifier that the following four all depend on, which is why the order matters and why a fixed script would not have served.
How well it works
Measured, not asserted
Retrieval is the part of a system like this that fails quietly — an answer reads just as fluently when the wrong passage was fetched. So quality is a number here: 96 labelled questions, 24 of which the documents genuinely cannot answer.
| Search | Right first | Found at all | Noise | Says “I don’t know” |
|---|---|---|---|---|
| Keyword only | 0.86 | 0.91 | 0.39 | 25% |
| Meaning only | 0.93 | 0.96 | 0.37 | 62% |
| Both, combined | 0.92 | 0.97 | 0.52 | 21% |
Combining both searches finds the right document 97% of the time. Abstention is the weakest column and the honest one: fusion returns something whenever either search does, so the keyword arm sets the ceiling. A regression gate fails the build if any of these slip.
Right now
Read live from the same health endpoint the desk uses, so this page cannot describe a system that is not running.
Checking…