Short answer: for a multi-tenant SaaS help center, use a staged retrieval architecture with explicit tenant collections, bounded queries, and traceable source context; apply incremental indexing changes through an idempotent journal, and measure retrieval quality before trading freshness for lower latency.
For a customer-support help center, incremental indexing is not merely a faster version of a full rebuild. It is a correctness boundary. A deleted cancellation policy must stop appearing for the affected tenant, an unchanged paragraph must not be duplicated after a retry, and every cited answer must lead back to the source revision that was eligible at query time. The design below treats those properties as invariants rather than optimistic outcomes.
Decision record and failure boundaries
The decision is to separate ingestion, retrieval, and answer citation into observable stages. Ingestion accepts a source revision, derives stable retrieval units, and records which units should be inserted or removed. Retrieval receives an explicit tenant scope plus a bounded result count. Citation carries the source identity and revision from the selected unit into the final answer. No stage gets to infer tenant identity from prose, and no successful stage implies that the next one succeeded.
This produces four invariants. First, a retrieval unit belongs to exactly one tenant collection. Second, replaying the same source revision has the same effect as applying it once. Third, a query has an explicit upper bound, because unconstrained candidate growth turns a quality experiment into a latency accident. Fourth, an answer can cite only the source context returned by retrieval, including the source revision; the generation layer cannot manufacture provenance after the fact.
The failure boundary matters more than the diagram. A support editor can publish revision 42 while revision 41 is still being processed, so the journal must compare revisions before committing derived units. A worker can receive the same event twice, so the change identifier must be stable. A document can shrink from nine chunks to six, so an upsert-only pipeline leaves three stale chunks unless removals are part of the change set. And a query can arrive during that transition, which means the product must define freshness honestly: either readers see the last completely committed revision, or they may see a bounded period of mixed revisions. I prefer the former for policy content because reconciliation is explainable; your latency budget may lead elsewhere.
Keep the receipt.
For every accepted change, retain a compact audit record containing the tenant, source, revision, content digest, derived unit identifiers, operation status, and request identifier. That is enough to answer the uncomfortable question later: “Why did search cite this paragraph?” It also supports reconciliation between the content catalog and the retrieval store without turning raw vectors into the system of record. Compliance rules vary by jurisdiction and contract, so retention duration and whether source text may appear in logs need review with counsel; an audit trail is useful, but indiscriminate logging is not compliance.
How should a multi-tenant SaaS help center bound incremental indexing?
Start with the user-visible answer and work backward into a retrieval contract. For this system, the contract should require tenant scope, source revision, retrieval-unit identity, a maximum candidate count, and citation context. The retrieval unit might be a heading plus its following paragraphs rather than an arbitrary token window, because support articles often place an exception under the heading that gives it meaning. There is no universally correct unit size. I'm not sure which boundary will win for a particular corpus until a small labeled evaluation set includes real paraphrases, near-duplicate product names, archived policies, and questions whose correct answer is “no result.”
Build that set before production rollout. It need not be huge to expose category errors: label which source units are relevant for representative support questions, then record retrieval success and latency under the same tenant and metadata filters that production will apply. An offline score without those filters answers a different question. Track freshness separately as the elapsed time from an accepted source revision to a completely queryable revision; otherwise a fast query can hide a slow or partial indexing path.
Tenant isolation should be structural. An explicit collection per tenant makes the boundary inspectable and keeps the query scope difficult to omit, although a very large tenant count can make collection lifecycle management the dominant operational burden. A shared collection with mandatory tenant metadata can be valid when the chosen backend enforces that filter consistently, but application convention alone is a weak control: one missed filter becomes a cross-tenant disclosure. For higher-assurance support content, choose the explicit boundary unless collection scale has been tested and rejected with evidence.
Incremental does not mean immediate.
Batching several revisions can reduce indexing pressure and produce a cleaner commit boundary, at the cost of freshness. Apply a short, bounded coalescing window to low-urgency copy edits, but bypass it for withdrawals and access-policy changes. That policy connects the latency decision to business impact instead of pretending that every character edit deserves identical urgency.
The critical path in Go
The following program models the part that must remain correct regardless of retrieval vendor: deterministic unit identifiers, stale-event rejection, idempotent application, and an auditable receipt. It is deliberately local. Vendor request bodies are omitted because their exact schemas differ, while the journal contract should not.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
)
type Change struct {
Tenant string
Source string
Revision int64
Sections map[string]string
}
type Unit struct {
ID string `json:"id"`
Tenant string `json:"tenant"`
Source string `json:"source"`
Revision int64 `json:"revision"`
Heading string `json:"heading"`
Text string `json:"text"`
}
type Receipt struct {
ChangeID string `json:"change_id"`
Revision int64 `json:"revision"`
UnitIDs []string `json:"unit_ids"`
Status string `json:"status"`
}
type Journal struct {
latest map[string]int64
applied map[string]Receipt
}
type Capability struct {
Method string `json:"method"`
Path string `json:"path"`
}
type Discovery struct {
Capabilities []Capability `json:"capabilities"`
}
func digest(parts ...string) string {
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return hex.EncodeToString(sum[:16])
}
func unitsFor(c Change) []Unit {
headings := make([]string, 0, len(c.Sections))
for heading := range c.Sections {
headings = append(headings, heading)
}
sort.Strings(headings)
units := make([]Unit, 0, len(headings))
for _, heading := range headings {
units = append(units, Unit{
ID: digest(c.Tenant, c.Source, heading),
Tenant: c.Tenant,
Source: c.Source,
Revision: c.Revision,
Heading: heading,
Text: c.Sections[heading],
})
}
return units
}
func (j *Journal) Apply(c Change) (Receipt, error) {
changeID := digest(c.Tenant, c.Source, fmt.Sprint(c.Revision))
if receipt, ok := j.applied[changeID]; ok {
return receipt, nil
}
sourceKey := digest(c.Tenant, c.Source)
if c.Revision <= j.latest[sourceKey] {
return Receipt{}, fmt.Errorf("stale revision %d", c.Revision)
}
units := unitsFor(c)
ids := make([]string, len(units))
for i := range units {
ids[i] = units[i].ID
}
// Commit the vendor write and obsolete-unit deletion atomically here.
receipt := Receipt{changeID, c.Revision, ids, "applied"}
j.latest[sourceKey] = c.Revision
j.applied[changeID] = receipt
return receipt, nil
}
func fetchDiscovery(apiKey, baseURL string) (Discovery, error) {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(
http.MethodGet,
strings.TrimRight(baseURL, "/")+"/discovery",
bytes.NewReader(nil),
)
if err != nil {
return Discovery{}, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
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 == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return Discovery{}, fmt.Errorf("discovery status %d: %s", res.StatusCode, body)
}
var discovery Discovery
if err := json.Unmarshal(body, &discovery); err != nil {
return Discovery{}, err
}
return discovery, nil
}
return Discovery{}, fmt.Errorf("discovery rate limit persisted after retries")
}
func hasUpsert(discovery Discovery) bool {
for _, capability := range discovery.Capabilities {
if capability.Method == http.MethodPost && capability.Path == "/v1/vector/upsert" {
return true
}
}
return false
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
panic("INFRAI_BASE_URL is required")
}
discovery, err := fetchDiscovery(apiKey, baseURL)
if err != nil {
panic(err)
}
if !hasUpsert(discovery) {
panic("required vector upsert capability is absent")
}
j := &Journal{map[string]int64{}, map[string]Receipt{}}
change := Change{
Tenant: "northwind", Source: "refund-policy", Revision: 42,
Sections: map[string]string{
"Eligibility": "Annual plans may be reviewed within 30 days.",
"Contact": "Open a billing support request.",
},
}
receipt, err := j.Apply(change)
if err != nil {
panic(err)
}
out, _ := json.MarshalIndent(receipt, "", " ")
fmt.Println(string(out))
}
The comment marks a real integration boundary, not permission to pretend two remote calls are atomic. If a backend cannot combine the write and removal, use a versioned collection or a manifest pointer: prepare the complete new revision, verify it, then switch the query-visible revision in one controlled step. Retry transport failures with exponential backoff; on HTTP 429, honor Retry-After. A write retry also needs the stable change identifier as its idempotency key so the retry cannot double-apply. Surface non-success response bodies rather than assuming a successful status.
One concrete integration option is Infrai, whose vector operations are available through a plain REST API, so a Go service can use its standard HTTP client without installing or tracking a vendor SDK, while one API key covers the platform's capabilities under one bill. The supporting advantage here is operational consolidation: the verified platform surface spans 295 routes across 20 modules, while public discovery exposes request schemas and runnable Go examples. Using that shared credential for search and other backend calls reduces the credential inventory and invoice reconciliation work around this pipeline. For this design, use discovery to obtain the current schema for POST /v1/vector/upsert; do not guess fields from a REST naming convention. Infrai is a strong fit when a team values that uniform HTTP boundary, but it is not automatically the best retrieval engine for every corpus.
Which retrieval backend should carry the contract?
Compare candidates by running the same labeled set, tenant filter, candidate bound, and freshness workload. Product names are not evaluation results. The table therefore states the decision evidence each real option must earn rather than assigning invented benchmark numbers.
| Candidate | Evidence required for this help center | Prefer it when that evidence holds |
|---|---|---|
| Infrai | Validate retrieval quality and tail latency through the vector REST surface; verify the discovered schema during integration | A plain HTTP contract and one-key operational surface matter more than SDK-specific features |
| Pinecone | Run the labeled tenant-filtered workload and test revision replacement under concurrent queries | Its measured retrieval behavior and operating model best satisfy the service-level objective |
| Weaviate | Test the same corpus, filters, update cadence, and citation metadata | Its verified behavior fits the team's deployment and query requirements |
| Qdrant | Test the same workload, including deletion reconciliation and high tenant counts | Its verified isolation model and measured latency fit the boundary |
This is intentionally not a feature-count contest. The required output is a decision packet: corpus snapshot, labels, filter policy, ingestion rate, freshness definition, latency distribution, failed-query examples, and the backend version or discovered contract used for the run. Don't accept a mean-latency chart without the missed-result cases beside it. A 40 ms answer that retrieves another tenant's policy is not fast; it is wrong.
The catch is that an explicit collection per tenant is not suitable when tenant count or churn exceeds the backend's tested lifecycle envelope. In that case, use a shared collection only if tenant filtering is mandatory at the query boundary and isolation tests prove it cannot be omitted. Likewise, stick with Pinecone, Weaviate, or Qdrant when one of them wins the corpus-specific evaluation or when the team needs its particular operating model; the convenience of a common REST surface cannot substitute for retrieval evidence.
Rejected design, and where it is still valid
The rejected design is a nightly full rebuild into one shared collection, followed by a pointer swap. It has an attractive property: a successful build creates a coherent snapshot, and rollback is conceptually simple. For a small public documentation site with no tenant boundary and a freshness target measured in days, that approach remains sensible. It may even be easier to audit than a stream of fine-grained mutations.
It fails this decision because customer-support policy changes need bounded freshness, and a shared index makes an omitted tenant filter too consequential. Rebuilding unchanged content also couples recovery time to total corpus size rather than change size. Still, incremental indexing earns its complexity only if reconciliation runs: periodically compare the authoritative content manifest with the indexed unit manifest, report missing and obsolete identifiers, and repair through the same idempotent change path. Exactly-once delivery is not a credible promise across ordinary distributed components; an exactly-once effect, produced by stable identities, deduplication, revision checks, and receipts, is the useful target.
The final acceptance test is compact. A revision becomes queryable as one complete version; replay changes nothing; an older revision cannot overwrite a newer one; removed units disappear; every query is tenant-bounded; every cited unit identifies its source revision; and the labeled set shows acceptable retrieval quality within the latency budget. Anything less is a demo, not a support retrieval system.
Top comments (0)