Blog

engineering

Our booking parser learns from every correction. Here's the vector DB underneath.

28 June 2026

·

7 min read

When you forward a hotel confirmation to StayHawk, something has to turn a wall of marketing HTML into a clean booking: check-in date, cancellation deadline, price, currency. A cheap deterministic parser handles most of it — we know the platform, we know where Booking.com hides the refund date. But the long tail never ends: new platforms, redesigned templates, a French confirmation with the total buried in a footer. Those fall through to a language model.

That fallback used to be zero-shot: one fixed prompt per booking type, a Zod schema, and hope. It mostly worked, but "mostly" is expensive when a missed deadline means someone eats a non-refundable night.

Now it's few-shot. Before the model sees a hard email, we hand it a few worked examples of the same problem — real bookings other users already confirmed. It stops reasoning from a blank page and starts copying a shape it has seen.

The trick isn't the prompt. It's finding the right examples, and getting a better set of them every day without touching code. That's the vector database.

The idea in one sentence

Store every verified (email → booking JSON) pair as a point in meaning-space. When a new email arrives, grab the nearest few and paste them into the prompt.

Meaning-space is the whole point. A LIKE '%cancellation%' query won't find similar emails, because two confirmations can mean the same thing while sharing almost no words. So we turn each email into 1024 numbers (an embedding) that encode what it's about, and similar emails land near each other. Finding examples becomes geometry: which stored points are closest to this one?

HotelsTrainsFlightsCars

Every stored example is a point; same-meaning bookings cluster together. A new hotel email lands in the hotel cluster, and its nearest neighbours — the examples we retrieve — are right beside it. 1024 dimensions, drawn as 2 to fit the page.

That geometry is the read path in full. Here it is stage by stage:

Email arrivesstep 1 of 5

A forwarded confirmation the cheap parsers couldn't crack. It heads for the LLM fallback.

Dear Marie Lefebvre, your stay at Hotel Le
Germain is confirmed. Confirmation: GRM-4821X.
Check-in 18 Apr, check-out 21 Apr. Free
cancellation until 16 Apr. Total CA$612.

One forwarded email, walked through the five stages of few-shot retrieval. Nothing un-redacted ever leaves the Lambda, and the examples the model copies are real bookings other users already confirmed.

Embeddings, and why "S3 Vectors"

We embed with Amazon Titan Text Embeddings V2 (amazon.titan-embed-text-v2:0) through Bedrock: redacted text in, a 1024-dimension float32 vector out. The same model stores an example and answers a query, so the distances between them actually mean something.

The store is S3 Vectors, AWS's native vector index. A vector bucket holds an index; you PutVectors to write, QueryVectors for k-nearest-neighbour search with a metadata filter, and DeleteVectors to remove. The config is short:

index:     stayhawk-fewshot-extraction
dimension: 1024
data_type: float32
metric:    cosine

We picked it over pgvector or OpenSearch because there's nothing to run: no cluster to size, no node to patch, no idle monthly bill before the corpus has grown into it. Pay for what you store and query, cheap on day one, no re-architecting when the index is orders of magnitude larger.

Retrieval is deliberately timid. Top 3 neighbours, drop anything below 0.75 cosine similarity, and a hard 1500ms budget on the whole redact-embed-query round trip. It's fail-open: flag off, cold start, Bedrock hiccup, or timeout all return an empty list, and the prompt falls back to the exact zero-shot prompt we started with. Adding examples can help extraction; it can't break it.

We filter before we rank. Every query carries booking_type and variant, so a Chrome-extension page DOM never matches an email example and a hotel never matches a train. platform and locale narrow it further when we know them. Vectors handle the fuzzy part, exact filters handle the parts we're sure of.

Nothing un-redacted ever leaves the box

Confirmations are full of PII: guest names, booking refs, sometimes the tail of a card number. We never store the raw email, and we're certainly not embedding one to sit in an index forever.

