Solving the Fitment Problem with On-Device AI: A Practical Demo
If you’ve ever built or maintained a parts catalog — brake pads, spark plugs, filters, wiper blades, anything model-specific — you already know the hardest part isn’t storing the data. It’s matching a customer’s vehicle to the right SKU.
This is the fitment problem, and it’s harder than it looks.
Why fitment matching is deceptively hard
A parts catalog usually stores compatibility as structured data: make, model, generation, year range, engine code. That part is fine — it’s exactly the kind of data a vehicle registration lookup returns cleanly.
The hard part is the other side of the equation: what the customer actually types. In the real world, that’s rarely a clean match to your structured fields. People type:
- “Skoda Fabia” — no year, no engine, no generation
- “2016 Skoda Fabia 1.2 TSI” — precise, but phrased differently than your catalog’s fitment string
- “Opel Corsa” — when your catalog, sourced from a UK supplier, only lists “Vauxhall Corsa” (same car, different badge, depending on market)
- “VW Polo” — when the real question is which generation of Polo, because the answer changes the SKU
Traditional approaches — exact string matching, dropdown selectors, SQL LIKE queries — handle the first case fine and fall over on the rest. You either build an enormous manual mapping table (badge names, generation aliases, regional naming quirks) or you accept a worse search experience and push the ambiguity onto the customer.
A different approach: let a language model do the matching
The insight here isn’t “use AI to know about cars.” It’s narrower and more reliable than that: keep your fitment data as the source of truth, and use a language model purely as a fuzzy matcher between what the customer typed and what your catalog already says.
That distinction matters a lot in practice. Asking an LLM to recall precise technical specs (exact torque values, exact part numbers) is a recipe for hallucination. Asking it to reason about whether “Opel Corsa” and “Vauxhall Corsa D” refer to the same car — a fact any experienced parts counter person would know instantly — is a much safer, much more tractable task. The model isn’t inventing anything; it’s doing the kind of common-sense normalization a human would do at the counter.
Why on-device (Chrome’s built-in Gemini Nano) specifically
Chrome now ships a local language model — Gemini Nano — accessible directly in the browser via the Prompt API, with no server round-trip and no API key. For a fitment-matching use case, that has some genuinely useful properties:
- Zero marginal cost per search. No inference bill per catalog lookup, which matters a lot at scale for a high-traffic parts store.
- No data leaves the browser. The customer’s search and your catalog snippet stay local — relevant if you’re matching against commercially sensitive supplier data.
- Works offline / on flaky connections, which is a nice property for a trade counter or a warehouse app running on unreliable mobile data.
- Fast enough for interactive search — this isn’t a background batch job, it’s a live filter-as-you-type experience.
The trade-off is that Gemini Nano is a small model. It won’t out-reason a frontier model on genuinely obscure technical trivia. But for the kind of common-sense normalization fitment matching actually needs — generation aliases, badge equivalence, rough year-range reasoning — that’s well within a small model’s capability, and it’s worth proving that out before assuming you need a heavier (and costlier) server-side model.
The demo
I built a small working demo of this: marvelous-liger-5c793b.netlify.app
It mocks the whole flow end to end:
- Step 1 — registration lookup (simulated). Type or click a fake number plate, get back a vehicle record (make, model, year, engine) — standing in for a real reg-lookup API call.
- Step 2 — fitment filter (real inference). That vehicle description is matched, live, against a small catalog of brake pad SKUs — each carrying structured fitment data (platform, generation, year range, engine) — using Gemini Nano running entirely in your browser.
A few deliberately awkward test cases are built in, specifically to check whether the model is reasoning or just pattern-matching:
- Typing “Skoda Fabia” with no year correctly surfaces parts across all three Fabia generations in the catalog.
- Typing “2016 Skoda Fabia 1.2 TSI” correctly narrows that down to the one generation it actually fits.
- Typing “Opel Corsa” correctly matches a catalog entry that only ever says “Vauxhall Corsa” — nowhere on the page or in the underlying data does the word “Opel” appear. The model has to know, from general knowledge, that Opel and Vauxhall are the same car under different regional badges (Vauxhall in the UK/Ireland market naming, Opel everywhere else in Europe). Typing “Opel Astra” — a real Opel model with no equivalent in this catalog — correctly returns nothing, ruling out a lucky blanket association.
That last case is really the crux of the demo. A keyword search could never make that connection unless someone manually added an alias table entry for every badge-engineering pair across every market. The model does it for free, because it’s reasoning about the underlying vehicle rather than matching text.
Where this is genuinely useful — and where to be careful
Good fit:
- Free-text “what’s my car” search boxes on a parts catalog, replacing rigid make/model/year dropdowns
- Normalizing incoming customer descriptions before hitting a structured fitment database
- Client-side pre-filtering of a large catalog before a final, precise server-side lookup
Worth being careful about:
- Don’t let the model be the only check on a safety-critical fitment decision (brakes, tyres, anything where an actual wrong-fit part is dangerous) — treat its output as a shortlist, then validate against exact structured fields (year within range, engine code match) before confirming a sale.
- Small on-device models can occasionally drift off the expected output format — build in parsing that degrades gracefully rather than assuming a perfectly clean response every time.
- Availability depends on the user’s Chrome version, flags, and device capability (the model needs to download once on first use) — this is a progressive enhancement, not something to rely on as your only search path yet.
Try it yourself
The demo is live at marvelous-liger-5c793b.netlify.app — open it in a recent Chrome build (chrome://flags/#prompt-api-for-gemini-nano enabled), type a vehicle into the reg-lookup box or the fitment search directly, and watch it reason through generation and badge differences in real time, entirely on your own machine.
Technical appendix: how the JavaScript works
Everything runs client-side, in a single HTML file, with no backend. The interesting part is a small, generic aiFilter() function — it knows nothing about cars or brake pads specifically, and could just as easily filter a product catalog, a document set, or a list of job candidates against any natural-language criteria.
1. Feature detection and session setup
The Prompt API is exposed as a global LanguageModel object in current Chrome builds. The first step is just checking it exists, then checking on-device model availability before creating a session:
function getPromptAPI() { if ('LanguageModel' in self) return self.LanguageModel; if (self.ai && self.ai.languageModel) return self.ai.languageModel; // older API surface return null;}async function createFilterSession(onStatus) { const api = getPromptAPI(); const availability = await api.availability(); // 'available' | 'downloadable' | 'unavailable' if (availability === 'unavailable') throw new Error('Gemini Nano not available on this device.'); return api.create({ expectedOutputLanguages: ['en'], monitor(m) { // fires progress events the first time the model needs downloading m.addEventListener('downloadprogress', (e) => onStatus(`Downloading: ${Math.round(e.loaded * 100)}%`)); }, initialPrompts: [{ role: 'system', content: '...instructions below...' }], });}
A system prompt is set once, at session creation, rather than repeated on every call. It tells the model exactly what shape of task this is and exactly what shape of answer to give back:
“You are a precise fitment-matching engine… match generously but sensibly: account for generation nicknames, platform-sharing and rebadging between related manufacturers… Reply with ONLY a comma-separated list of the item numbers that are compatible. No explanations.”
That last constraint matters more than it looks. Small on-device models are far more reliable at producing a short, tightly-constrained output (a list of numbers) than at producing well-formed free text or JSON — so the whole design leans on getting the model to do the minimum possible amount of “talking.”
2. One batched prompt, not one call per item
Rather than asking the model “does item 1 fit? does item 2 fit?” — N separate inferences — the whole catalog is serialized into one numbered list and sent in a single prompt:
const list = items.map((item, i) => `${i + 1}. ${describe(item, i)}`).join('\n');const prompt = `Customer vehicle: "${criteria}"\n\n` + `Catalog items:\n${list}\n\n` + `Which item numbers are compatible with the vehicle "${criteria}"? ` + `Respond with only the comma-separated numbers (e.g. "1,3,5") or "none".`;const raw = await session.prompt(prompt);
The describe() function is the only thing that’s catalog-specific — it’s a small callback that turns one item into a line of text the model reads. For the brake pad demo it’s just part name + fitment string; for a different catalog it’d read different fields entirely. This is what makes aiFilter() reusable rather than one-off.
3. Parsing the response back into real objects
The model’s reply is a plain string like "1,3,7" or "none". Parsing deliberately doesn’t assume a perfectly clean format — it just pulls out any digits present, which degrades gracefully if the model adds stray words despite instructions not to:
const matchedIndexes = new Set( (raw.match(/\d+/g) || []).map(n => parseInt(n, 10) - 1));const kept = items.filter((_, i) => matchedIndexes.has(i));
Those indexes map straight back to the original array, so the function returns real catalog objects — not text the model generated — which is what keeps the model firmly in a matching role rather than a data generation role. It never gets the chance to invent a SKU or a fitment string; it only ever gets to point at ones that already exist.
4. Loading the model once, not per search
The first version of this demo created and destroyed a session on every click, which meant a delay on the very first search while the model loaded. The current version preloads a single session as soon as the page opens — the “Find compatible parts” button stays disabled with a spinner until createFilterSession() resolves — and that same session is reused for every subsequent search, so only the very first page load pays any loading cost.
5. The registration lookup is the one part that’s fake
Worth being explicit: Step 1 (plate → vehicle) in the demo is a hard-coded lookup table with an artificial setTimeout delay, standing in for what would be a real API call in production — e.g. against a vehicle registration dataset like the ones we work with at carregistrationapi.ie. Step 2 (the actual fitment filtering) is the only part doing real inference, and it’s a self-contained function that would drop into any catalog UI unchanged.