Blog/E-COMMERCE & PRODUCT

Semantic Product Search for Ecommerce: Architecture for a Growing Catalog

Giorgi Kenchadze

By Giorgi Kenchadze

· 16 min read

How to design semantic product search for a store that starts at 10,000 products and keeps growing: the components, what each one stores, how data flows, and the decisions you can't easily undo.

A question we get a lot goes something like this: "We have about 10,000 products, we add more every month, and we want shoppers to search by meaning instead of exact keywords. What should we use, and how should it fit together so we're not rebuilding it in a year?"

This post is the answer we give. It's the architecture we recommend to stores building on Vecstore, but most of it applies no matter what you use underneath: which components you need, what each one stores, how data moves between them, and the handful of decisions that are painful to change later.

The short version:

  • Keep your product database as the single source of truth. The search index is a copy you can always rebuild.
  • Use hybrid search (meaning plus exact keywords), not pure semantic search. Shoppers type SKUs and brand names too.
  • Index a text document per product and a separate image index for product photos, linked by product_id.
  • Put anything you filter on (category, stock, price band) in metadata, not in the searchable text.
  • Sync with events on create, update and delete, plus a nightly reconciliation job that catches anything the events missed.

What Semantic Product Search Actually Fixes

Default store search matches words. That fails in three predictable ways:

  • Vocabulary mismatch. The shopper types "couch", your catalog says "sofa". The shopper types "trainers", you sell "sneakers". Zero results.
  • Descriptive queries. "Warm jacket for hiking in the rain" contains no product name at all. Keyword search ranks whatever happens to contain the word "jacket" first.
  • Language. A shopper searching in Spanish gets nothing from an English catalog.

Semantic search turns both the query and each product into embeddings, which are vectors that capture meaning, and returns the closest products. "Couch" and "sofa" land next to each other. If you want the full background, see keyword vs semantic search.

What it doesn't fix on its own: exact identifiers. Search "SKU-48213" with pure semantic search and you may get products with similar-looking codes instead of the exact one. That's why the architecture below uses hybrid search.

The Architecture

Here's the whole thing:

                 ┌────────────────────┐
                 │  Product database  │  source of truth
                 │ (Postgres, Shopify │
                 │   or your PIM)     │
                 └─────────┬──────────┘
          product.created / updated / deleted
                           │
                 ┌─────────▼──────────┐
                 │    Sync worker     │  builds search documents
                 └────┬──────────┬────┘
                      │          │
           ┌──────────▼───┐  ┌───▼───────────┐
           │  Text index  │  │  Image index  │
           │ (1 doc per   │  │ (1 doc per    │
           │   product)   │  │ product photo)│
           └──────────▲───┘  └───▲───────────┘
                      │          │
                 ┌────┴──────────┴────┐
                 │  Search endpoint   │  parses query, runs searches,
                 │  (your backend)    │  merges, hydrates from DB
                 └─────────▲──────────┘
                           │
                 ┌─────────┴──────────┐
                 │    Storefront      │
                 └────────────────────┘
Component What it stores Why it exists
Product database Everything: prices, stock, variants, descriptions, images Source of truth. Search never writes to it.
Sync worker Nothing permanent Turns product changes into search documents and keeps the indexes current
Text index One document per product: searchable text plus filter metadata Hybrid text search, autocomplete
Image index One document per product photo, with product_id in metadata Text-to-image search, "search by photo", similar products
Search endpoint Nothing Parses filters, merges text and image results, fetches live prices and stock

Two rules make this design hold up as the catalog grows.

The index is disposable. It only holds what search needs. If you change your embedding text, add a new filter or move providers, you rebuild it from the product database. Nothing is lost, because nothing in it is original.

Results are IDs, not truth. The search response tells you which products match. Prices, stock and anything else that changes often get read from your database when you render results. Otherwise you'll eventually show a shopper a price that changed an hour ago.

Step 1: Decide What Goes Into the Searchable Text

This is the decision that affects relevance the most, and it's easy to get wrong.

The tempting move is to embed the full product description. Don't. Descriptions are full of marketing copy ("elevate your everyday style"), shipping notes and size charts. That text dilutes the meaning of the product, so a search for "linen shirt" competes with every product that mentions "breathable fabric for summer".

Build a compact document from structured fields instead, most important first:

