DEV Community

QuintonShaw1483
QuintonShaw1483

Posted on

Implement Per-Tenant Isolation — 1 Vector Collection Metadata Filter for Property Alerts

Treat tenant_id as a required retrieval input, attach it to every indexed chunk, and apply one exact-match metadata predicate inside the search request. Never retrieve a shared candidate set and remove other tenants afterward. For a property-management help center that watches web pages and alerts on diffs, this rule protects isolation while keeping one collection operationally manageable.

TL;DR: make the unfiltered search method private, reject empty tenant IDs before calling the index, and test with deliberately similar pages from two tenants. If the index cannot enforce the predicate during candidate selection, use separate collections or namespaces instead. The design goal is straightforward: a cross-tenant result count of zero, measured continuously, with a rollback path that disables retrieval-based alerts without discarding source documents.

How can one vector collection implement per-tenant isolation?

Retrieval-augmented generation combines a retriever with a generator, so isolation has to hold before retrieved text reaches any prompt or alert payload. A filter applied after approximate search is too late: unauthorized chunks have already crossed the retrieval boundary, and removing them can also leave too few valid neighbors. Empty results are safer than foreign results.

The property-management case makes collisions likely. Two operators may monitor the same municipal guidance page, use templates with nearly identical lease language, or ingest copies of a building notice. Semantic similarity is doing its job when those chunks cluster closely; authorization must be a separate, exact constraint.

Similarity is not permission.

Use an opaque, immutable tenant identifier. Do not filter on a customer name, domain, property name, or user-supplied label. Normalize ownership when the document enters the system, then copy that identifier onto every derived chunk. A useful record contract is small:

type Chunk struct {
    ID         string
    TenantID   string
    DocumentID string
    SourceURL  string
    Content    string
    Checksum   string
}
Enter fullscreen mode Exit fullscreen mode

The checksum supports change detection; it is not an authorization field. The source URL is evidence for an alert, but it is not ownership either, because multiple tenants may watch the same page.

Put the invariant in one search boundary

The safest application interface does not expose a generic Search(query, filter) method to handlers. It accepts trusted tenant context and constructs the metadata predicate internally. That removes a recurring failure mode: one new endpoint remembers semantic search but forgets isolation.

package retrieval

import (
    "context"
    "errors"
)

type Match struct {
    ChunkID  string
    TenantID string
    Text     string
    Score    float64
}

type Index interface {
    Search(ctx context.Context, vector []float32, topK int, exact map[string]string) ([]Match, error)
}

type Embedder interface {
    Embed(ctx context.Context, text string) ([]float32, error)
}

type Service struct {
    index Index
    embed Embedder
}

func (s *Service) SearchTenant(ctx context.Context, tenantID, query string, topK int) ([]Match, error) {
    if tenantID == "" {
        return nil, errors.New("tenant ID is required")
    }
    if topK < 1 || topK > 100 {
        return nil, errors.New("topK must be between 1 and 100")
    }

    vector, err := s.embed.Embed(ctx, query)
    if err != nil {
        return nil, err
    }

    matches, err := s.index.Search(ctx, vector, topK, map[string]string{
        "tenant_id": tenantID,
    })
    if err != nil {
        return nil, err
    }

    for _, match := range matches {
        if match.TenantID != tenantID {
            return nil, errors.New("retrieval isolation violation")
        }
    }
    return matches, nil
}
Enter fullscreen mode Exit fullscreen mode

The final loop is defense in depth, not the primary filter. On mismatch, fail the whole request and increment a security counter; returning the apparently valid subset hides an incident. Tenant context should come from authenticated server-side claims or an authorization lookup, never directly from a request body.

Keep ingestion under the same ownership contract. A page fetch produces a document for one tenant, chunking inherits that tenant, and upserts use IDs that cannot overwrite another tenant's record. If a shared page is watched by 40 tenants, there is a genuine cost choice: duplicate chunks for simple ownership semantics, or store content once and maintain a tenant-to-document authorization relation. The latter may reduce index bytes, but only use it when the retrieval engine can enforce the relation during search; an application-side join after retrieval recreates the leak.

Capacity planning for one collection

