For e-commerce product search, use a two-speed retrieval architecture: a small, fast index for price and availability, plus a slower semantic index for descriptive content. Set freshness windows per field, then prove the choice with replayed queries and freshness-aware metrics.
That is the result I would ship for a customer-support or catalog-search system. A single nightly rebuild is easy to explain, but it makes a saleable item look unavailable for hours. Re-embedding every edit is current, yet wasteful and difficult to operate. The useful middle is an explicit freshness contract.
What should a freshness window mean in product search?
A freshness window is a maximum tolerated age for a piece of indexed information. It is not a promise that every search sees the database transaction immediately. That distinction matters when inventory changes several times a minute but product descriptions change once a quarter.
I split the document into independently updated records:
| Field | Example change | Target window | Retrieval treatment |
|---|---|---|---|
| Availability and price | stock count, sale price | seconds to 1 minute | filter or keyword index |
| Product facts | size, material, compatibility | 5 to 30 minutes | lexical plus metadata index |
| Description and support text | care guide, warranty prose | hours to a day | embeddings and hybrid search |
The numbers are starting points, not universal settings. Your mileage may vary. A flash-sale catalog needs a tighter contract; a long-tail marketplace with sparse edits may not. Write the window next to the field owner, because an undocumented number becomes a surprise during an incident.
How do retrieval architecture and freshness windows work together?
The ingestion path should carry both content and version metadata. A catalog event gets an immutable product revision, an event timestamp, and a source-of-truth pointer. The fast path upserts a compact record immediately. The semantic path consumes the same revision asynchronously, chunks the text, computes embeddings, and records the embedding model and chunking policy.
Here is a deliberately small Python shape for that contract. It uses generic interfaces so the storage and vector systems can be swapped without rewriting the indexing policy.
from dataclasses import dataclass from datetime import datetime, timezone from typing import Protocol @dataclass(frozen=True) class ProductRevision: product_id: str revision: int changed_at: datetime title: str description: str price_cents: int in_stock: bool class FastIndex(Protocol): def upsert(self, revision: ProductRevision) -> None: ... class SemanticQueue(Protocol): def publish(self, revision: ProductRevision) -> None: ... def ingest(revision: ProductRevision, fast: FastIndex, semantic: SemanticQueue) -> None: fast.upsert(revision) semantic.publish(revision) The revision number prevents an older queue message from overwriting newer text. Consumers should be idempotent: processing revision 42 twice must leave the same state as processing it once. Keep the old semantic chunks until the new revision is committed, then delete by (product_id, revision); this avoids a brief empty result during re-indexing.
Chunking is part of freshness, too. Smaller chunks reduce the amount of text to re-embed after a one-line warranty edit, while overly small chunks lose the relationship between a product and its compatibility notes. I start with headings and paragraph boundaries, cap chunks by tokens, and preserve product_id, category, locale, and revision as metadata rather than repeating them in every chunk.
The experiment: nightly rebuild versus incremental ingestion
The simple approach was a scheduled full rebuild. It had a clean operational story and a nasty search consequence: a product removed at 09:02 remained retrievable until the next run. The chosen design applied availability filters from the fast index while semantic chunks caught up in the background. During the overlap, a result could have fresh stock status and slightly stale prose, which is preferable to recommending an item that cannot be bought.
I would not copy that choice from a blog post. Build a replay set from real catalog events, then measure recall at fixed age thresholds. Track stale-result rate, p95 event-to-search latency, duplicate upserts, embedding tokens, and the fraction of queries whose answer crosses a freshness boundary. Include adversarial sequences: price down then up, delete then restore, and two revisions arriving out of order.
A small evaluator can make the contract executable:
from dataclasses import dataclass from datetime import timedelta @dataclass(frozen=True) class SearchResult: product_id: str indexed_at: datetime in_stock: bool def is_fresh(result: SearchResult, observed_at: datetime, window: timedelta) -> bool: age = observed_at - result.indexed_at return age <= window and age >= timedelta(0) The evaluator should report failures by field, not only by query. Otherwise a good semantic recall score can hide a bad availability contract. I also sample the queue lag and index commit lag separately; combining them makes it hard to tell whether the bottleneck is embedding work, transport, or storage.
Failure modes that look like relevance problems
Stale metadata often gets blamed on the ranker. A discontinued SKU can win because its description is an excellent semantic match. Apply hard filters from the freshest authoritative record before ranking, and return a reason when a candidate is removed. That makes support tickets diagnosable.
Another trap is deleting by document text. Text changes, so a delete request built from the new text misses the old vector. Stable product and revision identifiers make cleanup deterministic. Locale drift is similar: an English update must not silently replace the French chunk set.
Backpressure needs a policy. When embedding capacity falls behind, prioritize revisions that affect availability-adjacent facts, coalesce multiple text edits for the same product, and expose the age of the oldest pending revision. Do not hide lag behind a green ingestion counter.
Here is the failure sequence I test first: revision 42 lowers the price, revision 43 removes the item, and the queue delivers 43 before 42. The fast index accepts 43 and marks the SKU unavailable. When 42 finally arrives, its revision check rejects the stale write, while the semantic worker can still finish 43's text. A replay should show one unavailable result throughout, zero stale overwrites, and a measurable queue age. If it does not, the problem is ordering or idempotency, not ranking quality.
The catch is operational complexity. A two-speed system is not suitable when the team cannot own event ordering, replay, and freshness monitoring; stick with a simpler rebuild and a clear maintenance banner in that case. It is also a poor fit for tiny catalogs where a full rebuild finishes inside the required window. The architecture earns its keep only when freshness and semantic coverage pull in different directions.
Choose the shortest window that the business can defend with an observable metric, then assign an indexing path that can meet it without starving the others. If availability must be current in under a minute, it belongs in a transactional or keyword path with filters. If prose can be an hour old, asynchronous chunking and embedding are reasonable. If neither window is known, run a one-week event replay before selecting infrastructure.
Ship the contract.
RAG research describes retrieval as a separate source of evidence for generation, which is useful here: retrieval quality is a system property, not an embedding-model score. In product search, freshness is part of relevance. A beautiful vector match for an unavailable product is a wrong answer.