function toSearchText(p: Product): string {
  return [
    p.title,
    p.brand,
    p.category,
    p.attributes.color,
    p.attributes.material,
    p.attributes.style,
    p.shortDescription,
  ]
    .filter(Boolean)
    .join('. ');
}

For a typical product that gives you something like:

Relaxed Linen Shirt. Northfield. Men's shirts. Off-white. 100% linen. Casual, relaxed fit. Lightweight button-down for warm weather.

A few things we've learned from catalogs on Vecstore:

  • Titles carry the most signal. If your titles are vague ("The Classic"), fix the titles. No search engine can make up for them.
  • Include the category in words. "Men's shirts" helps the model separate a shirt from a shirt-print poster.
  • Keep it short. A few sentences is plenty. Long documents don't make search smarter, they make it vaguer.
  • Write it in your catalog's main language. Multilingual embedding models match a Spanish query to an English product directly, so you don't need translated copies. See multilingual semantic search.

Step 2: Put Filters in Metadata, Not Text

Anything shoppers filter or sort on belongs in metadata: category, brand, in-stock, gender, price. Metadata filters are exact and fast. Putting "in stock" into the searchable text and hoping the model figures it out does not work.

function toMetadata(p: Product) {
  return {
    product_id: p.id,
    category: p.categorySlug,
    brand: p.brandSlug,
    in_stock: p.inventory > 0,
    price_band: priceBand(p.price),
  };
}

Price ranges need a small trick. Vecstore's metadata filters support exact values and $in, not numeric ranges. So instead of storing a raw price and filtering with "less than 100", store a band and filter on the set of bands:

const BANDS = [25, 50, 100, 200, 500];

function priceBand(price: number): string {
  const upper = BANDS.find((b) => price < b);
  return upper ? `under-${upper}` : '500-plus';
}

function bandsBelow(max: number): string[] {
  return BANDS.filter((b) => b <= max).map((b) => `under-${b}`);
}

A query with "under $100" becomes price_band: { $in: ["under-25", "under-50", "under-100"] }. Then, when you hydrate results from your database, apply the exact price check for edge cases. Bands keep the search request simple, and your database keeps the final word.

Step 3: Index Images Separately

Product photos go into a separate image index, one document per photo, with the product ID in metadata:

await fetch(`${BASE_URL}/databases/${IMAGE_DB}/documents/batch`, {
  method: 'POST',
  headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    documents: product.images.map((img) => ({
      image_url: img.url,
      metadata: { product_id: product.id, category: product.categorySlug, in_stock: product.inventory > 0 },
    })),
  }),
});

Why a separate index instead of mixing photos into the text one:

  • Text-to-image search. A query like "floral summer dress with puff sleeves" is often answered better by the photos than by the product copy, especially when copy is thin.
  • Search by photo. Shoppers upload a screenshot from Instagram and get matching products. The same index handles it.
  • Similar products. "More like this" on every product page is one call, using the stored photo's vector_id. No query embedding needed.

Index the main photo and one or two clean alternates. Skip lifestyle shots with five products in the frame and size-chart images. They match everything, which means they match nothing well.

Step 4: Build the Search Endpoint

You have two options for where search runs, and you'll probably use both.

From the browser, with a search-only key. Vecstore API keys can be limited to search and autocomplete, restricted to specific databases and allowed origins, and given an expiry date. A key like that can't insert, delete or read account data, so it's safe to ship in your storefront JavaScript. This is the right choice for autocomplete, where every millisecond counts and there's nothing to merge or hydrate.

From your backend, with a key that never leaves the server. Full results pages need a place to parse filters, combine text and image results, and read live prices and stock from your database. That logic belongs on your server, and so do keys with write access, which your sync worker uses.

A typical setup: autocomplete calls Vecstore directly from the search box with a search-only key scoped to the text database and your domain. Pressing Enter calls your own search endpoint.

The endpoint does four things: parse the query, run the searches, merge the results, and hydrate them from your database.

const SKU_PATTERN = /^[A-Z]{2,5}-?\d{3,}$/i;

