AM Consulting · case study · travel booking · AWS

Two Projects, One Platform

How we fine-tuned an embedding model to deflect support tickets and a small LLM to power conversational search for a large online travel booking platform, and what each phase taught us.

The business challenge

Two problems landed in the same quarter

The client is a large online travel booking platform (name withheld) offering hotels, vacation rentals and activities. It lists hundreds of thousands of properties and serves millions of travelers a month. It runs entirely on AWS and has a small in-house ML team.

Project 1 · fine-tuned embedding model

Support costs rose every holiday season

About 40,000 tickets a month, with sharp peaks before holidays. Roughly a third asked questions the help center already answered: changing dates, cancellation policies, refund status, a missing confirmation email. Travelers never found those answers because help search was keyword-based.

Goal: show the 3 most useful articles or past resolutions before a ticket is opened. Deflect 20% of tickets.

Project 2 · fine-tuned small LLM

Search failed on how travelers actually ask

Queries like "quiet beach hotel in Crete for a family of four in late June, under €200 a night" or "romantic weekend in Lisbon with a view" got poor results. Nearly one in five free-text searches ended without a click, mostly on mobile.

Goal: a trip-planning assistant inside search that turns conversation into a structured search. +5% relative booking conversion on assistant sessions.

We deliberately started with the lower-risk embedding project, so it would build the shared platform (data pipelines, experiment tracking, model registry, evaluation practice) that the LLM project would later reuse.

Project 1

Support deflection

Fine-tuned embedding model (~570M parameters), plus a distilled reranker, served from an OpenSearch vector index.

Phase 0Discovery & data audit3 weeks

We pulled two years of resolved tickets and linked each one to the article or macro that closed it. We sampled 500 pairs and read them by hand.

Roughly a third of the ticket labels were unusable.

Agents closed tickets with generic macros ("Thanks, closing this") or workflow notes that solved nothing. Training on all of them would have taught the model to retrieve generic closing messages. We filtered and kept about 70% of the pairs.

PII masking can break the data in its own way.

Travel tickets are full of personal data: names, passport numbers, phone numbers, booking references, card details. Our first masking dictionary was far too aggressive and blanked out thousands of passages, including hotel and city names that looked like surnames. We added a frequency cap: a "name" that shows up in more than a few percent of tickets is treated as ordinary text.

Phase 1Baseline3 weeks

Before training anything, we built a fixed evaluation set: 1,000 held-out real tickets with known correct answers. We tested four off-the-shelf embedding models, BM25, a hybrid of both, and an off-the-shelf reranker.

The newest model wasn't the best, and simple fixes captured part of the gain.

A mid-sized multilingual model beat newer, higher-ranked models on our data, which mattered because travelers write in many languages. BM25 alone beat pure embeddings on short tickets and was essential for booking references and payment error codes. The hybrid helped. The off-the-shelf reranker made results slightly worse. Since then we choose models on our own eval set and use public benchmark rankings only to build the shortlist.

Phase 2Fine-tuning4 weeks

We fine-tuned the embedding model on about 6,000 cleaned ticket→resolution pairs with mined hard negatives. One epoch on a single L40S GPU took about three hours.

Fine-tuning made part of the system worse, and only per-type evaluation caught it.

Ticket recall@10 rose by about 18 points, but help-article retrieval fell below the base model. The model had learned the language of tickets and forgotten the language of policy documentation. Generating synthetic questions for every article and mixing them in fixed both.

More training hurt.

A second epoch and larger batches gave no gain or a small drop. We kept runs short and watched validation loss.

The reranker was the better investment.

We distilled a small reranker from an LLM's relevance scores using only about 500 query groups. Top-1 accuracy rose sharply, with no reindexing. It was especially good at telling apart look-alike policies, such as a free cancellation versus a non-refundable rate. If we did it again, we would start here.

Phase 3Pilot6 weeks

We ran an A/B test with suggestions shown on 20% of ticket forms.

Deflection needs a guardrail metric.

Early results looked great until we checked how often travelers came back within 48 hours with the same problem. Some apparent deflections were travelers giving up. We made a reopen rate below a set threshold a release condition. Net deflection settled around 17%: below the 20% target, but well above break-even. The suggestions also cut handling time on tickets that did go through, which we had not planned for.

Some tickets should never be deflected.

A traveler standing at a hotel desk with no reservation doesn't need an article. We added an urgency check (check-in today, "at the hotel now", overbooking) that skips suggestions and routes straight to an agent.

Phase 4Productionongoing

We set up monthly retraining from "this solved it" clicks, with an extra retrain before each peak season when policies change.

Every new model version means a full reindex.

OpenSearch can't change a vector field in place, so each version gets its own index, filled in the background, checked against the evaluation set and switched in through an alias. We keep the old index for a week as a rollback. We should have designed this in Phase 0. We built it in a rush before the first retrain.

Under the hood

Hard technical lessons

The phase stories above cover what we learned about the work itself. This section covers the platform: AWS capacity, SageMaker, Bedrock, model serving, the interfaces between components, and the models themselves. Each item is what broke and what we did about it.

