Overview
A marketplace client's product search was keyword-only with a hand-maintained synonym file that had grown to 14,000 lines nobody dared touch. Queries like 'warm jacket for hiking in rain' returned nothing useful because no product title contains those words. Meanwhile 2M SKUs churned at ~50k updates a day, so any solution had to handle continuous reindexing without search downtime.
I built a Qdrant-backed semantic layer: SKU titles, attributes, and category paths are composed into an embedding document, indexed with HNSW, and searched with mandatory payload filters (in-stock, region, category). Semantic results are blended with the existing lexical engine — semantic recall plus keyword precision — behind a single search API. Zero-result queries dropped 38% and add-to-cart from search rose 9% in the A/B test.
Architecture
- Kafka consumer ingests product-change events; an embedding worker batches 256 SKUs per call and upserts vectors with full payloads into Qdrant.
- Embedding text is a structured composition — title, brand, category path, key attributes — not raw description dumps, which embedded poorly.
- Qdrant collection uses HNSW (m=16, ef_construct=200) with payload indexes on category, region, and in_stock for filtered search.
- Query path: embed query (cached in Redis at ~35% hit rate), filtered vector search top-50, blend with lexical top-50 via RRF, apply business ranking on the fused list.
- Blue/green collection aliases in Qdrant allow full reindexes (embedding model upgrades) with atomic cutover and instant rollback.
- Nightly recall job replays the top-1000 queries against a labeled relevance set and alerts if recall@10 drifts more than 2 points.
Code Snippets
async def semantic_search(query: str, region: str, category: str | None, limit: int = 50):
vector = await embed_cached(query) # Redis-cached, ~35% hit rate
conditions = [
FieldCondition(key="region", match=MatchValue(value=region)),
FieldCondition(key="in_stock", match=MatchValue(value=True)),
]
if category:
conditions.append(
FieldCondition(key="category_path", match=MatchText(text=category))
)
hits = await qdrant.query_points(
collection_name="skus_active", # alias -> blue or green collection
query=vector,
query_filter=Filter(must=conditions),
search_params=SearchParams(hnsw_ef=128),
limit=limit,
with_payload=["sku", "title", "price_cents"],
)
return [SkuHit(score=p.score, **p.payload) for p in hits.points]Lessons Learned
- What you embed matters more than which model embeds it. Structured 'title | brand | category | attributes' documents beat raw descriptions by 12 points of recall@10.
- Filter inside the vector store, never after. Post-filtering top-K in application code silently starved narrow categories of results — filtered HNSW fixed it.
- Semantic search complements lexical, it doesn't replace it. Users searching exact model numbers still need keyword precision; RRF blending gave us both without a reranking model.
- Collection aliases are the deployment primitive. Every embedding-model upgrade was a full reindex, and atomic alias cutover made those a non-event.
- Cache query embeddings aggressively. Search queries follow a steep Zipf curve — a 35% embedding-cache hit rate took real latency and cost off the table for free.