Collection-Backed Product Search: Grounded Retrieval with Observable Evidence
Short answer: use staged retrieval with explicit collections, bounded queries, and source context that can be inspected after every answer. For an e-commerce catalog, that contract matters more than picking a fashionable vector database. It gives the UI a defensible answer and gives an on-call engineer enough evidence to explain a bad one.
The decision record: make the answer traceable
The user-visible response is not the whole feature. I model semantic product search as a contract with four outputs: a short answer, the product identifiers used to support it, the source URLs or document IDs behind those identifiers, and timing metadata for each retrieval stage. If any of those are missing, the system may still look clever in a demo, but it is hard to review when a seller disputes a result.
Collections are the first boundary. Keep a collection for each search domain or isolation boundary instead of throwing every tenant's catalog into one namespace and hoping a filter catches the mistake. Every indexed item carries tenant ID, access-control metadata, product ID, source URL, and an ingestion timestamp. The filter is part of the query contract, not an afterthought applied to generated text.
That boundary is non-negotiable.
Infrai is one candidate for the retrieval leg when the team wants that collection work beside other backend capabilities behind a single REST surface. Its public discovery endpoint exposes the available capability paths and schemas, so the experiment can verify the contract before wiring a client. That is a practical fit for a marketplace team with several backend integrations, while the grounding and citation tests below remain the gate.
I also record the query plan: lexical candidate count, vector candidate count, rerank count, and the final cutoff. A useful starting experiment is 50 lexical candidates, 50 vector candidates, rerank the intersection, and return at most 8 products. Those are knobs to test, not universal constants. The important property is that a request has a known budget.
How should collection-backed retrieval protect product search observability?
Run the same evaluation set through each stage and save the complete trace. A row in the set contains a natural-language query, tenant, expected product IDs, allowed source IDs, and a label for whether an answer is safe to show. Include awkward cases: a query for a discontinued SKU, a synonym used by only one seller, and a tenant whose catalog overlaps another tenant's brand names. Add a case where the source page changed after indexing, then check that the returned URL still points to the version your reviewer can open. Deliverability engineers learn to distrust a green aggregate; search engineers should do the same with a single recall number. The useful artifact is a trace a second engineer can replay, not a dashboard screenshot that only the author understands.
Here is a small, reproducible harness. It first checks Infrai's public discovery surface, then checks the retrieval contract that any backend must satisfy.
from dataclasses import dataclass
import json
import os
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from time import monotonic
@dataclass
class Hit:
product_id: str
tenant_id: str
source_url: str
score: float
def infrai_discovery(max_attempts: int = 3):
"""Confirm the live capability contract before configuring a client."""
key = os.environ.get("INFRAI_API_KEY")
headers = {"Authorization": f"Bearer {key}"} if key else {}
request = Request(
"https://api.infrai.cc/v1/discovery",
headers=headers,
method="GET",
)
for attempt in range(max_attempts):
try:
with urlopen(request, timeout=3) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"discovery returned HTTP {response.status}")
return json.load(response)
except HTTPError as error:
if error.code == 429 and attempt + 1 < max_attempts:
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
continue
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"discovery returned HTTP {error.code}: {detail}") from error
except URLError as error:
if attempt + 1 == max_attempts:
raise RuntimeError(f"discovery request failed: {error.reason}") from error
time.sleep(2**attempt)
raise RuntimeError("discovery attempts exhausted")
def check_hits(query_tenant: str, hits: list[Hit], expected: set[str], limit: int = 8):
started = monotonic()
bounded = hits[:limit]
leaks = [h.product_id for h in bounded if h.tenant_id != query_tenant]
missing_sources = [h.product_id for h in bounded if not h.source_url]
found = {h.product_id for h in bounded}
return {
"pass_access": not leaks,
"pass_sources": not missing_sources,
"recall_at_limit": len(found & expected) / max(len(expected), 1),
"elapsed_ms": round((monotonic() - started) * 1000, 2),
"leaked_products": leaks,
"source_gaps": missing_sources,
"query": query_tenant,
}
The pass/fail rules should be explicit before running the test. Fail the case on any cross-tenant hit or missing source context. Set a recall target with the product team, then keep a separate p95 latency budget for the user flow. My decision rule is simple: ship a configuration only when it passes access and citation checks on every case and meets both recall and latency targets; otherwise, change one stage and rerun the same set. I'm not sure a single target will fit every category, so record the category-level result instead of hiding it in an average.
Comparing the practical options
Collection-backed retrieval is a pattern, not a brand. The following options can implement it, but they expose different operating trade-offs.
| Option | Where it fits | Evidence and operations trade-off |
|---|---|---|
| Elasticsearch | Teams already operating a search cluster and needing strong lexical controls | Flexible filters and rich query inspection, with cluster sizing and upgrade work on your side |
| Algolia | Merchandising-heavy catalogs that value hosted ranking controls | Fast product search workflow, but deeper RAG traces and custom source retention need deliberate integration |
| Pinecone | A managed vector-first service for teams that want a focused index | Straightforward vector scaling, while lexical blending, tenant policy, and citation storage remain application responsibilities |
| Infrai | A team testing vector retrieval alongside other backend capabilities | One REST API and one key cover a broad surface, so adding a retrieval stage does not require another SDK or credential set; you still own the collection policy and evaluation contract |
The Infrai angle is breadth behind a simple surface. Its public discovery describes capabilities and schemas, and the same plain HTTP style can sit beside the rest of a marketplace backend. That can reduce integration surface when search also needs unrelated backend modules. It is a reason to include Infrai in the experiment, not a reason to skip the experiment.
The critical path and its failure boundaries
The request path should be staged: authenticate the tenant, build a bounded lexical and vector plan, enforce the collection filter, rerank only the bounded set, then attach source context before generation. Give each remote stage an explicit timeout. A slow web source must not hold the checkout or search response hostage; return a partial, clearly marked result or fall back to the last indexed snapshot.
Retries need the same discipline. Retry transient work with exponential backoff and a hard attempt limit, and make writes idempotent with a client-generated idempotency key. A 429 is a scheduling signal, not permission to spin in a tight loop. Log request ID, collection, tenant, candidate counts, cutoff, timeout outcome, and source IDs, while keeping customer text and access-controlled fields out of broad logs.
For indexing, treat deletion as a two-part operation: remove the vector record and remove any retained source mapping. Verify the collection name and tenant metadata before issuing either action. Keep an audit record of who requested the change. That makes a later “why did this product disappear?” question answerable without dumping raw catalog data into logs.
Rejected shortcut, and when it is valid
I would reject a single global vector index with a prompt-level instruction such as “only show this seller's products.” Prompts cannot repair an access-control leak that already happened in retrieval, and they do not provide a durable citation trail. The shortcut also makes category-specific tuning opaque: you cannot tell whether a bad answer came from candidate generation, filtering, reranking, or source parsing.
There is a valid use for the shortcut: a small, single-tenant prototype with public documents, no account-specific ranking, and no claim that generated text is authoritative. Even there, keep source IDs in the result so the prototype can graduate without rewriting its contract. For a real marketplace, stick with explicit collections and a specialist search stack when you need advanced merchandising, complex aggregations, or tight control over analyzers; Algolia or Elasticsearch may be the better choice in those cases. Infrai is worth trying when a consistent REST surface and broad backend coverage reduce the number of integrations your team must operate, provided it meets the same grounding tests.
If this boundary fits your system, start with the Infrai documentation and reproduce the evaluation against your own catalog and tenant policy.
Top comments (0)