Building an LLM recommender that respects hard constraints
How to build an LLM-powered recommender that respects hard constraints: code enforces budget and compatibility, and the model only ranks and explains.
By Hamza Ghouri
An LLM-powered recommender is easy to demo and hard to trust. Ask a model for "the best gaming PC for 1,200" and it will produce a confident, well-written build that is 150 over budget and pairs a CPU with a motherboard it does not fit. The fix is an architecture where hard constraints are enforced by code and the model only does what it is good at: weighing trade-offs and explaining them. That is the approach I'd take for any recommender with rules that must never break, and it is the problem at the heart of RigRadar, my project that scrapes local hardware-retailer listings and uses an LLM to recommend gaming-PC builds within a user's budget.
Hard constraints and soft preferences
Start by sorting every requirement into one of two buckets.
Hard constraints are true or false, and a violation makes the recommendation wrong:
- Total price is at or under the budget.
- The CPU socket matches the motherboard.
- The power supply covers the build's draw with headroom.
- Every part is actually in stock at a store the user can reach.
Soft preferences are matters of degree, where judgement helps:
- "Mostly for competitive shooters, some streaming."
- "Quiet matters more than raw speed."
- "I might upgrade the GPU next year."
The rule is simple: hard constraints go in code, soft preferences go to the model. An LLM is a probabilistic text generator. It is very good at reading "quiet, some streaming" and reasoning about which build fits. It is unreliable at adding up eight prices and comparing the total to a number. Arithmetic and compatibility tables are a solved problem in ordinary code, so solve them there.
The pipeline: filter, rank, verify
The shape I use for constrained recommenders has three stages.
- Filter in code. Load current listings, apply every hard constraint, and produce a list of valid candidates. Nothing invalid survives this stage.
- Rank with the model. Send the user's preferences and a compact description of the valid candidates. Ask the model to pick the best few by id and explain why.
- Verify in code. Check that every id the model returned exists in the candidate set, recompute totals from your own data, and drop anything that fails.
Here is a simplified version of the first and last stages in TypeScript. Real builds have more part categories, but the pattern is the same.
type Part = { id: string; price: number; socket?: string; watts?: number };
type Build = { id: string; parts: Part[]; total: number };
export function validBuilds(
cpus: Part[], boards: Part[], gpus: Part[], budget: number
): Build[] {
const builds: Build[] = [];
for (const cpu of cpus) {
for (const board of boards) {
if (board.socket !== cpu.socket) continue;
for (const gpu of gpus) {
const total = cpu.price + board.price + gpu.price;
if (total > budget) continue;
builds.push({ id: `${cpu.id}:${board.id}:${gpu.id}`, parts: [cpu, board, gpu], total });
}
}
}
return builds;
}
export function verifyPicks(
picks: { id: string; reason: string }[], candidates: Build[], budget: number
) {
const byId = new Map(candidates.map((b) => [b.id, b]));
return picks
.map((p) => ({ build: byId.get(p.id), reason: p.reason }))
.filter((p): p is { build: Build; reason: string } =>
p.build !== undefined && p.build.total <= budget
);
}
verifyPicks looks redundant, since every candidate already passed the filter. It is there because models occasionally return an id that was never in the list, or mangle one. The verify step makes that a dropped result instead of a broken page.
Keeping the candidate list small
The nested loop above explodes quickly: a few hundred parts per category produces millions of combinations. You don't want to generate them all, and you definitely don't want to send them to a model. Ways to keep it manageable:
- Prune per category first. Drop parts that are dominated (another part is both cheaper and better on the metric that matters) before combining.
- Allocate budget shares. For a gaming build, a rough split between GPU, CPU and the rest narrows each category to a price band.
- Score in code, then shortlist. Use a simple deterministic score (performance per unit of price for the user's use case) to keep the top 20 or 30 valid builds.
- Describe candidates compactly. The model needs names, key specs and totals. It does not need full listing pages.
The model then chooses among a few dozen options it can actually reason about, which also keeps the prompt short and the cost per request low.
What the model is good at
With constraints handled, the model's job becomes the part users value most:
- Interpreting vague preferences. "Mostly Valorant, some video editing" means something specific about the CPU and GPU balance. The model can apply that knowledge.
- Ranking among close options. Two valid builds within a small price difference of each other can suit different users. The model can weigh that against stated priorities.
- Explaining the choice. "This build spends more on the GPU because you said gaming comes first; the CPU is enough for streaming at 1080p." Explanations build trust and help users decide.
Ask for structured output (a JSON list of { id, reason }) and validate it before use. The broader habit of checking generated content before a user sees it is covered in how to validate LLM output before it reaches users.
Data quality decides everything
A constrained recommender is only as good as the data the constraints run on. If a scraped price is wrong or a socket field is missing, the filter will pass a build that should have failed. That makes the extraction step upstream just as important as the recommender. For listings that come from scraping, the approach in scraping product listings and extracting data with an LLM applies: parse structure where it exists, validate model-extracted values against the source text, and treat missing compatibility data as "unknown" so the filter excludes it.
Freshness is a hard constraint too. A build recommendation built on yesterday's stock is wrong if the GPU sold out this morning. Show when the data was last refreshed, and re-check availability before the user acts on a recommendation.
The short version
Put every rule that must never break in code, run it before the model sees anything, and verify the model's picks against your own data afterwards. Let the LLM interpret preferences, rank a short list of valid options and explain its reasoning. RigRadar and my other builds are on the projects page.