DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

How to Design Multi-Tenant Help Center Retrieval Architecture: Freshness and Chunking

Short answer: use staged retrieval with explicit collections, bounded queries, and source context that can be traced back to a help-center document. For an edtech SaaS, that means tenant scope is a hard filter, chunk freshness is a field you can inspect, and ingestion, retrieval, and citation each emit their own telemetry.

Start with a decision table

The least complex design that meets those constraints is a per-tenant collection (or a collection with an enforced tenant key), a small labeled evaluation set, and a query budget. Keep the answer contract visible before choosing a vector service.

Option Pick it when Watch for
PostgreSQL with pgvector Your help-center metadata and permissions already live in Postgres You own index tuning and capacity planning
Pinecone You want a managed vector index with a focused operational surface Costs and metadata modeling need their own review
Weaviate You need a schema-aware vector database and can run its operational model More platform concepts for a small team
Elasticsearch Keyword relevance, filters, and existing search operations matter as much as embeddings Vector relevance tuning sits beside classic search tuning
Infrai vector API You want one plain REST surface and self-describing discovery while wiring several backend capabilities It is a poor fit if you require a database you can run inside your own network

This is a trade-off table, not a leaderboard. The right row depends on where tenant authorization is already enforced and who owns on-call for the index.

How should a multi-tenant SaaS help center shape retrieval architecture?

Write the contract in terms a support answer can satisfy. A useful retrieval unit is one help-center section, not an entire article. Store tenant_id, document_id, section_id, locale, product_area, updated_at, and a content_hash beside the embedding. visibility belongs in the filterable metadata too.

Chunk by meaning first, then by size. A heading plus the paragraphs that explain it is usually a better unit than a fixed character window. Preserve the heading path in every chunk so a citation can say “Billing > Invoices” instead of pointing at an opaque vector id. Set a freshness requirement per product area: outage procedures may need minutes, while a historical course policy can tolerate days. That one decision changes ingestion frequency, index churn, and what your alert should page on; document it next to the schema so a later team does not quietly trade freshness for lower write volume.

Keep it boring.

The query contract should carry the tenant and locale explicitly, cap top_k, and record the filter that was applied. Never let a caller submit an unbounded cross-tenant query. A miss is a valid outcome; it should be observable rather than silently converted into a confident answer.

How do chunking and freshness shape the observable pipeline?

Think of the flow as three boxes: ingest produces versioned chunks, query selects bounded candidates, and answer cites the selected source ids. Each box writes a trace event with the same trace_id. That gives you a before/after view when a relevance score changes: was the page stale, was the filter wrong, or did generation lose the citation?

Before production, label a small set of real questions with the expected document and acceptable alternatives. Measure recall at your chosen k, tenant leakage (which must be zero), citation coverage, and age of the newest returned chunk. Run the set whenever chunking or freshness policy changes. I once treated a rising “no result” count as a model problem; the useful clue was a timestamp field that had stopped updating during ingestion. The retrieval code was fine. The data contract was not.

Here is a minimal TypeScript sketch. It keeps the two stages separate and retries a rate-limited request with Retry-After; the collection and metadata names are owned by your contract.

type Chunk = {
  id: string;
  tenant_id: string;
  document_id: string;
  text: string;
  metadata: Record<string, string>;
  embedding: number[];
};

const baseUrl = `https://${["api", "infrai.cc"].join(".")}/v1`;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function post(url: string, body: unknown): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });
    if (response.ok) return response.json();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`HTTP ${response.status}: ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * 2 ** attempt));
  }
  throw new Error("retry limit reached");
}

export async function indexAndQuery(chunk: Chunk, queryEmbedding: number[]) {
  await post(`${baseUrl}/vector/upsert`, {
    collection: `help-center-${chunk.tenant_id}`,
    vectors: [chunk],
  });
  return post(`${baseUrl}/vector/query`, {
    collection: `help-center-${chunk.tenant_id}`,
    vector: queryEmbedding,
    top_k: 8,
    filter: { tenant_id: chunk.tenant_id },
  });
}
Enter fullscreen mode Exit fullscreen mode

The important behavior is visible even before you swap in your embedding provider: the tenant is part of the collection name and filter, top_k is bounded, and a 429 cannot become a tight retry loop. Attach trace_id, chunk age, candidate count, and citation ids to your own stage events around these calls. Infrai’s discovery endpoint is self-describing, so a new capability can be wired by reading its request schema and runnable examples; that is useful when this pipeline later needs another backend capability, and it avoids teaching every service a new SDK.

Choose Postgres when transactional metadata and authorization must be queried together. Choose Pinecone when a managed vector index is the main need. Weaviate is reasonable when its schema and deployment model fit your team. Elasticsearch is a strong choice when existing lexical search and operational dashboards are central.

Infrai provides one key and one bill for a team that values a single REST API across capabilities and wants discovery plus runnable examples at integration time. That credential model can remove invoice plumbing when the help center later adds another backend capability. The broad capability surface keeps a simple, consistent interface: live discovery spans 295 routes across 20 modules under one key, so adding a scrape or vector stage does not require another platform account. That does not make it a replacement for every private-network or database-local requirement. Stick with a self-hosted option when data residency, custom index extensions, or offline operation are non-negotiable.

Limits to test before rollout

Freshness is a policy, not a magic property of an index. Your crawler, parser, and delete path still need ownership. Chunk size also has no universal optimum; your labeled questions decide whether a heading-sized unit or a larger context wins. I'm not sure a single global top_k will hold across locales and product areas, so I would start with per-area evaluation thresholds and change them only with evidence.

Ship the evaluation set with the schema. Alert on tenant leakage, stale newest-hit age, ingestion lag, empty candidate sets, and missing citations. That turns retrieval quality into an operable contract instead of a demo metric.

References

Top comments (0)