paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

July 18, 2026

Vector Stores for Fast AI Search: Abstraction, Multi-Tenancy, and Scaling Architecture

How to design a vector store layer that stays portable across backends and scales cleanly across tenants and collections

Introduction

A vector store that works well for a single-tenant prototype with a few thousand documents faces an entirely different set of architectural questions once it needs to serve many customers, each with their own data, at a scale where a single collection can no longer comfortably hold everything. These questions - how to isolate one tenant's data from another's, how to structure collections so the system can grow horizontally rather than just vertically, and how to keep the application layer from being permanently welded to one specific vector database's API - are rarely covered in an introductory guide to vector search, and they're exactly the questions that determine whether a system that worked in a demo can actually be operated reliably in production.

This article focuses on that architectural layer: how to build a vector store abstraction that keeps your application's retrieval code portable across backends, how to design multi-tenant collections so isolation and performance both hold up as the number of tenants grows, and how to think about horizontal scaling once a single collection or a single node is no longer sufficient. These are systems-design questions more than algorithm questions, and they deserve the same deliberate architecture as any other data layer that needs to scale across many customers and a growing dataset.

Context: Why Vector Store Architecture Becomes a Real Design Problem at Scale

A vector store accessed directly through one specific database's native client, with application code calling that client's methods throughout the codebase, works fine for a single prototype and becomes a genuine liability the moment a team needs to change backends - whether to move from a self-hosted option to a managed service, to adopt a database with better support for a specific feature, or simply to run a controlled comparison between two candidates before committing. Every place in the application that directly calls a specific vector database's API is a place that needs to be rewritten during that kind of change, and a codebase with vector store calls scattered throughout tends to make what should be a contained infrastructure decision into a much larger, riskier refactor.

Multi-tenancy introduces a second, distinct set of architectural questions that a single-tenant prototype never has to answer. When multiple customers' data lives in the same underlying vector store infrastructure, the system needs a reliable way to guarantee that a query for customer A never returns a result belonging to customer B - not as a best-effort filter that could theoretically be bypassed by a bug, but as a structural guarantee built into how collections and access are organized. This is a genuinely different problem from single-tenant retrieval quality, and it interacts with performance in a specific way: the isolation mechanism chosen (separate collections per tenant, versus a shared collection filtered by a tenant field) has real, different performance and operational characteristics that need to be weighed deliberately rather than assumed.

The third architectural question is what happens once a single collection, or a single node serving it, is no longer sufficient for the data volume or query throughput involved. A vector index's performance characteristics don't scale indefinitely on a single machine - memory limits how much of an HNSW graph can stay resident for fast search, and a single node's query throughput has a ceiling regardless of how well-tuned the index is. Horizontal scaling - sharding a collection across multiple nodes, replicating for read throughput and availability - introduces its own trade-offs around query fan-out and result merging that a single-node system never has to confront, and building an architecture that can grow into these patterns, rather than hitting a wall and requiring an emergency re-architecture, is worth planning for before it becomes urgent.

Deep Technical Explanation: Abstraction, Isolation, and Horizontal Scale

A vector store abstraction layer works the same way any well-designed data-access abstraction does: define a small, stable interface - typically upsert, search, delete, and perhaps filter - that captures the operations the application actually needs, and implement that interface once per backend, with all backend-specific details (connection handling, the exact filter syntax, index configuration) contained inside the implementation rather than leaking into application code. This is precisely the pattern that frameworks like LangChain and LlamaIndex apply with their own vector store interfaces, and building an equivalent abstraction even without adopting a full orchestration framework gives an application the same portability benefit: swapping the backend behind the interface becomes a matter of instantiating a different implementation, not rewriting retrieval logic wherever it's called.

Multi-tenant isolation has two dominant architectural patterns, and the right choice depends on tenant count, per-tenant data volume, and how strict the isolation guarantee needs to be. A collection-per-tenant pattern creates a fully separate vector collection (or index) for each tenant, which gives the strongest possible isolation - there's no shared index structure a bug in filtering logic could accidentally cross - at the cost of per-collection overhead that can become significant with a very large number of tenants, since many vector databases have real per-collection memory and management costs that don't scale gracefully to thousands of small collections. A shared-collection-with-metadata-filter pattern stores all tenants' vectors in one collection, relying on a tenant ID field checked as part of every query's filter, which avoids per-collection overhead and is often more efficient at scale, at the cost of isolation being enforced by application and query logic rather than by physical separation - a mistake in filter construction becomes a genuine cross-tenant data leak rather than a contained bug. Several purpose-built vector databases, including Weaviate and Qdrant, provide native multi-tenancy features specifically designed to get much of the collection-per-tenant isolation guarantee within a shared-collection architecture's efficiency profile, which is worth evaluating directly against a hand-rolled filtering approach before committing to either extreme.