LayerWhat we ended up withWhat we tried and dropped
Embedding trainingSageMaker Training Jobs on 1× g6e.xlarge (L40S 48GB), sentence-transformersLarger multi-GPU instances: no quality gain
LLM trainingSageMaker Training Jobs on g6e.12xlarge (4× L40S), LoRA r32, Spot with S3 checkpointsp5 Capacity Blocks: not needed at 4–8B
Experiment trackingSageMaker managed MLflow plus Model RegistryNotebook-only tracking in the first weeks
Vector searchOpenSearch, one index per model version, alias swap, BM25 in the same indexSeparate keyword and vector clusters
LLM servingvLLM on a SageMaker real-time endpoint, g6e, adapter merged, minimum 1 instanceBedrock Custom Model Import for the live path; an EKS prototype
Frontier modelBedrock Converse API: baseline, synthetic data, judge, fallback for multi-stop tripsUsing the same model family as both teacher and judge

AWS capacity and quotas

Approved quota did not guarantee capacity.

With approved quota, g6e launches still failed with InsufficientInstanceCapacity in our main region during a busy week.

FixLaunch logic that tries every availability zone, then a fallback instance type (g6e, then g6/g5 for embedding jobs), with a retry window of about 30 minutes.

Every quota is separate.

EC2 on-demand, Spot, SageMaker training, SageMaker endpoints and HyperPod each have their own quota per instance type. New accounts start at zero for GPU families.

FixWe filed every quota request in Phase 0. P-family requests took the longest.

"Limit 0" on deploy with quota showing available.

We had raised the training quota for an instance type, but the endpoint quota for the same ml.* type is a different line item.

FixCheck the specific endpoint usage quota before the first deployment, not on launch day.

Capacity Blocks gave little discount.

Blocks are an EC2 feature; SageMaker has its own training plans. After the 2026 price increases, a p5 block was only about 13% cheaper than on-demand.

FixWe skipped them. Our 4–8B LoRA runs fit on g6e, which we could get on demand or as Spot.

SageMaker training and tooling

Startup time dominated short jobs.

Each training job pulls a multi-GB container. For a 40-minute embedding run, provisioning and image pull were a large share of the wall clock.

FixWarm pools during hyperparameter sweeps (keep-alive is capped at 1 hour), and fewer, longer sweep jobs.

Long runs hit the default time limit.

Training jobs default to a 1-day maximum runtime, and Spot interruptions restart from scratch unless you checkpoint.

FixCheckpoint to S3 every N steps and resume automatically. This made Spot safe for LoRA runs.

An SDK upgrade broke the pipeline.

Moving to SageMaker Python SDK v3 produced an empty input config that the pipeline API rejected, and step caching stopped hitting when we used source_dir.

FixPin the SDK version in the lockfile and upgrade only in a dedicated change with a full pipeline run.

Organization policies triggered false role errors.

Our security team's SCPs used conditions, and the SDK's client-side role validation rejected roles that actually worked.

FixWorked through it with the platform team and documented the known false positive. Budget a day for IAM in any locked-down account.

SageMaker costs about 25% more per GPU-hour than EC2.

For per-job training that premium was worth it: no idle machines and no cluster to run. For always-on research boxes it wasn't.

FixTraining on SageMaker jobs; dev boxes on EC2 with auto-stop after 1 hour idle.

Bedrock

We used Bedrock for the frontier model throughout and evaluated Custom Model Import (CMI) for serving our fine-tuned 4B model.

Imported models go cold.

After an idle period, CMI evicts the model and calls return ModelNotReadyException. Restore time ranged from about a minute to tens of minutes, with no way to guarantee it.

FixKeep-warm calls every few minutes plus a long retry budget work for batch jobs. For the live search path we moved to a SageMaker endpoint.

Always-warm CMI cost more than an instance.

CMI bills per active model copy in 5-minute windows. Keeping one copy warm around the clock priced out at roughly 3x a g6e.xlarge.

FixCMI only makes sense for traffic with real idle periods, such as nightly enrichment jobs.

No adapter import.

We found no way to import a LoRA adapter on its own. Every retrain meant merging the adapter into the base weights and re-importing the full model.

FixMerge as a pipeline step, version the merged weights in S3, and account for 5–20 minutes of import time per release.

Converse didn't support our imported architecture.

We had to render the chat template ourselves and call InvokeModel with a raw prompt. That created a second place where the prompt format could drift from training.

FixOne shared prompt-building library used by training data generation, evaluation and every serving path.

Pinning the frontier model.

The same Converse calls served four jobs: baseline, data generation, judging and fallback. A silent model version change would have shifted all four.

FixPin exact model IDs in config, use temperature 0 for judging, and use a judge from a different model family than the teacher that generated the data.

Serving the LLM

Cold starts took minutes.

Our first 8B endpoint took about 9 minutes from scale-out to first request. SageMaker container caching roughly halved it. On the EKS prototype, model streaming plus a persistent compile cache took it from about 5 minutes to under 3.

FixNever scale to zero. Keep a floor of one warm instance and scale out early on queue depth.

The concurrency cliff.