export async function searchProducts(raw: string) {
  const { text, maxPrice } = parseQuery(raw);
  const filters = {
    in_stock: true,
    ...(maxPrice ? { price_band: { $in: bandsBelow(maxPrice) } } : {}),
  };

  if (SKU_PATTERN.test(text)) {
    const exact = await search(TEXT_DB, { content: text, mode: 'keyword', top_k: 5 });
    return hydrate(exact.results.map((r) => r.metadata.product_id));
  }

  const [textRes, imageRes] = await Promise.all([
    search(TEXT_DB, { content: text, metadata: filters, top_k: 50 }),
    search(IMAGE_DB, { content: text, metadata: filters, top_k: 50, min_score: 0.3 }),
  ]);

  const ids = mergeByProduct([textRes.results, imageRes.results]);
  return hydrate(ids.slice(0, 24));
}

function parseQuery(raw: string) {
  const match = raw.match(/under \$?(\d+)/i);
  return {
    text: raw.replace(/under \$?\d+/i, '').trim(),
    maxPrice: match ? Number(match[1]) : undefined,
  };
}

search is a thin wrapper around POST /databases/{id}/search. The text index uses the default hybrid mode, which scores both meaning and exact terms, so "nike running shoes" rewards products that actually say Nike. Image indexes always search by meaning.

Merging Text and Image Results

The two searches return scores on different scales, so don't sort by raw score. Use reciprocal rank fusion: each result contributes based on its position in its list, and a product that appears in both lists rises to the top.

function mergeByProduct(lists: { metadata: { product_id: string } }[][], k = 60): string[] {
  const scores = new Map<string, number>();

  for (const list of lists) {
    const seen = new Set<string>();
    list.forEach((r, rank) => {
      const id = r.metadata.product_id;
      if (seen.has(id)) return;
      seen.add(id);
      scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1));
    });
  }

  return [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}

The seen set matters: one product can have three photos in the image results, and you only want to count its best one. Without it, products with more photos win just for having more photos.

If you want to weight text higher than images, which is a sensible default when your product copy is good, multiply each list's contribution by a weight. Start with equal weights and adjust once you're measuring (see Step 7).

Step 5: Keep the Index in Sync

This is where most homegrown search setups quietly break: a product is deleted but stays searchable, or a price band changes and the filter is wrong for weeks.

Handle three events:

async function onProductUpserted(p: Product) {
  await deleteByFilter(TEXT_DB, { product_id: p.id });
  await deleteByFilter(IMAGE_DB, { product_id: p.id });
  if (!p.published) return;

  await insert(TEXT_DB, { content: toSearchText(p), metadata: toMetadata(p) });
  await insertImages(IMAGE_DB, p);
}

async function onProductDeleted(id: string) {
  await deleteByFilter(TEXT_DB, { product_id: id });
  await deleteByFilter(IMAGE_DB, { product_id: id });
}

Delete-then-insert keyed on product_id means you never have to store Vecstore's vector_ids in your own database. The product_id in metadata is the only link you need.

Two details that save you pain later:

  • Skip no-op updates. Stock counts change constantly. Only re-index when the search text or a filter value actually changes. Hash toSearchText(p) plus toMetadata(p) and compare it with the last hash you indexed.
  • Inserts are asynchronous. A document becomes searchable a few seconds after the insert returns. If you need to confirm it landed, the status endpoint is free to call. Failed inserts report a reason and the credit is refunded.

The Nightly Reconciliation Job

Webhooks get dropped. Deploys restart workers mid-queue. Once a night, compare what's in the index with what should be there.

A metadata-only search (a filter with no query) lists matching documents and costs nothing, so you can page through the index for free and compare product IDs with your database. Delete any IDs that no longer exist and re-index any that are missing. It's 40 lines of code, and it means index drift is fixed overnight instead of being found by a customer.

Step 6: Plan for Growth

Here's what changes as the catalog grows, and what doesn't.

Catalog size What to think about
~10K products Nothing special. A full re-index is a few minutes of batch inserts.
~100K products Batch inserts (up to 100 documents per request, under 10 MB), a few requests in parallel. Skip no-op updates or stock changes will dominate your volume.
~1M products Re-index in the background into a new database and switch over, rather than rebuilding in place. Watch image sizes: downscale photos before indexing, since the model only sees 384×384 pixels anyway.

What doesn't change: query latency. The vector lookup stays in single-digit milliseconds at these sizes. In our own production setup, the database query is around 5ms and embedding the query is around 10ms, so the rest of your latency budget is network and your own hydration query. We wrote up the numbers behind this in the pgvector benchmark.

