[02]
PRODUCT RETRIEVAL
█ TIER A · YEAR 2026 · STATUS: LIVE · LANGUAGES: JAVASCRIPT
Hybrid search & shopping assistant over 10,000 products
[FIG. 1] MISSION
Human-typed queries — with typos (“watterproof jaket men”) or in Polish (“namiot dla 2 osób”) — stood no chance against an English corpus of 10,000 outdoor products, so I built a rewrite → hybrid → rerank pipeline and measured every link separately: an LLM first fixes and translates the query, then a single SQL query fuses vector ranking (pgvector, HNSW) with full-text (FTS, GIN) via Reciprocal Rank Fusion, and finally a model reranks the candidates listwise. On the hard set, MRR rose from 0.676 to 0.934 at Recall@10 = 1.000. The project is a deliberate rebuild of an older FastAPI/Spark/GCP stack into a single Next.js deploy with one Postgres and exactly five npm dependencies — the whole chat assistant, product grid and dual-range price slider were built without a single UI library. On top of the search I added a shopping assistant that extracts criteria from the conversation, filters price hard in SQL and semantically verifies that a product actually meets the requirements before recommending it. The demo runs publicly on Vercel + Supabase + OpenRouter.
[FIG. 2] ARCHITECTURE
hover a block to see its description
[FIG. 3] CHALLENGES
[+][CH-01]
The assistant's core is checking whether a product meets the requirements (color, size, capacity) BEFORE recommending it — and the cheapest model turned out too weak for that in three distinct ways. First it described the contradiction correctly in the reason field but filed the key into the wrong bucket; then the verification JSON for ten products at once blew past the token limit, got truncated and fell into a fallback that rejected nothing — a “White/Black” shoe passed a “red” requirement; finally, even with smaller batches, the model lazily marked whole sets as “unknown”. Two moves together fixed it: chain-of-thought in JSON (the model first writes out the value from the text, only then the status) and batches of at most five products — and I moved the meets/doesn't-meet decision itself from the model into code. The mini-eval went from 5/8 to 8/8, with no escalation to a pricier model.
[+][CH-02]
Right before the production deploy, an OpenAI key leaked from an old repository and I had to switch providers — with a double risk: a different embedding space would mean re-embedding 10,000 products, and a cheaper chat model could wreck rewrite and rerank quality. I kept the embeddings in the same space (the same model via OpenRouter — zero re-embedding) and picked the chat model by measurement: the cheapest candidate scored MRR 0.517 against a 0.739 baseline and was out, while gemini-2.5-flash-lite scored 0.761 at a third of the price. In code, the whole provider migration is a single environment variable.
[+][CH-03]
Two LLM calls on every query's path meant two new failure points for a public endpoint: provider timeouts, broken JSON, hallucinated candidate indexes. I adopted the rule that a model error never yields HTTP 500: on timeout, rewrite returns the original query, and rerank validates the output structurally (dedup, index range), backfills gaps with the hybrid order and returns the fusion top-10 on any exception. I tested it with a forced 1 ms timeout and a deliberately corrupted JSON response — in both cases the user still gets results, just without that one link.
[FIG. 4] AI LAYER
The LLM plays several roles on the production path: it rewrites queries before embedding, reranks candidates listwise, extracts criteria from the conversation, verifies them per attribute and writes a grounded reply in Polish. Every role has a timeout and a fallback — a model failure never ends in HTTP 500, just a result without that link. I picked the model by eval, not by price list: the cheapest candidate was rejected at MRR 0.517.
[FIG. 5] GALLERY
SCREENSHOTS INCOMING — see SHOTS-WANTED