One collection avoids a collection-per-tenant control plane, but it does not make index cost disappear. Estimate indexed chunks as watched pages multiplied by retained page versions multiplied by chunks per version. Measure those three quantities from production input distributions rather than choosing a single optimistic average. Retention is often the cleanest lever because a diff alert usually needs current evidence and a bounded audit history, not every embedding forever.

The filter also changes capacity tests. Benchmark with the real tenant-size distribution, including small tenants whose eligible vectors are a tiny fraction of the collection. Record filtered-search latency, empty-result rate, recall on a labeled query set, index bytes, and write amplification during recrawls. A global latency percentile can conceal one tenant cohort, so report the retrieval SLI by tenant-size bucket while keeping tenant IDs out of high-cardinality metric labels.

Here is the buy-versus-build decision I would take to roadmap review:

Option Isolation boundary Index-cost behavior On-call burden Lock-in
Shared collection with exact metadata filtering Query predicate plus application assertion Shared index; duplicate ownership metadata and possibly chunks Requires filter-contract tests and leak telemetry Filter syntax and execution semantics vary
Collection or namespace per tenant Physical or logical partition selected before search More partitions and lifecycle operations Provisioning, quotas, migrations, and cleanup expand Namespace limits and APIs vary
Self-managed index with an authorization-aware query layer Team-owned storage and query code Tunable, but capacity and compaction are yours Highest operational surface Lowest service dependency, highest internal ownership

No row wins universally. Shared storage is reasonable only after the engine's documented filter semantics and your adversarial tests demonstrate that filtering occurs during retrieval. Tenant partitions are the conservative fallback for engines that cannot make that guarantee or for tenants requiring a separate deletion and residency boundary.

This approach has limitations.

The trade-off is concentrated operational simplicity in exchange for a larger shared blast radius and dependence on the index's filtering semantics. Choose separate collections when deletion, residency, encryption keys, noisy-neighbor controls, or independent restore boundaries must differ by tenant; choose a shared collection when those boundaries can remain common and the engine demonstrably applies exact metadata filtering before returning candidates. A relational authorization join may reduce duplicated chunks for pages watched by many tenants, but it adds another stateful system and must participate in candidate selection. Those costs belong in the capacity plan, not in a footnote.

Verify the alert path, not just search

Start with two tenants and the same query. Give tenant A and tenant B nearly identical chunks, including a distinctive canary string in B's content, then assert that A never receives B's chunk ID, text, source URL, or canary. Repeat the test for pagination, retries, caches, background jobs, and any hybrid keyword-plus-vector path. The alert renderer deserves its own assertion because a correctly filtered search can still be mixed with stale cached context under an incomplete cache key.

Test deletion as an ownership operation. Removing a tenant should make its chunks unretrievable, cancel its page-watch jobs, invalidate tenant-scoped caches, and prevent queued alerts from sending. Verify that another tenant watching the same URL remains intact.

The operational objectives should be asymmetric. Isolation violations have a zero-event target; latency and recall use conventional SLOs chosen from product needs. Useful telemetry includes rejected empty-tenant requests, filtered query latency, results discarded by the defense-in-depth assertion, and alert suppression after authorization failure. Never put retrieved text or raw page bodies into logs.

Run these checks before deployment and as scheduled probes against synthetic tenants. A unit test proves the wrapper built a filter. An integration test proves the selected engine honored it. Both matter.

Trust the boundary, then verify it.

Roll back without weakening isolation

If a release increases latency or empty results, do not remove the tenant predicate to restore traffic. Roll back the query implementation, reduce nonessential retrieval depth, or pause retrieval-generated alerts while ordinary page-diff detection continues. Preserve the source snapshot and diff so alerts can be replayed after recovery, subject to the same authorization check.

A migration between index layouts needs dual writes before read switching, tenant-scoped count and checksum comparisons, and a reversible read flag. Do not merge results from old and new layouts unless both searches independently enforce the same tenant boundary. After cutover, keep the old index read-only for the rollback window, then delete it according to the retention policy.

The decision rule remains strict: one collection is an efficiency choice, not an authorization shortcut. Ship it when the tenant predicate is structurally mandatory, verified at the returned-result boundary, exercised through the entire alert path, and observable without logging customer content. Otherwise, partition the index and accept the extra operations.

References

Top comments (0)