Semantic product search: Vectorize embeddings with a SQL safety net
“Show me something elegant for a night out.” A keyword search for elegant will fight you — the
products never use the word. This post is about the search that gets it right anyway, and the
graceful fallback that keeps the storefront working when the AI stack is unreachable.
The story
The demo’s search is three layers, each with one job:
- Workers AI turns a query into a 768-dimension vector using the
bge-base-en-v1.5embedding model. - Vectorize finds the closest product vectors (cosine similarity, top 5).
- D1 is then re-queried by product id, so every result shows live stock and price.
That third layer is the design decision worth copying: the index holds only ids and light metadata, and results are re-hydrated from the source of truth before rendering. A semantic index is always slightly stale — a database is not.
How it works
Embedding is a single AI.run with a defensive shape:
export const EMBEDDING_MODEL = '@cf/baai/bge-base-en-v1.5';
export async function embedTexts(env: Env, texts: string[]): Promise<number[][] | null> {
try {
const res = (await env.AI.run(EMBEDDING_MODEL, { text: texts })) as { data?: number[][] };
return res?.data?.length === texts.length ? res.data : null;
} catch (err) {
console.warn('[search] embedding failed:', err);
return null; // caller falls back
}
}
Each product is indexed as one string — "<name>. <description> Category: <category>" — which
is what makes “warm winter coat” match a Merino wool coat that never says “warm” or “winter”:
export function productText(name: string, description: string, category: string): string {
return `${name}. ${description} Category: ${category}`;
}
The search route: embed the query, ask Vectorize for the 5 nearest ids, then hydrate fresh rows from D1:
const vector = (await embedTexts(env, [q]))?.[0];
if (vector && env.VECTORIZE) {
const res = await env.VECTORIZE.query(vector, { topK: 5 });
const products = await fetchProductsByIds(env, res.matches.map((m) => String(m.id)));
const results = res.matches
.map((m) => (products.get(String(m.id)) ? { product: products.get(String(m.id)), score: m.score } : null))
.filter(Boolean);
return jsonResponse({ query: q, backend: 'vectorize', results });
}
And the safety net — if the embedding call fails, Vectorize is unreachable, or the index is
empty, the response says so honestly and a plain SQL LIKE search answers instead:
// Fallback: plain SQL keyword search so the demo still works offline.
const like = `%${q.replace(/[%_]/g, '!')}%`;
const { results } = await env.DB.prepare(
`SELECT ... FROM products
WHERE active = 1 AND (name LIKE ? ESCAPE '!' OR description LIKE ? ESCAPE '!' OR category LIKE ? ESCAPE '!')
LIMIT 5`
).bind(like, like, like).all();
return jsonResponse({ query: q, backend: 'sql', results });
The backend field in the response is the tell: the UI badges results as vector search vs
fallback, so you always know which path answered.
Indexing is a button in the admin panel, not a build step: read active products from D1 → embed → upsert to Vectorize in batches of 16.
const vectors = await embedTexts(env, products.map((p) => productText(p.name, p.description, p.category)));
await env.VECTORIZE.upsert(products.map((p, i) => ({
id: p.id, values: vectors[i], metadata: { name: p.name, category: p.category },
})));
What the demo shows
Type “warm winter coat” → the Merino wool coat at 71% match. “Something elegant for a night out” → the slip dress and the blazer. Sold-out items still match but show their stock badge, because stock came from D1, not the index.
Evidence: what to capture
- Storefront → Featured section → search “warm winter coat” → merino coat with the 71% badge →
04-search-coat.png - Search “something elegant for a night out” → slip dress / blazer results →
04-search-elegant.png - Admin panel → Search tab → Reindex catalog button + success message →
04-reindex.png - Cloudflare dashboard → Storage & Databases → Vectorize →
lumina-product-searchindex (dimensions 768, vector count) →04-vectorize-dashboard.png - (Optional) DevTools on the search request showing
backend: "vectorize"in the JSON →04-backend-field.png
Key takeaways
- Embeddings beat keywords when customers describe intent, not product names.
- Store ids in the index; hydrate live data from the source of truth at query time.
- Every AI feature needs a non-AI fallback that answers honestly (
backend: sql) rather than pretending.