n4nAI

Building semantic product search with LlamaIndex

Step-by-step tutorial to build semantic product search with LlamaIndex using local embeddings, metadata filters, and persistence for e-commerce catalogs.

n4n Team3 min read750 words

Audio narration

Coming soon — every post will get a voice note here.

Building semantic product search llamaindex-style means turning a static catalog into a retrieval system that understands intent, not just keywords. This hands-on tutorial takes a small product JSON file from disk to a queryable vector index, then layers in metadata filters and persistence. You’ll run every snippet locally without spending on API calls.

Prerequisites

  • Python 3.10 or newer
  • Basic familiarity with Python and pip
  • No external API keys required for the core build; we use local HuggingFace embeddings
  • Install the minimal packages:
pip install llama-index-core llama-index-embeddings-huggingface

If you later swap to a hosted embedding model, add llama-index-embeddings-openai.

The product catalog

We’ll use a tiny catalog of six items. Save as products.json:

[
  {"id": "p1", "name": "Trail Runner Pro", "description": "Lightweight breathable running shoes for off-road trails", "category": "shoes", "price": 89.99},
  {"id": "p2", "name": "City Jogger", "description": "Cushioned road running shoes for daily commute", "category": "shoes", "price": 49.99},
  {"id": "p3", "name": "Summer Floral Dress", "description": "Airy cotton dress with floral pattern for hot days", "category": "apparel", "price": 34.99},
  {"id": "p4", "name": "Insulated Bottle", "description": "Keeps drinks cold 24h, fits bike cage", "category": "accessories", "price": 19.99},
  {"id": "p5", "name": "Waterproof Hiking Jacket", "description": "Seam-sealed shell for heavy rain in mountains", "category": "apparel", "price": 129.99},
  {"id": "p6", "name": "Yoga Mat", "description": "Non-slip biodegradable mat for home practice", "category": "accessories", "price": 29.99}
]

In a real system this file is replaced by a database dump or a stream from your PIM. The shape matters: keep a human-readable text field and push structured attributes into metadata.

Load and embed

LlamaIndex treats each product as a Document. We concatenate the name and description into the document text and attach category and price as metadata so we can filter later. The embedding model runs locally via HuggingFaceEmbedding—no network calls, no API keys.

import json
from llama_index.core import Document, VectorStoreIndex, Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-en-v1.5")

with open("products.json") as f:
    products = json.load(f)

docs = [
    Document(
        text=f"{p['name']}: {p['description']}",
        metadata={"id": p["id"], "category": p["category"], "price": p["price"]},
    )
    for p in products
]

index = VectorStoreIndex.from_documents(docs)

The bge-small-en-v1.5 model is roughly 130MB and gives decent English embeddings for dev work. It indexes the concatenated text as a single vector; we are not chunking because each product is short. For longer product blurbs, set chunk_size=256 on the loader or use a SentenceSplitter node parser.

Query the index

For semantic product search llamaindex retriever is enough—we want the matching products, not a synthesized paragraph. Use as_retriever and inspect the returned nodes. The default in-memory vector store returns nodes ranked by cosine similarity.

retriever = index.as_retriever(similarity_top_k=3)
results = retriever.retrieve("something for running on pavement")

for r in results:
    print(r.node.metadata["id"], "|", r.node.text, f"| score={r.score:.3f}")

Expected output (scores vary slightly by model version):

p2 | City Jogger: Cushioned road running shoes for daily commute | score=0.712
p1 | Trail Runner Pro: Lightweight breathable running shoes for off-road trails | score=0.658
p4 | Insulated Bottle: Keeps drinks cold 24h, fits bike cage | score=0.402

The retriever ranked the road shoe first, then the trail shoe, then an unrelated bottle. That is correct semantic behavior: “running on pavement” maps to “road running shoes” and “daily commute”. The bottle scores far lower but still surfaces because the vector space is small.

Understand the scores

