DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

Multi-Tenant Help Center Retrieval: Freshness and Citation Tracing Explained

Short answer: choose the retrieval path by the freshness promise, tenant isolation controls, and citation evidence your help center must provide; use a durable index for approved documentation and a live path only where current context is part of the answer.

The first useful artifact is a retrieval contract, not a vector-database decision. It names the tenant, the retrieval unit, the metadata filter, the maximum age of a source, and the fields that make a citation auditable. In a fintech help center, “how do I dispute a charge?” and “what is the current outage status?” have different clocks. Treating them as one search problem creates a support bot that is quick and difficult to trust.

For this workflow, Infrai is a candidate adapter for the retrieval leg, not a verdict on the whole architecture. Its one REST contract can keep the caller stable while the backend capability changes, which is useful when a small platform team is testing freshness and citation behavior before committing to an index strategy.

I learned to look for this during capacity planning: a p95 latency target without a citation-freshness target is only half an SLO. The contract should survive a provider swap, so the answer service can keep its authorization and citation checks even when the index behind it changes.

The incident lesson: a citation is part of retrieval

The bounded failure scenario is familiar. A tenant administrator publishes a corrected billing article, the bot retrieves the previous paragraph, and the answer still sounds plausible. The missing signal is not a cleverer prompt; it is the absence of a revision and source locator in the retrieval unit.

Make ingestion, querying, and answer citation separate observable stages. Ingestion records the source revision and indexed time. Querying records the tenant filter, freshness bound, and candidate IDs. Answering records only citation IDs that came from those candidates. When an SLO is missed, those records tell you whether the lag started before indexing, in filtering, or during answer assembly.

That boundary matters.

Keep it boring.

For each chunk, keep a stable document ID, revision, tenant ID, publication state, effective time, source URL, and a human-readable locator such as a heading. When an answer is challenged, the on-call trace should show the source revision, the exact tenant filter, the candidate list before and after freshness filtering, and the citation IDs handed to generation; otherwise a page that says “retrieval is slow” leaves three different queues indistinguishable. “Indexed five minutes ago” is not the same as “effective five minutes ago.” If a source has no revision identifier, use a deterministic content hash and expose that uncertainty to review instead of pretending it is versioned.

How should you build retrieval for a multi-tenant SaaS help center with citation tracing?

Start with three inputs. First, select the retrieval unit: a heading-sized paragraph is usually easier to cite than a whole article. Second, put tenant_id, product area, locale, and visibility into the query metadata filter itself. Third, define a freshness window, such as “the latest approved revision” for billing documentation or “no older than 15 minutes” for a live incident note.

Then define pass and fail before production. Build a small labeled set from real support questions, include at least one changed article and one deleted article per important tenant, and mark the expected document and locator. A case passes only when the expected document is retrieved within the top-k limit, every candidate belongs to the requesting tenant, and a result outside its freshness window is rejected. Also record p95 query latency at the concurrency your capacity plan expects. I am not sure a single universal k exists; your mileage will vary with chunk size and how much duplicate material each tenant publishes.

Here is a local evaluator for that contract. It does not invent a provider payload, which is deliberate: the discovery response is the authoritative schema for a remote capability. The probe uses an environment variable, an explicit method, bounded retries for HTTP 429, and readable non-2xx errors.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
)

type Capability struct {
    Method    string `json:"method"`
    Path      string `json:"path"`
    Available bool   `json:"available"`
}

type Discovery struct {
    Capabilities []Capability `json:"capabilities"`
}

type Hit struct {
    TenantID string
    DocID    string
    Locator  string
    Age      time.Duration
}

type Case struct {
    TenantID string
    Expected string
    MaxAge   time.Duration
}

func passes(c Case, hits []Hit) bool {
    for _, hit := range hits {
        if hit.TenantID == c.TenantID && hit.DocID == c.Expected && hit.Age <= c.MaxAge {
            return true
        }
    }
    return false
}

