[02]
PRODUCT SEARCH / AI ASSISTANT
█ TIER A · YEAR 2026 · STATUS: LIVE · LANGUAGES: JAVASCRIPT
A rewrite → hybrid → rerank pipeline measured link by link
[FIG. 1] MISSION
I assemble multi-stage search pipelines rewrite → hybrid → rerank and measure every link separately instead of judging the whole by feel. 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 handle them by rewriting the query before embedding: an LLM fixes typos and translates, and only then does the query reach the search. I fuse vector ranking with full-text in a single SQL query (pgvector with HNSW plus FTS with GIN, Reciprocal Rank Fusion) instead of stitching two systems together in the application, and finally a model reranks the candidates listwise. I deliver measurable improvement: on the hard set MRR rose from 0.676 to 0.934 at Recall@10 = 1.000. On top of the search I built conversational RAG: the assistant extracts criteria from the conversation, filters price hard in SQL and semantically verifies per attribute that a product really meets the requirements before recommending it. I treat reliability and cost as requirements, not wishes: every model role has a timeout and a fallback path. 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. 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