Redaction runs before embedding, on both paths. On read we pattern-match — emails, card-shaped digit runs, phone numbers, street addresses, the greeting-line name — and swap each for a token like [GUEST] or [REF]. On write we also have the verified JSON, so we go field-by-field: we know the guest's exact name, so we replace that literal string wherever it appears, in both the text and the stored JSON. The pair stays consistent, and the model learns the pattern instead of the person.

Tip

The prompt tells the model to emit real values, never the [GUEST] / [REF] placeholders it sees in the examples. The tokens are scaffolding, not a template to copy.

Redaction is also what makes deletion tractable. Each vector's key is a salted SHA-256 of the booking id (sha256("${salt}:${bookingId}")). Re-learning a booking overwrites its slot instead of duplicating it; a delete is one DeleteVectors call with a key we recompute from the booking id; and the index holds no back-reference to a user, just an opaque hash and a redacted example. Account deletion walks the workspace's bookings and drops each vector by recomputed key. The salt lives in Secrets Manager, never in code or Terraform state.

The loop that makes it better

Every example in the index started as a booking a user confirmed.

When the model drafts a booking from a hard email, we stash the redacted (email → draft JSON) pair as a candidate, with a ~90-day TTL so unconfirmed ones expire. When the user saves it — or fixes a wrong date and then saves — we promote the candidate: re-redact with the final values, embed, and write it into the index. The next similar email retrieves it.

1

Extract

model drafts JSON

2

Confirm

user saves or edits

3

Learn

write vector

4

Retrieve

next email finds it

the loop closes: a correction today is an example tomorrow
booking.com · hotel3/10 examples

Every booking a user confirms becomes a worked example for the next one. Each platform-and-type slot is capped at 10, so the corpus stays small, current, and cheap to search.

Today's correction is tomorrow's example. No retraining, no fine-tuning, no engineer in the loop, just one write to a vector index on the path the user was already taking.

The cap is deliberate: 10 examples per (platform, variant) slot, then new writes are rejected. A thousand near-identical Booking.com confirmations shouldn't crowd out the rare platforms. A few clean, diverse examples per shape beat a redundant pile, and keep the index fast to search.

Do we even need a vector database?

A vector DB is the textbook-correct architecture for this, but it's still machinery, and machinery you don't need is a liability. Two ways to get it wrong: reach for a vector index when a key-value store would do, or ship something that has to be ripped out the moment volume climbs. So we split the call in two.

Does it hold up as we grow? We're building for scale. The pipeline is designed for volume orders of magnitude past today's, and the example corpus grows with it. A serverless, pay-per-use index with k-NN and metadata filters built in is the part that doesn't need re-architecting later: the retrieval path is identical at a hundred bookings a day and a hundred thousand, and only the numbers in the index change. Cheap to choose now, expensive to retrofit.

Does semantic similarity actually earn its place? That we're measuring, not assuming. Before committing we built an offline eval — no AWS infra, just fixtures and a leave-one-platform-out harness — with four arms, each isolating one variable:

ArmExamples in promptChosen by embeddingsNeeds the vector DBHuman curation
AStatic
BFew-shot + vectorsshipped
CFew-shot, no vectors
DStatic + hand-picked

B · Few-shot + vectors. The shipped design. Examples chosen by semantic nearest-neighbour over the index.

Decision rule: ship embeddings only if B clearly beats C and D. If C comes close, semantic similarity isn't earning its keep yet, so metadata selection does the ranking while the corpus is young.

What each arm isolates — not a results chart. The eval hasn't run, so accuracy bars would be invented; this shows the honest structural difference instead. Tap a row for what it tests.

We expect B to win in the long tail, where wording diverges and meaning doesn't, and that's exactly the slice that grows with volume. If C lands close instead, we let metadata pick while the corpus is young — the serverless store and the write-back loop stand either way. The eval decides which knob does the work, not the architecture diagram.

The idea worth stealing isn't "use a vector database." It's the loop: capture the corrections users already make, and feed them back in where the work happens. The vector DB is just the index that makes those corrections findable, and the one we can grow into without a rewrite.