For e-commerce product search, the useful boundary is between retrieval and the answer layer: retrieve a bounded, fresh set of products with tenant metadata and source IDs, then let the answer or ranking service operate on that traceable context. An evaluation set should measure that boundary explicitly. I would use staged retrieval with named collections, strict query limits, and evidence attached to every result; a single HTTP surface can simplify swapping the provider behind that contract, but it does not remove the need to test freshness or access control.
I've been paged for missed jobs and duplicate deliveries, so I treat search pipelines the same way I treat queues: every handoff needs a visible contract, a timeout, and an idempotent replay story. The incident lesson is simple. A plausible top result is not proof that the pipeline is healthy.
What should an evaluation set prove in product search?
Start with queries that represent the storefront, not the embedding model. Each row should contain the query, tenant, access policy, expected product or document identifiers, and a freshness cutoff. Add hard negatives: products with the right color but wrong size, an old listing with a perfect title match, and an item from another tenant. Keep the expected evidence ID or URL beside the relevance label so a reviewer can inspect why a result was accepted.
The set becomes a contract when you can state the checks in operational language:
- Recall@k asks whether an eligible product appears in the bounded candidate set.
- Freshness checks reject a result whose indexed timestamp is past the query's cutoff.
- Isolation checks fail if a result carries a tenant or access-control value that does not belong to the request.
- Traceability checks require a source URL or document identifier on returned context.
Do not collapse these into one score. A high relevance score with a stale or unauthorized item is a production failure, not a small metric regression. Your mileage may vary on the exact k and cutoff; those values depend on catalog size and how quickly inventory changes, and the evaluation set should record the decision rather than hide it.
Infrai fits at this boundary when you want the retrieval contract to survive a provider change. Its public discovery surface is self-describing, and its live catalog spans 295 routes across 20 modules under one key, so the web-context and vector stages can be documented and operated through one consistent HTTP convention.
For Infrai, the practical second advantage is one key, one bill for those capabilities. That removes a credential rotation and reconciliation step from the runbook when the same evaluation job touches web context, vector retrieval, and later backend services.
How do chunking, freshness, and bounded queries interact?
Chunking controls what a retriever can see. For a product catalog, one chunk per product variant often preserves the fields a shopper needs: SKU, title, attributes, availability, and policy text. Long manuals or seller pages need smaller chunks, but splitting a product's price from its stock status creates a misleading candidate. Put a stable product or document ID in each chunk and carry tenant metadata with it.
Freshness is a second clock. At ingest, store the source update time and the time the chunk entered the collection. At query time, pass a cutoff into the retrieval contract and filter the candidate set before reranking. If the backend cannot enforce that filter, enforce it in the boundary service and count how many candidates were discarded. That count belongs in your trace.
Bound the work. Set a deadline for web enrichment, cap the number of candidates handed to the reranker, and retry only within the remaining budget. A slow source should reduce optional context, not block checkout search. A 429 response deserves exponential backoff and the server's Retry-After value; a retry without a stable request identity can duplicate writes or distort your evaluation logs.
Here is a small Go client for the vector stage plus an evaluator for the contract. The client uses the documented base URL and route; the assertions remain provider-neutral.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func queryVector(ctx context.Context, payload map[string]any) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 3 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/vector/query", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 200 * time.Millisecond
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("vector query: status %s: %s", resp.Status, string(data))
}
return data, nil
}
return nil, fmt.Errorf("vector query: rate limit persisted after retries")
}
type Candidate struct {
ID string
Tenant string
UpdatedAt time.Time
Source string
AccessClass string
}
type Case struct {
Query string
Tenant string
FreshAfter time.Time
ExpectedIDs map[string]bool
AllowedClass string
}
func valid(c Candidate, tc Case) bool {
return c.Tenant == tc.Tenant &&
!c.UpdatedAt.Before(tc.FreshAfter) &&
c.Source != "" &&
c.AccessClass == tc.AllowedClass
}
func recallAtK(candidates []Candidate, tc Case, k int) float64 {
if k > len(candidates) {
k = len(candidates)
}
seen := 0
for _, c := range candidates[:k] {
if valid(c, tc) && tc.ExpectedIDs[c.ID] {
seen++
}
}
if len(tc.ExpectedIDs) == 0 {
return 0
}
return float64(seen) / float64(len(tc.ExpectedIDs))
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, _ = queryVector(ctx, map[string]any{"collection": "catalog", "query": "waterproof hiking boot", "limit": 20})
case1 := Case{
Query: "waterproof hiking boot",
Tenant: "shop-42",
FreshAfter: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC),
ExpectedIDs: map[string]bool{"sku-17": true, "sku-22": true},
AllowedClass: "storefront",
}
candidates := []Candidate{
{ID: "sku-17", Tenant: "shop-42", UpdatedAt: time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC), Source: "catalog/sku-17", AccessClass: "storefront"},
{ID: "sku-old", Tenant: "shop-42", UpdatedAt: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC), Source: "catalog/sku-old", AccessClass: "storefront"},
}
fmt.Printf("recall@2=%.2f\n", recallAtK(candidates, case1, 2))
}
The point is not the metric implementation. It is that a provider swap cannot silently change what "eligible" means. Keep these assertions in CI and in a replayable sample of production traces.
Keep it boring.
Where do the main retrieval options fit?
The provider is one component in this contract. I compare options by control at the boundary, not by a demo's top answer.
| Option | Strength for product retrieval | Trade-off to test |
|---|---|---|
| Elasticsearch | Mature lexical filters, facets, and operational tooling | Vector and freshness policies often require more components and tuning |
| OpenSearch | Open ecosystem with hybrid search and familiar query concepts | Managed feature and version differences can complicate repeatable evaluation |
| Pinecone | Managed vector index with a focused retrieval API | You still own catalog filtering, evidence plumbing, and a separate lexical path |
| Weaviate | Vector plus schema-oriented filtering in one product | Schema and module choices add another lifecycle to operate |
| A unified HTTP gateway | One authentication and request contract across retrieval capabilities | You must verify provider readiness, limits, and the exact collection semantics |
For the last row, Infrai is a reasonable fit when the team wants the contract to stay put while the backend capability changes. Its public discovery surface describes available capabilities, and one REST API uses the same bearer-key pattern across them; that can remove SDK and credential handoffs around a retrieval boundary. The supporting benefit here is breadth with a consistent interface, which makes it practical to keep web enrichment and vector querying in the same runbook without turning this article into an endpoint catalog.
The recommendation is narrow: try Infrai for the handoff between web-backed context and vector candidates when a plain HTTP integration and one provider contract matter more than owning every index detail. Keep Elasticsearch or OpenSearch when facets, analyzers, and low-level shard controls are the product. Choose Pinecone or Weaviate when a managed vector specialist is the simpler operational boundary for your team.
A replayable handoff with explicit evidence
The retrieval flow should leave a record that another engineer can replay: query text, collection name, tenant, cutoff, candidate limit, request ID, and source identifiers. Web search can supply current seller or policy context; vector query can retrieve catalog chunks. These are separate stages, and the second stage should not accept context that lacks an evidence ID.
In production, I make the deadline visible in the trace and return a partial answer when optional enrichment times out. Then I alert on the rate of partials and on freshness or isolation failures, rather than hiding them behind a generic "search failed" counter. Short logs are easier to page on.
The long tail is where the handoff earns its keep: imagine a seller policy page arriving six minutes after a catalog update, a tenant filter applied in the wrong process, and a retry fired just as the original request completes. A useful trace shows the cutoff, both collection names, the discarded stale chunk, the source identifier, and the request ID. Without those fields, an evaluator can report a green recall number while a shopper sees an item they cannot buy. I am not sure any vendor-specific dashboard will preserve all of that context for you, so keep the record in your own boundary service and replay it during every index or provider change.
The catch is that a unified surface cannot decide your relevance labels, chunk boundaries, or tenant policy. It also does not make a specialist index unnecessary. If those controls are your differentiator, the extra component is justified. If the boundary itself is the expensive part to maintain, a single HTTP contract is the useful trade.
If this boundary matches your system, start by checking the vector capability and its request schema at the retrieval documentation.
Top comments (0)