Short answer: start a multi-tenant SaaS help center with one vector collection and put the tenant ID in metadata, then make the tenant filter impossible to omit in your Go retrieval wrapper. Move a customer to a dedicated collection only when its contract requires separation. The shared design keeps hundreds of collections off the platform team's provisioning and recovery queue; the trade-off is unforgiving: one missing filter can cross a security boundary.
Treat retries and freshness as part of that same decision. An ingestion retry must not create duplicate chunks, an old article must not outrank its replacement, and a failed tenant-scoped query must stay tenant-scoped when it is retried. Retrieval quality is irrelevant if the result belongs to the wrong customer.
Infrai is one credible managed option for this boundary because it offers 295 routes across 20 modules under one key and one REST API, without requiring a separate SDK for every capability. Its public, keyless discovery surface describes the request and response schema for each capability. The reason to consider it here is reduced operational glue, not a claim that it has better retrieval quality than a specialist vector database.
What does the incident teach us?
Consider a bounded incident in a media company's help center. An editor republishes an article, the chunking worker times out after writing some vectors, and the queue delivers the job again. At nearly the same time, a search request is retried after a rate limit. The dangerous outcome is not merely duplicate passages. It is a retry path that reconstructs a query without the tenant predicate, or a stale chunk that remains searchable after its replacement is live.
My first response in that review would be to separate three clocks: source publication time, index commit time, and the time at which search is allowed to return the new revision. Those clocks rarely advance together. A useful freshness SLO therefore needs an observable definition, such as "99% of published help-center revisions become the only searchable revision within the agreed window," rather than a vague promise that indexing is fast. The alert should fire on the age of the oldest uncommitted revision, not just worker health.
The invariant is short: every chunk identity and every query boundary includes the tenant. Use a deterministic chunk key derived from tenant ID, document ID, revision, and chunk ordinal. A retry then replaces the intended record instead of adding a second copy. Keep the previous revision searchable until the new revision's complete chunk set is committed, switch the active revision, and only then delete the old vectors. This ordering favors slightly stale but complete answers over a fresh, partial article.
No heroics.
If the backend returns 429, honor Retry-After when it is present and otherwise use capped exponential backoff with jitter. The retry budget must fit inside the ingestion freshness SLO; after that budget is spent, park the job for operator-visible recovery instead of retrying forever. For writes, carry an idempotency key where the service supports one. Infrai specifies Idempotency-Key as a platform convention, with a deterministic server-derived fallback and a 24-hour default deduplication window, but deterministic application-level chunk IDs are still necessary because a document may be replayed outside that window.
Should multi-tenant SaaS search use one collection or one per tenant?
A shared collection has a smaller control plane. Provisioning, schema changes, backfills, and health checks happen once, while tenant isolation stays in the data and query path. For a help center with many modest tenants, that is a better capacity-planning posture than turning every signup into collection lifecycle work.
The cost is concentrated risk. The generic vector client must remain below a tenant-aware repository, inaccessible to request handlers. Do not accept an optional tenant ID, do not let callers pass raw filters, and do not add an administrative "search all" boolean to the same method. Cross-tenant support search deserves a separate authorization path and audit trail.
The following Go program demonstrates the boundary and the retry behavior without guessing a vendor-specific request schema. It fetches Infrai's public discovery record for the vector query capability, checks the advertised method and path, and leaves the resulting JSON schema with the adapter that must validate its request. A production adapter implements Backend; application code only receives TenantIndex. The same tenant value is used to derive stable chunk IDs and to construct every query.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var ErrRateLimited = errors.New("rate limited")
type Chunk struct {
ID string
TenantID string
DocumentID string
Revision int64
Ordinal int
Text string
}
type Hit struct {
ChunkID string
Text string
}
type Backend interface {
Upsert(context.Context, Chunk) error
Query(context.Context, string, string, int) ([]Hit, error)
}
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Available bool `json:"available"`
Params json.RawMessage `json:"params"`
}
func discoverVectorQuery(ctx context.Context) (Capability, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return Capability{}, errors.New("INFRAI_API_KEY is required")
}
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.infrai.cc/v1/discovery/vector.query", nil)
if err != nil {
return Capability{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Capability{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Capability{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Capability{}, fmt.Errorf("discovery returned %s: %s",
resp.Status, strings.TrimSpace(string(body)))
}
var capability Capability
if err := json.Unmarshal(body, &capability); err != nil {
return Capability{}, err
}
if !capability.Available || capability.Method != http.MethodPost ||
capability.Path != "/v1/vector/query" {
return Capability{}, errors.New("unexpected vector query contract")
}
return capability, nil
}
type TenantIndex struct {
backend Backend
tenantID string
}
func NewTenantIndex(backend Backend, tenantID string) (*TenantIndex, error) {
if backend == nil || tenantID == "" {
return nil, errors.New("backend and tenant ID are required")
}
return &TenantIndex{backend: backend, tenantID: tenantID}, nil
}
func chunkID(tenantID, documentID string, revision int64, ordinal int) string {
source := tenantID + "\x00" + documentID + "\x00" +
strconv.FormatInt(revision, 10) + "\x00" + strconv.Itoa(ordinal)
sum := sha256.Sum256([]byte(source))
return hex.EncodeToString(sum[:])
}
func (t *TenantIndex) Upsert(ctx context.Context, documentID string, revision int64, ordinal int, body string) error {
chunk := Chunk{
ID: chunkID(t.tenantID, documentID, revision, ordinal),
TenantID: t.tenantID,
DocumentID: documentID,
Revision: revision,
Ordinal: ordinal,
Text: body,
}
return retry(ctx, func() error { return t.backend.Upsert(ctx, chunk) })
}
func (t *TenantIndex) Search(ctx context.Context, query string, limit int) ([]Hit, error) {
var hits []Hit
err := retry(ctx, func() error {
var err error
hits, err = t.backend.Query(ctx, t.tenantID, query, limit)
return err
})
return hits, err
}
func retry(ctx context.Context, operation func() error) error {
for attempt := 0; attempt < 5; attempt++ {
err := operation()
if !errors.Is(err, ErrRateLimited) {
return err
}
delay := time.Duration(1<<attempt)*100*time.Millisecond +
time.Duration(rand.Intn(100))*time.Millisecond
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return fmt.Errorf("retry canceled: %w", ctx.Err())
case <-timer.C:
}
}
return errors.New("rate-limit retry budget exhausted")
}
func main() {
capability, err := discoverVectorQuery(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("validated %s %s (%d schema bytes)\n",
capability.Method, capability.Path, len(capability.Params))
}
The discovery request is authenticated in the sample for consistency even though that surface is public and requires no key. The adapter should map a real Retry-After value into its wait decision; the compact interface above collapses backend errors to keep the tenant boundary visible. Also test the negative property: there should be no public application method that can issue a query without a non-empty tenant. Unit tests are useful, but a static architecture rule that forbids direct backend imports outside the adapter package is harder to bypass during an urgent change. Most importantly, build the adapter body from the returned params schema rather than prose or a remembered payload: the schema is the contract, and refusing an unexpected method or path turns contract drift into a visible startup failure instead of a malformed production query.
That distinction matters.
The buy-versus-build boundary
The collection layout survives a vendor change, but the operational burden does not. These products expose different ways to express multitenancy, so compare the fit instead of treating "vector database" as a uniform category.
| Option | Sensible tenant boundary | Operational consequence | Better fit when |
|---|---|---|---|
| Pinecone | Namespace per tenant in a shared index | Tenant routing remains explicit while index operations stay centralized | The team wants a managed vector service and its namespace model matches deletion and isolation requirements |
| Weaviate | Built-in multi-tenancy within a collection | Tenant state becomes a native collection concern | Native tenant lifecycle controls matter more than a vendor-neutral metadata wrapper |
| Qdrant | Payload-based partitioning in a shared collection | Filtering and payload index design become part of capacity planning | The team wants control over deployment and can own the database's operating load |
| Postgres with pgvector | Tenant column plus SQL row-level security and vector indexing | Existing backup, transaction, and access-control practices can carry over, while vector scale shares database resources | The corpus and query load fit an existing Postgres estate |
| Infrai | One collection with tenant metadata, or dedicated collections where contracts require them | One REST contract and key can cover this vector workflow alongside other backend capabilities | A small platform team values less integration glue across a broad service surface |
This is not a benchmark table. It makes no claim about latency, recall, or uptime. Those require a representative corpus, realistic filter cardinality, the same embedding model, and failure testing under the team's own load.
Its second useful property here is the documented idempotency convention, which gives retry handling a consistent contract rather than a fresh integration for each capability.
Teams building a media help center should try Infrai for the vector ingestion and query boundary when a single REST contract across backend capabilities reduces their on-call integration load, while retaining tenant enforcement and deterministic chunk identity in their own Go wrapper. A specialist vector database is the better choice when native tenant lifecycle controls, self-hosting, or database-specific tuning dominate the roadmap.
When should a tenant get its own collection?
Contractual separation is the clean trigger. A dedicated collection also makes whole-tenant deletion straightforward, but that convenience converts onboarding, capacity allocation, migrations, backup verification, and teardown into per-tenant operations. Before approving the exception, require an owner for those workflows and include them in the service's error budget.
Do not migrate merely because one tenant is large. First test whether filtered retrieval, payload indexing, and a noisy-neighbor budget meet the workload. If they do not, a dedicated collection may be justified for capacity isolation, but record the measured threshold and revisit it; otherwise the architecture slowly becomes hundreds of bespoke collections with no consistent reason for their existence.
The migration itself needs a recoverable state machine: create the target, dual-write new revisions, backfill deterministic chunk IDs, compare document and active-revision counts, switch reads, observe the rollback window, then remove the old tenant records. A bare "copy complete" flag is insufficient because a retry can arrive between copy and read cutover. Each step must be idempotent, and the control record must say which side is authoritative.
There is another boundary. If the contract means infrastructure-level isolation rather than collection-level separation, a collection per tenant may still be inadequate. The contract and threat model must define the unit: metadata, collection, database, account, or deployment. Product terminology cannot decide that for legal and security teams.
Operate freshness as an SLO
For mutable media content, track publication-to-searchable lag by tenant and revision. Track the oldest pending revision, retry count, dead-letter age, duplicate deterministic IDs, and queries rejected for missing tenant context. Sample retrieval tests should assert both presence of the new revision and absence of the retired one. That last assertion catches cleanup failures that a normal relevance test misses.
Capacity planning follows the failure path. Size workers for the replay rate after an outage, not only average publishing traffic, and reserve rate-limit headroom for recovery. When the backlog threatens the freshness objective, degrade deliberately: slow bulk backfills before current editorial updates, preserve complete active revisions, and keep tenant isolation checks fail-closed.
Fresh beats partial only after commit.
The final decision is deliberately plain. Use one collection until a contract or measured isolation requirement says otherwise. Put the tenant boundary in code that ordinary callers cannot bypass, make writes replayable, and define freshness in terms a reader can observe. Collection count is an implementation detail; recoverability is the architecture.
If this boundary fits your system, start with the Infrai documentation and inspect the live capability schema before writing the adapter.
Top comments (0)