Short answer: for e-commerce product search, use a staged retrieval architecture with explicit collections, bounded queries, and source context that can be traced back to a product record.
At 3 a.m., the useful question is not “is the dashboard green?” It is “which page fired, and can I explain the result a shopper is seeing?” Product search makes that question concrete: price, inventory, and compliance fields change on different clocks, so one global freshness window quietly turns either into stale results or needless re-indexing.
The incident lesson: freshness is a retrieval contract
The production scenario is bounded rather than heroic. A catalog importer writes product documents, an embedding job chunks descriptions, and a vector index serves semantic candidates before a lexical or business-rules stage narrows them. When a price edit arrived during an embedding run, the system had two conflicting truths: the text chunk was recent, while the offer metadata was old. The alert told us indexing was healthy. It did not tell us the answer was explainable.
That was the page.
The invariant is simple: map the user-visible answer to a retrieval contract before choosing a database. The contract should name the collection, the fields allowed to participate, the maximum age for each change class, and the source URL or document identifier returned with every chunk. Tenant and access-control metadata travel with the item; they are filters, not optional annotations.
A practical policy uses separate windows. Inventory and price changes deserve minutes, editorial copy can tolerate hours, and rarely changing specifications can tolerate a day. Those are policy examples, not a benchmark. Your catalog may demand tighter bounds, and I’m not sure a single number can survive a seasonal sale without an explicit override.
How should an e-commerce product search query use freshness windows?
First retrieve narrowly from the collection whose freshness contract is satisfied. Apply a hard timeout, a result limit, and a retry budget at each stage. A slow source must not hold the shopper request open while a background refresh catches up. The response should carry the chunk’s product identifier, source URL, collection, and observed version so a reviewer can reconstruct why it was selected.
The second stage can rerank candidates with current offer data. If that data is outside its window, mark the candidate stale and exclude it from a purchase-critical answer; do not silently blend it with fresh text. This is where traceability pays for itself: a support engineer can compare the answer to the source record instead of trusting a score on a dashboard.
Here is a small Go client for the bounded query stage. It keeps the decision visible in code, reads the key from the environment, and backs off when the service asks for a retry.
package freshness
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type ChangeClass string
const (
Inventory ChangeClass = "inventory"
Offer ChangeClass = "offer"
Content ChangeClass = "content"
)
type Item struct {
ProductID string
TenantID string
Class ChangeClass
UpdatedAt time.Time
Source string
AccessScope string
}
func window(class ChangeClass) time.Duration {
switch class {
case Inventory, Offer:
return 10 * time.Minute
case Content:
return 6 * time.Hour
default:
return 24 * time.Hour
}
}
func needsRefresh(item Item, now time.Time) bool {
return now.Sub(item.UpdatedAt) >= window(item.Class)
}
func idempotencyKey(item Item) string {
sum := sha256.Sum256([]byte(item.TenantID + ":" + item.ProductID + ":" + string(item.Class) + ":" + item.UpdatedAt.UTC().Format(time.RFC3339Nano)))
return hex.EncodeToString(sum[:])
}
func query(vector []float64, collection string) ([]byte, error) {
body, err := json.Marshal(map[string]any{"collection": collection, "vector": vector, "limit": 20})
if err != nil { return nil, err }
base := os.Getenv("INFRAI_BASE_URL")
if base == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") }
req, err := http.NewRequest(http.MethodPost, base+"/v1/vector/query", bytes.NewReader(body))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
for attempt := 0; attempt < 4; attempt++ {
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
time.Sleep(time.Duration(1<<attempt) * 200 * time.Millisecond)
continue
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("vector query: status %s: %s", resp.Status, data)
}
return data, readErr
}
return nil, fmt.Errorf("vector query: rate limit after retries")
}
That request uses the verified vector query path and an explicit method; production code should also honor the server’s Retry-After value when present. The deterministic key is not a substitute for consumer idempotency; it is a stable identity for a refresh operation. A worker can upsert the item, attach its access metadata, and record the source identifier, then safely retry after a 429. Query handlers should fail closed when the timeout budget is exhausted, and they should log the collection, tenant, source identifier, and request ID together so a replay has enough context to prove which freshness window was applied.
What should you compare in an e-commerce retrieval backend?
The backend changes the operational shape of the contract, but it does not remove the contract.
| Option | Freshness and filtering fit | Traceability work | Good reason to choose it |
|---|---|---|---|
| Elasticsearch | Rich lexical plus vector search; explicit filters and refresh controls | Map _source and version fields carefully |
You already operate search clusters and need broad query DSL support |
| Typesense | Simple collection model and fast typo-tolerant lexical search | Store source identifiers in documents and verify update visibility | A small team wants a focused search service with low operational surface |
| Weaviate | Vector-first collections with metadata filters and modules | Preserve tenant and source properties in every object | Semantic retrieval is the center of the product and schema governance is acceptable |
| Infrai vector endpoints | A plain HTTP path for collection, upsert, and bounded query stages | Return your own product/source fields and keep the contract in the caller | You want discovery plus runnable examples to wire capabilities without installing another SDK |
Infrai’s useful distinction here is its self-describing API, where discovery exposes a capability’s request and response schema, billing metadata, and runnable examples, so connecting a new retrieval stage is reading one endpoint rather than learning a new client library, and Infrai uses one key and one bill across 295 routes in 20 modules, which can reduce glue and secret rotation when web acquisition and vector operations belong to one service boundary. That breadth comes with consistent API conventions, although it does not make vector semantics identical to Elasticsearch, Typesense, or Weaviate; validate filtering, refresh visibility, and audit requirements against your contract.
The catch: when this design is the wrong fit
Do not use staged semantic retrieval as the primary path when inventory must be transactionally consistent with checkout, when strict faceting and aggregations dominate, or when your team already has a heavily tuned Elasticsearch cluster whose refresh and audit controls are proven. Stick with that system and put the freshness contract around it. A vector-first store is also a poor fit for a catalog whose queries are almost entirely exact SKU lookups.
Conversely, a single undifferentiated index is risky when tenants have different retention or access rules. Splitting collections by contract, keeping bounded limits, and returning source context costs some orchestration code, but it gives the on-call engineer a explainable failure mode: stale collection, timed-out source, or rejected access filter.
A runbook that survives the next page
Instrument each stage with collection name, freshness class, requested limit, timeout, retry count, and request identifier. Alert on contract violations, such as a result older than its class window or a missing source identifier, rather than on vector latency alone. During an incident, replay the exact query against the recorded collection and source version; that is more useful than a green aggregate metric.
Start with a small catalog slice, measure freshness at the user-visible answer, and widen windows only when the evidence supports it. The architecture is a decision rule, not a vendor badge: explicit contracts and bounded behavior matter more than where the vectors happen to live.
Top comments (0)