RAG Multi-Tenant : Architecture SaaS pour Servir 1000 Clients avec un Seul Système
Concevez une architecture RAG multi-tenant scalable. Isolation des données, performance, sécurité et optimisation des coûts pour un SaaS RAG servant des centaines de clients.
RAG Multi-Tenant : Architecture SaaS pour Servir 1000 Clients avec un Seul Système
Construire un chatbot RAG pour un client, c'est simple. Le faire tourner pour 1000 clients simultanément sur la même infrastructure tout en garantissant une isolation totale des données ? C'est un tout autre défi. Ce guide détaille les architectures, patterns et pièges du RAG multi-tenant, basé sur l'expérience d'Ailog qui sert des centaines de clients depuis une infrastructure partagée.
TL;DR
- Multi-tenancy = un seul système RAG servant plusieurs clients avec isolation des données
- Trois stratégies d'isolation : namespace/partition, collection dédiée, base dédiée
- Le choix critique : compromis entre isolation, coût et complexité opérationnelle
- Qdrant : payload filtering (le plus courant), collections séparées, ou instances séparées
- Coût : de $0.50/tenant/mois (shared) à $50/tenant/mois (dédié)
- Sécurité : la fuite de données entre tenants est le risque n.1
Pourquoi le multi-tenancy ?
Sans multi-tenancy, chaque nouveau client nécessite une infrastructure dédiée. C'est un cauchemar opérationnel et financier.
| Approche | 10 clients | 100 clients | 1000 clients |
|---|---|---|---|
| Infrastructure dédiée | $500/mois | $5 000/mois | $50 000/mois |
| Multi-tenant partagé | $200/mois | $400/mois | $2 000/mois |
| Économie | 60% | 92% | 96% |
Infrastructure dédiée (1 système par client) :
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client A │ │ Client B │ │ Client C │ ... │Client 999│
│ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │
│ │Vector│ │ │ │Vector│ │ │ │Vector│ │ │ │Vector│ │
│ │ DB │ │ │ │ DB │ │ │ │ DB │ │ │ │ DB │ │
│ ├──────┤ │ │ ├──────┤ │ │ ├──────┤ │ │ ├──────┤ │
│ │ LLM │ │ │ │ LLM │ │ │ │ LLM │ │ │ │ LLM │ │
│ └──────┘ │ │ └──────┘ │ │ └──────┘ │ │ └──────┘ │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
❌ Ne scale pas. Coût linéaire.
Multi-tenant (1 système, N clients) :
┌─────────────────────────────────────────────────────┐
│ Système RAG partagé │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Vector Database │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │ A │ │ B │ │ C │ ... │ 999 │ │ │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ LLM Gateway (partagé) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
✅ Scale. Coût sub-linéaire.
Les trois stratégies d'isolation
Stratégie 1 : Namespace / Payload Filtering
Les données de tous les tenants sont dans la même collection, différenciées par un champ tenant_id.
DEVELOPERpythonfrom qdrant_client import QdrantClient from qdrant_client.models import ( Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue ) client = QdrantClient(url="http://localhost:6333") # 1. Créer une collection partagée client.create_collection( collection_name="shared_knowledge", vectors_config=VectorParams( size=1536, distance=Distance.COSINE ) ) # 2. Indexer les documents avec tenant_id def index_document(tenant_id: str, doc_id: str, embedding: list, content: str, metadata: dict): """Indexe un document avec le tenant_id comme payload.""" client.upsert( collection_name="shared_knowledge", points=[ PointStruct( id=doc_id, vector=embedding, payload={ "tenant_id": tenant_id, "content": content, **metadata } ) ] ) # 3. Rechercher avec filtre tenant def search_tenant(tenant_id: str, query_embedding: list, top_k: int = 5) -> list: """Recherche limitée aux documents du tenant.""" results = client.search( collection_name="shared_knowledge", query_vector=query_embedding, query_filter=Filter( must=[ FieldCondition( key="tenant_id", match=MatchValue(value=tenant_id) ) ] ), limit=top_k ) return results # Usage results = search_tenant("tenant_abc123", query_embedding) # Retourne UNIQUEMENT les documents de tenant_abc123
Avantages : Simple, économique, pas de gestion de multiples collections. Inconvénients : Isolation logique seulement, performances dégradées si index très large, risque de fuite de données si bug dans le filtrage.
Stratégie 2 : Collection Dédiée par Tenant
Chaque tenant a sa propre collection dans la même instance de base vectorielle.
DEVELOPERpythonclass CollectionPerTenantStrategy: """Une collection Qdrant par tenant.""" def __init__(self, qdrant_url: str): self.client = QdrantClient(url=qdrant_url) def create_tenant(self, tenant_id: str, vector_size: int = 1536): """Crée une collection dédiée pour un nouveau tenant.""" collection_name = f"tenant_{tenant_id}" self.client.create_collection( collection_name=collection_name, vectors_config=VectorParams( size=vector_size, distance=Distance.COSINE ) ) return collection_name def delete_tenant(self, tenant_id: str): """Supprime toutes les données d'un tenant.""" collection_name = f"tenant_{tenant_id}" self.client.delete_collection(collection_name) def index_document(self, tenant_id: str, doc_id: str, embedding: list, content: str): """Indexe un document dans la collection du tenant.""" collection_name = f"tenant_{tenant_id}" self.client.upsert( collection_name=collection_name, points=[ PointStruct( id=doc_id, vector=embedding, payload={"content": content} ) ] ) def search(self, tenant_id: str, query_embedding: list, top_k: int = 5): """Recherche dans la collection du tenant.""" collection_name = f"tenant_{tenant_id}" return self.client.search( collection_name=collection_name, query_vector=query_embedding, limit=top_k )
Avantages : Bonne isolation, performances prévisibles par tenant, suppression facile des données. Inconvénients : Plus de collections à gérer, surcharge mémoire, limite du nombre de collections.
Stratégie 3 : Base de Données Dédiée par Tenant
Chaque tenant a sa propre instance de base vectorielle. Réservé aux grands comptes.
DEVELOPERpythonclass DedicatedInstanceStrategy: """Une instance Qdrant dédiée par tenant (ou pool).""" def __init__(self): self.instances = {} def provision_tenant(self, tenant_id: str, tier: str = "standard") -> str: """Provisionne une instance dédiée.""" config = self._get_tier_config(tier) # Kubernetes / Docker provisioning instance_url = self._deploy_instance( tenant_id=tenant_id, cpu=config["cpu"], memory=config["memory"], storage=config["storage"] ) self.instances[tenant_id] = { "url": instance_url, "client": QdrantClient(url=instance_url), "tier": tier } return instance_url def _get_tier_config(self, tier: str) -> dict: """Configuration par niveau de service.""" tiers = { "starter": {"cpu": "0.5", "memory": "512Mi", "storage": "5Gi"}, "standard": {"cpu": "1", "memory": "2Gi", "storage": "20Gi"}, "enterprise": {"cpu": "4", "memory": "8Gi", "storage": "100Gi"}, } return tiers[tier]
Avantages : Isolation totale, performances garanties, conformité réglementaire. Inconvénients : Coût élevé, complexité opérationnelle, ressources sous-utilisées.
Comparaison des stratégies
| Critère | Namespace/Filtering | Collection Dédiée | Instance Dédiée |
|---|---|---|---|
| Isolation des données | Logique | Forte | Totale |
| Risque de fuite | Moyen | Faible | Quasi-nul |
| Coût par tenant (100 docs) | ~$0.50/mois | ~$2/mois | ~$20/mois |
| Coût par tenant (10k docs) | ~$5/mois | ~$8/mois | ~$50/mois |
| Performance | Dégradée à l'échelle | Bonne | Excellente |
| Max tenants | 10 000+ | 1 000-5 000 | 100-500 |
| Suppression données | Complexe | Simple | Très simple |
| Conformité RGPD | Difficile | Bonne | Excellente |
| Complexité ops | Faible | Moyenne | Élevée |
| Cas d'usage | SaaS self-service | SaaS Pro | Grands comptes |
Matrice de décision
Nombre de tenants ?
├── > 5 000 → Namespace/Filtering (obligatoire)
├── 500 - 5 000 → Collection Dédiée
├── < 500
│ ├── RGPD strict / données sensibles → Instance Dédiée
│ ├── Budget limité → Namespace/Filtering
│ └── Standard → Collection Dédiée
└── < 10 (grands comptes) → Instance Dédiée
Architecture complète multi-tenant
DEVELOPERpythonfrom enum import Enum from typing import Optional import hashlib class IsolationLevel(Enum): SHARED = "shared" # Namespace/Filtering COLLECTION = "collection" # Collection par tenant DEDICATED = "dedicated" # Instance dédiée class MultiTenantRAGService: """Service RAG multi-tenant avec routing automatique.""" def __init__(self): self.shared_client = QdrantClient(url="http://qdrant-shared:6333") self.tenant_registry = {} # tenant_id -> config def register_tenant(self, tenant_id: str, plan: str, isolation: IsolationLevel = None): """Enregistre un nouveau tenant.""" if isolation is None: isolation = self._determine_isolation(plan) config = { "plan": plan, "isolation": isolation, "created_at": datetime.utcnow(), } if isolation == IsolationLevel.COLLECTION: collection_name = f"tenant_{tenant_id}" self.shared_client.create_collection( collection_name=collection_name, vectors_config=VectorParams( size=1536, distance=Distance.COSINE ) ) config["collection"] = collection_name elif isolation == IsolationLevel.DEDICATED: instance_url = self._provision_dedicated(tenant_id, plan) config["instance_url"] = instance_url config["client"] = QdrantClient(url=instance_url) self.tenant_registry[tenant_id] = config def _determine_isolation(self, plan: str) -> IsolationLevel: """Détermine le niveau d'isolation selon le plan.""" plan_mapping = { "free": IsolationLevel.SHARED, "starter": IsolationLevel.SHARED, "pro": IsolationLevel.COLLECTION, "business": IsolationLevel.COLLECTION, "enterprise": IsolationLevel.DEDICATED, } return plan_mapping.get(plan, IsolationLevel.SHARED) async def query(self, tenant_id: str, question: str, query_embedding: list) -> list: """Recherche des documents pour un tenant spécifique.""" config = self.tenant_registry[tenant_id] isolation = config["isolation"] if isolation == IsolationLevel.SHARED: return self.shared_client.search( collection_name="shared_knowledge", query_vector=query_embedding, query_filter=Filter(must=[ FieldCondition( key="tenant_id", match=MatchValue(value=tenant_id) ) ]), limit=5 ) elif isolation == IsolationLevel.COLLECTION: return self.shared_client.search( collection_name=config["collection"], query_vector=query_embedding, limit=5 ) elif isolation == IsolationLevel.DEDICATED: return config["client"].search( collection_name="knowledge", query_vector=query_embedding, limit=5 ) async def delete_tenant_data(self, tenant_id: str): """Supprime toutes les données d'un tenant (RGPD).""" config = self.tenant_registry[tenant_id] isolation = config["isolation"] if isolation == IsolationLevel.SHARED: # Suppression par filtre (plus lent) self.shared_client.delete( collection_name="shared_knowledge", points_selector=Filter(must=[ FieldCondition( key="tenant_id", match=MatchValue(value=tenant_id) ) ]) ) elif isolation == IsolationLevel.COLLECTION: self.shared_client.delete_collection(config["collection"]) elif isolation == IsolationLevel.DEDICATED: self._deprovision_dedicated(tenant_id) del self.tenant_registry[tenant_id]
Isolation des performances
L'isolation des données ne suffit pas. Un tenant gourmand ne doit pas dégrader les performances des autres.
Rate Limiting par Tenant
DEVELOPERpythonfrom datetime import datetime, timedelta from collections import defaultdict import asyncio class TenantRateLimiter: """Rate limiter par tenant.""" def __init__(self): self.limits = { "free": {"rpm": 10, "rpd": 100, "tokens_per_day": 50000}, "starter": {"rpm": 30, "rpd": 1000, "tokens_per_day": 500000}, "pro": {"rpm": 100, "rpd": 10000, "tokens_per_day": 5000000}, "enterprise": {"rpm": 500, "rpd": 100000, "tokens_per_day": 50000000}, } self.usage = defaultdict(lambda: { "minute": [], "day": [], "tokens_today": 0 }) async def check_and_consume(self, tenant_id: str, plan: str, tokens: int = 0) -> bool: """Vérifie et consomme le quota.""" limits = self.limits[plan] usage = self.usage[tenant_id] now = datetime.utcnow() # Nettoyer les anciens compteurs usage["minute"] = [ t for t in usage["minute"] if now - t < timedelta(minutes=1) ] usage["day"] = [ t for t in usage["day"] if now - t < timedelta(days=1) ] # Vérifier les limites if len(usage["minute"]) >= limits["rpm"]: return False # Rate limit minute if len(usage["day"]) >= limits["rpd"]: return False # Rate limit jour if usage["tokens_today"] + tokens > limits["tokens_per_day"]: return False # Token limit # Consommer usage["minute"].append(now) usage["day"].append(now) usage["tokens_today"] += tokens return True
Tableau des limites par plan
| Plan | Requêtes/min | Requêtes/jour | Tokens/jour | Documents max | Taille max/doc |
|---|---|---|---|---|---|
| Free | 10 | 100 | 50K | 50 | 1 MB |
| Starter | 30 | 1 000 | 500K | 500 | 5 MB |
| Pro | 100 | 10 000 | 5M | 5 000 | 20 MB |
| Business | 300 | 50 000 | 20M | 50 000 | 50 MB |
| Enterprise | 500+ | 100 000+ | 50M+ | Illimité | 100 MB |
Optimisation des coûts
Modèle de coût par tenant
Coût total par tenant = Stockage + Compute + LLM + Infra
Stockage :
├── Vecteurs : $0.10 / 1M vecteurs / mois
├── Payloads : $0.05 / GB / mois
└── Backup : $0.02 / GB / mois
Compute :
├── Recherche : $0.0001 / requête
├── Indexation : $0.001 / document
└── Reranking : $0.0005 / requête
LLM :
├── Embedding : $0.02 / 1M tokens (text-embedding-3-small)
├── Génération : $0.60 / 1M tokens en sortie (GPT-4o-mini)
└── Évaluation CRAG : $0.001 / requête
Infra partagée (répartie) :
├── Qdrant : $200/mois / 100 tenants = $2/tenant
├── API Gateway : $100/mois / 100 tenants = $1/tenant
└── Monitoring : $50/mois / 100 tenants = $0.50/tenant
Coût estimé par profil de tenant
| Profil | Documents | Requêtes/mois | Coût estimé/mois |
|---|---|---|---|
| Micro (vitrine) | 20 | 500 | $0.80 |
| Petit (PME) | 200 | 5 000 | $3.50 |
| Moyen (e-commerce) | 2 000 | 50 000 | $25 |
| Grand (enterprise) | 20 000 | 500 000 | $180 |
| Très grand | 100 000+ | 2M+ | $800+ |
Sécurité : prévention des fuites de données
Le risque n.1 : la fuite inter-tenant
DEVELOPERpythonclass TenantSecurityMiddleware: """Middleware de sécurité pour prévenir les fuites inter-tenant.""" def __init__(self): self.audit_logger = AuditLogger() async def validate_request(self, request, tenant_id: str): """Valide que la requête est légitime pour ce tenant.""" # 1. Vérifier l'authentification auth_tenant = self._extract_tenant_from_auth(request) if auth_tenant != tenant_id: self.audit_logger.log_security_event( "TENANT_MISMATCH", f"Auth tenant {auth_tenant} != request tenant {tenant_id}" ) raise SecurityError("Tenant mismatch") # 2. Vérifier que le tenant existe et est actif tenant = await self._get_tenant(tenant_id) if not tenant or tenant.status != "active": raise SecurityError("Tenant not found or inactive") # 3. Logger l'accès self.audit_logger.log_access(tenant_id, request.path) return True async def validate_response(self, response, tenant_id: str): """Vérifie qu'aucune donnée d'un autre tenant ne fuite.""" # Vérifier les metadata des documents retournés if hasattr(response, "documents"): for doc in response.documents: doc_tenant = doc.metadata.get("tenant_id") if doc_tenant and doc_tenant != tenant_id: self.audit_logger.log_security_event( "DATA_LEAK_PREVENTED", f"Doc from {doc_tenant} nearly served to {tenant_id}" ) response.documents.remove(doc) return response
Checklist sécurité multi-tenant
| Contrôle | Priorité | Implémenté ? |
|---|---|---|
| Authentification par API key liée au tenant | Critique | |
| Filtre tenant_id sur TOUTES les requêtes DB | Critique | |
| Validation response (pas de fuite) | Critique | |
| Rate limiting par tenant | Haute | |
| Audit log de tous les accès | Haute | |
| Chiffrement des données au repos | Haute | |
| Tests d'intrusion inter-tenant | Haute | |
| Backup isolé par tenant | Moyenne | |
| Suppression certifiée (RGPD) | Moyenne | |
| Monitoring des anomalies d'accès | Moyenne |
FAQ
Comment gérer la migration d'un tenant d'un plan à un autre ?
La migration de plan (ex: shared vers collection dédiée) nécessite de copier les données d'un espace de stockage à l'autre. Procédez en trois étapes : (1) créer la nouvelle collection, (2) copier les données en batch, (3) basculer le routing. Pendant la migration, les deux espaces coexistent. Vérifiez que toutes les données sont correctement copiées avant de supprimer l'ancien espace.
Comment faire le scaling horizontal quand un seul serveur Qdrant ne suffit plus ?
Qdrant supporte le sharding et la réplication native. Configurez un cluster Qdrant avec N noeuds, et les collections seront automatiquement distribuées. Pour le multi-tenant, le sharding par tenant_id est idéal : les données d'un tenant sont sur le même shard, ce qui optimise les performances de filtrage. Pinecone et Weaviate offrent des solutions similaires en managed.
Comment garantir la suppression complète des données d'un tenant (RGPD) ?
Avec la stratégie "collection dédiée", c'est simple : supprimez la collection. Avec "namespace/filtering", c'est plus complexe : il faut supprimer tous les points ayant le tenant_id, puis vérifier qu'aucun résidu ne reste dans les caches ou les backups. Conservez un certificat de suppression avec la date et le nombre de documents supprimés pour la conformité RGPD.
Quel est l'impact du nombre de tenants sur les performances de recherche ?
Avec la stratégie namespace/filtering, les performances de recherche se dégradent quand l'index dépasse 10M de vecteurs (tous tenants confondus). La recherche doit filtrer parmi tous les vecteurs avant de retourner les résultats. Avec des collections dédiées, chaque tenant a un petit index indépendant, donc les performances sont constantes quelle que soit le nombre de tenants.
Comment facturer les tenants de manière précise ?
Tracez trois métriques par tenant : (1) le nombre de documents indexés (stockage), (2) le nombre de requêtes (compute), (3) le nombre de tokens LLM consommés (génération). Stockez ces métriques dans une table de facturation mise à jour en temps réel. La facturation peut être au forfait (limites par plan) ou à l'usage (pay-per-query).
Conclusion
L'architecture multi-tenant est le fondement de tout SaaS RAG viable. Le choix de la stratégie d'isolation impacte directement les coûts, les performances et la sécurité de votre plateforme.
Points clés :
- Commencez par namespace/filtering pour les plans gratuits et starter, c'est le plus économique
- Collections dédiées pour les plans pro : bon compromis isolation/coût
- Instances dédiées uniquement pour les grands comptes avec exigences réglementaires
- Le rate limiting est indispensable pour protéger la plateforme des abus
- L'audit de sécurité inter-tenant doit être automatisé et continu
Ailog est architecturé nativement en multi-tenant, avec une isolation stricte des données et des performances garanties pour chaque client. Créez votre compte et déployez votre chatbot RAG en quelques minutes sur une infrastructure partagée et sécurisée.
Ressources
- Qdrant Multi-tenancy - Documentation officielle
- Pinecone Namespaces - Isolation par namespace
- RAG Security & Compliance - Sécurité RAG complète
- Production Deployment - Déploiement en production
- RAG Cost Optimization - Optimisation des coûts
- RGPD et Chatbot - Conformité RGPD
Tags
Articles connexes
Securite et Conformite RAG : RGPD, AI Act et bonnes pratiques
Securisez votre systeme RAG : conformite RGPD, AI Act europeen, protection des donnees et audit. Guide complet pour les entreprises.
RAG pour PME : Guide complet sans équipe data
Déployez un système RAG performant dans votre PME sans compétences techniques avancées : solutions no-code, budget maîtrisé et ROI rapide.
RAG Souverain : Hebergement France et donnees europeennes
Deployez un RAG souverain en France : hebergement local, conformite RGPD, alternatives aux GAFAM et bonnes pratiques pour les donnees europeennes.