We shipped a hybrid CLIP+BGE search API in a weekend — and caught it auth-broken before launch
There's a category gap most Indian D2C brands quietly suffer from. You set up your Shopify or WooCommerce store. The default search is keyword-only and unforgiving — *"red silk wedding saree"* doesn't surface a product titled *"Banarasi gold zari kameez"* even when that's exactly what the customer means. Algolia NeuralSearch starts north of ₹35,000/month. Doofinder's text-only tier is cheaper but adds zero visual or semantic understanding. OpenSearch is a build-it-yourself project that needs an EC2 GPU instance you don't want to manage.
So we built SearchPixel over the last few days. It's a single REST endpoint that takes a query — text or an image — and returns a ranked list of matching products. It runs on our own RTX 6000 GPU, costs us roughly nothing per request because the GPU is amortized across other PixelAPI workloads, and the open beta is free for the first 50 stores.
This post is the honest version of what we shipped, what we tried to ship and almost sent out broken, and where it goes next.
What's actually in the box
Two embedding models, run side by side:
| Model | Job | Library | Vector dim |
|---|---|---|---|
| CLIP ViT-L/14 (datacomp_xl_s13b_b90k) | Image and short-phrase embeddings | open_clip_torch | 768 |
| BGE base (BAAI/bge-base-en-v1.5) | Long-form text embeddings | transformers | 768 |
| ChromaDB 1.5.8 | Vector store | chromadb | — |
Each store gets two ChromaDB collections — one CLIP, one BGE. A query goes to both, the per-collection scores are min-max normalised, and the final score is a 0.5/0.5 mix. The result, on the demo catalog of 12 sample SKUs, looks like this:
"saree bridal red" → Saree Gold Silk Wedding Party Traditional 0.685
"formal leather shoes office"→ Leather Shoes Brown Formal Office 0.829
"kurti summer cotton" → Kurti Pink Floral Printed Summer 0.778
"red outfit for a wedding" → Men's Cotton Kurta Red Traditional 0.808
A pure keyword search would miss most of those — none of the queries are substrings of the product titles.
The bug we almost shipped
Honest part. The first version that was handed to me to validate "worked" — it returned 200 OK, the search results were relevant, the agent who built it sent over a tidy report saying *"VERIFIED WORKING ✅"*.
It wasn't.
The auth dependency in main.py had this shape:
def get_store(x_api_key: str = Header(None)) -> str:
if not x_api_key:
return cfg.DEMO_STORE
return x_api_key.split("_")[0] if "_" in x_api_key else x_api_key
That looks fine on a quick read. It's not. It means:
1. Sending no key at all silently routes you to the demo store. Read endpoints behaving this way is fine. Write endpoints behaving this way is not. I sent a POST /index with no auth and a body containing {product_id: "hack", name: "injected", description: "poisoned"}, and got a 200 back. The poison product showed up in the next public query.
2. Even with a key, *any string was accepted* — there was no check against an actual customer database. X-API-Key: acme_anything would have given the caller full control of the acme store.
The fix was to swap the dependency for one that asks Postgres whether the key is real:
async def auth_ctx(authorization=Header(None), x_api_key=Header(None)) -> AuthCtx:
key = _extract_bearer_or_x_api_key(authorization, x_api_key)
if not key:
return AuthCtx(is_demo=True, user_id=None, store_id="demo", plan="demo")
user = await _user_by_key(key) # 60s TTL cache → asyncpg → users.api_key
if not user: raise HTTPException(401, "Invalid API key")
if user["blocked"]: raise HTTPException(403, "Account suspended")
return AuthCtx(is_demo=False, user_id=user["id"], store_id=f"u{user['id']}", ...)
async def require_real_user(ctx: AuthCtx = Depends(auth_ctx)) -> AuthCtx:
if ctx.is_demo:
raise HTTPException(401, "API key required for write operations.")
return ctx
/search (read) takes auth_ctx — demo OK. /index, /index/image, /product/ DELETE, /stores all take require_real_user. The demo store_id is reserved — even with a valid key you can't write into it, because your store_id comes from f"u{user.id}", not from anything user-supplied.
Eleven pytest tests now pin this contract. Three of them probe the bug class above directly:
- test_index_without_key_is_rejected — no X-API-Key header → 401
- test_index_with_invalid_key_is_rejected — X-API-Key: garbage_key_does_not_exist → 401
- test_cross_tenant_isolation — user A indexes a uniquely-phrased product, user B searches for that phrase with their own key, asserts the product is *not* in the results
If any of those go red on the next deploy, the regression is impossible to miss.
What I dropped from the launch copy
The first cut of the landing page promised *"₹4,999/mo · Razorpay built-in · 14-day free trial"*. There was no Razorpay code in the repository. There was no billing endpoint. There was no users-table integration on the SearchPixel side beyond the auth dependency I just described. Promising those on the landing page would have been the marketing equivalent of return 200 for POST /index — looks fine, isn't.
So pricing on the live page now says *"Free in beta"*, the CTA is a mailto:[email protected], and the Razorpay row in the comparison table got swapped for *"Self-hosted GPU (no cloud markup)"* — which is a thing we actually do.
When we put a real price on it, current beta users will get a discount and a heads-up, not a surprise card charge.
Where this came from
SearchPixel sits inside PixelAPI, the same project that already runs portrait restoration, virtual try-on, pattern generation, and a handful of other AI endpoints on a small fleet of GPUs in our office. The PixelAPI gateway already has an auth model, a usage table, a Razorpay subscription flow, a Zoho Books integration. Reusing that machinery — same key, same dashboard — was strictly cheaper than rebuilding any of it.
That decision is also why this is actually serviceable: SearchPixel is just *one more endpoint family* to operate, not its own SaaS with its own on-call schedule.
What's left
- Shopify and WooCommerce plugins. The REST API works today; the one-line embed is on the bench.
- A /search query parameter for category and price filters. The data is in ChromaDB metadata; the route layer just doesn't expose it yet.
- Visual search via uploaded image (POST /search with query_image) is implemented but underexercised — the path needs a real test catalog, not just 12 demo SKUs.
- Honest pricing. We'll publish a number when we know what the steady-state GPU cost looks like in production. Beta users will find out before anyone else.
Try it
Live at searchpixel.pixelapi.dev — the homepage demo runs against the public catalog, no key needed. Docs at /docs. For a real index of your own, reply to this post or email [email protected] with your store URL and platform — we'll send back an API key (or pair it with the PixelAPI key you already have) within a working day.
Bug reports welcome. Hostile bug reports especially welcome.