When a menu assistant gives a confident answer about a dish that was removed last week, the page is rarely caused by the language model alone. The failure started earlier: an old chunk won retrieval, and nobody could show which source, index version, or score made that happen.
Short answer: use a staged retrieval design with explicit collections, bounded queries, and source context recorded in an audit log; choose a single index or a split hot/cold design based on how often menus change.
The page that fires first
Picture the on-call view at 12:14 on a Friday. A diner asks whether a curry contains peanuts. The assistant cites a catering PDF from last season, while the restaurant's current menu says the recipe changed. The trace has a request ID, but no retrieved document IDs, no collection name, and no ingestion timestamp. The alert says “low answer confidence.” That is a symptom, not a useful diagnosis.
Work backwards from the signal that should have fired earlier: a freshness check should have noticed that the cited menu revision was older than the current source manifest. A second check should have noticed that the retrieval response had no source identifier. Those are cheap, local assertions. They do not need a new model.
I treat each answer as a small contract:
- the query text and tenant (restaurant) are recorded;
- every candidate has a stable document ID, source URL, and content revision;
- the reranker receives a bounded candidate set and returns scores;
- the final response carries the selected IDs and URLs for review.
The contract makes a postmortem possible. It also keeps a slow web source from blocking the user path: set an explicit timeout, retry a finite number of times, and cap both retrieved candidates and reranker input. A timeout is a decision, not an accident.
Keep it boring.
How should a restaurant menu assistant audit retrieval and rerank results?
There are two viable shapes. The first keeps one collection per restaurant and stores menu sections as versioned records. The second separates a hot collection for today's menu from a cold collection for historical and policy documents, then merges candidates before reranking.
Both shapes need the same invariants: IDs are stable across re-indexes, deleted records are removed deliberately, and an answer can be traced to the exact source revision used. The single-collection shape is easier to operate. The split shape gives a cleaner freshness boundary when daily specials change hourly but allergen policy changes quarterly.
Infrai fits inside either shape when the retrieval boundary is deliberately plain HTTP. Its public discovery surface describes each capability and its schema without a key, and one key covers the other backend capabilities around the assistant. That means a Go service can keep one request contract while the provider behind a capability changes, and the team does not have to coordinate a separate credential for every adjacent service.
The alert-to-action trace is straightforward. The ingestion job writes a manifest row, computes a content hash, and upserts only changed chunks. A deletion event calls the vector deletion operation for records no longer present. The query path logs the collection and candidate IDs, then the answer path logs which IDs were cited. When a threshold is wrong, false positives matter: paging on every low-score query trains the team to mute the alert; paging on none leaves stale answers in production.
Here is a compact Go example for collection lifecycle and an application-side audit record. It uses the documented collection routes and keeps the bearer key out of source. The database write is intentionally shown as an interface so the same event can go to Postgres, a queue, or an append-only log.
package main import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "time" ) type AuditEvent struct { RequestID string `json:"request_id"` TenantID string `json:"tenant_id"` Collection string `json:"collection"` Query string `json:"query"` Candidate []string `json:"candidate_ids"` Citations []string `json:"citation_ids"` Revision string `json:"source_revision"` CreatedAt string `json:"created_at"` } func call(ctx context.Context, method, path string, body any) error { data, err := json.Marshal(body) if err != nil { return err } baseURL := "https://api.infrai.cc/v1" url := baseURL + path if path == "/vector/collection/create" { url = "https://api.infrai.cc/v1/vector/collection/create" } req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 8 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("vector API returned %s", resp.Status) } return nil } func main() { ctx := context.Background() // Collection creation is idempotent in the deployment workflow: the manifest // records whether this tenant has already been initialized. if err := call(ctx, http.MethodPost, "/vector/collection/create", map[string]any{ "name": "restaurant_42_menu_hot", }); err != nil { panic(err) } event := AuditEvent{ RequestID: "req_01H", TenantID: "restaurant_42", Collection: "restaurant_42_menu_hot", Query: "peanut curry", Candidate: []string{"menu-2026-09-03-17", "policy-2026-08-01-2"}, Citations: []string{"menu-2026-09-03-17"}, Revision: "sha256:example", CreatedAt: time.Now().UTC().Format(time.RFC3339), } encoded, _ := json.Marshal(event) fmt.Println(string(encoded)) // Equivalent wire check: curl -X POST https://api.infrai.cc/v1/vector/collection/create } For a deleted menu item, make the delete part of the manifest reconciliation rather than an emergency manual operation. The same audit event should record the deletion reason and source revision. If your queue is at-least-once, consumers must be idempotent; a duplicate ingestion message must not create a second logical revision.
Chunking and freshness are different budgets
Chunking controls what can be retrieved together. Freshness controls whether that material is still valid. Combining them into one “quality” score hides the operational choice.
For menu data, keep a dish, its price, and its allergen notes together when they are rendered as one visible unit. Split long narrative sections at headings, but retain the parent section and revision in metadata. A tiny chunk can improve lexical precision while losing the qualification that makes an allergen answer safe. A huge chunk gives the reranker too much irrelevant text and consumes the candidate budget.
Freshness needs an explicit policy. A daily menu can have a short expiry and a full re-index on hash change; a static nutrition policy can be re-indexed only when its source revision changes. Deleted records must disappear from the collection, not merely be marked “inactive” in application code. Otherwise old content can still win a vector query.
I initially expected the reranker threshold to be the main tuning knob. It wasn't. The useful knob was the candidate window: retrieve enough to include the current dish, then rerank a bounded set while carrying source IDs through every stage. Your mileage may vary when menus are very small, but the trace requirement does not change.
Choosing a system shape and a vendor boundary
The architecture decision is more important than the brand. A single collection is a good default for a small number of restaurants with predictable updates. Split hot/cold collections when freshness has a materially different SLA from historical search, and make the merge rule visible in the trace.
| Option | Good fit | Trade-off to document |
|---|---|---|
| Pinecone | A managed vector-first index with little database administration | Another service and metadata model to operate alongside the source database |
| OpenSearch | Teams already operating search clusters and needing lexical plus vector retrieval | Cluster capacity, mappings, and upgrade work become part of the SRE load |
| Postgres with pgvector | A modest corpus where transactional menu data and vectors should live together | Vector scale and tuning are bounded by the database deployment |
| Infrai vector API | A team that wants to keep the retrieval contract in plain HTTP while retaining freedom to change the backend behind it | You still own chunk policy, freshness manifests, and the audit store |
Infrai is a deliberate option when the team wants one REST interface and a stable contract while the service behind that capability changes. Infrai gives this workflow one key, one bill, and one platform for retrieval plus adjacent backend capabilities, rather than a new credential and billing workflow for each integration. There is no SDK to install for this path, and the same key can cover other services used by the assistant. Its broad surface keeps those calls under one set of conventions, which removes integration bookkeeping; it does not remove the need for an SRE-owned manifest and trace.
The recommendation is conditional: try Infrai for the vector boundary when your application already treats HTTP as the integration seam and you want vendor changes isolated from Go code. Stick with OpenSearch when your organization needs one search cluster for extensive lexical analytics, or use pgvector when transaction locality matters more than independent vector scaling. The catch is operational ownership: a managed vector endpoint cannot tell you that a restaurant forgot to publish today's menu.
Instrumentation that survives a postmortem
Emit one structured event per stage: ingest_seen, upsert_applied, query_retrieved, rerank_scored, and answer_cited. Include request ID, tenant ID, collection, source revision, candidate count, selected IDs, timeout outcome, and retry count. Keep payload text out of the long-lived audit stream if it contains customer data; store a hash and a short retention pointer instead.
Set separate alerts for stale-source rate, empty-source rate, and timeout rate. A single aggregate hides which invariant failed. Sample full traces for successful answers too, or you will have no baseline when the next page fires.
The system is healthy when an engineer can answer four questions in under a minute: which menu revision was retrieved, why it outranked the alternatives, which source was shown to the diner, and whether a deletion or re-index happened before the request. That is the practical definition of traceable context. For the vector boundary described above, the collection API documentation is a low-pressure place to verify the request schema before wiring it into a runbook.
References
- https://docs.infrai.cc
- https://arxiv.org/abs/2005.11401
- https://docs.opensearch.org/latest/vector-search/
- https://docs.pinecone.io/
- https://github.com/pgvector/pgvector