Horizontal scaling for a single large collection generally follows the same sharding and replication patterns familiar from other distributed data systems: a large collection is partitioned across multiple nodes (sharding), with each shard holding a subset of the vectors, and a query fans out to all relevant shards, with results merged and re-ranked centrally before being returned. Replication - maintaining multiple copies of the same shard - improves both read throughput and availability, at the cost of additional storage and the need to keep replicas consistent as new vectors are written. This is precisely the distributed-systems trade-off familiar from horizontally scaled relational and NoSQL databases, applied to the specific access pattern of vector similarity search, and most purpose-built vector databases (Milvus, Qdrant, Weaviate) provide this sharding and replication behavior natively, which is a meaningful part of the case for choosing a purpose-built system once a single node's capacity is genuinely insufficient.

Implementation: Building a Portable, Multi-Tenant Vector Store Layer

The Python example below implements a minimal vector store abstraction with two concrete backends - Qdrant and pgvector - behind a single interface, so application code depends only on the abstraction rather than on either backend's specific API.

# vector_store_interface.py
from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class SearchResult:
    id: str
    score: float
    payload: dict

class VectorStore(ABC):
    @abstractmethod
    def upsert(self, id: str, vector: list[float], payload: dict, tenant_id: str) -> None: ...

    @abstractmethod
    def search(self, vector: list[float], tenant_id: str, limit: int = 10) -> list[SearchResult]: ...

    @abstractmethod
    def delete(self, id: str, tenant_id: str) -> None: ...


class QdrantVectorStore(VectorStore):
    def __init__(self, client, collection: str):
        self.client = client
        self.collection = collection

    def upsert(self, id: str, vector: list[float], payload: dict, tenant_id: str) -> None:
        from qdrant_client.models import PointStruct
        self.client.upsert(
            collection_name=self.collection,
            points=[PointStruct(id=id, vector=vector, payload={**payload, "tenant_id": tenant_id})],
        )

    def search(self, vector: list[float], tenant_id: str, limit: int = 10) -> list[SearchResult]:
        from qdrant_client.models import Filter, FieldCondition, MatchValue
        results = self.client.search(
            collection_name=self.collection,
            query_vector=vector,
            query_filter=Filter(must=[FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))]),
            limit=limit,
        )
        return [SearchResult(str(r.id), r.score, r.payload) for r in results]

    def delete(self, id: str, tenant_id: str) -> None:
        self.client.delete(collection_name=self.collection, points_selector=[id])


class PgVectorStore(VectorStore):
    def __init__(self, connection_pool, table: str):
        self.pool = connection_pool
        self.table = table

    def upsert(self, id: str, vector: list[float], payload: dict, tenant_id: str) -> None:
        vector_literal = f"[{'',.join(map(str, vector))}]"
        self.pool.execute(
            f"INSERT INTO {self.table} (id, embedding, payload, tenant_id) "
            f"VALUES (%s, %s, %s, %s) "
            f"ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding, payload = EXCLUDED.payload",
            (id, vector_literal, payload, tenant_id),
        )

    def search(self, vector: list[float], tenant_id: str, limit: int = 10) -> list[SearchResult]:
        vector_literal = f"[{'',.join(map(str, vector))}]"
        rows = self.pool.fetch(
            f"SELECT id, payload, embedding <=> %s AS distance FROM {self.table} "
            f"WHERE tenant_id = %s ORDER BY embedding <=> %s LIMIT %s",
            (vector_literal, tenant_id, vector_literal, limit),
        )
        return [SearchResult(r["id"], 1 - r["distance"], r["payload"]) for r in rows]

    def delete(self, id: str, tenant_id: str) -> None:
        self.pool.execute(f"DELETE FROM {self.table} WHERE id = %s AND tenant_id = %s", (id, tenant_id))

# Application code depends only on the VectorStore interface, never on
# QdrantVectorStore or PgVectorStore directly - swapping backends means
# changing one instantiation, not any calling code.
def build_store(backend: str, **kwargs) -> VectorStore:
    if backend == "qdrant":
        return QdrantVectorStore(**kwargs)
    if backend == "pgvector":
        return PgVectorStore(**kwargs)
    raise ValueError(f"Unknown backend: {backend}")

