Fashion e-commerce AI engine: fine-tuned CLIP (decoupled LR, text→image R@1 26.5% → 39.2%), BM25 + dense + visual hybrid search on pgvector, LangChain shopping copilot.
See the codeToko Marcell is an end-to-end, production-deployed e-commerce platform built from scratch to benchmark and serve Multimodal Foundation Models (VLMs), hybrid lexical–dense–visual vector retrieval, and an agentic AI shopping copilot with grounded RAG over a real catalog of 4,670 curated products (built from an initial 6,000 candidate extract).
| 🌐 Live storefront | https://toko-marcell.vercel.app |
| ⚡ Live API / interactive docs | https://toko-marcell-api.onrender.com/docs |
| 🧠 Fine-tuned CLIP on HF Hub | Marcell-Kristianto/toko-marcell-clip |
| 🔬 Research report (failure analysis) | pipelines/failure_case_analysis.md |
| 📑 Literature review (CLIP fine-tuning) | pipelines/literature_review_clip_finetuning.md |
| 📊 Machine-readable leaderboard | pipelines/multimodal_benchmark_results.json |
| 🧾 Benchmark source ledger | pipelines/BENCHMARKS.md — what each number is and how to reproduce it |
Note on the live API: the backend runs on Render's free tier, so it sleeps after 15 minutes of inactivity and takes 30–50 s to wake. The storefront handles this explicitly with a health-check overlay (see Cold-start UX) instead of silently failing. The catalog itself is served from Vercel's edge cache and stays fast.
Toko Marcell is a working e-commerce engine, not a UI mock — a Next.js storefront, a FastAPI backend and PostgreSQL + pgvector, over a real 4,670-product catalog, deployed live.
The five things worth inspecting (each backed by a committed artifact):
| # | What | Result | Where |
|---|---|---|---|
| 1 | VLM fine-tuning study — 4 fine-tuning strategies vs a zero-shot baseline, 5,378 image–text pairs, 539 held-out test | champion lifts text→image Recall@1 26.53% → 39.15% (+47.57% rel) | leaderboard |
| 2 | Hybrid retrieval — BM25 + MiniLM (384-d) + CLIP (512-d) → RRF (k=60); optional ONNX cross-encoder on the copilot path | exact SKUs and semantic/visual queries; ?mode= switchable | API internals |
| 3 | Grounded agentic copilot — LangChain v1 create_agent + OpenRouter (free tier), deterministic zero-LLM guardrail, 7 tools, LangGraph Postgres memory, hypothetical-question policy RAG | answers only from tool evidence; refuses off-topic and injection attempts without an LLM call | copilot |
| 4 | Recommendations from real behaviour — 3.36 M interactions | co-occurrence + Bayesian popularity, session-aware, cold-start safe | recommendation engine |
| 5 | Review-derived fit & sizing — 622,530 fit mentions regex-mined from 3.36 M reviews | per-product/brand runs_small / true_to_size / runs_large, Dirichlet-smoothed, surfaced as fit badges + copilot recommend_size | review-derived fit |
Measured results (committed artifacts in pipelines/results/):
| Benchmark | Metric | BM25 | Hybrid | Hybrid+Rerank | Trimodal |
|---|---|---|---|---|---|
| IR (32 queries) | nDCG@10 | 0.794 | 0.879 | 0.893 | 0.908 |
| IR (32 queries) | MRR@10 | 0.792 | 0.906 | 0.885 | 0.932 |
| IR — Indonesian (8q) | nDCG@10 | 0.334 | 0.622 | 0.739 | 0.821 |
| Recs (5 K users) | HR@10 | nDCG@10 |
|---|---|---|
| Random | 0.22% | 0.0011 |
| Popularity | 0.18% | 0.0008 |
| Item-to-Item CF | 7.96% | 0.0501 |
| RAG retrieval (12 ID+EN queries) | HR@1 | HR@3 | MRR@3 |
|---|---|---|---|
| Direct-chunk | 0.250 | 0.333 | 0.292 |
| Hypothetical-question | 0.750 | 0.917 | 0.819 |
Indexing a few template-generated hypothetical questions per policy chunk roughly triples HR@1 over
embedding the raw chunk text (artifacts: pipelines/results/rag_eval.json,
pipelines/eval_rag.py).
Multilingual embedding A/B (pipelines/results/search_eval_multilingual.json):
paraphrase-multilingual-MiniLM-L12-v2 lifts overall hybrid nDCG@10 0.879 → 0.894 and the
Indonesian group 0.622 → 0.765, but roughly doubles peak RSS (~1024 MB vs ~610–650 MB) with both
CLIP ONNX encoders loaded — so MiniLM stays the default and the multilingual model is opt-in.
The cross-encoder reranker improves nDCG@10 overall (+1.4 pts vs hybrid) and substantially on Indonesian queries (+0.117), but slightly lowers MRR@10 overall (0.906 → 0.885). On Category/Attr it drops nDCG (0.990 → 0.940). Net: a modest positive, strongest on the hardest group.
Stack: Next.js 16 · React 19 · FastAPI · PostgreSQL 16 + pgvector · PyTorch/CLIP · LangChain + OpenRouter (free tier, Gemini fallback) · Docker · Vercel + Render + Neon.
Try it locally
Prerequisite: Docker with Compose. Nothing else — Python, Node and PostgreSQL are not needed on the host, and the database seeds itself on first boot.
# 1. get the code
git clone https://github.com/marknshoot/toko-marcell.git
cd toko-marcell
# 2. (optional) enable the AI copilot with a free OpenRouter key
# https://openrouter.ai/keys — skip this and everything else still works
cp .env.example .env # then set OPENROUTER_API_KEY=... in .env
# 3. start the full stack
./run-local.sh # → http://localhost:3000
run-local.sh also prompts for the key if .env is empty — press Enter to skip. Pressing Enter
leaves the copilot disabled; the rest of the site is unaffected.
| Works with no configuration | Needs an LLM key (OpenRouter, or Gemini as fallback) |
|---|---|
| catalog, hybrid text search, recommendations, reviews, demo checkout | the copilot's answers (/copilot/chat returns a friendly 503 without one) |
Or use the hosted build: storefront / API docs.
Scope, honestly: the storefront, QRIS checkout, couriers and order flow are a demo simulation —
they charge nothing and ship nothing. What is real: the catalog, the vector search, the ML study, the
recommendations and the agent's grounding. Every headline number is tagged in
BENCHMARKS.md as committed-artifact / locally-reproducible /
author-measured. Open gaps are in Honest limitations.
This is a portfolio project, and it is deliberately not a CRUD tutorial with a prettier homepage. It exists to demonstrate four things a hiring manager can verify rather than take on trust:
create_agent loop that can only speak
from tool results, fronted by a deterministic zero-LLM guardrail, with a 7-tool whitelist,
prompt-injection/off-topic refusal, token-budget and model-call-count safeguards, and tests.Every number below is either measured and stored in the repository or read from the code at runtime. Nothing is invented. Where something is simplified or unfinished, it is written down as such in Honest limitations.
Four tiers, each with a single job. The storefront never opens a database connection; the backend never renders HTML; the ML pipelines never serve a request.
flowchart TD
subgraph Client["Tier 0 — Shopper experience (Next.js 16 App Router)"]
UI["Storefront (Tailwind v4, Geist)"]
Chat["Floating AI copilot"]
Wakeup["Cold-start wakeup overlay<br/>(Render /health ping)"]
ClientState["localStorage cart (v2) +<br/>sessionStorage anonymous session id"]
end
subgraph Edge["Tier 1 — Vercel edge network"]
ISR["ISR cache (revalidate = 300s)<br/>Product detail pages"]
Headers["Security headers<br/>(nosniff, DENY, strict-origin)"]
end
subgraph API["Tier 2 — FastAPI backend (Render, free tier)"]
CORS["Configurable CORS allowlist"]
Cache["In-memory facet TTL cache (300s)"]
Val["Pydantic bounds<br/>(token & payload safeguards)"]
subgraph Orchestration["Agentic copilot (LangChain v1 create_agent + OpenRouter)"]
Guard["Deterministic guardrail node<br/>(greeting / off-topic / injection → 0 LLM calls)"]
Agent["create_agent loop<br/>+ middleware (context injection,<br/>history trim, 3-call cap)"]
Tools7["7 grounding tools"]
end
end
subgraph Retrieval["Tier 3 — hybrid retrieval & fusion"]
BM25["BM25 (lexical)"]
MiniLM["Dense text: all-MiniLM-L6-v2 (384-d)"]
CLIP["Visual / cross-modal: fine-tuned CLIP ViT-B/32 (512-d)"]
RRF["Reciprocal Rank Fusion (k=60)"]
CE["Cross-encoder reranker (optional, copilot only)<br/>(ms-marco-MiniLM-L-6-v2, ONNX)"]
end
subgraph Storage["Tier 4 — PostgreSQL 16 + pgvector"]
PG["Neon Postgres"]
HNSW["HNSW cosine indexes (384-d & 512-d)"]
RAG["store_knowledge + store_knowledge_questions RAG"]
Fit["product_fit · brand_fit"]
Ckpt["LangGraph checkpoints<br/>(Postgres saver, created at runtime)"]
Ops["events · orders · order_items · item_recommendations · reviews"]
end
UI --> Edge --> API
Chat --> API
Wakeup --> API
ClientState --> UI
API --> Guard
Guard -. "needs tools" .-> Agent --> Tools7
Tools7 --> BM25 & MiniLM & CLIP
BM25 & MiniLM & CLIP --> RRF --> Storage
RRF -. "copilot, ENABLE_RERANKER" .-> CE
Tools7 --> RAG
Tools7 --> Fit
Agent --> Ckpt
Agent --> Chat
Cache --> PG
Val --> Guard
Ops --> PG
| Tier | Technology | Owns | Explicitly does not |
|---|---|---|---|
| 0 — Client | Next.js 16 (App Router), React 19, Tailwind v4 | Rendering, cart state, session id, cold-start UX | Touch the database, know prices |
| 1 — Edge | Vercel CDN + ISR | Caching product pages, security headers | Hold business logic |
| 2 — API | FastAPI, Pydantic, LangChain, OpenRouter | REST validation, search, recs, copilot, checkout | Render HTML |
| 3 — Retrieval | BM25, fastembed (MiniLM + CLIP), ONNX reranker | Ranking | Persist state |
| 4 — Storage | PostgreSQL 16 + pgvector (Neon) | Catalog, vectors, events, orders, reviews, knowledge | Serve HTTP |
The single hard rule that keeps this clean: the frontend never opens a database connection — it
only ever talks HTTP (shop/src/lib/api.js is the only place that knows the API URL).
The problem. General-purpose CLIP (openai/clip-vit-base-patch32) is good at separating a dog
from a car and bad at separating clean dress chinos from rugged work pants, a relaxed straight cut
from a slim taper, or glossy waterproof nylon from matte fleece. These "fine-grained semantic gaps"
are exactly where e-commerce search lives. The study asks a specific, testable question:
If we domain-adapt CLIP on this catalog, which fine-tuning strategy buys the most retrieval accuracy, and where does it still fail?
pipelines/prepare_clip_dataset.py).Brand + Title + (Category / Department) + first fabric feature bullets.
The literature review (Chia et al., 2022) found structured captions outperform raw titles by a
wide margin on fashion recall; this pipeline follows that finding.Four adaptation hypotheses (plus a zero-shot reference), each derived from a paper (see
literature_review_clip_finetuning.md):
| Experiment | What it changes | Source idea |
|---|---|---|
| Zero-shot (reference) | Frozen pretrained CLIP — the number to beat | — |
| LoRA | Freeze base, train low-rank adapters on q_proj, v_proj | Hu et al., LoRA (2021) |
| SigLIP | Replace batch softmax with pairwise sigmoid loss | Zhai et al., SigLIP (2023) |
| WiSE-FT | Weight-space ensemble of fine-tuned + zero-shot | Wortsman et al. (2022) |
| Decoupled LR 🏆 | Freeze ViT layers 0–5, asymmetric LRs per module | Wortsman et al. (2022) |
Training ran on Kaggle GPU — NVIDIA Tesla T4 16 GB, mixed precision (AMP FP16),
3 epochs, batch size 64, base LR 5e-6. The exact run configuration lives in
multimodal_benchmark_results.json.
| Experiment | Architecture / approach | Recall@1 | Recall@5 | Recall@10 | MRR | Latency (T4) |
|---|---|---|---|---|---|---|
| Zero-shot base | openai/clip-vit-base-patch32 (frozen) | 26.53% | 54.92% | 70.69% | 0.4033 | 27.80 ms |
| PEFT LoRA | LoRA, early-stopped, r=16, α=32 on q_proj, v_proj | 33.40% | 71.61% | 83.49% | 0.5038 | 15.50 ms |
| SigLIP | Pairwise sigmoid loss, learnable temperature/bias | 35.25% | 71.43% | 82.19% | 0.5091 | 19.38 ms |
| WiSE-FT | Weight-space ensemble (decoupled LR + zero-shot, α=0.35) | 37.85% | 73.28% | 85.53% | 0.5363 | 9.76 ms |
| 🏆 Champion — Decoupled LR | ViT 0–5 frozen · vision 0.2× · text 1.0× · projection 2.0× | 39.15% | 78.11% | 87.20% | 0.5592 | 14.01 ms |
Headline result: Recall@1 26.53% → 39.15% = +12.62 points absolute, +47.57% relative.
Marcell-Kristianto/toko-marcell-clip).
Serving uses ONNX Runtime on CPU (< ~150 MB RAM, 2 intra-op threads, graph optimizations on).pipelines/failure_case_analysis.md is the qualitative
half of the study: it dissects cases where rank improved dramatically or did not, e.g. distressed
denim texture, the structural drape of 8.5 oz twill, and specular sheen on waterproof nylon. The
remaining known failure mode is sub-brand logo invariance (e.g. a small embroidered logo patch),
proposed fix multi-crop / RoI-Align training. The research report's qualitative case section and
the aggregate scoreboard in this README come from different evaluation passes; the aggregate table
here is the authoritative Kaggle 4-way leaderboard stored in the JSON above.
Pure vector search fails on exact tokens (SKU numbers, model numbers like 501, brand names).
Pure lexical search fails on typos, synonyms, and conversational intent. Toko Marcell runs all the
rankers that matter and fuses them.
flowchart LR
Q["Query (text or image)"] --> BM25["① BM25 lexical<br/>title ×3 · brand ×2 · category ×1.5<br/>department ×1 · features ×1 · desc ×0.5"]
Q --> Dense["② Dense sentence vectors<br/>all-MiniLM-L6-v2, 384-d, pgvector HNSW"]
Q --> Visual["③ Cross-modal CLIP<br/>fine-tuned ViT-B/32 text encoder, 512-d"]
Img["Uploaded image"] --> Vision["CLIP vision encoder<br/>512-d → pgvector HNSW"]
BM25 --> RRF["Reciprocal Rank Fusion<br/>score = Σ 1/(60 + rank)"]
Dense --> RRF
Visual --> RRF
RRF --> Out["Ranked products"]
RRF -. "copilot only, ENABLE_RERANKER" .-> CE["Cross-encoder rerank<br/>ms-marco-MiniLM-L-6-v2 (ONNX)"]
CE -.-> Out
Vision --> Out
api/search.py implements Robertson & Zaragoza BM25 (k1=1.5, b=0.75, +1 IDF smoothing) with
zero search dependencies, because a baseline is only useful if it is honest enough to beat.
Field weighting (title 3.0 → description 0.5) is a practical alternative to one index per field;
BM25's saturation handles fractional term frequencies fine. The tokenizer does lowercase → accent
folding → alphanumeric filtering → stopword removal → a small plural fold (sneakers→sneaker,
jeans→jean). It is not a Porter stemmer; that limitation is stated where it lives, in the code.
Why in-process? At this catalog size the whole index rebuilds in well under a second and lives comfortably in RAM, so an external service would add a dependency for nothing. Known gaps are deliberate: no typo tolerance, no synonyms, no phrases — and those gaps are precisely what the embedding half exists to close. That is why the baseline was measured first.
Honest baseline behaviour (measured in api/tests/test_search_unit.py + manual probing):
'levis 501' → exact model numbers, four distinct variants
'waterproof jacket' → raincoats and watertight shells
'shoes' == 'shoe' → plural folding makes the two score identically
'zzzz' → 0 results (no matching postings, returns immediately)
Duplicate-title suppression. 842 of the 6,000 built products are variants sharing a title with a
different ASIN. Without dedupe, a query like jeans shows the same title twice in the top 10.
Dedupe runs before pagination so total counts results a shopper can actually reach.
384-dimensional sentence-transformers/all-MiniLM-L6-v2 embeddings (via fastembed, ONNX runtime)
stored in a vector(384) column with an HNSW cosine index. Text representation =
title + brand + category + features + description. This ranker is what lets "shoes for women" and
"white sneakers" work without exact token overlap.
An uploaded photo is encoded with the fine-tuned CLIP vision tower to a 512-d vector and matched
against products.image_embedding (HNSW cosine). The same 512-d space lets a text query search
product images (mode=trimodal) — so "waterproof hooded packable rain jacket nylon shell" can be
retrieved by the visual sheen of the garment, not only by words. Encoders are loaded lazily and
cached per process. The champion fine-tuned encoders are served as ONNX (no torch): the text
tower is fp16 (~127 MB) and the vision tower is int8 (~96 MB), both published and fetched at runtime
(pipelines/export_text_onnx.py,
pipelines/export_vision_onnx.py). If no champion vision
encoder is present, image search returns 503 rather than falling back to a mismatched zero-shot
space.
$$ \text{RRF}(d) = \sum_{m \in M} \frac{1}{60 + r_m(d)} $$
RRF is used instead of score normalization because BM25 scores are unbounded positives while cosine
similarities are bounded in [-1, 1]; RRF needs only ranks, so the heterogeneous rankers can be
combined with no calibration and no per-query tuning. k=60 is the constant from
Cormack, Clarke & Buettcher (2009).
The HTTP /search endpoint returns the RRF order. The copilot's search_catalog tool can add a
second stage behind the ENABLE_RERANKER flag (off on the 512 MB deployment, on in local Compose):
top candidates are re-scored by Xenova/ms-marco-MiniLM-L-6-v2 run through ONNX Runtime
(~15–25 ms for ~20 candidates). A cross-encoder sees the query and document together, so it
captures relevance a bi-encoder cannot — the standard two-stage retrieve-then-rerank pattern.
If the model cannot be downloaded, the code logs a warning and returns the Stage-1 order, so the
search endpoint degrades rather than breaks.
pipelines/eval_search.py ships a 32-query benchmark across four groups — exact brand/SKU,
category + explicit features, semantic/intent, and Indonesian/casual cross-lingual — and reports
HitRate@10, Precision@10, MRR@10 and nDCG@10 for BM25 vs hybrid, with a group breakdown and
relative gains. mode=bm25|vector|hybrid|trimodal is returned in every response so the eval harness
and the UI can always tell which ranker produced a result set instead of inferring it.
Many shoppers type Indonesian. pipelines/results/search_eval_multilingual.json records a hybrid-mode
A/B of paraphrase-multilingual-MiniLM-L12-v2 against the default all-MiniLM-L6-v2: overall
nDCG@10 0.879 → 0.894, MRR@10 0.906 → 0.919, and the Indonesian query group nDCG@10
0.622 → 0.765. The catch is memory — peak RSS with both CLIP ONNX encoders loaded is ~1024 MB
vs ~610–650 MB for MiniLM, which overruns the 512 MB free tier. So MiniLM stays the default and
the multilingual model is opt-in via TEXT_EMBED_MODEL + TEXT_EMBED_COLUMN=embedding_ml (the
embedding_ml column already ships in the seed).
The copilot's policy answers retrieve from store_knowledge, but embedding a raw policy chunk and
embedding the question a shopper actually asks live in different parts of the vector space.
pipelines/eval_rag.py quantifies the gap on 12 labelled ID+EN queries
(pipelines/results/rag_eval.json): direct-chunk retrieval
scores HR@1 0.250 / HR@3 0.333 / MRR@3 0.292, while indexing 82 template-generated hypothetical
questions (3–5 ID+EN per chunk, in store_knowledge_questions) lifts it to HR@1 0.750 / HR@3
0.917 / MRR@3 0.819 — roughly triple HR@1.
Search answers "find me this". Recommendations answer "what next" — the surface where most e-commerce demos cheat with a hard-coded "you may also like". There is none of that here: every rail is built from the real 3.36 M-interaction log and the catalog's own co-buy graph, then served through a fallback chain so a cold visit still gets a useful rail.
| Surface | Endpoint | Mechanism | Cold fallback |
|---|---|---|---|
| Home rail | GET /recs/session | the visit's last 5 view_product / add_to_cart events → union of their item recs, excluding seen items | global popularity (cold_popularity) |
| Product page | GET /recs/item/{asin} | offline item-to-item table (item_recommendations) | same category → department popularity |
| Browse / cold | GET /recs/popular | review-volume ordering, optional department filter | — |
Each response includes a strategy field (session_cf, item_cf, category_popularity_fallback,
cold_popularity), so the UI can say how a rail was produced rather than guessing.
pipelines/build_recs.py)co counts users who interacted with both.
Ultra-popular items are capped at 5,000 sampled users so the candidate product stays bounded.also_buy adds +0.50 and also_view +0.30 to a candidate's
score, so explicit co-purchase signals beat coincidental co-views.flowchart LR
I["interactions.csv.gz<br/>3.36M rows"] --> CO["co-occurrence<br/>cosine of user sets"]
M["products.jsonl<br/>also_buy / also_view"] --> BOOST["metadata boosts<br/>+0.50 / +0.30"]
P["rating_count + avg_rating"] --> BAY["Bayesian popularity<br/>m=100, C=4.31"]
CO --> MERGE["merge + title dedupe"]
BOOST --> MERGE
BAY --> MERGE
MERGE --> ITEM["item_recommendations<br/>asin → [recs]"]
BAY --> POP["popular_recs.json"]
ITEM --> API2["/recs/item · /recs/session"]
POP --> API2
The store has no accounts — deliberately, since the funnel needs to attribute a visit, not a
person, and carrying no PII is a feature. So online personalization uses the anonymous
sessionStorage id and the current visit's events, while the offline co-occurrence table supplies
long-horizon behaviour. That is the cold-start problem answered at four levels
(session → item → category → global), not hidden behind a login wall.
pipelines/eval_recs.py runs sequential leave-one-out on users with ≥5 interactions: the target is
the user's final interaction and the penultimate item is the session context (a realistic "current
product page"), with everything else masked. It reports HR@10 / HR@5 / MRR@10 / nDCG@10 against random
and popularity baselines.
Fashion returns are driven by fit, and shoppers do not trust a generic size chart — they trust other
shoppers. pipelines/build_fit_signals.py turns the review corpus into a per-product fit signal so
the storefront and the copilot can answer "does this run small?" from evidence, not guesswork.
runs_small, true_to_size or runs_large, handling negations
(so "doesn't run small" is not counted as runs_small). 622,530 fit mentions are extracted —
18.5% of reviews carry a usable fit signal.brand_fit.Coverage of the 6,000 built ASINs: 99.8% have ≥ 1 fit mention, 98.1% have ≥ 5, 90.9%
have ≥ 20. Two tables ship in the seed: product_fit (4,670 rows, the served catalog) and
brand_fit (1,495 brands). The classifier is covered by 40 regex unit tests.
Mining the actual reviews overturned several "common knowledge" sizing rules that an authored chart would have gotten wrong:
api/knowledge/size_chart.json is the structured source (TB/BB tops, waist bottoms, footwear, and
per-brand offsets), labelled "panduan umum toko" — a store rule-of-thumb, explicitly not an
official brand chart. size_charts.md explains it in prose for the RAG index.
The copilot's recommend_size tool reads this plus the product/brand fit signal in a 3-layer
scheme (product fit → brand fit → chart offset) and always returns an advisory, never a
certainty.
GET /products/{id} now includes a fit object (label, source = product | brand,
nMentions, shares, fitScore, phrasing, disclaimer) or null when there is no signal, and a
dedicated GET /products/{id}/fit endpoint exposes the same. The storefront renders these as fit
badges on the copilot's product cards.
An in-store stylist and customer assistant built on LangChain v1 create_agent (langchain
1.4.3, langgraph 1.2.12), split across api/copilot_agent.py, api/copilot_tools.py,
api/copilot_llm.py, api/copilot_memory.py and api/guardrail.py. The provider is OpenRouter
(OpenAI-compatible) whenever OPENROUTER_API_KEY is set — its free tier is far more generous than
Gemini's — with an optional generic OpenAI-compatible endpoint and Google Gemini behind it. The
design principle is boring on purpose: the model never invents a product, a price, or a policy.
Text-only on purpose — the chat does not accept images. The copilot runs on a text LLM, so it
takes no image_url/imageUrl and binds no search_by_image tool. Free vision-capable models have
very small request quotas, so no multimodal LLM is used. Photo search still exists in the
storefront via POST /search/image (the CLIP vision encoder embeds the upload and matches
image_embedding deterministically) — it is just a separate storefront feature, not a chat turn.
sequenceDiagram
participant S as Shopper
participant API as /copilot/chat
participant G as Guardrail (deterministic, 0 LLM)
participant A as create_agent loop (≤ 3 model calls)
participant T as 7 tools
participant D as Postgres + pgvector (incl. checkpointer)
S->>API: {thread_id, message, context?}
API->>G: classify
Note over G: greeting / off-topic / injection<br/>→ canned reply, ZERO LLM calls
G-->>API: canned reply (guardrail hit)
G->>A: otherwise, run the agent
Note over A: middleware injects page/pinned product,<br/>shown-products registry, browsing context;<br/>trims history; caps model calls at 3
A->>T: tool calls
T->>D: catalog / details / reviews / size / outfit / policy / cart
D-->>T: authoritative rows
T-->>A: compacted evidence
A->>D: persist turn (PostgresSaver, thread_id)
A-->>S: reply + products[≤6]+fit + citations + suggestions + ui_actions
api/guardrail.py). Before any LLM runs, greetings/thanks/identity,
off-topic requests (code, SQL, maths, politics, medical/legal, creative) and prompt-injection
attempts get a canned "Admin Toko Marcell" reply with zero LLM calls. Anything else goes to
the agent. The response carries a guardrail field (greeting | off_topic | injection |
null) and the tests assert it.create_agent loop. The agent plans tool calls, runs them, and composes the grounded reply in
a single LangGraph loop. Middleware wraps it: ContextInjectionMiddleware injects the current
page / pinned product (re-read from the DB), a [Produk dalam percakapan ini] [1]… shown-products
registry and recent browsing context; TrimHistoryMiddleware keeps history under a 2,000-token
budget (char/4 estimate); ModelCallLimitMiddleware caps the turn at 3 model calls.Seven tools are bound to the agent (GET /copilot/tools lists them):
| Tool | Reads from | Answers |
|---|---|---|
search_catalog | BM25 + MiniLM (pgvector) fused with RRF, optional reranker, real category enum + budget filters | "do you have…", style, outfit, budget |
get_product_details | products by registry ref (nomor 2, produk ini), ASIN/id, or title fragment | fabric, specs, exact IDR price |
get_product_reviews | reviews, with a topic filter (fit / size / fabric / durability) | aspect-level social proof |
recommend_size | size_chart.json + product/brand fit, 3-layer (product → brand → chart offset) | TB/BB sizing advisory, never a certainty |
build_outfit | catalog search; total summed and verified ≤ budget in code | top + bottom + shoes within budget |
lookup_store_policy | store_knowledge / store_knowledge_questions (hypothetical-question RAG) | sizing (TB/BB), shipping, returns, QRIS demo |
add_to_cart | re-reads the price from the DB, returns a ui_action the browser executes | "add this to my cart" |
Tool results are compacted before they re-enter the prompt — a token-budget safeguard that also keeps answers focused. There is deliberately no order-status tool, because checkout is a demo simulation with no fulfillment to look up.
api/knowledge/size_charts.md and
api/knowledge/size_chart.json, not hallucinated.Conversation memory is a LangGraph PostgresSaver keyed by a browser-generated UUID thread_id
stored in localStorage under tm_copilot_thread. A sidecar copilot_thread_activity table tracks
last-touch time; a 7-day TTL cleanup runs on startup and daily. GET /copilot/threads/{thread_id}
returns a thread's messages and DELETE /copilot/threads/{thread_id} clears it (204). The
checkpoint tables are created by the saver at runtime, not shipped in the seed.
Before the agent runs, ContextInjectionMiddleware also injects the shopper's recent
view_product / add_to_cart events and any page/pinned product — so "do you have these in black?"
has a referent.
The LLM is assembled with .with_fallbacks: OpenRouter (primary) → an optional generic
OpenAI-compatible endpoint (LLM_FALLBACK_BASE_URL / LLM_FALLBACK_API_KEY / LLM_FALLBACK_MODEL)
→ Gemini. Setting COPILOT_FAKE_LLM=1 swaps in a deterministic fake tool-calling model, so CI
exercises the full agent and tool path without ever calling a real LLM.
POST /copilot/chat accepts {session_id?, thread_id, message (1..2000), context?: {page_asin?, referenced_asins[≤3]}} with extra="forbid". It returns {reply, thread_id, products[≤6] (each with a refandfit), citations, suggestions (2–3 follow-up chips), ui_actions (add_to_cart|size_form), tool_calls, guardrail, took_ms}. Errors are 422 (validation), 429 (rate limit),
503 (no LLM key) and 502 (upstream failure). GET /copilot/tools returns the 7 tool names.
api/knowledge_seed.py chunks the two hand-written markdown sources
(size_charts.md,
store_policies.md) by section header, embeds each chunk with
MiniLM, and upserts them into store_knowledge (23 chunks in the seed). It also generates 82
hypothetical questions (3–5 ID+EN per chunk) into store_knowledge_questions and indexes those —
embedding questions a shopper would actually ask rather than the raw policy text roughly triples
HR@1 (see Research pillar 2). Nothing here is
scraped; it is authored store policy, which is why the copilot can be trusted about shipping, returns
and sizing.
Render's free tier spins the API down after ~15 minutes idle. The naive result is a storefront that
appears broken for 30–50 seconds. shop/src/components/ServerWakeup.js turns that into a designed
state:
GET /health fires on first mount. If the server answers immediately, the
overlay never appears — fast paths stay fast.checkHealth() falls back to /categories if /health is blocked by a client shield/extension,
because a false "down" is worse than a slightly heavier probe.
Built to be fast and honest on free infrastructure (Vercel + Render + Neon).
| Layer | Where | Setting | Why |
|---|---|---|---|
| Edge ISR | app/product/[id]/page.js | revalidate = 300 | Product pages are read-heavy and change rarely; stale-while-revalidate removes origin latency on repeat visits |
| HTTP | Catalog endpoints | public, max-age=60, s-maxage=300, stale-while-revalidate=60 | Lets any CDN/proxy cache; short browser TTL keeps the demo fresh |
| In-memory TTL | /categories facets | 300 s dict cache | Replaces a GROUP BY aggregate scan on every page load (~60 ms → ~2 ms) |
POST /search/reindex rebuilds the BM25 index and invalidates the facet cache, because after a
reseed the old index is silently stale and "why are my new products not searchable" is otherwise a
confusing hour.
/copilot/chat enforces strict Pydantic bounds: ≤ 2,000 chars for the
single message per turn, extra="forbid" on the request body, and no image payload (the chat is
text-only). Oversized or malformed requests get a 422, not a metered bill.{asin, qty}; the server looks up every
price and computes the total. ConfirmIn sets extra="forbid", so a request that tries to send
a price is rejected with a 422 rather than silently ignored. A tampered "pay Rp 1" is structurally
impossible.image/*, payload must be 50 bytes–10 MB; the copilot client
caps uploads at 5 MB.CORS_ORIGINS is an allowlist in code (default localhost +
https://toko-marcell.vercel.app) with an explicit ^https://.*\.vercel\.app$ regex so preview
deployments work. The shipped Render blueprint sets CORS_ORIGINS=* so that every preview URL
works without redeploys — a deliberate tradeoff, acceptable here because the API is stateless,
credential-free, and sets no cookies. It is documented rather than hidden.next.config.mjs sets X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and a restrictive
Permissions-Policy (camera=(), microphone=(), geolocation=()).lib/api.js never surfaces raw backend errors for the copilot/image
search; it returns a friendly message instead of leaking internals.GET /events/summary computes funnel conversion and returns its own caveats array, so the
numbers cannot be read as more precise than they are:
search_to_pdp is a session-level proxy (a session that both searched and viewed a product),
not a click-through rate — the click is not its own event yet.results_count for search counts matches among currently loaded products, because filtering is
client-side; zero-result rate becomes a true metric only when search moves fully server-side.null, never 0.0, when a denominator is missing — an empty events table must not
produce a fake "0% conversion".GET /events/summary also carries a copilot block (messages, product_clicks, add_to_cart,
total_add_to_cart, assisted_add_to_cart_rate = copilot_add_to_cart / all add-to-cart, null
when there is no denominator) fed by the copilot_message / copilot_product_click /
copilot_add_to_cart event types — with a caveat that a copilot add fires both the generic and the
copilot event.
| Source | Amazon Reviews 2018, category Clothing, Shoes & Jewelry (McAuley Lab, UCSD) |
| Paper | Ni, Li, McAuley — Justifying recommendations using distantly-labeled reviews and fine-grained aspects (EMNLP 2019) |
| Licence | Free for research / non-commercial use; product text/images remain Amazon's |
| Raw files | reviews 1.27 GB + metadata 1.57 GB (~2.8 GB, not committed) |
Why this dataset. It is the canonical recommender-systems benchmark — so HR@10 / nDCG@10 numbers are comparable with published work instead of self-graded — and it carries exactly the fields the product needs: real titles, brands, prices, category trees, feature bullets and descriptions (search
user_id / asin / rating / timestamp (recs), plus also_buy / also_view (co-view).Why not the 2023 refresh. McAuley-Lab/Amazon-Reviews-2023 was tried and rejected on evidence:
in Amazon_Fashion, ~90% of items have no price and categories is empty, which makes a usable shop
catalog impossible.
11,285,464 reviews read → top candidates by interaction → quality filters → 6,000 products
→ dead-image & duplicate-title cleanup → 4,670 products served
Full provenance, quirks and filters: pipelines/README.md.
| Data | Built | Served (live/seeded) |
|---|---|---|
| Products | 6,000 | 4,670 |
| Reviews | 46,700 | 46,700 |
| Interactions | 3,363,680 | (offline signal) |
| Distinct users | 972,184 | — |
| Knowledge chunks | — | 23 |
| Hypothetical questions | — | 82 |
| Product fit signals | — | 4,670 |
| Brand fit signals | — | 1,495 |
Why 4,670 and not 6,000: after the catalog is built, pipelines/cleanup_catalog.py deletes products
whose image CDN link is dead (image_embedding IS NULL) and collapses duplicate title variants to the
best-rated one. The shipped seed (data/seed/init.sql.gz) is a dump of that cleaned database, so the
storefront's "4,670 verified products" copy is literally true.
The interactions matrix is 0.057% dense (3,363,680 filled of 5,833,104,000 cells; 972,184 users, min 198 / median 333 / max 19,693 interactions per item). The long tail is real, which is why item recommendations combine co-occurrence with an embedding-based backfill for cold items.
api/main.py, seeded offline)products (
id INTEGER PRIMARY KEY, -- stable int, ordered by popularity
asin TEXT NOT NULL UNIQUE, -- real product id; the ML/event join key
title TEXT NOT NULL,
brand TEXT,
price_usd NUMERIC(10,2) NOT NULL, -- untouched source price
price_idr INTEGER NOT NULL, -- shop price at a fixed, documented demo rate
department TEXT, category TEXT, category_path JSONB,
description TEXT, features JSONB, image_url TEXT,
avg_rating NUMERIC(3,2), rating_count INTEGER,
also_buy JSONB, also_view JSONB,
embedding vector(384), -- MiniLM, HNSW cosine
embedding_ml vector(384), -- multilingual MiniLM (opt-in), HNSW cosine
image_embedding vector(512) -- CLIP, HNSW cosine
)
events (id, session_id, event_type, asin, query, results_count, qty, price_idr, created_at)
orders (id, token, total_idr, item_count, status, session_id, created_at)
order_items (id, order_id, asin, title, qty, unit_price_idr)
item_recommendations (asin PRIMARY KEY, recs JSONB)
reviews (id, asin, rating, summary, comment, author, verified, review_date, created_at)
store_knowledge (id, category, title, content, embedding vector(384))
store_knowledge_questions (id, knowledge_id, question, embedding vector(384))
product_fit (asin PRIMARY KEY, label, n_mentions, shares JSONB, fit_score, …)
brand_fit (brand PRIMARY KEY, label, n_mentions, shares JSONB, …)
copilot_thread_activity (thread_id PRIMARY KEY, last_active_at)
-- LangGraph checkpoint tables are created by the PostgresSaver at runtime (not in the seed)
Notes that matter:
id vs asin. Ints for the shop and the cart; ASIN for events, recs and ML joins. Both exist
because the two consumers want different keys.price_usd is the dataset value, kept for auditability; price_idr is what the shop
displays at a fixed demo rate of 16,000 IDR/USD, recorded in stats.json. It is a presentation
conversion, not a live FX rate.init_db() creates tables with CREATE TABLE IF NOT EXISTS and then diffs
information_schema to ALTER TABLE ADD COLUMN whatever is missing, so a column added after a
database was created cannot silently disappear. A real migration tool (Alembic) is still a known
gap — see Honest limitations.The whole stack is containerized. Docker Compose boots the Next.js storefront, the FastAPI backend, and PostgreSQL 16 with pgvector auto-seeded with the real catalog, reviews, vector embeddings and knowledge base on first boot — no manual pipelines, no downloads.
docker + the Compose plugin).Everything runs with no configuration. To turn on the copilot, create .env from the example and
set the key — or let run-local.sh prompt you for it:
cp .env.example .env
# edit .env → OPENROUTER_API_KEY=sk-or-...
| Variable | Needed for | Default |
|---|---|---|
OPENROUTER_API_KEY | the AI copilot (preferred provider) | empty → copilot disabled |
LLM_MODEL | OpenRouter model id | openrouter/free (auto-routes the free pool) |
LLM_BASE_URL | OpenRouter endpoint | https://openrouter.ai/api/v1 |
LLM_FALLBACK_MODELS | optional OpenRouter fallbacks (429/503) | (empty) |
LLM_FALLBACK_BASE_URL · LLM_FALLBACK_API_KEY · LLM_FALLBACK_MODEL | optional generic OpenAI-compatible fallback endpoint | (empty) |
GEMINI_API_KEY | the copilot fallback provider | empty → Gemini not used |
GEMINI_MODEL | fallback model override | gemini-3.5-flash-lite |
TEXT_EMBED_MODEL · TEXT_EMBED_COLUMN | opt-in multilingual embeddings (…-MiniLM-L12-v2 + embedding_ml) | MiniLM default |
COPILOT_FAKE_LLM | deterministic fake tool-calling model (tests/CI only) | unset |
DATABASE_URL · NEXT_PUBLIC_API_URL · INTERNAL_API_URL | wiring the three services | pre-set in .env.example and docker-compose.yml |
git clone https://github.com/marknshoot/toko-marcell.git
cd toko-marcell
./run-local.sh # builds + starts everything, waits for health, prints the URLs
# or: docker compose up -d --build
| Service | URL |
|---|---|
| 🌐 Storefront | http://localhost:3000 |
| ⚡ API + interactive docs | http://localhost:8001/docs |
| ❤️ Health | http://localhost:8001/health |
docker compose logs -f # stream logs
docker compose down # stop
Browsing, filtering, hybrid text search, recommendations, reviews and the demo checkout all run
against the seeded database. Without a key, /copilot/chat returns a clean 503 with a friendly
message instead of crashing, and the copilot panel simply can't produce answers.
Got a 500 or an out-of-memory from the API instead? That's a known image-search issue, not a setup problem — see Honest limitations. Text search is unaffected.
How the auto-seed works. The Postgres container mounts
data/seed/init.sql.gz(~33 MB) into/docker-entrypoint-initdb.d/, so the official image restores the complete schema, 4,670 products, HNSW indexes, 46,700 reviews, 23 knowledge chunks (plus 82 hypothetical questions), theproduct_fit/brand_fittables, recommendations and a small demo funnel (~357 events, ~106 orders) in seconds on first boot. The ephemeral LangGraph checkpoint tables are excluded from the dump and recreated at startup.
# Backend + DB only (from api/), with an offline seed job behind a profile
cd api
docker compose up -d --build
docker compose run --rm seed --reset # optional: reload catalog from data/processed
curl -s localhost:8001/health # {"status":"ok"}
# Frontend only
cd shop && npm install && npm run dev # needs an API at NEXT_PUBLIC_API_URL
To regenerate the catalog from raw data (≈8 min, streaming, no pandas), see
pipelines/README.md.
manual/
├── api/ # FastAPI backend (owns all data + logic)
│ ├── main.py # thin app factory: lifespan, CORS, schema migration, routers
│ ├── config.py # pydantic-settings Settings, cached get_settings()
│ ├── db.py # psycopg_pool ConnectionPool, get_conn() / Depends
│ ├── schemas.py # shared Pydantic request/response models
│ ├── routers/ # one module per domain
│ │ ├── health.py # GET /health
│ │ ├── catalog.py # categories, products, product detail + row_to_product
│ │ ├── search.py # text search, image search, reindex
│ │ ├── recs.py # popular, item-to-item, session recommendations
│ │ ├── reviews.py # product & ASIN reviews
│ │ ├── events.py # funnel events + summary
│ │ ├── checkout.py # confirm checkout, order lookup
│ │ └── copilot.py # AI copilot chat + tool listing
│ ├── search.py # BM25 + embeddings + RRF + CLIP encoders (ONNX)
│ ├── reranker.py # Stage-2 ONNX cross-encoder
│ ├── copilot_agent.py # LangChain v1 create_agent loop + middleware
│ ├── copilot_tools.py # the 7 grounding tools (schema bound to the agent)
│ ├── copilot_llm.py # provider chain (.with_fallbacks) + COPILOT_FAKE_LLM
│ ├── copilot_memory.py # LangGraph PostgresSaver + thread activity / TTL
│ ├── guardrail.py # deterministic zero-LLM greeting/off-topic/injection gate
│ ├── fit.py # product/brand fit lookup served on the product API
│ ├── sizing.py # 3-layer recommend_size (product → brand → chart offset)
│ ├── rate_limiter.py # sliding-window per-IP rate limiter
│ ├── knowledge_seed.py # markdown → embedded store_knowledge + hypothetical questions
│ ├── knowledge/ # authored size_charts.md + size_chart.json + policies (RAG source)
│ └── tests/ # 188 pytest cases (unit + live API, fake-LLM copilot)
├── shop/ # Next.js 16 storefront
│ └── src/{app,components,lib} # routes, UI (CopilotChat + ProductCopilotEntry), API client, session & cart state
├── pipelines/ # offline ML + data engineering
│ ├── build_catalog.py # raw Amazon data → 6,000-product catalog (3 streaming passes)
│ ├── seed.py # catalog → Postgres (offline, upsert by asin)
│ ├── cleanup_catalog.py # drop dead images + duplicate titles → 4,670 live
│ ├── build_recs.py # co-occurrence + co-view + embedding backfill → recs
│ ├── build_fit_signals.py # review regex-mining → product_fit / brand_fit (negation-aware)
│ ├── embed_catalog.py # MiniLM 384-d text embeddings + HNSW
│ ├── embed_images.py / embed_catalog_vlm.py # CLIP 512-d image embeddings
│ ├── extract_reviews.py # top reviews per ASIN → reviews table
│ ├── prepare_clip_dataset.py # 5,378 verified pairs → 80/10/10 splits
│ ├── train_clip.py # PyTorch InfoNCE training loop (hard negatives, AMP)
│ ├── experiments_clip.py # the 4 ablation experiments (baseline/LoRA/SigLIP/decoupled)
│ ├── eval_multimodal.py # text→image Recall@K / MRR evaluation + error mining
│ ├── eval_search.py # 32-query BM25 vs hybrid IR harness
│ ├── eval_recs.py # leave-one-out HR@K / MRR / nDCG harness
│ ├── eval_rag.py # direct-chunk vs hypothetical-question RAG eval
│ ├── eda_interactions.py # sparsity, long tail, rating distribution
│ ├── migrate_to_neon.py # local → Neon serverless migration
│ ├── migrations/ # idempotent SQL migrations (2026_10_phase4.sql)
│ ├── kaggle/ # notebook + CLI metadata for the GPU study
│ ├── failure_case_analysis.md # qualitative research report
│ └── literature_review_clip_finetuning.md
├── data/
│ ├── seed/init.sql.gz # COMMITTED: the seeded database dump (works without raw data)
│ ├── knowledge/*.md # COMMITTED: RAG source documents
│ ├── raw/ # gitignored: ~2.8 GB downloads
│ └── processed/ # gitignored: reproducible artifacts (embeddings, splits, …)
├── models/ # weights gitignored; small model cards & summaries tracked
├── docker-compose.yml # one-command full stack
├── render.yaml # Render blueprint (API)
├── run-local.sh # friendly wrapper around docker compose
└── .github/workflows/ci.yml # lint + test + build on every push/PR
What is and is not committed. Only the seed dump and the knowledge documents are committed under
data/; raw and processed data are gitignored because they are large and reproducible. Model weights (*.onnx,*.safetensors,*.pt) are gitignored too — the production text encoder is published on Hugging Face Hub and fetched at runtime, and the rest can be regenerated with the pipelines above. The small model cards, adapter configs and experiment summaries undermodels/experiments/are tracked, so the study's evidence is browsable on GitHub without shipping binaries. A fresh clone runs immediately via Docker and can reproduce the ML from source.
# Frontend: 21 tests across 5 files (components, cart, copilot, cold-start, session)
cd shop && npm test
# Backend + pipelines
cd api && python3 -m pytest tests/ # 188 passed + 1 skipped (fake-LLM copilot; needs a live DB)
python3 -m pytest pipelines/tests/ # 46 tests (6 pipeline-math + 40 fit classifier, no DB)
Every push and PR runs .github/workflows/ci.yml:
| Job | Steps |
|---|---|
| Frontend (Node 22) | npm ci → npm run lint → npm run test → npm run build |
| Backend & pipelines (Python 3.12) | Ruff → full API suite against a Postgres service restored from the seed, with COPILOT_FAKE_LLM=1 → pipeline tests |
CI runs Ruff + the full API suite against a Postgres service restored from the committed seed,
with COPILOT_FAKE_LLM=1 so the whole agent and tool path is exercised without ever calling a real
LLM — plus the frontend lint/test/build. The API tests cover the things that are easy to get wrong:
price-tampering rejection, duplicate-ASIN aggregation, event-type validation, session-id bounds,
search modes/filters/reindex, recommendation cold-start fallbacks, review aggregates, the fit
classifier, and the copilot's use cases + guardrails.
These are the choices that shaped the project. Each is stated with the alternative that was rejected.
pipelines/seed.py does. This keeps docker compose up a server and keeps the ML/data pipeline
independent of request handling.eval_search.py.total a lie.{asin, qty}. The alternative — trusting a client
total — is a demo that can be made to "pay Rp 1" from the URL bar. The confirmation page fetches
the order by token, so it survives refresh/new tab/another device and cannot show a fake receipt.sessionStorage + crypto.randomUUID().null when undefined; missing catalog fields stay missing. A demo
that reports "0%" from an empty table is worse than one that says "no data yet".503 (never a mismatched zero-shot
fallback). Couriers/shipping/QRIS are explicitly labelled simulations, so nobody mistakes the
demo for a real store.This section exists because a portfolio that hides its edges is less useful than one that names them.
/checkout/confirm is not idempotent. A double-click can create two orders. The button disables
while in flight, but a production system wants an Idempotency-Key header.tk_… tokens; there are no user accounts.results_count in funnel events reflects loaded
products, not the whole catalog, until filtering moves fully server-side.search_to_pdp is a session proxy, not a click-through rate (the click event is not recorded
separately yet).init_db() diffs and adds columns additively; it is not a versioned
migration system. A schema rewrite would need care.POST /search/reindex must be called (or the container
restarted) because the BM25 index lives for the life of the process.POST /search/image → CLIP vision → image_embedding);
the chat is a separate text surface. There is also deliberately no order-status tool: checkout
is a demo simulation with no fulfillment, shipping or tracking to report.torch and matches the stored fine-tuned vectors (cosine 0.991 vs fp32). The artifact is
published at
HF Hub: Marcell-Kristianto/toko-marcell-clip
and fetched at runtime, so it works for a local build (mounted models/) and for a public deploy.
Serving memory is trimmed for the free tier: the vision encoder is int8 (~96 MB) and the text
encoder is fp16 (~127 MB, identical embeddings) — see
pipelines/BENCHMARKS.md. Even so, loading every model at once
(vision + text + MiniLM + reranker) can approach the 512 MB Render free-tier budget; a ≥1 GB plan
removes the cliff. Because mode=trimodal is the one request that loads both the text ONNX and
MiniLM, it is disabled by default on the deployment (ENABLE_TRIMODAL) and returns 400 when
off. If no encoder is present the endpoint returns 503 by design (a zero-shot encoder would
occupy a different space). Text search and the copilot are unaffected.TEXT_EMBED_MODEL + TEXT_EMBED_COLUMN=embedding_ml) and MiniLM is the default.build_fit_signals.py classifies fit
mentions with regex + negation handling (not an aspect-sentiment model); runs_large is the
weakest-signalled label, and the copilot always frames sizing as an advisory, never a certainty.COPILOT_FAKE_LLM=1 is what CI uses for determinism.ENABLE_RERANKER=false)
to stay within the memory budget; it falls back to Stage-1 ranking. The /search endpoint never
used the reranker, so HTTP search quality is unchanged; local Compose keeps it on.multimodal_benchmark_results.json). The qualitative case section of
failure_case_analysis.md comes from an earlier evaluation pass; treat its individual rank
numbers as illustrative of phenomena, not as the current leaderboard.Ordered by value to the product, not by ease:
product_click event so search_to_pdp
becomes a true CTR.Idempotency-Key support to checkout.Marcell Hermawan Kristianto Data Science, BINUS University — 5th semester
pipelines/kaggle/README.md.Fashion e-commerce AI engine: fine-tuned CLIP (decoupled LR, text→image R@1 26.5% → 39.2%), BM25 + dense + visual hybrid search on pgvector, LangChain shopping copilot.
See the codeToko Marcell is an end-to-end, production-deployed e-commerce platform built from scratch to benchmark and serve Multimodal Foundation Models (VLMs), hybrid lexical–dense–visual vector retrieval, and an agentic AI shopping copilot with grounded RAG over a real catalog of 4,670 curated products (built from an initial 6,000 candidate extract).
| 🌐 Live storefront | https://toko-marcell.vercel.app |
| ⚡ Live API / interactive docs | https://toko-marcell-api.onrender.com/docs |
| 🧠 Fine-tuned CLIP on HF Hub | Marcell-Kristianto/toko-marcell-clip |
| 🔬 Research report (failure analysis) | pipelines/failure_case_analysis.md |
| 📑 Literature review (CLIP fine-tuning) | pipelines/literature_review_clip_finetuning.md |
| 📊 Machine-readable leaderboard | pipelines/multimodal_benchmark_results.json |
| 🧾 Benchmark source ledger | pipelines/BENCHMARKS.md — what each number is and how to reproduce it |
Note on the live API: the backend runs on Render's free tier, so it sleeps after 15 minutes of inactivity and takes 30–50 s to wake. The storefront handles this explicitly with a health-check overlay (see Cold-start UX) instead of silently failing. The catalog itself is served from Vercel's edge cache and stays fast.
Toko Marcell is a working e-commerce engine, not a UI mock — a Next.js storefront, a FastAPI backend and PostgreSQL + pgvector, over a real 4,670-product catalog, deployed live.
The five things worth inspecting (each backed by a committed artifact):
| # | What | Result | Where |
|---|---|---|---|
| 1 | VLM fine-tuning study — 4 fine-tuning strategies vs a zero-shot baseline, 5,378 image–text pairs, 539 held-out test | champion lifts text→image Recall@1 26.53% → 39.15% (+47.57% rel) | leaderboard |
| 2 | Hybrid retrieval — BM25 + MiniLM (384-d) + CLIP (512-d) → RRF (k=60); optional ONNX cross-encoder on the copilot path | exact SKUs and semantic/visual queries; ?mode= switchable | API internals |
| 3 | Grounded agentic copilot — LangChain v1 create_agent + OpenRouter (free tier), deterministic zero-LLM guardrail, 7 tools, LangGraph Postgres memory, hypothetical-question policy RAG | answers only from tool evidence; refuses off-topic and injection attempts without an LLM call | copilot |
| 4 | Recommendations from real behaviour — 3.36 M interactions | co-occurrence + Bayesian popularity, session-aware, cold-start safe | recommendation engine |
| 5 | Review-derived fit & sizing — 622,530 fit mentions regex-mined from 3.36 M reviews | per-product/brand runs_small / true_to_size / runs_large, Dirichlet-smoothed, surfaced as fit badges + copilot recommend_size | review-derived fit |
Measured results (committed artifacts in pipelines/results/):
| Benchmark | Metric | BM25 | Hybrid | Hybrid+Rerank | Trimodal |
|---|---|---|---|---|---|
| IR (32 queries) | nDCG@10 | 0.794 | 0.879 | 0.893 | 0.908 |
| IR (32 queries) | MRR@10 | 0.792 | 0.906 | 0.885 | 0.932 |
| IR — Indonesian (8q) | nDCG@10 | 0.334 | 0.622 | 0.739 | 0.821 |
| Recs (5 K users) | HR@10 | nDCG@10 |
|---|---|---|
| Random | 0.22% | 0.0011 |
| Popularity | 0.18% | 0.0008 |
| Item-to-Item CF | 7.96% | 0.0501 |
| RAG retrieval (12 ID+EN queries) | HR@1 | HR@3 | MRR@3 |
|---|---|---|---|
| Direct-chunk | 0.250 | 0.333 | 0.292 |
| Hypothetical-question | 0.750 | 0.917 | 0.819 |
Indexing a few template-generated hypothetical questions per policy chunk roughly triples HR@1 over
embedding the raw chunk text (artifacts: pipelines/results/rag_eval.json,
pipelines/eval_rag.py).
Multilingual embedding A/B (pipelines/results/search_eval_multilingual.json):
paraphrase-multilingual-MiniLM-L12-v2 lifts overall hybrid nDCG@10 0.879 → 0.894 and the
Indonesian group 0.622 → 0.765, but roughly doubles peak RSS (~1024 MB vs ~610–650 MB) with both
CLIP ONNX encoders loaded — so MiniLM stays the default and the multilingual model is opt-in.
The cross-encoder reranker improves nDCG@10 overall (+1.4 pts vs hybrid) and substantially on Indonesian queries (+0.117), but slightly lowers MRR@10 overall (0.906 → 0.885). On Category/Attr it drops nDCG (0.990 → 0.940). Net: a modest positive, strongest on the hardest group.
Stack: Next.js 16 · React 19 · FastAPI · PostgreSQL 16 + pgvector · PyTorch/CLIP · LangChain + OpenRouter (free tier, Gemini fallback) · Docker · Vercel + Render + Neon.
Try it locally
Prerequisite: Docker with Compose. Nothing else — Python, Node and PostgreSQL are not needed on the host, and the database seeds itself on first boot.
# 1. get the code
git clone https://github.com/marknshoot/toko-marcell.git
cd toko-marcell
# 2. (optional) enable the AI copilot with a free OpenRouter key
# https://openrouter.ai/keys — skip this and everything else still works
cp .env.example .env # then set OPENROUTER_API_KEY=... in .env
# 3. start the full stack
./run-local.sh # → http://localhost:3000
run-local.sh also prompts for the key if .env is empty — press Enter to skip. Pressing Enter
leaves the copilot disabled; the rest of the site is unaffected.
| Works with no configuration | Needs an LLM key (OpenRouter, or Gemini as fallback) |
|---|---|
| catalog, hybrid text search, recommendations, reviews, demo checkout | the copilot's answers (/copilot/chat returns a friendly 503 without one) |
Or use the hosted build: storefront / API docs.
Scope, honestly: the storefront, QRIS checkout, couriers and order flow are a demo simulation —
they charge nothing and ship nothing. What is real: the catalog, the vector search, the ML study, the
recommendations and the agent's grounding. Every headline number is tagged in
BENCHMARKS.md as committed-artifact / locally-reproducible /
author-measured. Open gaps are in Honest limitations.
This is a portfolio project, and it is deliberately not a CRUD tutorial with a prettier homepage. It exists to demonstrate four things a hiring manager can verify rather than take on trust:
create_agent loop that can only speak
from tool results, fronted by a deterministic zero-LLM guardrail, with a 7-tool whitelist,
prompt-injection/off-topic refusal, token-budget and model-call-count safeguards, and tests.Every number below is either measured and stored in the repository or read from the code at runtime. Nothing is invented. Where something is simplified or unfinished, it is written down as such in Honest limitations.
Four tiers, each with a single job. The storefront never opens a database connection; the backend never renders HTML; the ML pipelines never serve a request.
flowchart TD
subgraph Client["Tier 0 — Shopper experience (Next.js 16 App Router)"]
UI["Storefront (Tailwind v4, Geist)"]
Chat["Floating AI copilot"]
Wakeup["Cold-start wakeup overlay<br/>(Render /health ping)"]
ClientState["localStorage cart (v2) +<br/>sessionStorage anonymous session id"]
end
subgraph Edge["Tier 1 — Vercel edge network"]
ISR["ISR cache (revalidate = 300s)<br/>Product detail pages"]
Headers["Security headers<br/>(nosniff, DENY, strict-origin)"]
end
subgraph API["Tier 2 — FastAPI backend (Render, free tier)"]
CORS["Configurable CORS allowlist"]
Cache["In-memory facet TTL cache (300s)"]
Val["Pydantic bounds<br/>(token & payload safeguards)"]
subgraph Orchestration["Agentic copilot (LangChain v1 create_agent + OpenRouter)"]
Guard["Deterministic guardrail node<br/>(greeting / off-topic / injection → 0 LLM calls)"]
Agent["create_agent loop<br/>+ middleware (context injection,<br/>history trim, 3-call cap)"]
Tools7["7 grounding tools"]
end
end
subgraph Retrieval["Tier 3 — hybrid retrieval & fusion"]
BM25["BM25 (lexical)"]
MiniLM["Dense text: all-MiniLM-L6-v2 (384-d)"]
CLIP["Visual / cross-modal: fine-tuned CLIP ViT-B/32 (512-d)"]
RRF["Reciprocal Rank Fusion (k=60)"]
CE["Cross-encoder reranker (optional, copilot only)<br/>(ms-marco-MiniLM-L-6-v2, ONNX)"]
end
subgraph Storage["Tier 4 — PostgreSQL 16 + pgvector"]
PG["Neon Postgres"]
HNSW["HNSW cosine indexes (384-d & 512-d)"]
RAG["store_knowledge + store_knowledge_questions RAG"]
Fit["product_fit · brand_fit"]
Ckpt["LangGraph checkpoints<br/>(Postgres saver, created at runtime)"]
Ops["events · orders · order_items · item_recommendations · reviews"]
end
UI --> Edge --> API
Chat --> API
Wakeup --> API
ClientState --> UI
API --> Guard
Guard -. "needs tools" .-> Agent --> Tools7
Tools7 --> BM25 & MiniLM & CLIP
BM25 & MiniLM & CLIP --> RRF --> Storage
RRF -. "copilot, ENABLE_RERANKER" .-> CE
Tools7 --> RAG
Tools7 --> Fit
Agent --> Ckpt
Agent --> Chat
Cache --> PG
Val --> Guard
Ops --> PG
| Tier | Technology | Owns | Explicitly does not |
|---|---|---|---|
| 0 — Client | Next.js 16 (App Router), React 19, Tailwind v4 | Rendering, cart state, session id, cold-start UX | Touch the database, know prices |
| 1 — Edge | Vercel CDN + ISR | Caching product pages, security headers | Hold business logic |
| 2 — API | FastAPI, Pydantic, LangChain, OpenRouter | REST validation, search, recs, copilot, checkout | Render HTML |
| 3 — Retrieval | BM25, fastembed (MiniLM + CLIP), ONNX reranker | Ranking | Persist state |
| 4 — Storage | PostgreSQL 16 + pgvector (Neon) | Catalog, vectors, events, orders, reviews, knowledge | Serve HTTP |
The single hard rule that keeps this clean: the frontend never opens a database connection — it
only ever talks HTTP (shop/src/lib/api.js is the only place that knows the API URL).
The problem. General-purpose CLIP (openai/clip-vit-base-patch32) is good at separating a dog
from a car and bad at separating clean dress chinos from rugged work pants, a relaxed straight cut
from a slim taper, or glossy waterproof nylon from matte fleece. These "fine-grained semantic gaps"
are exactly where e-commerce search lives. The study asks a specific, testable question:
If we domain-adapt CLIP on this catalog, which fine-tuning strategy buys the most retrieval accuracy, and where does it still fail?
pipelines/prepare_clip_dataset.py).Brand + Title + (Category / Department) + first fabric feature bullets.
The literature review (Chia et al., 2022) found structured captions outperform raw titles by a
wide margin on fashion recall; this pipeline follows that finding.Four adaptation hypotheses (plus a zero-shot reference), each derived from a paper (see
literature_review_clip_finetuning.md):
| Experiment | What it changes | Source idea |
|---|---|---|
| Zero-shot (reference) | Frozen pretrained CLIP — the number to beat | — |
| LoRA | Freeze base, train low-rank adapters on q_proj, v_proj | Hu et al., LoRA (2021) |
| SigLIP | Replace batch softmax with pairwise sigmoid loss | Zhai et al., SigLIP (2023) |
| WiSE-FT | Weight-space ensemble of fine-tuned + zero-shot | Wortsman et al. (2022) |
| Decoupled LR 🏆 | Freeze ViT layers 0–5, asymmetric LRs per module | Wortsman et al. (2022) |
Training ran on Kaggle GPU — NVIDIA Tesla T4 16 GB, mixed precision (AMP FP16),
3 epochs, batch size 64, base LR 5e-6. The exact run configuration lives in
multimodal_benchmark_results.json.
| Experiment | Architecture / approach | Recall@1 | Recall@5 | Recall@10 | MRR | Latency (T4) |
|---|---|---|---|---|---|---|
| Zero-shot base | openai/clip-vit-base-patch32 (frozen) | 26.53% | 54.92% | 70.69% | 0.4033 | 27.80 ms |
| PEFT LoRA | LoRA, early-stopped, r=16, α=32 on q_proj, v_proj | 33.40% | 71.61% | 83.49% | 0.5038 | 15.50 ms |
| SigLIP | Pairwise sigmoid loss, learnable temperature/bias | 35.25% | 71.43% | 82.19% | 0.5091 | 19.38 ms |
| WiSE-FT | Weight-space ensemble (decoupled LR + zero-shot, α=0.35) | 37.85% | 73.28% | 85.53% | 0.5363 | 9.76 ms |
| 🏆 Champion — Decoupled LR | ViT 0–5 frozen · vision 0.2× · text 1.0× · projection 2.0× | 39.15% | 78.11% | 87.20% | 0.5592 | 14.01 ms |
Headline result: Recall@1 26.53% → 39.15% = +12.62 points absolute, +47.57% relative.
Marcell-Kristianto/toko-marcell-clip).
Serving uses ONNX Runtime on CPU (< ~150 MB RAM, 2 intra-op threads, graph optimizations on).pipelines/failure_case_analysis.md is the qualitative
half of the study: it dissects cases where rank improved dramatically or did not, e.g. distressed
denim texture, the structural drape of 8.5 oz twill, and specular sheen on waterproof nylon. The
remaining known failure mode is sub-brand logo invariance (e.g. a small embroidered logo patch),
proposed fix multi-crop / RoI-Align training. The research report's qualitative case section and
the aggregate scoreboard in this README come from different evaluation passes; the aggregate table
here is the authoritative Kaggle 4-way leaderboard stored in the JSON above.
Pure vector search fails on exact tokens (SKU numbers, model numbers like 501, brand names).
Pure lexical search fails on typos, synonyms, and conversational intent. Toko Marcell runs all the
rankers that matter and fuses them.
flowchart LR
Q["Query (text or image)"] --> BM25["① BM25 lexical<br/>title ×3 · brand ×2 · category ×1.5<br/>department ×1 · features ×1 · desc ×0.5"]
Q --> Dense["② Dense sentence vectors<br/>all-MiniLM-L6-v2, 384-d, pgvector HNSW"]
Q --> Visual["③ Cross-modal CLIP<br/>fine-tuned ViT-B/32 text encoder, 512-d"]
Img["Uploaded image"] --> Vision["CLIP vision encoder<br/>512-d → pgvector HNSW"]
BM25 --> RRF["Reciprocal Rank Fusion<br/>score = Σ 1/(60 + rank)"]
Dense --> RRF
Visual --> RRF
RRF --> Out["Ranked products"]
RRF -. "copilot only, ENABLE_RERANKER" .-> CE["Cross-encoder rerank<br/>ms-marco-MiniLM-L-6-v2 (ONNX)"]
CE -.-> Out
Vision --> Out
api/search.py implements Robertson & Zaragoza BM25 (k1=1.5, b=0.75, +1 IDF smoothing) with
zero search dependencies, because a baseline is only useful if it is honest enough to beat.
Field weighting (title 3.0 → description 0.5) is a practical alternative to one index per field;
BM25's saturation handles fractional term frequencies fine. The tokenizer does lowercase → accent
folding → alphanumeric filtering → stopword removal → a small plural fold (sneakers→sneaker,
jeans→jean). It is not a Porter stemmer; that limitation is stated where it lives, in the code.
Why in-process? At this catalog size the whole index rebuilds in well under a second and lives comfortably in RAM, so an external service would add a dependency for nothing. Known gaps are deliberate: no typo tolerance, no synonyms, no phrases — and those gaps are precisely what the embedding half exists to close. That is why the baseline was measured first.
Honest baseline behaviour (measured in api/tests/test_search_unit.py + manual probing):
'levis 501' → exact model numbers, four distinct variants
'waterproof jacket' → raincoats and watertight shells
'shoes' == 'shoe' → plural folding makes the two score identically
'zzzz' → 0 results (no matching postings, returns immediately)
Duplicate-title suppression. 842 of the 6,000 built products are variants sharing a title with a
different ASIN. Without dedupe, a query like jeans shows the same title twice in the top 10.
Dedupe runs before pagination so total counts results a shopper can actually reach.
384-dimensional sentence-transformers/all-MiniLM-L6-v2 embeddings (via fastembed, ONNX runtime)
stored in a vector(384) column with an HNSW cosine index. Text representation =
title + brand + category + features + description. This ranker is what lets "shoes for women" and
"white sneakers" work without exact token overlap.
An uploaded photo is encoded with the fine-tuned CLIP vision tower to a 512-d vector and matched
against products.image_embedding (HNSW cosine). The same 512-d space lets a text query search
product images (mode=trimodal) — so "waterproof hooded packable rain jacket nylon shell" can be
retrieved by the visual sheen of the garment, not only by words. Encoders are loaded lazily and
cached per process. The champion fine-tuned encoders are served as ONNX (no torch): the text
tower is fp16 (~127 MB) and the vision tower is int8 (~96 MB), both published and fetched at runtime
(pipelines/export_text_onnx.py,
pipelines/export_vision_onnx.py). If no champion vision
encoder is present, image search returns 503 rather than falling back to a mismatched zero-shot
space.
$$ \text{RRF}(d) = \sum_{m \in M} \frac{1}{60 + r_m(d)} $$
RRF is used instead of score normalization because BM25 scores are unbounded positives while cosine
similarities are bounded in [-1, 1]; RRF needs only ranks, so the heterogeneous rankers can be
combined with no calibration and no per-query tuning. k=60 is the constant from
Cormack, Clarke & Buettcher (2009).
The HTTP /search endpoint returns the RRF order. The copilot's search_catalog tool can add a
second stage behind the ENABLE_RERANKER flag (off on the 512 MB deployment, on in local Compose):
top candidates are re-scored by Xenova/ms-marco-MiniLM-L-6-v2 run through ONNX Runtime
(~15–25 ms for ~20 candidates). A cross-encoder sees the query and document together, so it
captures relevance a bi-encoder cannot — the standard two-stage retrieve-then-rerank pattern.
If the model cannot be downloaded, the code logs a warning and returns the Stage-1 order, so the
search endpoint degrades rather than breaks.
pipelines/eval_search.py ships a 32-query benchmark across four groups — exact brand/SKU,
category + explicit features, semantic/intent, and Indonesian/casual cross-lingual — and reports
HitRate@10, Precision@10, MRR@10 and nDCG@10 for BM25 vs hybrid, with a group breakdown and
relative gains. mode=bm25|vector|hybrid|trimodal is returned in every response so the eval harness
and the UI can always tell which ranker produced a result set instead of inferring it.
Many shoppers type Indonesian. pipelines/results/search_eval_multilingual.json records a hybrid-mode
A/B of paraphrase-multilingual-MiniLM-L12-v2 against the default all-MiniLM-L6-v2: overall
nDCG@10 0.879 → 0.894, MRR@10 0.906 → 0.919, and the Indonesian query group nDCG@10
0.622 → 0.765. The catch is memory — peak RSS with both CLIP ONNX encoders loaded is ~1024 MB
vs ~610–650 MB for MiniLM, which overruns the 512 MB free tier. So MiniLM stays the default and
the multilingual model is opt-in via TEXT_EMBED_MODEL + TEXT_EMBED_COLUMN=embedding_ml (the
embedding_ml column already ships in the seed).
The copilot's policy answers retrieve from store_knowledge, but embedding a raw policy chunk and
embedding the question a shopper actually asks live in different parts of the vector space.
pipelines/eval_rag.py quantifies the gap on 12 labelled ID+EN queries
(pipelines/results/rag_eval.json): direct-chunk retrieval
scores HR@1 0.250 / HR@3 0.333 / MRR@3 0.292, while indexing 82 template-generated hypothetical
questions (3–5 ID+EN per chunk, in store_knowledge_questions) lifts it to HR@1 0.750 / HR@3
0.917 / MRR@3 0.819 — roughly triple HR@1.
Search answers "find me this". Recommendations answer "what next" — the surface where most e-commerce demos cheat with a hard-coded "you may also like". There is none of that here: every rail is built from the real 3.36 M-interaction log and the catalog's own co-buy graph, then served through a fallback chain so a cold visit still gets a useful rail.
| Surface | Endpoint | Mechanism | Cold fallback |
|---|---|---|---|
| Home rail | GET /recs/session | the visit's last 5 view_product / add_to_cart events → union of their item recs, excluding seen items | global popularity (cold_popularity) |
| Product page | GET /recs/item/{asin} | offline item-to-item table (item_recommendations) | same category → department popularity |
| Browse / cold | GET /recs/popular | review-volume ordering, optional department filter | — |
Each response includes a strategy field (session_cf, item_cf, category_popularity_fallback,
cold_popularity), so the UI can say how a rail was produced rather than guessing.
pipelines/build_recs.py)co counts users who interacted with both.
Ultra-popular items are capped at 5,000 sampled users so the candidate product stays bounded.also_buy adds +0.50 and also_view +0.30 to a candidate's
score, so explicit co-purchase signals beat coincidental co-views.flowchart LR
I["interactions.csv.gz<br/>3.36M rows"] --> CO["co-occurrence<br/>cosine of user sets"]
M["products.jsonl<br/>also_buy / also_view"] --> BOOST["metadata boosts<br/>+0.50 / +0.30"]
P["rating_count + avg_rating"] --> BAY["Bayesian popularity<br/>m=100, C=4.31"]
CO --> MERGE["merge + title dedupe"]
BOOST --> MERGE
BAY --> MERGE
MERGE --> ITEM["item_recommendations<br/>asin → [recs]"]
BAY --> POP["popular_recs.json"]
ITEM --> API2["/recs/item · /recs/session"]
POP --> API2
The store has no accounts — deliberately, since the funnel needs to attribute a visit, not a
person, and carrying no PII is a feature. So online personalization uses the anonymous
sessionStorage id and the current visit's events, while the offline co-occurrence table supplies
long-horizon behaviour. That is the cold-start problem answered at four levels
(session → item → category → global), not hidden behind a login wall.
pipelines/eval_recs.py runs sequential leave-one-out on users with ≥5 interactions: the target is
the user's final interaction and the penultimate item is the session context (a realistic "current
product page"), with everything else masked. It reports HR@10 / HR@5 / MRR@10 / nDCG@10 against random
and popularity baselines.
Fashion returns are driven by fit, and shoppers do not trust a generic size chart — they trust other
shoppers. pipelines/build_fit_signals.py turns the review corpus into a per-product fit signal so
the storefront and the copilot can answer "does this run small?" from evidence, not guesswork.
runs_small, true_to_size or runs_large, handling negations
(so "doesn't run small" is not counted as runs_small). 622,530 fit mentions are extracted —
18.5% of reviews carry a usable fit signal.brand_fit.Coverage of the 6,000 built ASINs: 99.8% have ≥ 1 fit mention, 98.1% have ≥ 5, 90.9%
have ≥ 20. Two tables ship in the seed: product_fit (4,670 rows, the served catalog) and
brand_fit (1,495 brands). The classifier is covered by 40 regex unit tests.
Mining the actual reviews overturned several "common knowledge" sizing rules that an authored chart would have gotten wrong:
api/knowledge/size_chart.json is the structured source (TB/BB tops, waist bottoms, footwear, and
per-brand offsets), labelled "panduan umum toko" — a store rule-of-thumb, explicitly not an
official brand chart. size_charts.md explains it in prose for the RAG index.
The copilot's recommend_size tool reads this plus the product/brand fit signal in a 3-layer
scheme (product fit → brand fit → chart offset) and always returns an advisory, never a
certainty.
GET /products/{id} now includes a fit object (label, source = product | brand,
nMentions, shares, fitScore, phrasing, disclaimer) or null when there is no signal, and a
dedicated GET /products/{id}/fit endpoint exposes the same. The storefront renders these as fit
badges on the copilot's product cards.
An in-store stylist and customer assistant built on LangChain v1 create_agent (langchain
1.4.3, langgraph 1.2.12), split across api/copilot_agent.py, api/copilot_tools.py,
api/copilot_llm.py, api/copilot_memory.py and api/guardrail.py. The provider is OpenRouter
(OpenAI-compatible) whenever OPENROUTER_API_KEY is set — its free tier is far more generous than
Gemini's — with an optional generic OpenAI-compatible endpoint and Google Gemini behind it. The
design principle is boring on purpose: the model never invents a product, a price, or a policy.
Text-only on purpose — the chat does not accept images. The copilot runs on a text LLM, so it
takes no image_url/imageUrl and binds no search_by_image tool. Free vision-capable models have
very small request quotas, so no multimodal LLM is used. Photo search still exists in the
storefront via POST /search/image (the CLIP vision encoder embeds the upload and matches
image_embedding deterministically) — it is just a separate storefront feature, not a chat turn.
sequenceDiagram
participant S as Shopper
participant API as /copilot/chat
participant G as Guardrail (deterministic, 0 LLM)
participant A as create_agent loop (≤ 3 model calls)
participant T as 7 tools
participant D as Postgres + pgvector (incl. checkpointer)
S->>API: {thread_id, message, context?}
API->>G: classify
Note over G: greeting / off-topic / injection<br/>→ canned reply, ZERO LLM calls
G-->>API: canned reply (guardrail hit)
G->>A: otherwise, run the agent
Note over A: middleware injects page/pinned product,<br/>shown-products registry, browsing context;<br/>trims history; caps model calls at 3
A->>T: tool calls
T->>D: catalog / details / reviews / size / outfit / policy / cart
D-->>T: authoritative rows
T-->>A: compacted evidence
A->>D: persist turn (PostgresSaver, thread_id)
A-->>S: reply + products[≤6]+fit + citations + suggestions + ui_actions
api/guardrail.py). Before any LLM runs, greetings/thanks/identity,
off-topic requests (code, SQL, maths, politics, medical/legal, creative) and prompt-injection
attempts get a canned "Admin Toko Marcell" reply with zero LLM calls. Anything else goes to
the agent. The response carries a guardrail field (greeting | off_topic | injection |
null) and the tests assert it.create_agent loop. The agent plans tool calls, runs them, and composes the grounded reply in
a single LangGraph loop. Middleware wraps it: ContextInjectionMiddleware injects the current
page / pinned product (re-read from the DB), a [Produk dalam percakapan ini] [1]… shown-products
registry and recent browsing context; TrimHistoryMiddleware keeps history under a 2,000-token
budget (char/4 estimate); ModelCallLimitMiddleware caps the turn at 3 model calls.Seven tools are bound to the agent (GET /copilot/tools lists them):
| Tool | Reads from | Answers |
|---|---|---|
search_catalog | BM25 + MiniLM (pgvector) fused with RRF, optional reranker, real category enum + budget filters | "do you have…", style, outfit, budget |
get_product_details | products by registry ref (nomor 2, produk ini), ASIN/id, or title fragment | fabric, specs, exact IDR price |
get_product_reviews | reviews, with a topic filter (fit / size / fabric / durability) | aspect-level social proof |
recommend_size | size_chart.json + product/brand fit, 3-layer (product → brand → chart offset) | TB/BB sizing advisory, never a certainty |
build_outfit | catalog search; total summed and verified ≤ budget in code | top + bottom + shoes within budget |
lookup_store_policy | store_knowledge / store_knowledge_questions (hypothetical-question RAG) | sizing (TB/BB), shipping, returns, QRIS demo |
add_to_cart | re-reads the price from the DB, returns a ui_action the browser executes | "add this to my cart" |
Tool results are compacted before they re-enter the prompt — a token-budget safeguard that also keeps answers focused. There is deliberately no order-status tool, because checkout is a demo simulation with no fulfillment to look up.
api/knowledge/size_charts.md and
api/knowledge/size_chart.json, not hallucinated.Conversation memory is a LangGraph PostgresSaver keyed by a browser-generated UUID thread_id
stored in localStorage under tm_copilot_thread. A sidecar copilot_thread_activity table tracks
last-touch time; a 7-day TTL cleanup runs on startup and daily. GET /copilot/threads/{thread_id}
returns a thread's messages and DELETE /copilot/threads/{thread_id} clears it (204). The
checkpoint tables are created by the saver at runtime, not shipped in the seed.
Before the agent runs, ContextInjectionMiddleware also injects the shopper's recent
view_product / add_to_cart events and any page/pinned product — so "do you have these in black?"
has a referent.
The LLM is assembled with .with_fallbacks: OpenRouter (primary) → an optional generic
OpenAI-compatible endpoint (LLM_FALLBACK_BASE_URL / LLM_FALLBACK_API_KEY / LLM_FALLBACK_MODEL)
→ Gemini. Setting COPILOT_FAKE_LLM=1 swaps in a deterministic fake tool-calling model, so CI
exercises the full agent and tool path without ever calling a real LLM.
POST /copilot/chat accepts {session_id?, thread_id, message (1..2000), context?: {page_asin?, referenced_asins[≤3]}} with extra="forbid". It returns {reply, thread_id, products[≤6] (each with a refandfit), citations, suggestions (2–3 follow-up chips), ui_actions (add_to_cart|size_form), tool_calls, guardrail, took_ms}. Errors are 422 (validation), 429 (rate limit),
503 (no LLM key) and 502 (upstream failure). GET /copilot/tools returns the 7 tool names.
api/knowledge_seed.py chunks the two hand-written markdown sources
(size_charts.md,
store_policies.md) by section header, embeds each chunk with
MiniLM, and upserts them into store_knowledge (23 chunks in the seed). It also generates 82
hypothetical questions (3–5 ID+EN per chunk) into store_knowledge_questions and indexes those —
embedding questions a shopper would actually ask rather than the raw policy text roughly triples
HR@1 (see Research pillar 2). Nothing here is
scraped; it is authored store policy, which is why the copilot can be trusted about shipping, returns
and sizing.
Render's free tier spins the API down after ~15 minutes idle. The naive result is a storefront that
appears broken for 30–50 seconds. shop/src/components/ServerWakeup.js turns that into a designed
state:
GET /health fires on first mount. If the server answers immediately, the
overlay never appears — fast paths stay fast.checkHealth() falls back to /categories if /health is blocked by a client shield/extension,
because a false "down" is worse than a slightly heavier probe.
Built to be fast and honest on free infrastructure (Vercel + Render + Neon).
| Layer | Where | Setting | Why |
|---|---|---|---|
| Edge ISR | app/product/[id]/page.js | revalidate = 300 | Product pages are read-heavy and change rarely; stale-while-revalidate removes origin latency on repeat visits |
| HTTP | Catalog endpoints | public, max-age=60, s-maxage=300, stale-while-revalidate=60 | Lets any CDN/proxy cache; short browser TTL keeps the demo fresh |
| In-memory TTL | /categories facets | 300 s dict cache | Replaces a GROUP BY aggregate scan on every page load (~60 ms → ~2 ms) |
POST /search/reindex rebuilds the BM25 index and invalidates the facet cache, because after a
reseed the old index is silently stale and "why are my new products not searchable" is otherwise a
confusing hour.
/copilot/chat enforces strict Pydantic bounds: ≤ 2,000 chars for the
single message per turn, extra="forbid" on the request body, and no image payload (the chat is
text-only). Oversized or malformed requests get a 422, not a metered bill.{asin, qty}; the server looks up every
price and computes the total. ConfirmIn sets extra="forbid", so a request that tries to send
a price is rejected with a 422 rather than silently ignored. A tampered "pay Rp 1" is structurally
impossible.image/*, payload must be 50 bytes–10 MB; the copilot client
caps uploads at 5 MB.CORS_ORIGINS is an allowlist in code (default localhost +
https://toko-marcell.vercel.app) with an explicit ^https://.*\.vercel\.app$ regex so preview
deployments work. The shipped Render blueprint sets CORS_ORIGINS=* so that every preview URL
works without redeploys — a deliberate tradeoff, acceptable here because the API is stateless,
credential-free, and sets no cookies. It is documented rather than hidden.next.config.mjs sets X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and a restrictive
Permissions-Policy (camera=(), microphone=(), geolocation=()).lib/api.js never surfaces raw backend errors for the copilot/image
search; it returns a friendly message instead of leaking internals.GET /events/summary computes funnel conversion and returns its own caveats array, so the
numbers cannot be read as more precise than they are:
search_to_pdp is a session-level proxy (a session that both searched and viewed a product),
not a click-through rate — the click is not its own event yet.results_count for search counts matches among currently loaded products, because filtering is
client-side; zero-result rate becomes a true metric only when search moves fully server-side.null, never 0.0, when a denominator is missing — an empty events table must not
produce a fake "0% conversion".GET /events/summary also carries a copilot block (messages, product_clicks, add_to_cart,
total_add_to_cart, assisted_add_to_cart_rate = copilot_add_to_cart / all add-to-cart, null
when there is no denominator) fed by the copilot_message / copilot_product_click /
copilot_add_to_cart event types — with a caveat that a copilot add fires both the generic and the
copilot event.
| Source | Amazon Reviews 2018, category Clothing, Shoes & Jewelry (McAuley Lab, UCSD) |
| Paper | Ni, Li, McAuley — Justifying recommendations using distantly-labeled reviews and fine-grained aspects (EMNLP 2019) |
| Licence | Free for research / non-commercial use; product text/images remain Amazon's |
| Raw files | reviews 1.27 GB + metadata 1.57 GB (~2.8 GB, not committed) |
Why this dataset. It is the canonical recommender-systems benchmark — so HR@10 / nDCG@10 numbers are comparable with published work instead of self-graded — and it carries exactly the fields the product needs: real titles, brands, prices, category trees, feature bullets and descriptions (search
user_id / asin / rating / timestamp (recs), plus also_buy / also_view (co-view).Why not the 2023 refresh. McAuley-Lab/Amazon-Reviews-2023 was tried and rejected on evidence:
in Amazon_Fashion, ~90% of items have no price and categories is empty, which makes a usable shop
catalog impossible.
11,285,464 reviews read → top candidates by interaction → quality filters → 6,000 products
→ dead-image & duplicate-title cleanup → 4,670 products served
Full provenance, quirks and filters: pipelines/README.md.
| Data | Built | Served (live/seeded) |
|---|---|---|
| Products | 6,000 | 4,670 |
| Reviews | 46,700 | 46,700 |
| Interactions | 3,363,680 | (offline signal) |
| Distinct users | 972,184 | — |
| Knowledge chunks | — | 23 |
| Hypothetical questions | — | 82 |
| Product fit signals | — | 4,670 |
| Brand fit signals | — | 1,495 |
Why 4,670 and not 6,000: after the catalog is built, pipelines/cleanup_catalog.py deletes products
whose image CDN link is dead (image_embedding IS NULL) and collapses duplicate title variants to the
best-rated one. The shipped seed (data/seed/init.sql.gz) is a dump of that cleaned database, so the
storefront's "4,670 verified products" copy is literally true.
The interactions matrix is 0.057% dense (3,363,680 filled of 5,833,104,000 cells; 972,184 users, min 198 / median 333 / max 19,693 interactions per item). The long tail is real, which is why item recommendations combine co-occurrence with an embedding-based backfill for cold items.
api/main.py, seeded offline)products (
id INTEGER PRIMARY KEY, -- stable int, ordered by popularity
asin TEXT NOT NULL UNIQUE, -- real product id; the ML/event join key
title TEXT NOT NULL,
brand TEXT,
price_usd NUMERIC(10,2) NOT NULL, -- untouched source price
price_idr INTEGER NOT NULL, -- shop price at a fixed, documented demo rate
department TEXT, category TEXT, category_path JSONB,
description TEXT, features JSONB, image_url TEXT,
avg_rating NUMERIC(3,2), rating_count INTEGER,
also_buy JSONB, also_view JSONB,
embedding vector(384), -- MiniLM, HNSW cosine
embedding_ml vector(384), -- multilingual MiniLM (opt-in), HNSW cosine
image_embedding vector(512) -- CLIP, HNSW cosine
)
events (id, session_id, event_type, asin, query, results_count, qty, price_idr, created_at)
orders (id, token, total_idr, item_count, status, session_id, created_at)
order_items (id, order_id, asin, title, qty, unit_price_idr)
item_recommendations (asin PRIMARY KEY, recs JSONB)
reviews (id, asin, rating, summary, comment, author, verified, review_date, created_at)
store_knowledge (id, category, title, content, embedding vector(384))
store_knowledge_questions (id, knowledge_id, question, embedding vector(384))
product_fit (asin PRIMARY KEY, label, n_mentions, shares JSONB, fit_score, …)
brand_fit (brand PRIMARY KEY, label, n_mentions, shares JSONB, …)
copilot_thread_activity (thread_id PRIMARY KEY, last_active_at)
-- LangGraph checkpoint tables are created by the PostgresSaver at runtime (not in the seed)
Notes that matter:
id vs asin. Ints for the shop and the cart; ASIN for events, recs and ML joins. Both exist
because the two consumers want different keys.price_usd is the dataset value, kept for auditability; price_idr is what the shop
displays at a fixed demo rate of 16,000 IDR/USD, recorded in stats.json. It is a presentation
conversion, not a live FX rate.init_db() creates tables with CREATE TABLE IF NOT EXISTS and then diffs
information_schema to ALTER TABLE ADD COLUMN whatever is missing, so a column added after a
database was created cannot silently disappear. A real migration tool (Alembic) is still a known
gap — see Honest limitations.The whole stack is containerized. Docker Compose boots the Next.js storefront, the FastAPI backend, and PostgreSQL 16 with pgvector auto-seeded with the real catalog, reviews, vector embeddings and knowledge base on first boot — no manual pipelines, no downloads.
docker + the Compose plugin).Everything runs with no configuration. To turn on the copilot, create .env from the example and
set the key — or let run-local.sh prompt you for it:
cp .env.example .env
# edit .env → OPENROUTER_API_KEY=sk-or-...
| Variable | Needed for | Default |
|---|---|---|
OPENROUTER_API_KEY | the AI copilot (preferred provider) | empty → copilot disabled |
LLM_MODEL | OpenRouter model id | openrouter/free (auto-routes the free pool) |
LLM_BASE_URL | OpenRouter endpoint | https://openrouter.ai/api/v1 |
LLM_FALLBACK_MODELS | optional OpenRouter fallbacks (429/503) | (empty) |
LLM_FALLBACK_BASE_URL · LLM_FALLBACK_API_KEY · LLM_FALLBACK_MODEL | optional generic OpenAI-compatible fallback endpoint | (empty) |
GEMINI_API_KEY | the copilot fallback provider | empty → Gemini not used |
GEMINI_MODEL | fallback model override | gemini-3.5-flash-lite |
TEXT_EMBED_MODEL · TEXT_EMBED_COLUMN | opt-in multilingual embeddings (…-MiniLM-L12-v2 + embedding_ml) | MiniLM default |
COPILOT_FAKE_LLM | deterministic fake tool-calling model (tests/CI only) | unset |
DATABASE_URL · NEXT_PUBLIC_API_URL · INTERNAL_API_URL | wiring the three services | pre-set in .env.example and docker-compose.yml |
git clone https://github.com/marknshoot/toko-marcell.git
cd toko-marcell
./run-local.sh # builds + starts everything, waits for health, prints the URLs
# or: docker compose up -d --build
| Service | URL |
|---|---|
| 🌐 Storefront | http://localhost:3000 |
| ⚡ API + interactive docs | http://localhost:8001/docs |
| ❤️ Health | http://localhost:8001/health |
docker compose logs -f # stream logs
docker compose down # stop
Browsing, filtering, hybrid text search, recommendations, reviews and the demo checkout all run
against the seeded database. Without a key, /copilot/chat returns a clean 503 with a friendly
message instead of crashing, and the copilot panel simply can't produce answers.
Got a 500 or an out-of-memory from the API instead? That's a known image-search issue, not a setup problem — see Honest limitations. Text search is unaffected.
How the auto-seed works. The Postgres container mounts
data/seed/init.sql.gz(~33 MB) into/docker-entrypoint-initdb.d/, so the official image restores the complete schema, 4,670 products, HNSW indexes, 46,700 reviews, 23 knowledge chunks (plus 82 hypothetical questions), theproduct_fit/brand_fittables, recommendations and a small demo funnel (~357 events, ~106 orders) in seconds on first boot. The ephemeral LangGraph checkpoint tables are excluded from the dump and recreated at startup.
# Backend + DB only (from api/), with an offline seed job behind a profile
cd api
docker compose up -d --build
docker compose run --rm seed --reset # optional: reload catalog from data/processed
curl -s localhost:8001/health # {"status":"ok"}
# Frontend only
cd shop && npm install && npm run dev # needs an API at NEXT_PUBLIC_API_URL
To regenerate the catalog from raw data (≈8 min, streaming, no pandas), see
pipelines/README.md.
manual/
├── api/ # FastAPI backend (owns all data + logic)
│ ├── main.py # thin app factory: lifespan, CORS, schema migration, routers
│ ├── config.py # pydantic-settings Settings, cached get_settings()
│ ├── db.py # psycopg_pool ConnectionPool, get_conn() / Depends
│ ├── schemas.py # shared Pydantic request/response models
│ ├── routers/ # one module per domain
│ │ ├── health.py # GET /health
│ │ ├── catalog.py # categories, products, product detail + row_to_product
│ │ ├── search.py # text search, image search, reindex
│ │ ├── recs.py # popular, item-to-item, session recommendations
│ │ ├── reviews.py # product & ASIN reviews
│ │ ├── events.py # funnel events + summary
│ │ ├── checkout.py # confirm checkout, order lookup
│ │ └── copilot.py # AI copilot chat + tool listing
│ ├── search.py # BM25 + embeddings + RRF + CLIP encoders (ONNX)
│ ├── reranker.py # Stage-2 ONNX cross-encoder
│ ├── copilot_agent.py # LangChain v1 create_agent loop + middleware
│ ├── copilot_tools.py # the 7 grounding tools (schema bound to the agent)
│ ├── copilot_llm.py # provider chain (.with_fallbacks) + COPILOT_FAKE_LLM
│ ├── copilot_memory.py # LangGraph PostgresSaver + thread activity / TTL
│ ├── guardrail.py # deterministic zero-LLM greeting/off-topic/injection gate
│ ├── fit.py # product/brand fit lookup served on the product API
│ ├── sizing.py # 3-layer recommend_size (product → brand → chart offset)
│ ├── rate_limiter.py # sliding-window per-IP rate limiter
│ ├── knowledge_seed.py # markdown → embedded store_knowledge + hypothetical questions
│ ├── knowledge/ # authored size_charts.md + size_chart.json + policies (RAG source)
│ └── tests/ # 188 pytest cases (unit + live API, fake-LLM copilot)
├── shop/ # Next.js 16 storefront
│ └── src/{app,components,lib} # routes, UI (CopilotChat + ProductCopilotEntry), API client, session & cart state
├── pipelines/ # offline ML + data engineering
│ ├── build_catalog.py # raw Amazon data → 6,000-product catalog (3 streaming passes)
│ ├── seed.py # catalog → Postgres (offline, upsert by asin)
│ ├── cleanup_catalog.py # drop dead images + duplicate titles → 4,670 live
│ ├── build_recs.py # co-occurrence + co-view + embedding backfill → recs
│ ├── build_fit_signals.py # review regex-mining → product_fit / brand_fit (negation-aware)
│ ├── embed_catalog.py # MiniLM 384-d text embeddings + HNSW
│ ├── embed_images.py / embed_catalog_vlm.py # CLIP 512-d image embeddings
│ ├── extract_reviews.py # top reviews per ASIN → reviews table
│ ├── prepare_clip_dataset.py # 5,378 verified pairs → 80/10/10 splits
│ ├── train_clip.py # PyTorch InfoNCE training loop (hard negatives, AMP)
│ ├── experiments_clip.py # the 4 ablation experiments (baseline/LoRA/SigLIP/decoupled)
│ ├── eval_multimodal.py # text→image Recall@K / MRR evaluation + error mining
│ ├── eval_search.py # 32-query BM25 vs hybrid IR harness
│ ├── eval_recs.py # leave-one-out HR@K / MRR / nDCG harness
│ ├── eval_rag.py # direct-chunk vs hypothetical-question RAG eval
│ ├── eda_interactions.py # sparsity, long tail, rating distribution
│ ├── migrate_to_neon.py # local → Neon serverless migration
│ ├── migrations/ # idempotent SQL migrations (2026_10_phase4.sql)
│ ├── kaggle/ # notebook + CLI metadata for the GPU study
│ ├── failure_case_analysis.md # qualitative research report
│ └── literature_review_clip_finetuning.md
├── data/
│ ├── seed/init.sql.gz # COMMITTED: the seeded database dump (works without raw data)
│ ├── knowledge/*.md # COMMITTED: RAG source documents
│ ├── raw/ # gitignored: ~2.8 GB downloads
│ └── processed/ # gitignored: reproducible artifacts (embeddings, splits, …)
├── models/ # weights gitignored; small model cards & summaries tracked
├── docker-compose.yml # one-command full stack
├── render.yaml # Render blueprint (API)
├── run-local.sh # friendly wrapper around docker compose
└── .github/workflows/ci.yml # lint + test + build on every push/PR
What is and is not committed. Only the seed dump and the knowledge documents are committed under
data/; raw and processed data are gitignored because they are large and reproducible. Model weights (*.onnx,*.safetensors,*.pt) are gitignored too — the production text encoder is published on Hugging Face Hub and fetched at runtime, and the rest can be regenerated with the pipelines above. The small model cards, adapter configs and experiment summaries undermodels/experiments/are tracked, so the study's evidence is browsable on GitHub without shipping binaries. A fresh clone runs immediately via Docker and can reproduce the ML from source.
# Frontend: 21 tests across 5 files (components, cart, copilot, cold-start, session)
cd shop && npm test
# Backend + pipelines
cd api && python3 -m pytest tests/ # 188 passed + 1 skipped (fake-LLM copilot; needs a live DB)
python3 -m pytest pipelines/tests/ # 46 tests (6 pipeline-math + 40 fit classifier, no DB)
Every push and PR runs .github/workflows/ci.yml:
| Job | Steps |
|---|---|
| Frontend (Node 22) | npm ci → npm run lint → npm run test → npm run build |
| Backend & pipelines (Python 3.12) | Ruff → full API suite against a Postgres service restored from the seed, with COPILOT_FAKE_LLM=1 → pipeline tests |
CI runs Ruff + the full API suite against a Postgres service restored from the committed seed,
with COPILOT_FAKE_LLM=1 so the whole agent and tool path is exercised without ever calling a real
LLM — plus the frontend lint/test/build. The API tests cover the things that are easy to get wrong:
price-tampering rejection, duplicate-ASIN aggregation, event-type validation, session-id bounds,
search modes/filters/reindex, recommendation cold-start fallbacks, review aggregates, the fit
classifier, and the copilot's use cases + guardrails.
These are the choices that shaped the project. Each is stated with the alternative that was rejected.
pipelines/seed.py does. This keeps docker compose up a server and keeps the ML/data pipeline
independent of request handling.eval_search.py.total a lie.{asin, qty}. The alternative — trusting a client
total — is a demo that can be made to "pay Rp 1" from the URL bar. The confirmation page fetches
the order by token, so it survives refresh/new tab/another device and cannot show a fake receipt.sessionStorage + crypto.randomUUID().null when undefined; missing catalog fields stay missing. A demo
that reports "0%" from an empty table is worse than one that says "no data yet".503 (never a mismatched zero-shot
fallback). Couriers/shipping/QRIS are explicitly labelled simulations, so nobody mistakes the
demo for a real store.This section exists because a portfolio that hides its edges is less useful than one that names them.
/checkout/confirm is not idempotent. A double-click can create two orders. The button disables
while in flight, but a production system wants an Idempotency-Key header.tk_… tokens; there are no user accounts.results_count in funnel events reflects loaded
products, not the whole catalog, until filtering moves fully server-side.search_to_pdp is a session proxy, not a click-through rate (the click event is not recorded
separately yet).init_db() diffs and adds columns additively; it is not a versioned
migration system. A schema rewrite would need care.POST /search/reindex must be called (or the container
restarted) because the BM25 index lives for the life of the process.POST /search/image → CLIP vision → image_embedding);
the chat is a separate text surface. There is also deliberately no order-status tool: checkout
is a demo simulation with no fulfillment, shipping or tracking to report.torch and matches the stored fine-tuned vectors (cosine 0.991 vs fp32). The artifact is
published at
HF Hub: Marcell-Kristianto/toko-marcell-clip
and fetched at runtime, so it works for a local build (mounted models/) and for a public deploy.
Serving memory is trimmed for the free tier: the vision encoder is int8 (~96 MB) and the text
encoder is fp16 (~127 MB, identical embeddings) — see
pipelines/BENCHMARKS.md. Even so, loading every model at once
(vision + text + MiniLM + reranker) can approach the 512 MB Render free-tier budget; a ≥1 GB plan
removes the cliff. Because mode=trimodal is the one request that loads both the text ONNX and
MiniLM, it is disabled by default on the deployment (ENABLE_TRIMODAL) and returns 400 when
off. If no encoder is present the endpoint returns 503 by design (a zero-shot encoder would
occupy a different space). Text search and the copilot are unaffected.TEXT_EMBED_MODEL + TEXT_EMBED_COLUMN=embedding_ml) and MiniLM is the default.build_fit_signals.py classifies fit
mentions with regex + negation handling (not an aspect-sentiment model); runs_large is the
weakest-signalled label, and the copilot always frames sizing as an advisory, never a certainty.COPILOT_FAKE_LLM=1 is what CI uses for determinism.ENABLE_RERANKER=false)
to stay within the memory budget; it falls back to Stage-1 ranking. The /search endpoint never
used the reranker, so HTTP search quality is unchanged; local Compose keeps it on.multimodal_benchmark_results.json). The qualitative case section of
failure_case_analysis.md comes from an earlier evaluation pass; treat its individual rank
numbers as illustrative of phenomena, not as the current leaderboard.Ordered by value to the product, not by ease:
product_click event so search_to_pdp
becomes a true CTR.Idempotency-Key support to checkout.Marcell Hermawan Kristianto Data Science, BINUS University — 5th semester
pipelines/kaggle/README.md.