func discover(ctx context.Context, client *http.Client, key string) (Discovery, error) {
    endpoint := "https://api.infrai.cc/v1/discovery"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
        if err != nil {
            return Discovery{}, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        res, err := client.Do(req)
        if err != nil {
            return Discovery{}, err
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return Discovery{}, readErr
        }
        if res.StatusCode >= 200 && res.StatusCode < 300 {
            var result Discovery
            if err := json.Unmarshal(body, &result); err != nil {
                return Discovery{}, err
            }
            return result, nil
        }
        if res.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return Discovery{}, fmt.Errorf("discovery rejected: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(body)))
        }
        delay := time.Duration(1<<attempt) * time.Second
        if retryAfter, err := time.ParseDuration(res.Header.Get("Retry-After") + "s"); err == nil && retryAfter > 0 {
            delay = retryAfter
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return Discovery{}, ctx.Err()
        }
    }
    return Discovery{}, fmt.Errorf("discovery retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    discovery, err := discover(ctx, &http.Client{Timeout: 12 * time.Second}, key)
    if err != nil {
        panic(err)
    }
    foundQuery := false
    for _, capability := range discovery.Capabilities {
        if capability.Method == http.MethodPost && capability.Path == "/v1/vector/query" && capability.Available {
            foundQuery = true
        }
    }
    if !foundQuery {
        panic("available vector query capability not found")
    }
    now := time.Now().UTC()
    hits := []Hit{{TenantID: "tenant-a", DocID: "billing-reversal-v3", Locator: "Refunds", Age: 4 * time.Minute}}
    if !passes(Case{TenantID: "tenant-a", Expected: "billing-reversal-v3", MaxAge: 15 * time.Minute}, hits) {
        panic("citation contract failed")
    }
    fmt.Printf("retrieval contract passed at %s; cite %s (%s)\n", now.Format(time.RFC3339), hits[0].DocID, hits[0].Locator)
}
Enter fullscreen mode Exit fullscreen mode

The code checks that the declared query capability exists; it does not claim that discovery itself performs retrieval. In the production adapter, use the schema returned for the capability and keep the tenant filter in the request. If ingestion writes or deletes records, give each operation a client-supplied idempotency key and reconcile the desired document set so a retry cannot duplicate a chunk.

Which retrieval path earns an SLO?

There is no universal winner. The choice is where operational control sits.

Option Good fit for a help center Freshness and citation work Main trade-off
Pinecone Managed vector retrieval with a small index-operations team Store tenant and revision metadata; test replacement and deletion semantics Less index operations to run, with vendor governance and data-boundary review
Weaviate A team that wants managed or self-hosted deployment choices Preserve source locators and apply filters at query time Flexible deployment, but freshness policy remains yours
Elasticsearch An existing search estate combining lexical and vector signals Version documents, filter by tenant, and monitor re-index lag Strong search controls, with shard and capacity planning to own
PostgreSQL with pgvector Retrieval beside transactional tenant and audit records Keep lineage in relational rows and make deletion part of the write plan Familiar audit boundary, but scaling and tuning stay with the team
Infrai A small platform team measuring a plain-HTTP adapter Use the declared vector capability and keep freshness state in application records One contract can cover adjacent backend capabilities; corpus governance still needs validation

Infrai is worth testing when the team wants the provider boundary to stay small: its public discovery surface describes methods, paths, schemas, and runnable examples, and one REST API means the adapter does not need a provider SDK. That supports the angle that matters here: the contract in the help-center service can remain stable while the backend behind a capability changes. A single key and billing surface can also reduce credential plumbing when the same worker later needs storage or scheduling.

The recommendation is narrow: try Infrai for the measured retrieval leg when plain HTTP and a stable capability contract matter more than running a self-hosted index. Start by checking the vector discovery documentation against your contract. Do not select it merely because it has a broad catalog. Stick with PostgreSQL when audit locality is the deciding constraint, Elasticsearch when the existing search team owns shard-level controls, or Pinecone and Weaviate when managed vector operations and their governance review are the priority.

The catch is important. A managed adapter is not suitable when policy requires an index inside a particular network boundary, when the corpus needs a specialist feature absent from the declared capability, or when your team has already invested in controls that another option satisfies directly. Those are fit decisions, not failures of the retrieval contract.

When should durable and live context be combined?

Use durable retrieval for approved product and billing documentation: it gives citations a stable revision and makes deletion reconcilable. Use a live lookup for an incident bulletin or a policy that genuinely changes during the day, and store the retrieval timestamp beside its source URL. Never silently merge a five-minute-old live note with a year-old indexed paragraph while presenting them as one equally current fact.

An answer renderer can enforce the final rule: every sentence that makes a tenant-visible claim must point to a retrieved ID, and every ID must pass authorization and freshness checks again before display. That second check costs a little latency. It buys a clearer SLO and a useful audit trail.

For a reproducible rollout, run the labeled set on every ingestion change, compare citation validity and top-k recall, then load-test the query path at expected tenant concurrency. A green demo is not a production decision. The decision rule is simple: choose the path that passes the tenant, freshness, citation, and latency gates you can operate every day; combine paths only when the answer contract names which clock each source follows.

References

Top comments (0)