TL;DR:
A second-hand site can only search the text its sellers wrote, and most sellers write to get the thing out of the house, not to get it found. Keyword search then forces a choice: narrow queries that miss the bargains, or wide ones that bury them in the haystack of results.
The way out is to stop asking one mechanism to do both jobs.
Take the wide, generic search on purpose and then have a local LLM score every candidate against a plain-language description of what you actually want. Include what a poorly written but still interesting listing looks like, and what to avoid, just like you would ask a friend to help look out.
Finding a good deal is hard
If you buy second-hand from time to time, you probably know the frustration of finding what you're looking for. Depending on how specific the item you want is, you will either:
- spend way too much time scrolling through tens to hundreds of pages finding maybe one or two listings that look promising.
- feel like you're just having no luck at all because there are no (decent) results for your search.
The problem is that the people writing the listing are often not thinking about how to get it found. They just want to
sell and 'get the damn thing out of the house'.
A high-end Fender Telecaster then gets listed as 'electric guitar' in the 'Music -> Other' category, with the model
name mentioned only in the sixth line of the description, or not at all because the seller inherited it and doesn't know
anything about guitars or even musical instruments.
Those poorly written listings are often the gems to be found as they can be had for a bargain, but how do we find them between all the noise?
- Searching specific ("Fender Telecaster" in electric guitars + model year + price band + ...): You'll find the exact thing you're looking for, but these sellers know their stuff. The really good deals don't show up because they don't fall into the price band and/or don't have the keywords you used.
- Searching wide ("guitar" in the music category): You get to see every guitar on the platform (almost, there will be some listed in the garden section) and have to scroll through 20+ pages to find the first one that you like that's 20% over your budget.
There is also no real middle ground here. The amount of results only meaningfully reduces when you get specific with
filters and keywords and by that time you've already excluded most of the interesting potential matches.
The only solution is scanning every listing like a human would, which brings us to...
AI-powered screening of listings
Scanning every listing like a human would, without actually doing it ourselves, is exactly what an LLM is for.
LLMs are great at pattern recognition and processing human language. Those are the exact two things we need here: the listings are written in human language (for the most part by humans) and need to be analysed to see if they match a set of criteria.
Let's break down how we're going to tackle this in a few steps and use two (actually running) examples:
-
Telecaster copy in white
As you might have guessed by the amount 'guitar' was mentioned earlier, I'm a guitarist.
There's a relatively unknown brand, London City, that made really good copies of Fender guitars in the 2000's for not a lot of money, and specifically I'm still looking for their Telecaster model in white. -
Battery-powered hedge trimmer
I'm in the market for a battery-powered hedge trimmer, because wrangling an extension cord trying to grab my ankles or trying to get itself cut in half by the blades is making garden work much more exhausting than it already is.
Since I'm already 'locked' into Makita's battery platform because of other tools, I want this one to be from them as well.
Deliberately wide search
First, we deliberately do take the wide search we lamented earlier as our base result set.
We'll use the following search parameters:
- Telecaster copy in white
- Keywords: "telecaster, tele, London City, Comet"
- Category: 'electric guitar'
- Battery-powered hedge trimmer
- Keywords: Makita
- Category: 'hedge trimmers'
Both queries provide categories as I like the added filtering that gets me. My local second-hand platform is moderated well and actual misplaced listings are rare.
The hedge trimmer query might be a little narrow upfront, but I'm fine with that because this query alone already produces close to 100 results because of how popular Makita is.
The search is authored by hand rather than generated from the intent paragraph we'll be passing to the LLM.
That's on purpose: it keeps the result retrieval consistent, because we don't have potentially badly generated queries.
Multiple keywords are run as separate queries, to maximise the result set. I'm also in luck, because my local second-hand platform's frontend is built on a JSON API which I can just launch these queries at:
query=telecaster&categoryId=[categoryId]&limit=100&sortBy=SORT_INDEX&sortOrder=DECREASING&searchInTitleAndDescription=true
query=tele&categoryId=[categoryId]&limit=100&sortBy=SORT_INDEX&sortOrder=DECREASING&searchInTitleAndDescription=true
query=london+city&categoryId=[categoryId]&limit=100&sortBy=SORT_INDEX&sortOrder=DECREASING&searchInTitleAndDescription=true
...
Each result carries a stable itemId, title, full description, priceInfo, location with country code, image URLs,
and some extended attributes specific to the category (e.g.: clock speed for a CPU, odometer for a vehicle, ...).
That's everything (and more) the LLM needs to analyse the result and narrow our search to listings we actually care about.
{
"itemId": "123456789",
"title": "Fender Telecaster",
"description": "Fender Telecaster American Deluxe from 2008 in sunburst color",
"priceInfo": { "priceCents": 130000, "priceType": "FIXED" },
"location": { "cityName": "Antwerp", "countryAbbreviation": "BE" },
"date": "8 aug 26",
"categoryId": 123,
"imageUrls": ["//images.domain.com/images/1ab23c45-...?file=image.jpg"],
"extendedAttributes": [
{ "key": "condition", "value": "Zo goed als nieuw" },
{ "key": "kind", "value": "Solid body" },
{ "key": "brand", "value": "Fender" }
]
}
This example is trimmed. The real payload contains more, but also repeats most of this under
attributesandpictures, and carries seller information I won't publish here and discard in the app anyway.
The scan itself is driven by Symfony's Scheduler component, on an interval configured per saved search.
Results and their scores are persisted in an Observation Doctrine entity, so a listing that has already been analysed
never costs a second call.
Since this is a personal-scale tool polling the second-hand platform's public endpoint, it stays at personal scale: a page cap per scan, a delay between requests and an identifying User-Agent.
Using an LLM to screen listings
With our base result set retrieved, it's time to get to the actual AI part.
When defining our search, next to the keywords and category that drive the JSON API query, we also define an intent paragraph in which we define in human language what we're actually looking for. It closely resembles what I would tell someone that would help me in my search, including a few negatives.
For the Telecaster it looks like this:
A Telecaster in white (olympic white / vintage white / cream is fine, not blonde or butterscotch) from the London City brand only.
London City's telecaster model is also named 'Comet' instead of telecaster.
Not parts, not a body/neck only.
Not left-handed.
And for the hedge trimmer:
A hedge trimmer of the Makita brand.
Must use the 18V LXT battery system. Mentions of 36V are probably a match, because 2x18V = 36V.
Battery and/or charger included is a bonus, but not required.
'Buxusschaar' is not a decent match.
'Kettingzaag' is not a match.
Excluded models:
BUH550: Single 36V battery
UH5570: corded, 220V
UH6580: corded, 220V
Every listing from the base result set gets sent to the LLM for scoring, together with the intent paragraph and a
system prompt. The model used is Gemma 4 26B-A4B QAT, running on my own hardware behind an Ollama-compatible API.
One piece of that system prompt deserves highlighting:
Listings are written by ordinary sellers: titles are often vague or generic, categories are sometimes wrong, and model names and numbers are unreliable or absent.
Judge on the substance of the listing, not on whether its wording happens to echo the buyer's wording.
That last sentence is the important one. Without it a local model might get caught up in matching keywords and negate the entire purpose of having an LLM involved: not blindly matching keywords.
The model is further told to strictly return JSON with three fields:
score: How well this listing matches the intent, as an integer 0-100. The LLM is urged to score honestly and not guess high or low to seem decisive.reason: One or two sentences explaining the score, citing what in the listing drove it.caveats: Anything in the listing that would make this item cheaper or less desirable than it first appears: damage, missing or replaced parts, heavy wear, high mileage, ...
caveats earns its place separately from reason: we deliberately don't let it influence the score, so perfect matches
that are non-functional still surface. This way we can decide ourselves if the caveat is worth it (e.g.: no strings on a
guitar is mostly a non-issue).
AI is not the judge, I am
Having the LLM only return an integer score from 0 to 100 with some info on how it got to that score is a
fundamental difference from a judgment.
The actual judgment comes from me and lives in the application's code: a plain method on the SavedSearch Doctrine
entity, which is where both thresholds are stored per search:
public function verdictFor(int $score): SearchVerdict
{
if ($score >= $this->matchThreshold) {
return SearchVerdict::Match;
}
if ($score >= $this->maybeThreshold) {
return SearchVerdict::Maybe;
}
return SearchVerdict::Reject;
}
This is as much about keeping control as it is about versatility. A score and the reasoning behind it tells me way more than a hard Match/Maybe/Reject response that I can't audit. Together with being able to set the thresholds per search, I can now decide for myself how low or high to set the bar.
Perfect example is the Telecaster. I have set the bar high for that one, because the brand is unknown and needs to match
and because I want it specifically to be white.
The hedge trimmer on the other hand can be more forgiving. The only requirement there is a specific brand and being
battery-operated.
This also saves a few LLM calls. Because the scores are already saved on the Observation, updating the thresholds
instantly changes the decisions, where an AI-driven one would need to rerun all of the previously checked listings
against the changed thresholds.
Using vision for extra certainty
Colour, condition and verification of the claims in the text are something that can only be done by examining the
pictures.
Using AI to analyse pictures takes a significantly higher amount of processing time than text-only does, so it is only
run for listings where a text-only pass was unsure.
Fortunately, Gemma 4 supports vision by loading its mmproj alongside it, so there's no second model competing for VRAM.
The neat part is this doesn't need specific configuration. An unsure text-pass is by definition one that scores above
the rejection threshold, but below the match threshold.
This also means I can play with how much vision I want to use for a specific search. Low rejection bar and high match
acceptance increases the amount of vision calls and vice versa.
Running costs
Because I have a home server with a single Nvidia RTX 3090 running LocalAI on a llama.cpp backend, I've chosen to have
each listing be sent to the LLM in a separate call, rather than a batched one.
Symfony's Messenger component handles this nicely by moving it to the background and allowing auto retry if an LLM call
should spiral and time out.
The model helps here too: despite the 26B, only about 4B parameters are active per token, and MTP on top of that keeps a call per listing fast enough to not be worth batching away.
The cost is therefore only electricity and latency rather than API spend, which is what makes this a reasonable thing to do at all. At API pricing it would push you straight back toward a narrow search, and back into missing the listings that matter.
Results
The results are very promising but, as LLMs aren't perfect, there are weak spots.
The Telecaster search hasn't found me the right guitar yet, but it has already justified itself. It flagged a
London City Telecaster buried in a 'cleanup' listing that was selling several guitars at once. Exactly the listing a
keyword search probably would have missed, because the title said nothing about London City and nothing about which
guitar was which. The text pass caught it, the score landed in the uncertainty band, and the vision pass then correctly
worked out that the guitar wasn't white.
It was successfully rejected, on precisely the grounds I would have used myself.
The hedge trimmer search delivers too: most of its top matches are genuinely what I asked for, and it surfaced one listing at 30% of the median asking price across its own matches. I wasn't fast enough to grab that one, but that's a me-problem rather than a problem with this setup.
Unfortunately, the setup (or rather the model) has also shown its weak spots. For the hedge trimmer search, one of the most important criteria is that it needs to be battery-powered. The most reliable differentiator for that is the model number, which the model has no knowledge of, and vision has turned out to be unreliable at recognising a power tool's 'pigtail' as a mains cord. For now, I patch it by hand through the excluded-models list in the intent paragraph. A real fix could be giving the model a web search for model numbers it doesn't know, or I can just accept that the intent paragraph is where domain knowledge belongs and keeping it well-fed.
I'm very happy with how the app is performing so far on my local AI setup. A 26B mixture-of-experts with 4B active
is not a genius but, at least for this application, it doesn't need to be. The value is in the automation itself and
refusing to let it decide: it checks way more listings than I can (or want), returns a score and a sentence of reasoning,
and every actual decision stays in code I can read, change and re-run for free.
That boundary is what makes a local model good enough here, and it's why a guitar it correctly rejected counts as much
as the one it will eventually find.