Short answer: keep a durable snapshot history, search only evidence inside a declared freshness window, and re-fetch a page when the window expires before generating a menu answer or alert. This turns “fresh” from a hopeful prompt into a testable storage policy.
Architecture decision record: the invariants
The system is a restaurant marketplace that watches menu pages, detects changes, and answers questions such as “Does the Riverside location still offer the vegan ramen?” A retrieval-augmented generator (RAG) can quote the latest indexed text, but it cannot make an old index current. The index cost at scale is the decision axis, so every byte and re-embedding job needs a reason to exist.
I would record four invariants in the decision record:
- Every chunk carries
source_url,observed_at,content_hash, andmenu_location_id. - A generated answer may cite only chunks whose age is within the request's freshness window, or it must say that a refresh is pending.
- A page change creates a new immutable snapshot; it never mutates the evidence used by an earlier answer.
- An alert is emitted once per
(location, content_hash)and can be replayed from the snapshot log.
Those rules separate retrieval quality from retention. Keeping twelve months of snapshots may help an audit, while searching all twelve months for a “today” answer is a correctness bug.
How should a restaurant menu assistant use retrieval architecture and freshness windows?
Use windows as a routing decision, not as a filter bolted onto the end of generation. For an answer about current availability, use a short window (for example, 24 hours) and require a successful fetch if no qualifying chunk exists. For a historical question, select a window around the requested date and do not silently substitute today's menu. The exact duration is a product policy; I am not sure any universal number survives every restaurant's update habits.
The marketplace watcher can use conditional HTTP requests. Store the last ETag or Last-Modified value, send it on the next poll, and skip parsing and embedding when the server returns 304 Not Modified. MDN documents both validators and the semantics of a 304 response [1]. That is a concrete cost control because unchanged pages do not create new vectors.
Here is the critical path in Python. The interfaces are deliberately ordinary so the policy can sit over a SQL table, object storage, or a local index.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import requests
@dataclass
class Snapshot:
location_id: str
url: str
observed_at: datetime
body: str
content_hash: str
etag: str | None
def poll_menu(location_id: str, url: str, previous: Snapshot | None) -> Snapshot | None:
headers = {}
if previous and previous.etag:
headers["If-None-Match"] = previous.etag
response = requests.get(url, headers=headers, timeout=8)
if response.status_code == 304:
return None
response.raise_for_status()
body = response.text
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
if previous and digest == previous.content_hash:
return None
return Snapshot(
location_id=location_id,
url=url,
observed_at=datetime.now(timezone.utc),
body=body,
content_hash=digest,
etag=response.headers.get("ETag"),
)
def evidence_is_fresh(observed_at: datetime, now: datetime, window: timedelta) -> bool:
return now - observed_at <= window
The hash check matters because some origin servers omit validators or rewrite HTML without changing menu text. Parse and chunk only after this gate. A failed fetch should preserve the last snapshot and mark the refresh attempt separately; deleting usable evidence turns a transient network problem into an empty answer.
Three storage choices and their failure boundaries
There is no single “freshness index.” Pick a storage shape that matches the read path and retention promise.
| Choice | Useful property | Failure boundary | Cost pressure |
|---|---|---|---|
| Current-state index only | Smallest active search set | Cannot answer historical questions or audit a diff | Rebuilds are expensive after parser changes |
| Immutable snapshots plus time-aware index | Replays alerts and supports date-scoped retrieval | Storage grows with every genuine change | Embeddings and duplicate chunks need deduplication |
| Raw snapshots in object storage, hot index for recent text | Cheap retention with bounded search | Cold retrieval adds latency and operational paths | Requires lifecycle rules and a backfill queue |
For this marketplace, I would retain raw snapshots durably and index only the latest approved version plus a short history. The “approved” step is important: a parser that suddenly extracts navigation labels should not poison retrieval just because the page changed. Keep the old version searchable until the new one passes field-level checks for item name, price, availability, and location.
Short windows are not automatically safer. A two-hour window can produce repeated fetches during a lunch rush; a seven-day window can answer with a closed seasonal menu. Measure the distribution of observed changes per location, then set a default and let high-volatility locations override it. The override belongs in metadata, alongside its owner and review date, so it does not become folklore in a configuration file.
Failure modes I would test before launch
The first failure is temporal mixing: the retriever returns a current price and a three-month-old allergen note, and the generator presents them as one coherent menu. Attach observed_at to every chunk and reject a result set whose documents cross an incompatible window.
No exceptions.
The second is deletion blindness. A removed item is a meaningful change, but a naïve index only upserts what it sees and leaves the old item searchable. Diff the normalized item keys and write tombstones for removals. An alert should say “removed” with the prior snapshot timestamp, not quietly keep serving stale text.
The third is duplicate amplification. Tracking parameters, cookie banners, and reordered markup can create many hashes for the same menu. Canonicalize URLs, strip known boilerplate, and compare normalized item records before embedding. Keep the original body for audit, but charge index space to meaningful fields.
I also test clock behavior. Timestamps must be UTC, and a future observed_at should fail validation rather than make every chunk appear fresh. During a backfill, generation is paused for a location or explicitly labeled historical; otherwise users see an answer assembled from two parser versions.
The rejected option, and when it is still valid
I rejected a single rolling index that overwrites each location in place. It is attractive because it has one collection and a simple delete-and-upsert loop. It also destroys the evidence needed to explain an alert, makes rollback depend on backups, and cannot prove that an answer met its freshness promise at the time it was generated.
That design is valid for a narrow use case: a kiosk that displays only the current menu, has no alert history, and can tolerate a full refresh after parser changes. It is not suitable for a marketplace that must explain why a listing changed or settle a dispute about what was offered yesterday. Stick with a rolling index when the product truly has no historical or audit requirement; do not choose it merely because the first benchmark is smaller.
The practical decision rule is therefore simple: make snapshots the source of truth, make the freshness window part of the retrieval request, and make index admission depend on a content-level diff. That keeps costs visible without pretending that a cheaper index can answer a question it no longer has evidence for.
Top comments (0)