LlamaIndex returns score as the similarity metric used by the vector store. For the default SimpleVectorStore with cosine similarity, higher is better and values land between -1 and 1 after normalization. Don’t treat the absolute number as a probability. If you need a hard cutoff, wrap the retriever with a SimilarityPostprocessor:

from llama_index.core.postprocessor import SimilarityPostprocessor

retriever = index.as_retriever(
    similarity_top_k=5,
    node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.5)],
)

This drops the bottle in the earlier example, leaving only the two shoes.

Add metadata filters

A real store filters by category or price. LlamaIndex passes MetadataFilters to the underlying vector store. The default in-memory store supports exact matches and numeric ranges.

from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter, RangeFilter

filters = MetadataFilters(
    filters=[
        ExactMatchFilter(key="category", value="shoes"),
        RangeFilter(key="price", gt=0, lt=60),
    ]
)

filtered_retriever = index.as_retriever(similarity_top_k=5, filters=filters)
shoe_results = filtered_retriever.retrieve("comfortable shoes for the city")

for r in shoe_results:
    print(r.node.metadata["id"], r.node.metadata["price"], r.node.text)

Expected output:

p2 49.99 City Jogger: Cushioned road running shoes for daily commute

Only the sub-$60 shoe appears. The trail runner at $89.99 is excluded by the range filter before ranking happens. Note: not every vector database supports RangeFilter with the same semantics—pgvector does via metadata JSONB, but some managed stores require pre-defined scalar fields.

Persist the index

Re-embedding on every boot wastes compute. Persist the index to disk and reload it in a separate process.

index.storage_context.persist(persist_dir="./storage")

# Later, in a new process:
from llama_index.core import StorageContext, load_index_from_storage

ctx = StorageContext.from_defaults(persist_dir="./storage")
reloaded = load_index_from_storage(ctx)

The serialized artifacts include the vector store, document store, and index metadata. On reload, queries run against the frozen embeddings. If your catalog changes, call index.refresh_ref_docs(docs) or rebuild—LlamaIndex does not auto-sync external JSON.

Production swap: embeddings and LLMs

Local models are great for dev, but production catalogs need stronger embeddings and possibly an LLM to rewrite ambiguous queries. LlamaIndex speaks the OpenAI client protocol, so any OpenAI-compatible endpoint drops in without code changes to your indexing logic.

If you route through an inference gateway such as n4n.ai, the same OpenAI embedding client works and you get automatic fallback when a provider is rate-limited or degraded, plus per-token metering. Configure it by pointing api_base at the gateway and setting your key.

from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI

Settings.embed_model = OpenAIEmbedding(
    model="text-embedding-3-small",
    api_base="https://api.n4n.ai/v1",  # OpenAI-compatible
    api_key="YOUR_KEY",
)
Settings.llm = OpenAI(
    model="gpt-4o-mini",
    api_base="https://api.n4n.ai/v1",
    api_key="YOUR_KEY",
)

After this swap, the VectorStoreIndex.from_documents call embeds against the remote model; the rest of the semantic product search llamaindex code stays identical. The gateway forwards provider cache-control hints, so repeated catalog embeds with stable IDs can hit cache and cut cost.

Scaling beyond six items

The in-memory store is fine for a demo but falls over at 100k SKUs. Swap StorageContext for a persistent vector database:

from llama_index.vector_stores.milvus import MilvusVectorStore

vector_store = MilvusVectorStore(uri="http://localhost:19530", collection_name="products")
storage_ctx = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_documents(docs, storage_context=storage_ctx)

The retriever and filter APIs do not change. You only pay for the vector DB query latency instead of linear scans.

Recap

You now have a runnable pipeline: load JSON, embed locally with LlamaIndex, retrieve semantically, filter by metadata, persist to disk, and optionally move to a managed embedding endpoint. The core retrieval logic—the part that makes semantic product search llamaindex-powered—is unchanged whether you run on a laptop or behind a gateway serving 240+ models. From here, add a thin API layer (FastAPI) and a front-end search box, and you have a production-grade storefront search.

Tagsllamaindexecommercesemantic-search

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All framework tutorials: e-commerce search & recommendations posts →