<!-- orbyt:chrome:begin -->
---
title: "The sommelier who nearly landed in an AI salary hub"
description: "Thirteen category hubs replaced a pile of thin AI role pages. One ordered list decides where 687 roles land, and the obvious fix admits a sommelier."
author: "Justin Bartak"
published: 2026-11-13
modified: 2026-11-13
canonical: https://www.orbytlabs.ai/blog/building-salary-hubs
---
<!-- orbyt:chrome:end -->

# The sommelier who nearly landed in an AI salary hub

A sommelier nearly ended up on an AI salary page.

Not as a joke. As the output of a one line fix I sat down to ship and then did not ship. I will come back to him.

Start with the mess. I had a pile of thin AI role pages and they were all competing with each other. Each one covered a single job title. Agentic orchestrator. Knowledge engineer. Retrieval engineer. A number, a range, four paragraphs of prose. None of them was the page anyone actually wanted.

My read was that people ask the category question, not the title question. I have no query log that proves it. That was a design call, and the whole restructure sits on top of it.

So I collapsed them into categories.

That is what the [salary hubs](https://www.orbytlabs.ai/orbyt-jobs/salaries/hubs) are. Thirteen category pages. Six industry verticals, six functional disciplines, one for creative work.

Open AI healthcare roles and you get a category median, a full range, every tracked role ranked by base pay, a city table, and a short set of questions answered by somebody with opinions. Do you need a clinical background to work in healthcare AI. The answer there is no, but it helps, and then it names the profile that keeps losing offers: the generalist who has neither the clinical training nor the modelling depth.

## What does the classifier actually do?

There is no database table behind any of this. There is one array called SALARY_HUBS and one function called classifyRoleToHub. It takes a role slug and a role title. It hands back a hub slug, or nothing.

It runs in two steps. First there is a gate. Is this role AI related at all.

The gate is a list of slug patterns and title keywords. Anything starting with ai-. Anything ending in -ai. Anything starting with ml- or llm- or nlp-. Then a keyword list: machine learning, computer vision, retrieval, agentic, robotics, alignment, interpretability, a couple dozen more. A registered nurse fails and gets nothing back. So does an electrician.

Of the 3,445 roles in the dataset, 687 clear that gate.

If a role passes, step two walks the hub array in order and returns the first hub whose slug pattern or title keyword matches. **First match wins, and that one sentence is the entire taxonomy.** Everything else about the design is a consequence of it.

## Why does the order of that array decide everything?

Because with first match, the order of the array is the classification. Move a hub up and roles move with it.

Verticals go first. Healthcare, finance, legal and policy, security, industrial, consumer and commerce. Creative next. Then the disciplines: executive, research, product, design, operations, engineering.

Engineering sits last on purpose, and it is also the catch all. A role that clears the gate and matches nothing more specific lands there. That is why a clinical LLM engineer goes to healthcare and a fraud detection ML engineer goes to finance. Somebody hunting healthcare AI work wants the page about cleared workflows and protected health information, not the page with every AI job on it.

The bill for that arrives as one number. Of the 687 roles in hubs, 321 sit in the catch all. That is six times the next biggest hub. It is also the one hub defined by what it is not, and there is no unclassified bucket anywhere to show up as a hole in a report.

## So where does the sommelier come in?

The gate lowercases the role slug before testing it. It never lowercases the title.

Ten of its keywords are lowercase and more than one word. Machine learning. Deep learning. Computer vision. Natural language. Reinforcement learning. Not one of them can match a title in title case, which is how every title in this dataset is written. Those ten have never matched anything. The slug patterns have been carrying the gate on their own.

I did not see it until I read the file to write this post.

The fix is one call to toLowerCase, so I ran it before shipping it. With the case corrected, 733 roles clear the gate instead of 687. Forty six new arrivals. Some are exactly who you would want: Senior Machine Learning Engineer, Staff Machine Learning Engineer, Graph Neural Network Researcher.

The rest are Real Estate Agent, Insurance Agent, Travel Agent, and Sommelier / Beverage Director.

The word agent is in that keyword list, put there for agentic systems. Lowercase both sides and it starts catching people who sell things. Real Estate Agent, Insurance Agent, Travel Agent. Insurance Agent lands in AI finance roles.

The sommelier arrives through a different hole. RAG is in that list too, and lowercased it matches the middle of the word beverage. He lands in AI consumer and commerce roles, filed beside the retail forecasting engineers.

So the case bug has been hiding a second bug, and the one line patch makes the taxonomy worse rather than better. The keyword needs a boundary before the case gets touched. That is a content decision that wanted a measurement in front of it, and now it has one.

## What is a hub number, exactly?

A hub shows a low, a median and a high for the whole category. It sorts the roles by base median and takes the middle one, then takes the lowest low and the highest high across the set.

So it is a summary of role baselines, and those baselines are the part worth saying out loud. Each one is a hand authored band, not a cell lifted from a wage table. There are 513 distinct bands covering 3,445 titles, and 3,199 of those titles share a band with at least one other title. BLS publishes wages for 173 occupations. We serve 3,445 titles. No arithmetic closes that gap without inventing the difference, so the page says so instead of pretending.

Every figure is a computed estimate. The cost of living multiplier, taken from Bureau of Economic Analysis regional price parities, enters only on the city table further down the page and inside the [salary calculator](https://www.orbytlabs.ai/orbyt-jobs/salaries/calculator). It never touches the headline number.

The published wage data we hold is BLS Occupational Employment and Wage Statistics, plus Department of Labor H-1B filings. An H-1B filing records the floor an employer commits to for a sponsored worker rather than what the market pays, which is why it shows up on [comp by employer](https://www.orbytlabs.ai/orbyt-jobs/salaries/companies) carrying that qualifier and never inside an estimate.

That rule exists because we broke it. Six pages once said the ranges were pinned to published wage percentiles by seniority level. Specific, checkable, false. Fixing it in one place left it wrong in five, so the disclosure now lives in one module every surface imports, [comp by metro](https://www.orbytlabs.ai/orbyt-jobs/salaries/cities) included.

## Why are the hub questions hand written?

Because generated question text is where a content system starts saying things nobody meant. It fills a template with a statistic. Out comes a sentence that is grammatical, plausible, and about nothing.

The hand written set answers what people actually ask. Do AI engineers still need to know how to train models. The answer on the engineering hub says less than people assume, because most of the work in 2026 is calling APIs, building eval pipelines, designing retrieval and shipping surfaces. That is an argument, not a fact. Somebody can disagree with it, which is the whole point of putting it there.

## What will the hubs not do yet?

They are AI only. The gate runs before anything else. Nurses, accountants, electricians and welders all have pages, and not one of them has a hub, so [the salary index](https://www.orbytlabs.ai/orbyt-jobs/salaries) is still the front door for those.

There is a second, older grouping over the same catalogue, [comp by industry](https://www.orbytlabs.ai/orbyt-jobs/salaries/industries), which sorts every role by slug prefix and does cover them. Two taxonomies over one dataset is not something I would design on purpose. Non AI hubs are the pass that collapses them, and I have not built it.

The second thing they will not do is guess. Every hub is prebuilt from the array and the route sets dynamic params to false, so an invented URL gets a 404 from the router instead of a rendered page. We learned that on a sibling route the expensive way. It answered 200 for slugs that were never roles, and each invented URL minted its own cached copy for ninety days.

Counts on the page are computed at build time by running the same classifier the pages run, so the roles tracked line on a card cannot drift from what the hub holds.

The prose is what drifted. My own comments in that file still call it a twelve hub taxonomy, in four places. There are thirteen. The array is right and the rendered count is right and the comment is wrong, which is an on the nose demonstration of why I stopped typing counts into strings. Now it is written down somewhere other than my head, which is most of what the [blog](https://www.orbytlabs.ai/blog) is for.

Related reading:

- [Every salary page in one index](https://www.orbytlabs.ai/orbyt-jobs/salaries) the front door for the thousands of roles no hub covers yet
- [Comp by employer](https://www.orbytlabs.ai/orbyt-jobs/salaries/companies) where the H-1B filings surface, with the visa floor qualifier attached
- [Comp by metro](https://www.orbytlabs.ai/orbyt-jobs/salaries/cities) the cost of living side of the same arithmetic, city by city
- [The salary calculator](https://www.orbytlabs.ai/orbyt-jobs/salaries/calculator) one role against one city, with the multiplier doing its work

<!-- orbyt:chrome:begin -->
This is the markdown version of https://www.orbytlabs.ai/blog/building-salary-hubs. The page there is the canonical copy.
<!-- orbyt:chrome:end -->