Choosing between per-tenant collections and a shared, filtered collection is a decision worth encoding explicitly rather than hardcoding one approach permanently. The TypeScript example below implements a tenant-aware router that decides, per tenant, which isolation strategy to use - useful for a system supporting both very large enterprise tenants (who benefit from a dedicated collection) and many small tenants (better served by a shared, filtered collection).

// tenantRouter.ts
interface TenantConfig {
  tenantId: string;
  isolationStrategy: "dedicated" | "shared";
  estimatedVectorCount: number;
}

const DEDICATED_COLLECTION_THRESHOLD = 1_000_000;

function resolveIsolationStrategy(config: TenantConfig): "dedicated" | "shared" {
  // Large tenants get a dedicated collection where per-collection overhead
  // is justified by scale; smaller tenants share a collection filtered by
  // tenant_id, avoiding per-collection overhead that wouldn't pay for itself.
  if (config.estimatedVectorCount >= DEDICATED_COLLECTION_THRESHOLD) {
    return "dedicated";
  }
  return config.isolationStrategy ?? "shared";
}

interface CollectionTarget {
  collectionName: string;
  requiresTenantFilter: boolean;
}

function resolveCollectionTarget(config: TenantConfig): CollectionTarget {
  const strategy = resolveIsolationStrategy(config);

  if (strategy === "dedicated") {
    return {
      collectionName: `tenant_${config.tenantId}`,
      requiresTenantFilter: false, // isolation is structural, not filter-based
    };
  }

  return {
    collectionName: "shared_collection",
    requiresTenantFilter: true, // isolation depends on correct filtering
  };
}

Trade-offs and Pitfalls

The most consequential mistake in multi-tenant vector search is relying on application-level filtering as the sole isolation mechanism without any additional safeguard, treating a shared collection's tenant filter as equivalent in strength to a structurally separate collection. A single missed filter condition - a code path that constructs a query without the tenant constraint, a bug introduced during a refactor - becomes a genuine cross-tenant data leak in a shared-collection architecture, whereas the same bug in a collection-per-tenant architecture would, at worst, simply query the wrong tenant's dedicated collection and fail cleanly rather than silently returning another tenant's data. Any system choosing shared-collection isolation for its efficiency benefits needs to compensate with rigorous testing and, ideally, a database-level enforcement mechanism (native multi-tenancy features, row-level security) rather than trusting application code alone to always construct the filter correctly.

A second pitfall is building the abstraction layer with an interface that's either too thin to be useful or too backend-specific to actually provide portability. An abstraction that only exposes the lowest common denominator across every backend loses access to genuinely useful backend-specific features (native multi-tenancy, specific reranking integrations), while an abstraction whose interface methods leak backend-specific parameter shapes defeats the purpose of abstracting in the first place. Getting this balance right requires being honest about which backend-specific features are actually load-bearing for the application versus which can be sacrificed for portability, rather than trying to expose everything every backend offers through one universal interface.

A third pitfall is scaling reactively rather than planning for it, treating sharding and replication as a problem to solve only once a single node visibly can't keep up. Migrating a single-node collection into a sharded, replicated architecture after the fact is considerably more disruptive than choosing a backend and data model that support this growth path from the start - even if a system doesn't need multiple shards on day one, choosing infrastructure that can grow into that pattern without a full re-architecture is worth the modest additional evaluation effort during initial backend selection.

Best Practices for Vector Store Architecture

Build a vector store abstraction interface from the start, even for a system currently targeting only one backend, keeping the interface to the small set of operations the application genuinely needs and containing all backend-specific details inside each implementation. This decision costs relatively little upfront and pays for itself considerably the first time a backend migration, a competitive evaluation, or a multi-backend fallback strategy becomes necessary - exactly the situations where an application built directly against one backend's native client becomes expensive to change.

