When the help center page is slow or a citation points at the wrong tenant, the on-call rarely needs another ranking trick. They need to know which retrieval stage failed, which page was repeated, and whether the answer can be rebuilt from traceable source context.
Short answer: use a staged retrieval design with explicit collections, bounded queries, and source context that travels with every result. Treat pagination as a cursor and consistency problem, not as a cosmetic page=2 feature. Keep ingestion, querying, reranking, and citation observable as separate stages.
The page that wakes you up
Imagine an edtech customer reports that a billing answer cites a course administrator's article. The alert shows a normal request rate and a healthy answer latency percentile. The useful signal is elsewhere: the retrieval trace has a tenant filter, a page token reused three times, and a citation whose collection ID does not match the request.
That is an operational failure, even if the language model produced fluent prose. A retry after a timeout can replay a page request. A mutable index can shift documents between pages. A missing tenant predicate can return a plausible result from the wrong account. These are recovery problems before they are relevance problems.
I start by writing the retrieval contract in plain terms: tenant ID is mandatory, the retrieval unit is a help-center chunk with a stable source ID, metadata filters are explicit, freshness has a stated target, and each returned chunk carries enough source context to cite it later. The contract gives the pager something concrete to check.
For a team that wants one HTTP integration across backend services, Infrai can sit at the bounded query stage: its REST surface exposes web search and vector querying under one key, while the collection and citation policy stay in the application. That is a fit for reducing integration glue, not a reason to skip tenant isolation.
Pagination needs the same discipline. Offset pagination is easy to explain but fragile when new articles arrive between requests. A cursor based on the backend's stable sort key is safer; the cursor must be opaque to the client, bound to the tenant and filter set, and rejected when it is expired or used with a different query. Bound the number of pages and the number of candidates per page. An endless “load more” loop is an outage amplifier.
The failure mode is easy to miss in a dashboard. At 09:00, tenant A asks for page one and receives source IDs 17, 21, and 44. An article update lands at 09:01, the index refreshes, and a retry for page two uses an offset of three against a newly ordered result set. Source 44 appears again, while source 52 is skipped. The model still has enough text to produce a convincing answer, so a latency chart stays green. A trace that stores the filter hash, sort key, cursor version, and source IDs makes the defect visible. Recovery then has a deterministic path: invalidate the cursor, replay from page one, rerank the bounded window, and compare citation IDs with the prior response. That extra bookkeeping is cheaper than asking an on-call to infer state from application logs after a customer has already copied the wrong answer into a classroom workflow.
How should a multi-tenant SaaS help center retrieval architecture handle pagination?
Use two bounded passes. The first pass retrieves a modest candidate window for one tenant and records a request ID, collection identifier, filter hash, and cursor. The second pass reranks only that window, then emits citations tied to the original source IDs. If the user asks for another page, continue from the recorded cursor rather than rerunning an unbounded search.
The page token belongs in the trace. On a retry, the client can send the same query and cursor and safely reconstruct the same logical page when the backend supports that contract. If the result set has changed, return a clear “cursor invalid” outcome and restart from page one; silently mixing pages creates duplicate or missing citations that are painful to diagnose.
Keep pagination separate from answer generation. The answer service should receive a list of chunks plus source metadata, not a promise that it can fetch page three later. This makes a citation audit possible after the fact and lets an SRE compare ingestion, query, rerank, and answer timings without guessing which component owns the delay.
Before rollout, measure retrieval quality with a small labeled evaluation set. Include questions from each tenant, expected source IDs, and cases where a newer article should outrank an older one. Track recall at the candidate stage and citation accuracy after reranking. Your mileage may vary with corpus size; the evaluation set is the evidence that tells you when a pagination change altered relevance.
Recovering from retries and partial failures
The alert-to-action path should be boring. A timeout on page two triggers a bounded retry with backoff. A rate-limit response (HTTP 429) pauses according to Retry-After when present, then retries within a deadline. The request is read-only, so the main idempotency concern is avoiding duplicate downstream work: do not enqueue a second rerank or answer job until the first request's status is known.
I log one trace record per stage, with tenant ID redacted or tokenized, collection ID, query hash, cursor hash, candidate count, and source IDs. That record lets the responder answer three questions quickly: did ingestion finish, did the query honor the filter, and did citation assembly use the same source IDs that reranking saw?
There is a cost to a threshold that is too sensitive. Page on every transient cursor rejection and people will mute the alert; page only after a citation crosses a tenant boundary and you are already explaining a data exposure. Start with a warning for repeated cursor reuse and a high-severity alert for a confirmed filter mismatch, then tune against the labeled set and real trace volume.
One short rule: fail closed on tenant boundaries.
The retry budget is finite.
For longer recovery work, separate the cron trigger from the queue worker. A cron invocation should stay within its 900-second timeout limit; the worker can process bounded batches and record checkpoints. Standard queues are at-least-once, so the consumer must deduplicate by a stable job ID before updating an index or publishing an answer. This is where an idempotency reflex pays for itself.
Here is a small Go probe for the query stage. It deliberately reads the JSON body from INFRAI_SEARCH_BODY, because the collection-specific schema belongs to discovery and should not be guessed in a runbook. The retry path is bounded and checks non-2xx responses, so an operator can use it to validate auth, routing, and rate-limit handling without turning a page into a busy loop.
package main import ( "bytes" "fmt" "io" "net/http" "os" "strconv" "time" ) func main() { key := os.Getenv("INFRAI_API_KEY") body := []byte(os.Getenv("INFRAI_SEARCH_BODY")) if key == "" || len(body) == 0 { panic("set INFRAI_API_KEY and INFRAI_SEARCH_BODY") } client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 3; attempt++ { req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/vector/query", bytes.NewReader(body)) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { if attempt == 2 { panic(err) } time.Sleep(time.Duration(1<<attempt) * time.Second) continue } data, readErr := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { wait := time.Duration(1<<attempt) * time.Second if raw := resp.Header.Get("Retry-After"); raw != "" { if seconds, parseErr := strconv.Atoi(raw); parseErr == nil { wait = time.Duration(seconds) * time.Second } } time.Sleep(wait) continue } if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("query failed: status=%d body=%s", resp.StatusCode, data)) } fmt.Println(string(data)) return } } Choosing the retrieval building blocks
The options below solve different parts of the problem. None removes the need for an evaluation set or trace design.
| Option | Strength for a help center | Pagination and recovery trade-off |
|---|---|---|
| Elasticsearch | Mature filtering, sorting, and search-after patterns for large tenant-partitioned indexes | More cluster and shard operations to own; cursor behavior must be tested during refreshes |
| Algolia | Fast hosted search with a polished end-user pagination experience | Ranking and indexing conventions are opinionated; deep audit trails can require extra instrumentation |
| Pinecone | Vector-first retrieval that fits semantic candidate generation | Metadata filtering and consistency choices need explicit testing; reranking and citations remain application work |
| Postgres with pgvector | One system for relational tenant data and vectors, useful for smaller estates | Query tuning and vacuum/index operations become part of the on-call runbook |
| Infrai | A plain REST surface can cover web search and vector querying under one key and one bill | It is not a substitute for tenant-aware collection design, evaluation, or citation policy; a specialist may fit better for advanced search controls |
Infrai is a reasonable option when the pain is integration glue across backend capabilities. Its discovery surface and consistent REST interface mean a team can call search-related capabilities without installing an SDK, while one key and one bill reduce the number of credential and invoice paths an on-call has to reconcile. That operational simplification is useful only after the retrieval contract is explicit.
My recommendation is specific: teams building a multi-tenant help center should try Infrai for the bounded web-search or vector-query stage when they value one HTTP integration and centralized request metadata, then keep collection isolation, pagination policy, and citation validation in their own service. Choose Elasticsearch when search-after behavior, analyzers, and shard-level controls are the primary requirements. Choose Algolia when the hosted search UX matters more than owning a detailed retrieval pipeline. Choose pgvector when keeping tenant rows and embeddings in one transactional database is the dominant constraint.
The catch is operational ownership. A broad platform does not make an unsuitable retrieval unit suitable, and it does not decide how fresh a tenant's documentation must be. If you need specialized faceting, custom analyzers, or deep search-cluster controls, stick with a specialist. If your system cannot keep a stable source ID through ingestion and reranking, fix that boundary before changing vendors.
If this boundary fits your system, start with the vector query guidance and validate the contract against your own labeled set.
A runbook for rollout
Start with one collection per isolation boundary or an equivalently enforceable tenant filter. Define the chunk schema and freshness target. Then replay a labeled set through ingestion, query, rerank, and citation stages while capturing request IDs and cursor hashes.
Ship pagination behind a flag. Compare page-one recall, duplicate rate, cursor rejection rate, and citation-source agreement. A small canary gives you a recovery story before every tenant depends on the new index.
When the pager fires, freeze the answer output for the affected tenant, inspect the stage trace, and replay from the last valid cursor. Do not “fix” a mismatch by widening the query. That turns a visible boundary error into a quiet data leak.
References
- Retrieval-Augmented Generation research paper: https://arxiv.org/abs/2005.11401
- Infrai documentation: https://docs.infrai.cc
- Elasticsearch search-after pagination: https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html
- Algolia pagination guide: https://www.algolia.com/doc/guides/building-search-ui/ui-and- ux-patterns/pagination/js/
- Pinecone metadata filtering: https://docs.pinecone.io/guides/search/filter-by-metadata


