Embeddings for product search: build a semantic recommender
Keyword search matches strings; embeddings match meaning. This tutorial walks through building a semantic product search and a 'customers also liked' recommender from a raw catalog, step by step.
A shopper types "something for my aching back to sit on at my desk" into a store's search box. Keyword search sees the words "sit," "desk," and "aching" and returns dining chairs, standing desks, and back braces — technically matched, practically useless. An embedding-based search turns that query into a vector in a space where "ergonomic office chair with lumbar support" lives nearby, and the shopper finds what they actually meant.
That is the whole pitch: match intent, not strings. The same machinery powers the "customers also liked" carousel — just similarity search where the query is a product, not text. Here is how to build both from a plain catalog in an afternoon.
What you are building#
The architecture has four moving parts: catalog documents (each product serialized to text), an embedding model (text → dense vectors), a vector index (fast nearest-neighbor lookup), and two query paths — text-to-product (search) and product-to-product (recommendations).
Everything else in this guide is refinement of these four.
Step 1: Choose an embedding model#
This choice defines the semantic space your queries and products share. Pick in this order of effort:
| Option | Examples | When to use it |
|---|---|---|
| Managed API | OpenAI text-embedding-3-small | Fastest start; best general-purpose quality |
| Lightweight local | SBERT, MiniLM variants | Free, fast, strong precision on titles/attributes |
| Instruction-tuned local | E5, GTE, BGE families | Better recall on varied natural-language queries |
| Multimodal | CLIP, SigLIP | When discovery is visual (fashion, furniture) |
Managed APIs are the fastest path to a working system. OpenAI's text-embedding-3-small emits 1,536-dimensional, L2-normalized vectors with an 8,191-token window at roughly $0.02 per million input tokens as of 2026 — embedding a 100,000-product catalog costs well under a dollar. Normalized output makes cosine similarity the natural metric (dot product ranks identically and is faster in most ANN libraries).
For the rest of this tutorial we will use a local sentence-transformers model so everything runs offline, but the code is identical if you swap in an API client.
Rule of thumb: start small and cheap. A compact model with good chunking and filtering usually beats a large model applied naively, and rerankers (below) add more precision per dollar than model upgrades.
Step 2: Turn your catalog into embeddable text#
This step matters more than the model choice. Embedding a raw database dump produces a muddy vector; a well-serialized document produces a sharp one. The two standard approaches:
- Flat (single-vector): concatenate title + category path + key attributes + description into one string → one embedding per SKU. Simple, and fine for baseline.
- Multi-field: embed title, attributes, and description separately, store multiple vectors per product, and combine scores at query time. More precision, more bookkeeping.
For most catalogs, one well-crafted string gets you 80% of the way. Serialize attributes into natural language — "Color: navy. Material: merino wool" — not JSON keys, since embedding models are trained on prose. Do the same for numerics ("Price: $89. Weight: 340g") so queries like "under $100" have something to match. This serialization trick is one of the highest-ROI moves in product search: it turns fields keyword search ignores into searchable meaning.
def serialize(product):
parts = [
product["title"],
"Category: " + " > ".join(product["category_path"]),
"Brand: " + product["brand"],
"Attributes: " + "; ".join(f"{k}: {v}" for k, v in product["attributes"].items()),
product["description"],
]
return ". ".join(parts)
catalog_texts = [serialize(p) for p in catalog]
Clean and normalize first: dedupe SKUs, drop empty descriptions, strip HTML.
Step 3: Embed and index the catalog#
Batch-embed the catalog and store the vectors in an index. For a prototype, an exact flat index (brute-force cosine) is fine up to tens of thousands of SKUs. For anything larger, use HNSW — graph-based approximate nearest-neighbor search that is fast, high-recall, and supports upserts, which matters when stock and prices change constantly.
from sentence_transformers import SentenceTransformer
import chromadb
model = SentenceTransformer("BAAI/bge-small-en-v1.5") # 384 dims, runs on CPU
client = chromadb.PersistentClient(path="./product_index")
collection = client.get_or_create_collection(
name="products", metadata={"hnsw:space": "cosine"}
)
embeddings = model.encode(catalog_texts, batch_size=64, normalize_embeddings=True).tolist()
collection.add(
ids=[p["sku"] for p in catalog],
embeddings=embeddings,
documents=catalog_texts,
metadatas=[{"brand": p["brand"], "category": p["category_path"][-1],
"price": p["price"], "in_stock": p["in_stock"]} for p in catalog],
)
Keep price, brand, category, and stock as structured metadata alongside the vectors — never rely on embeddings alone for hard constraints. A vector index does not know that "in stock" is non-negotiable.
Step 4: Build semantic search#
Search is the same operation in reverse: embed the shopper's query with the same model and ask the index for the nearest product vectors.
def semantic_search(query, k=10, filters=None):
q = model.encode(query, normalize_embeddings=True).tolist()
return collection.query(
query_embeddings=[q], n_results=k, where=filters,
include=["documents", "metadatas", "distances"],
)
results = semantic_search("something for my aching back to sit on at my desk",
filters={"in_stock": True})
Try it with a query like that one and compare against your old keyword search. The semantic version should surface ergonomic chairs even though none of them contain the words "aching back." That gap — intent matched where strings failed — is the recall win that drives conversion.
One caution: semantic search over-includes. "Red dress" can surface a crimson sweater. That is what the filters above are for — structured metadata matters as much as the embeddings. Pre- and post-filtering on brand, category, price, and stock is what restores precision.
Step 5: Build "customers also liked"#
Here is the pleasant surprise: you have already built the recommender. A recommendation is similarity search where the query vector is an existing product's embedding instead of a query string. Fetch the product's vector by SKU and ask for its nearest neighbors.
def also_liked(sku, k=8, category=None):
anchor = collection.get(ids=[sku], include=["embeddings"])["embeddings"][0]
filters = {"category": category} if category else None
hits = collection.query(query_embeddings=[anchor], n_results=k + 1,
where=filters) # +1 to exclude the anchor itself
skus = hits["ids"][0]
return [s for s in skus if s != sku][:k]
also_liked("SKU-042", category="office-chairs")
This is pure content-based recommendation — no purchase history, no collaborative filtering, no cold-start problem. A brand-new SKU becomes recommendable the moment it is embedded, which is exactly where collaborative systems struggle most. For many stores, content-based similarity is the right baseline; add behavioral signals (views, purchases) later as a second ranking signal, not as the foundation.
A few production touches improve the carousel: diversity (cap per-product-family results so twelve colorways of one chair don't dominate), business rules (filter out-of-stock items; boost clearance via metadata-weighted scores), and category anchoring — constraining to the anchor's category usually beats unconstrained similarity, which drifts into odd cross-category matches.
Step 6: Production polish — hybrid, rerank, evaluate#
Three upgrades, in the order most practitioners recommend them:
1. Hybrid retrieval. Dense embeddings miss exact-string queries: a shopper searching "WD-40 3-IN-ONE 120ml" wants that SKU, not a semantic cousin. Combine your vector index with keyword search (BM25) and fuse the two rankings with reciprocal rank fusion, then take the top candidates. If your search quality is weak and you are embeddings-only, adding hybrid retrieval is typically the single highest-ROI fix available.
2. Cross-encoder reranking. Retrieve the top 50 candidates cheaply with embeddings, then re-score them with a reranker (e.g., Cohere's rerank-3 or BGE's reranker) that reads query and document together. Rerankers are pricey per call, but 50 candidates is cheap — and it is usually a bigger precision win than a larger embedding model.
3. Evaluate before you "improve." Build a small set of realistic queries with labeled relevant products — covering brands, attributes, and paraphrase cases ("the part that won't turn on" vs "ignition failure"). Track Recall@k, NDCG@k, and MRR offline, plus click-through and add-to-cart online. Without measurement, every change is a guess. Also watch for embedding drift as the catalog turns over: re-embed new SKUs incrementally and check the score distribution of known relevant pairs.
Cost and scaling notes#
- Indexing cost is one-time and tiny: embedding 100k products with a managed API at $0.02/1M tokens costs well under a dollar; locally it is free.
- Query cost with a managed API is per search query — trivial for a small store, worth watching at scale. Cache embeddings for repeated queries (autocomplete, category pages).
- Storage scales with dimensions: 384-dim local vectors are roughly 4x smaller than 1,536-dim API vectors. OpenAI's models support reducing dimensions without retraining; int8 quantization cuts another ~4x of memory for a ~1–2% recall hit.
- Catalog size dictates the index: flat exact search under ~50k SKUs, HNSW into the millions, and IVF/PQ compression at true scale (100M+ products).
Takeaway#
Semantic product search and "customers also liked" are not two systems — they are one embedding pipeline with two query paths. Serialize your catalog well, embed it, index it with metadata filters, and you have both in an afternoon. The rest is unglamorous but decisive: hybrid retrieval for exact matches, reranking for precision, faceted filtering for trust, and evaluation so you know whether any of it is working. Ship the baseline, measure, then add layers — in that order.