On a 24 GB GPU, latency was fine up to about 16–32 concurrent sessions and then collapsed, with timeouts not far beyond. Two 8B candidates behaved very differently under the same load.

FixMoved to L40S 48 GB and set the autoscaling target below the cliff measured in our own load test. Include load behavior in model selection.

Multi-LoRA had a cost.

Serving adapters on a shared base added latency, and vLLM reserves GPU memory for every --max-loras slot whether or not it's used.

FixProduction serves one merged model. Multi-LoRA is only for side-by-side experiments, with one standard rank for every adapter.

FP8 reduced accuracy on rare values.

Quantizing to FP8 cut latency but reduced accuracy on rare amenity codes and small regional place names.

FixShipped BF16. Re-test quantization on per-slice metrics for every new model, not just the overall score.

EKS prototype gotchas.

The default 64 MiB /dev/shm crashed vLLM. The liveness probe killed pods while weights were still loading. The load balancer's 60-second idle timeout cut off streamed responses. CPU-based autoscaling meant nothing for GPU load.

Fix8 GiB memory-backed /dev/shm, a startup probe, a 300-second idle timeout, and autoscaling on vllm:num_requests_waiting.

vLLM upgrades move performance.

A minor version upgrade changed throughput noticeably. LoRA-related crash bugs appeared in several 2026 releases of both vLLM and SGLang.

FixPin the engine version in the container image. Upgrade only after a load test and the full eval set.

Interfaces and formats

Chat template drift.

Training used the tokenizer's chat template; an early serving path built the prompt by hand. Quality dropped with no error anywhere.

FixAlways use the tokenizer's template, through the shared prompt library, and run a canary request with a known answer at deploy time.

Keep the output schema simple.

Constrained decoding with a flat JSON schema added little latency. An early, deeply nested schema with many optional fields was slow and error-prone.

FixFlat schema, enums for amenity and property-type codes, ISO dates, and validation on every response with fallback to keyword search.

One client for two API shapes.

vLLM and SageMaker endpoints speak an OpenAI-compatible API; Bedrock Converse uses a different request and response shape.

FixA thin adapter layer so the app can switch between fine-tuned and frontier models with a config change. This made the A/B tests and the fallback routing simple.

Timeouts must agree end to end.

The client, load balancer and endpoint each had different timeouts, so some requests were abandoned by one layer while another kept generating.

FixOne timeout budget (1 second for search calls), enforced at the client, with matching settings below it.

Models and training recipes

In-batch false negatives.

Many help articles answer the same question, so contrastive training punished the model for retrieving correct alternatives.

FixA no-duplicates batch sampler, and mined negatives filtered: skip the top 10 ranks and drop anything scoring above 95% of the positive.

Train at serving length.

Our first embedding run used a 512-token limit while serving embedded longer policy pages. It barely helped on those pages.

FixMatch the training max length to the serving chunk size. Larger chunks also retrieved better for policy pages.

Small rerankers didn't fine-tune well.

A 0.6B reranker got worse after fine-tuning. A larger model distilled from LLM scores improved steadily.

FixDistillation from an LLM teacher rather than plain labels, on a reranker of at least base size.

LoRA rank and forgetting.

Rank 16–32 was enough; 64 added nothing. General ability dropped sharply after fine-tuning, which was acceptable only because the model does one narrow job.

FixKeep the model's job narrow. If it ever has to chat, mix general instruction data back in.

The best checkpoint came early.

Later checkpoints often scored lower on the eval set while the LLM judge scored them higher.

FixEvaluate every checkpoint on task metrics and pick by those, with the judge as a secondary signal.

Embeddings and the vector index

Vector fields can't be changed in place.

In OpenSearch, a knn_vector field's dimension, engine and distance metric are fixed when the index is created.

FixOne index per model version, background backfill, an eval gate, then an alias swap. Plan for 2x storage during overlap.

Bulk re-embedding through the live endpoint was slow.

The online embedding server is built for requests, not millions of documents.

FixA separate batch job on a GPU instance. Tokenization on the CPU became the bottleneck, so we added CPU workers and grouped texts by length.

Silent CPU fallback.

One ONNX build couldn't find the CUDA provider and quietly ran on CPU, about 100x slower, with no error.

FixAssert the execution provider at startup and fail fast if the GPU isn't in use.

Newer embedders didn't win on multilingual tickets.

A newer, smaller embedding model scored below the older multilingual model on our ticket set before any fine-tuning.

FixModel selection on our own eval set, across every language with real traffic.

Across both projects

What we'd tell the next team

Build the evaluation set before the model.

It was the most valuable asset in both projects.

Beat the simple baseline first.

Hybrid search plus a reranker, or a prompted frontier model, got us part of the way with little effort. Fine-tuning had to earn its place against them.

Small models with one narrow task worked best.

Both successful models were small and did one job.

Compute was not the bottleneck.

Data cleaning, evaluation and rollout took about 80% of the effort. GPU availability was a bigger constraint than price.

Design operations from the start.

Reindexing, retraining, version pinning, warm replicas and seasonal peaks were all things we wished we had designed at the start.