Short answer: treat metadata filters as a typed policy checked before vector search, then verify the filtered evidence before generation. For a restaurant menu assistant, that means a vegan request never competes with stale seasonal items, an answer can name its menu source, and latency stays predictable enough for chat.
The application I have in mind is an internal bot for a gaming company that runs event venues and their restaurants. Staff ask questions such as “Which gluten-free dinner is available at the Seoul venue tonight?” The hard part is not producing fluent text. It is preventing a plausible answer from crossing venue, service date, or dietary boundaries.
The bill is mostly shaped by how much text we retain and resend, not by the filter expression itself. Keeping every historical menu revision in the prompt inflates embedding storage, reranking work, and generation input. A useful first pass is to retain the current menu plus a short audit trail, while storing older revisions outside the hot retrieval index. The change that moves the term is retention policy: one active revision per menu item, with an immutable record available for audit.
In practice, retention is a data-model decision that reaches into operations. Give each item a stable ID, attach the source document and approval timestamp, and store a compact change record rather than duplicating a full menu in every chunk. The ingestion job can then delete superseded chunks from the serving index while preserving their hashes in cold storage. During an incident, an operator can reconstruct exactly which text was searchable at 19:00 without forcing every normal chat request to carry that history. This also makes re-embedding selective: a changed allergen note invalidates one item's vectors, not an entire venue. I would still sample old records in a weekly audit, because a retention rule that nobody checks eventually drifts from the policy people think they have. The storage savings are a side effect; the real win is a clear boundary between “answer now” data and “explain what happened” data.
That policy has a cost. When a manager asks why an item disappeared, the bot cannot answer from the hot index alone; it must query the archive and label the result as historical. I accept that trade. Fresh operational answers should be fast, while an audit path can be slower and explicit.
One sentence is enough here: stale context is a correctness bug in the conversation, even when the model is healthy.
Keep it scoped.
Make the filter contract narrower than the natural-language question. Parse the request into fields with controlled vocabularies: venue_id, service_date, menu_version, dietary_tags, language, and availability. Reject an unknown venue instead of silently broadening the search. Normalize “tonight” in the caller's timezone, then pass an ISO date to the retriever.
The order matters. Apply hard filters first, retrieve candidates second, and use semantic ranking only inside that set. If the vector index cannot enforce a predicate, fetch a bounded candidate set and apply the predicate in application code before reranking; never ask the language model to perform the filtering.
Here is a small, vendor-neutral shape for the policy. It is intentionally boring.
from datetime import date
def build_filter(question: dict) -> dict:
allowed_diets = {"vegan", "vegetarian", "gluten_free", "halal"}
diets = set(question.get("dietary_tags", []))
if not question.get("venue_id"):
raise ValueError("venue_id is required")
if not diets.issubset(allowed_diets):
raise ValueError("unsupported dietary tag")
return {
"venue_id": question["venue_id"],
"service_date": date.fromisoformat(question["service_date"]).isoformat(),
"menu_version": question.get("menu_version", "active"),
"dietary_tags": {"contains_all": sorted(diets)},
"availability": "in_stock",
}
The filter should be logged as structured data, with sensitive free text kept out of ordinary logs. A trace needs the normalized filter, candidate count, post-filter count, and evidence identifiers. Those fields reveal whether an empty answer came from a real inventory gap or from a parser that misunderstood “no dairy.”
| Decision | Keep in the hot index | Move to an archive |
|---|---|---|
| Menu revision | Active version | Superseded versions |
| Availability | Current stock state | Past stock snapshots |
| Dietary review | Latest approved labels | Prior review records |
These dimensions fail differently, so putting them in one untyped metadata blob hides useful errors. Freshness is temporal: an item can be valid for lunch but not dinner. Dietary labels are a safety claim and should come from a reviewed source field, not an inferred embedding. Language affects display and tokenization, but it must not change the underlying item identity.
Use a versioned item record. A menu update creates a new version, and the active pointer changes atomically. Chunks inherit the version and venue identifiers. During a rollout, retrieve only one version unless the user explicitly asks for a comparison. This prevents a half-updated index from blending yesterday's price with today's availability.
I once assumed that adding dietary_tags to every chunk was enough. It wasn't: a modifier line lacked the tag, so a parent item passed the filter while its sauce did not. The fix was to materialize ingredient and modifier constraints at the item level, then copy the reviewed result to each searchable chunk. Your mileage may vary when menus are authored by several teams; the contract needs an owner.
A healthy test set contains more than successful searches. Include an unknown venue, a date with no service, conflicting tags, a sold-out item, and a question that mixes two venues. Assert that each case returns either a scoped answer or a clear “no matching item,” never an unfiltered fallback.
Measure retrieval quality and latency separately. Recall@k on reviewed menu items tells you whether the filter is too strict; p95 end-to-end latency tells you whether reranking or archive lookup is the bottleneck. Keep a small golden set for each venue and language, and run it whenever the parser, index, or menu-ingestion job changes.
The catch is operational: metadata filters are only as trustworthy as ingestion. This approach is not suitable when menu data has no stable item IDs, no service-date owner, or no review workflow for dietary claims. In that situation, stick with a simpler keyword search and a human escalation path until the source data can support typed constraints.
The retriever should return evidence objects, not just strings: item ID, venue, active version, source URL or document ID, and the fields that satisfied the filter. The generator receives those objects and is instructed to decline when evidence is empty or contradictory. A citation then points to the exact menu revision a staff member can inspect.
Retrieval-Augmented Generation was introduced as a way to combine parametric language knowledge with a non-parametric external memory; the original paper is a useful description of that division. In production, the metadata contract is the guardrail that keeps the external memory scoped.
No filter design survives unowned data. Assign responsibility for each field, alert on missing values, and make the archive query an explicit user-visible mode. That gives the bot a useful answer today without pretending that historical and current menus are interchangeable.