Choose a multi-tenant isolation strategy deliberately based on actual tenant characteristics - a large number of small tenants generally favors a shared, filtered collection (ideally backed by a database's native multi-tenancy features rather than hand-rolled filtering alone), while a smaller number of very large tenants can justify the per-collection overhead of dedicated collections. Where a shared-collection approach is chosen, invest in testing specifically designed to catch cross-tenant filter mistakes - automated tests that verify a query for tenant A genuinely cannot return tenant B's data under a range of query patterns, not just the happy path.

Evaluate a vector database's sharding, replication, and scaling characteristics as part of initial backend selection, even if current data volume doesn't yet require them, since migrating to a different backend specifically to gain scaling capability is a far more disruptive change than choosing infrastructure that already supports the growth path from the outset. This doesn't mean over-provisioning for scale that may never materialize - it means understanding, at selection time, what the migration path looks like if the application does grow substantially, so that growth is a planned transition rather than an emergency one.

Analogies and Mental Models

A vector store abstraction layer is well captured by a shipping company's standardized container interface rather than loading cargo directly to match each specific ship's hold. Standardized shipping containers can be loaded onto any ship, train, or truck built to accept the standard container size, regardless of what's actually inside them or which carrier ultimately transports them - switching carriers means finding a different vehicle built for the same standard container, not repacking every shipment's cargo into a carrier-specific shape. A vector store abstraction plays the same role for an application's data: retrieval logic is packed into a standard interface that any compliant backend implementation can transport, and switching backends means implementing that same interface against a new destination, not rewriting how the application packs and unpacks its data.

The choice between dedicated and shared multi-tenant collections is well captured by the difference between a private storage unit and a shared warehouse with labeled, separated sections. A private storage unit (a dedicated collection) gives one customer complete, physically separate space - the strongest possible isolation, at a real per-unit cost that adds up quickly if there are thousands of small customers each needing their own unit. A shared warehouse with clearly labeled sections (a shared collection with tenant filtering) is far more space-efficient for a large number of smaller customers, but its isolation depends entirely on everyone correctly reading and respecting the labels - a single misread label is a real, if hopefully rare, mistake that a fully private unit structurally cannot have.

The 80/20 of Vector Store Architecture

A small number of architectural decisions account for most of a vector store layer's long-term maintainability and scalability. Building a genuine abstraction interface between application code and any specific backend's API is the single highest-leverage decision, because it's what determines whether a future backend migration, competitive evaluation, or multi-backend strategy is a contained, manageable change or a disruptive rewrite touching code scattered throughout the application. Choosing a multi-tenant isolation strategy deliberately, based on actual tenant size distribution rather than a default assumption, is the second highest-leverage decision, since it directly determines both the system's efficiency at scale and the strength of its isolation guarantees.

The third disproportionately valuable practice is evaluating a backend's horizontal scaling characteristics - sharding, replication - at initial selection time rather than discovering them only once a single node becomes insufficient, since that discovery under pressure typically forces a far more disruptive migration than choosing appropriately scalable infrastructure from the start. Everything beyond these three - sophisticated cross-shard query optimization, dynamic tenant rebalancing between isolation strategies, exotic replication topologies - adds genuine value at very large scale, but is refinement layered on top of a foundation these three decisions already establish. Teams architecting their first multi-tenant vector search system get disproportionately more long-term value from getting the abstraction, the isolation strategy, and the scaling evaluation right than from any more advanced infrastructure investment made later.

Key Takeaways

Conclusion

Fast, reliable AI search at real production scale depends on architectural decisions that sit above any single indexing algorithm or query optimization: a portable abstraction layer that keeps a codebase from being permanently tied to one backend, a multi-tenant isolation strategy chosen deliberately for the actual distribution of tenant sizes involved, and infrastructure selected with a real understanding of how it scales horizontally once a single node is no longer enough. These decisions are genuinely systems-design problems, not algorithm problems, and they deserve the same deliberate architectural attention any other data layer serving many customers at scale would receive.

Teams that build this layer thoughtfully from the start - a clean interface, a well-reasoned isolation strategy, infrastructure chosen with its growth path already understood - end up with a vector search system that can evolve as data volume, tenant count, and backend requirements all change over time, rather than one that needs a disruptive rewrite the first time any of those dimensions grows past what the original, simpler architecture was built to handle.

References

  1. LangChain - VectorStore interface documentation: https://python.langchain.com/docs/concepts/vectorstores/
  2. LlamaIndex - Vector Store Index documentation: https://docs.llamaindex.ai/en/stable/module_guides/storing/vector_stores/
  3. Qdrant - Multitenancy documentation: https://qdrant.tech/documentation/guides/multiple-partitions/
  4. Weaviate - Multi-tenancy documentation: https://weaviate.io/developers/weaviate/concepts/data#multi-tenancy
  5. Milvus - official documentation: https://milvus.io/docs
  6. pgvector - PostgreSQL vector extension documentation: https://github.com/pgvector/pgvector
  7. Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs - Malkov & Yashunin, 2018: https://arxiv.org/abs/1603.09320