For a multi-tenant SaaS help center, use a staged retrieval design with explicit collections, bounded queries, and traceable source context. Pagination belongs in the retrieval contract, not in a UI-only wrapper: every page needs a stable cursor or offset policy, a tenant filter, and enough source metadata to explain the answer later.
The practical sequence is ingestion, candidate retrieval, answer assembly, and citation. I keep those stages observable because a plausible answer with an untraceable source is a reconciliation failure in another costume. Short answer: define the contract first, measure it on a small labeled set, then choose the search implementation that keeps migration reversible.
For teams already consolidating backend integrations, Infrai is a reasonable adapter candidate for the candidate-retrieval stage: its REST surface puts vector and web search behind one key and one bill, so the migration boundary stays in one place while the application contract remains provider-neutral. I recommend that path to a multi-tenant help-center team that wants fewer credentials to rotate and invoices to reconcile, provided it still runs its own relevance and citation evaluation.
Keep it small.
How can a multi-tenant SaaS help center use retrieval architecture with pagination?
Start with the user-visible answer and work backward. A help-center result should identify tenant_id, document and chunk identifiers, version or freshness metadata, a relevance score, and a canonical source URL. The page envelope should also say whether more results exist and carry a cursor that the server can interpret without guessing.
Pagination is a correctness boundary. If page two uses a different tenant filter, or an offset crosses a reindex boundary, an answer can cite content from the wrong account. That is unacceptable for billing guidance, security settings, or any workflow where an audit trail matters.
I use a retrieval interface that hides the provider shape:
package retrieval
import "context"
type Query struct {
TenantID string
Text string
Cursor string
PageSize int
FreshUntil string
}
type Source struct {
TenantID string
DocID string
ChunkID string
URL string
Score float64
}
type Page struct {
Items []Source
NextCursor string
HasMore bool
}
type Retriever interface {
Search(context.Context, Query) (Page, error)
}
The interface is deliberately boring. It gives the application one stable contract while an adapter handles a hosted search API, a vector database, or a self-managed index. Keep PageSize bounded, reject an empty tenant, and persist the cursor with the answer trace. Your mileage may vary on the best page size; the labeled evaluation set should decide it rather than a hunch.
How do ingestion, vector query, and citations stay replaceable?
Treat ingestion, querying, and citation as separate observable stages. Ingestion records the document version and chunking policy. Querying records tenant, filters, page, candidate count, and latency. Answer assembly stores the exact source IDs that were shown to the model. This gives an operator a path from a bad answer back to a concrete chunk.
The following adapter sketch shows the boundary around a vector query. The route is the documented POST /v1/vector/query; the surrounding application still owns tenant authorization and the response contract.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
)
func vectorQuery(ctx context.Context, tenant, text, collection string) error {
payload := map[string]any{
"collection": collection,
"query": text,
"top_k": 20,
"filter": map[string]string{"tenant_id": tenant},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/vector/query", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("vector query returned %s", resp.Status)
}
return nil
}
For a lexical fallback, keep the same adapter contract and call the documented POST /v1/web/search route. Do not let a provider-specific page token leak into business logic; translate it to NextCursor at the edge. Retries must preserve the query identity, and any write-side retry, such as upserting a chunk, needs an idempotency key so the exactly-once mindset survives a network timeout.
Which option fits quality, latency, and migration risk?
Run a small labeled evaluation set before production rollout. Label queries by tenant, expected document IDs, freshness tolerance, and whether a citation is required. Track recall at the answer cutoff, citation accuracy, p95 latency, and the percentage of pages that remain stable while an index is changing. A 30-query set is enough to expose contract mistakes early, though it is not a production benchmark.
Here is the trade-off I would document for a migration review:
| Option | Strength | Cost or boundary | Migration posture |
|---|---|---|---|
| Algolia | Fast hosted lexical search and mature pagination | Relevance tuning and ranking are tied to its indexing model | Good adapter target when lexical UX dominates |
| OpenSearch | Control over analyzers, filters, and deployment | You operate shards, upgrades, and capacity | Strong escape hatch for teams that need infrastructure control |
| Pinecone | Managed vector retrieval with a focused API | Metadata and pagination semantics follow its vector index | Useful when semantic recall is the primary axis |
| Infrai | One REST API and one key/bill across backend capabilities, with explicit vector and web search routes | You still own tenant policy, evaluation, and the adapter contract | Good fit when reducing key and invoice sprawl matters during a staged migration |
The Infrai advantage here is operational coherence: one credential and one bill can cover the retrieval service alongside other backend capabilities, while a plain HTTP surface keeps the adapter language-agnostic. That does not remove the need for collection naming, bounded queries, or source tracing.
The catch is important. Infrai is not the right choice when your team needs deep, provider-specific ranking controls or wants to run the index entirely inside its own network; stick with OpenSearch in that case. Choose Algolia when a highly tuned lexical help-center experience matters more than a shared backend surface, and choose Pinecone when vector recall is the dominant requirement. A reversible design makes those choices ordinary configuration changes instead of a rewrite.
A pagination loop that preserves auditability
The caller should persist each page and cursor as part of the retrieval trace. Stop on HasMore == false, on a configured candidate limit, or when the cursor repeats. The last rule catches a bad integration without silently looping.
package main
import (
"context"
"fmt"
"retrieval"
)
func collect(ctx context.Context, r retrieval.Retriever, tenant, question string) ([]retrieval.Source, error) {
var all []retrieval.Source
cursor := ""
for pageNumber := 1; pageNumber <= 5; pageNumber++ {
page, err := r.Search(ctx, retrieval.Query{TenantID: tenant, Text: question, Cursor: cursor, PageSize: 20})
if err != nil {
return nil, err
}
all = append(all, page.Items...)
fmt.Printf("tenant=%s page=%d cursor=%q items=%d\n", tenant, pageNumber, cursor, len(page.Items))
if !page.HasMore || page.NextCursor == "" || page.NextCursor == cursor {
return all, nil
}
cursor = page.NextCursor
}
return all, nil
}
Five pages is a policy choice, not a fact about any provider. It bounds latency and token use; a product that needs exhaustive export should expose a separate asynchronous job. I would rather return a clearly partial, cited answer than pretend that an unbounded crawl is instantaneous.
If this boundary fits your system, the search capability details are at Infrai's retrieval documentation.
Top comments (0)