On cost: each insert and each search is one credit. A 10,000-product catalog with two photos per product is about 30,000 credits to index once, which works out to roughly $30–66 depending on plan. After that, you pay for changes and searches, not for the size of the catalog sitting in the index. Autocomplete is billed at 1 credit per 25 requests, so it's fine to call on every keystroke.

Run it on your own infrastructure instead? You can. The pieces are an embedding model (served on a GPU for images), a vector database, a BM25 index for the keyword half of hybrid, and the fusion logic. It's a reasonable choice with an ML engineer on the team. For everyone else, the hidden cost is operations, not compute, which we broke down in the hidden costs of self-hosting a vector database.

Step 7: Measure Relevance Before You Tune Anything

The most common mistake after launch is tuning by gut feeling: someone searches one query, dislikes the result and changes a weight. Next week someone else does the same in the other direction.

Measure instead:

  1. Build a test set. Take 50 to 100 real queries from your search logs: the most common ones plus a sample of long, descriptive ones. For each, write down the two or three products that should appear.
  2. Track two numbers. How often the right product is in the top 5, and the zero-result rate in production.
  3. Change one thing at a time. Search text, text-vs-image weight, min_score. Rerun the test set after each change.

The zero-result rate is the quickest health check. With hybrid search it should be close to zero for anything resembling a product. When it isn't, the cause is almost always missing data, such as a product type you don't carry. That's useful merchandising information in itself.

Mistakes We See Most Often

  • Embedding the full description. Covered above, and still the number one cause of "semantic search feels vague".
  • Pure semantic mode for everything. SKUs, model numbers and exact brand names need keyword matching. Hybrid handles both.
  • Filtering in the browser. Fetching 100 results and filtering out-of-stock items on the client means a page that should show 24 products shows 6. Filter in the search request.
  • No reconciliation. Deleted products showing up in search for months.
  • Storing prices in the index and trusting them. Index a price band for filtering. Always show the price from your database.
  • Indexing every photo. Lifestyle and size-chart images add noise to image search. Index the clean product shots.
  • Shipping a full-access key to the browser. Use a search-only key scoped to the databases and domains it needs. Keys that can write stay on the server.

Where to Start

For a 10,000-product store, the first version takes about a day:

  1. Create one text database and one image database, a full-access key for your server and a search-only key for the storefront.
  2. Write toSearchText, toMetadata and a backfill script that batch-inserts your catalog.
  3. Add the search endpoint with hybrid search, the SKU shortcut and in-stock filtering.
  4. Wire up product webhooks and the nightly reconciliation.
  5. Add autocomplete to the search box and "similar products" to product pages, using the same indexes.

If you're on Shopify or WooCommerce, the platform-specific guides cover catalog sync for each: Shopify, WooCommerce. For a Next.js storefront, see how to add semantic search to Next.js.

You can try Vecstore free with 100 credits and no credit card. That's enough to index a sample of your catalog and run your own queries against it before deciding anything.

FAQ

What is semantic product search?

Semantic product search matches what a shopper means rather than the exact words they type. A search for "couch for a small apartment" finds compact sofas even if no product uses the word "couch". It works by comparing embeddings of the query and of each product.

Is semantic search better than keyword search for ecommerce?

For descriptive and natural-language queries, yes. For SKUs, model numbers and exact brand names, keyword search is more precise. Most stores should use hybrid search, which combines both in one ranking.

How many products do I need for semantic search to be worth it?

There's no minimum. Even a few hundred products benefit, because the problem it fixes (shoppers using different words than your catalog) exists at any size. The architecture in this post works the same from 1,000 to 1,000,000 products.

Should I embed the full product description?

Usually not. Build a short document from the title, brand, category, key attributes and a one-line description. Long marketing copy dilutes relevance.

How do I filter by price with semantic search?

Store price as a band in metadata (for example under-50, under-100) and filter on the set of bands that fits the query. Then apply the exact price check when you load results from your database.

How do I keep the search index in sync with my catalog?

Re-index a product on every create, update and delete event, keyed by product ID, and run a nightly job that compares the index with your database to catch anything missed.

Do I need a vector database for semantic product search?

Not necessarily. You need embeddings, a vector index, keyword matching and ranking. You can assemble those yourself around a vector database, or use a search API that includes all of them. See do you need a vector database?.

← Back to all posts

Better search for your product without the engineering overhead.

3–4ms average search latency

Sign up for Vecstore
Start for Free

100 Free credits. No credit card required.