Search · LLMs · lessons from a side project

Rules read the facts, an LLM reads the leftovers, and the rest is ranked.

Users type sentences, not filters. Cheap rules take every fact they can, an LLM gets only the words they missed, and whatever is still not a fact decides the order.

Rules (free) LLM guess (dashed, removable) Ranked by keyword + meaning

Example domain: a real-estate search. The pattern works for any catalog: products, jobs, cars, docs. Code: github.com/Amrabrazek/homescout-ai

Results

What each step buys

21 → 34/35

messy queries read fully right: rules only, then rules + LLM.

37% → 69%

of the top 10 homes match what the words ask for: random order, then meaning ranking.

2.0 s

median LLM call. A cached phrase skips it entirely.

From the project's search eval: 35 messy queries (slang, Arabic, landmarks, negation) and 16 lifestyle queries over 2,591 sample homes.

The pattern

Cheapest step first

"3br villa in tagamo3 under 20M, quiet street"
read the facts
1 · Rules (free)Regex reads numbers, types and known place names: 3 bed · villa · ≤ 20M. Leftover: "tagamo3 quiet street"
only the leftover goes on
2a · CacheSeen this leftover before? Reuse the answer: no LLM call, no cost. The key is the leftover plus which facts the rules already found, so "tagamo3" next to a price and "tagamo3" alone don't share an answer.
2b · LLM (on a miss)tagamo3 → New Cairo, as fixed JSON that is checked field by field. The answer is saved for 30 days.
all facts together
3 · Filters (yes / no)Villa, 3 bed, New Cairo, ≤ 20M. Homes that miss any fact never show. The LLM's part shows as a removable chip.
the rest: "quiet street"
4 · Rank (closest first)Keyword match plus meaning (vector) orders the homes that passed: "calm area" counts for "quiet street".

Caveat: in the homepage search, a home with no keyword hit and a meaning score past a cutoff is dropped, not just ranked lower. The chat assistant only ranks.

Under the hood

What the search engine receives

Each search sends two things in one request. The filters are the yes / no gate. The search text is matched two ways at once: keyword (the exact words appear in the listing's name, unit number, project, area, city, developer, amenities or description) and vector (the meaning of the text is compared with each listing's stored embedding). Only the search text is embedded, never the whole query, so "20M" can't blur the meaning.

Buyer typesFilters (gate)Search text (keyword + vector)
3br villa in new cairo under 20M, quiet streetarea = New Cairo · villa · rooms = 3 · price ≤ 20Mquiet street
3br villa in tagamo3 under 20M, quiet streetvilla · rooms = 3 · price ≤ 20M · area = New Cairoquiet street
apartment, not on the ground floor, no pool, near a parkapartment · floor ≥ 1 · no Swimming Poolpark
شقة بدون حمام سباحة في التجمعapartment · area = New Cairo · no Swimming Pool(empty: newest first)
chalet in north coastcity = North Coast · chalet(empty: no ranking, newest first)

Purple = set by the LLM.

Keyword and vector can't read "no"

Send "no pool" as search text and both halves rank pool homes first: the words match, and the meaning sits right next to "pool". So a "no" never reaches the search text.

"not on the ground floor"Floor is a field, so it becomes floor ≥ 1.
"no pool", "without a gym"A stored amenity, so it becomes an exclude filter. Pool homes never show.
"pool not needed", "no water feature"Phrasing a rule can't read safely goes to the LLM. In the eval, Arabic "بدون حمام سباحة" (without a pool) became an exclude filter. If the LLM is off, the phrase is deleted: not hidden, but not boosted either.
Tradeoffs

Every choice has a cost

ChoiceWhyCost
Rules before the LLMMost queries are simple. No latency, no bill.Two parsers to maintain.
Numbers as filters, not vectorsEmbeddings blur "under 5M" and "over 5M".A wrong filter hides good results.
A "no" only on what we storeOnly a field can enforce a "no". Rules catch the clear cases; the LLM reads the rest.A feature we don't store ("no water feature") is only left out of the search, never hidden. "Without pool and gym" excludes only the pool.
LLM returns fixed JSONEvery value is checked. If it fails, use the rules' answer.It can only fill fields you defined.
LLM sees only the leftover wordsThe same phrase gets the same answer, so it can be cached.Less context. 34/35 test queries pass; the miss is a size ("250m2+") that gets dropped.
Cache by leftover, not by queryWhole sentences rarely repeat, but "near AUC" does. A hit skips a 2.0 s call.A wrong but valid answer would be reused for 30 days. So every LLM guess shows as a removable chip, and removing it also clears the cached answer.
Keyword + vector on the same wordsKeyword finds the exact word ("Mivida", "park"). Vector finds the same idea in other words ("calm area" for "quiet street"). Each covers the other's blind spot.Vector needs a closeness cutoff. On the homepage, homes past it are dropped, not ranked lower (see the caveat above).
Takeaways

Five lessons

Don't embed numbers

Vectors are for meaning. Prices, sizes and dates belong in filters.

A "no" needs a field

Search engines and embeddings ignore negation. Turn it into a filter, or keep it out of the search text.

LLM as fallback

Let code handle the easy cases. Call the model only for what code can't read.

Show the guess

The LLM will sometimes be wrong. Make its guess visible and one tap to undo, and let the undo fix the cache too.

Test with messy queries

Clean test queries all passed. Real phrasing found most of the bugs, including a "no swimming pool" that ranked pool homes first.

From HomeScout, a portfolio project: NestJS, Postgres + pgvector, Typesense, Claude. Code and evals: github.com/Amrabrazek/homescout-ai