Accueil / Articles

Création d’un RAG multi-locataires sur Amazon S3 Vectors : le pipeline

RAG multi-locataires sur Amazon S3 Vectors : un index par locataire, des identifiants STS ciblés, suppression par clé, audit CloudTrail, ainsi que le code d’isolation que vous possédez toujours.

Éditeur : REACTAPP.TOP11 min de lecture2524 mots

Sources: Multi-Tenant RAG on Amazon S3 Vectors - Part 3: The Pipeline

En attribuant à chaque locataire d’un service RAG une ressource identifiable propre, au lieu d’un index partagé avec une étiquette de locataire, l’isolement devient quelque chose que AWS impose et que l’auditeur d’un client peut vérifier. Voici la structure : chaque exigence est associée à une appel API, à une politique ou à une ligne de journal, suivie des parties qui restent dans le code de l’application. L’idée de base est que l’isolement relève de la couche de stockage, et non d’une clause WHERE tenant = ? que quelqu’un pourrait oublier.

Disposition

Un compte AWS contient un bucket vectoriel par région desservie, et à l’intérieur de chaque bucket se trouve un index par locataire. Les exemples sont des snippets en TypeScript utilisant @aws-sdk/client-s3vectors et @aws-sdk/client-bedrock-runtime ; veuillez donc vérifier les noms des paramètres selon la version actuelle du SDK.

Ingest:  S3 (tenant prefix) ──▶ Lambda: extract + chunk ──▶ SQS ──▶ Lambda: embed (Bedrock Titan V2)
                                                                     └──▶ PutVectors → index/org-<tenant>
Query:   eID provider (OIDC) ──▶ API authorizer (tenant, role) ──▶ STS session policy (one index ARN)
                        ──▶ embed question ──▶ QueryVectors(index/org-<tenant>, filter=classification)
                        ──▶ LLM, grounded on returned chunk_text, citations = document_id + version
Audit:   CloudTrail data events, resource type AWS::S3Vectors::Index, queried by resources.ARN

Le catalogue réglementaire commun (lois, règlements, directives) est copié dans chaque index d’occupant. Le dupliquer coûte peu et permet de garder les données de chaque occupant autonomes.

Création d’un index

L’intégration d’un occupant se fait par une seule appel, comprenant deux décisions irréversibles : la dimension d’incorporation et la liste des clés de métadonnées non filtrables.

import { S3VectorsClient, CreateIndexCommand } from "@aws-sdk/client-s3vectors";
const s3v = new S3VectorsClient({ region: "eu-west-1" });
await s3v.send(new CreateIndexCommand({
  vectorBucketName: "kb-eu-west-1",      // the bucket is regional → residency
  indexName: "org-b",                       // the index is the tenant → isolation
  dataType: "float32",
  dimension: 1024,                          // Titan Text Embeddings V2, 1024-d
  distanceMetric: "cosine",
  metadataConfiguration: {
    nonFilterableMetadataKeys: ["chunk_text"],   // returned, never scanned, never filterable
  },
}));

Le nouveau index reçoit un ARN composé du nom du service, de la région (eu-west-1), du compte, du bucket kb-eu-west-1 et enfin de index/org-b. La région figure directement dans cette chaîne de caractères, ce qui signifie que la liste des index de chaque bucket vous fournit gratuitement le rapport de résidence des données.

Ingestion : choisissez vous-même les clés

Parmi tous les éléments du pipeline, le choix de la clé vectorielle est le plus important. Laisser le stockage générer les clés transforme l’action « supprimer ce document » en une recherche de ses fragments suivie de la suppression de tout ce que la recherche trouve, et la recherche approximative ne peut jamais prouver qu’il ne reste rien. Déduisez la clé à partir de l’ID du document : la suppression devient alors une simple recherche.

import { PutVectorsCommand } from "@aws-sdk/client-s3vectors";
async function ingestDocument(tenant: string, doc: Document, chunks: string[]) {
  const vectors = await Promise.all(chunks.map(async (text, i) => ({
    key: `${doc.id}-k${String(i + 1).padStart(3, "0")}`,      // chosen by us
    data: { float32: await embed(text) },
    metadata: {
      document_id: doc.id,                 // filterable
      version: doc.version,                // filterable → appears on the citation
      classification: doc.classification, // filterable → role filter at query time
      chunk_text: text,                    // non-filterable → rides with the vector
    },
  })));  for (const batch of chunk(vectors, 500)) {                    // ≤ 500 per call
    await s3v.send(new PutVectorsCommand({
      vectorBucketName: "kb-eu-west-1", indexName: `org-${tenant}`, vectors: batch,
    }));
  }
  await manifest.put(tenant, doc.id, doc.version, vectors.map(v => v.key));   // written down
}

Remarquez ce que les métadonnées omettent : le locataire. Rien dans le chunk ne mentionne « org-b ». Le chunk se trouve dans org-b, et c’est ce qui fait de lui un B. Ajouter également une étiquette de locataire reviendrait à réintroduire la faiblesse des architectures basées sur des étiquettes, où une étiquette erronée ressemble exactement à une étiquette correcte, sans aucun avantage.

Les écritures effectuées avec PutVectors sont fortement cohérentes, et chaque appel est facturé pour au moins 128 KB ; il convient donc de regrouper les écritures en lots.

Pour compléter, concernant l’incorporation :

import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
const bedrock = new BedrockRuntimeClient({ region: "eu-west-1" });
async function embed(text: string): Promise<number[]> {
  const res = await bedrock.send(new InvokeModelCommand({
    modelId: "amazon.titan-embed-text-v2:0",
    contentType: "application/json",
    body: JSON.stringify({ inputText: text, dimensions: 1024, normalize: true }),
  }));
  return JSON.parse(new TextDecoder().decode(res.body)).embedding;
}

Les inférences s’exécutent dans la région que vous avez choisie, et Bedrock ne conserve pas les prompts ni ne les utilise pour l’entraînement, ce qui fait partie du principe de résidence.

Interrogation avec des identifiants désignant un index

Dans cette conception, la limite du locataire n’est pas indiquée dans l’interrogation en tant que filtre. C’est la destination de l’interrogation, la seule destination que les identifiants de l’utilisateur sont autorisés à ouvrir. L’API crée de nouveaux identifiants pour chaque demande.

import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
function indexArn(tenant: string) {
  return `arn:aws:s3vectors:eu-west-1:${ACCOUNT}:bucket/kb-eu-west-1/index/org-${tenant}`;
}async function tenantCredentials(tenant: string) {
  const res = await sts.send(new AssumeRoleCommand({
    RoleArn: QUERY_ROLE_ARN,                       // broad role
    RoleSessionName: `q-${tenant}`,
    DurationSeconds: 900,
    Policy: JSON.stringify({                       // session policy: intersection, never a widening
      Version: "2012-10-17",
      Statement: [{
        Effect: "Allow",
        Action: ["s3vectors:QueryVectors", "s3vectors:GetVectors"],
        Resource: indexArn(tenant),                // exactly one ARN
      }],
    }),
  }));
  return res.Credentials!;
}

Une politique de session ne peut que restreindre un rôle et ne jamais lui accorder plus de droits. Les identifiants sont valables quelques minutes seulement et ne quittent jamais l’API. À grande échelle, le contrôle d’accès basé sur des attributs atteint le même résultat en utilisant une étiquette sur l’index qui est comparée à une étiquette du principal, de sorte que la politique ne croît pas avec la liste des locataires.

Vient ensuite la requête elle-même, le contrôle intra-locataire étant exprimé sous forme de filtre :

import { QueryVectorsCommand } from "@aws-sdk/client-s3vectors";
async function retrieve(tenant: string, role: Role, question: string) {
  const client = new S3VectorsClient({ region: "eu-west-1", credentials: await tenantCredentials(tenant) });
  const res = await client.send(new QueryVectorsCommand({
    indexArn: indexArn(tenant),
    queryVector: { float32: await embed(question) },
    topK: 10,
    filter: { classification: { $in: allowedClassifications(role) } },   // evaluated during search
    returnMetadata: true,
    returnDistance: true,
  }));
  return res.vectors ?? [];    // each: key, distance, metadata incl. chunk_text
}

Il existe donc deux limites et deux mécanismes. IAM sur l’ARN décide à quel locataire on peut accéder. Un filtre de métadonnées, appliqué directement lors de la recherche et non après, détermine quelle classification l’utilisateur peut voir au sein de ce locataire. Un filtrage effectué après la recherche, c’est-à-dire en récupérant dix résultats puis en éliminant ceux que l’utilisateur ne peut pas voir, détériore silencieusement la capacité de rappel pour les rôles restreints sans en informer l’utilisateur. Un filtrage effectué pendant la recherche renvoie quant à lui les dix résultats que l’utilisateur est autorisé à voir. Les classifications autorisées sont calculées à partir des affirmations de l’autoriseur et jamais à partir du corps de la demande.

Les références et les citations ne font pas partie de la structure de la réponse. Le modèle ne voit que le chunk_text des vecteurs retournés, et chaque citation correspond à l’ID du document ainsi qu’à sa version, extraits des mêmes métadonnées. Ce qui est cité correspond exactement à ce que le modèle a lu, puisque ce sont les mêmes données binaires.

Le bug délibéré

Il existe un test qui permet de distinguer un design où l’application gère les limites d’un design où c’est la plateforme qui le fait. Pointez l’application vers l’index incorrect tout en utilisant des identifiants spécifiques au locataire :

const client = new S3VectorsClient({ region: "eu-west-1", credentials: await tenantCredentials("b") });
await client.send(new QueryVectorsCommand({ indexArn: indexArn("a"), queryVector: { float32: q }, topK: 10 }));
// → AccessDeniedException: User ... is not authorized to perform: s3vectors:QueryVectors on resource: .../index/org-a

Rien ne revient. Dans un design qui repose sur le filtrage des applications, le même bug restituerait les données de A avec un code 200. Conservez cela comme un test d’intégration, car c’est la preuve la moins coûteuse que le problème ne réside pas dans votre code. L’appel refusé apparaît également dans le journal d’audit avec errorCode: AccessDenied pour index/org-a.

Suppression par clé, vérification par clé

La suppression utilise le manifeste créé lors de l’importation, sans aucune recherche impliquée.

import { DeleteVectorsCommand, GetVectorsCommand } from "@aws-sdk/client-s3vectors";
async function eraseDocument(tenant: string, docId: string, version: number) {
  const keys = await manifest.get(tenant, docId, version);
  for (const batch of chunk(keys, 500)) {
    await s3v.send(new DeleteVectorsCommand({ vectorBucketName: "kb-eu-west-1", indexName: `org-${tenant}`, keys: batch }));
  }
  // verify — strongly consistent, so this is valid immediately
  for (const batch of chunk(keys, 100)) {
    const res = await s3v.send(new GetVectorsCommand({ vectorBucketName: "kb-eu-west-1", indexName: `org-${tenant}`, keys: batch }));
    if ((res.vectors ?? []).length) throw new Error(`erasure incomplete: ${res.vectors!.length} keys remain`);
  }
  await audit.record({ tenant, docId, version, keyCount: keys.length, verifiedAt: new Date() });
}

Un auditeur peut exécuter à nouveau la deuxième boucle en utilisant ses propres identifiants en lecture seule, limités à l’ARN de l’index de ce locataire. La requête est identique, seuls les principaux utilisateurs diffèrent, et le résultat provient d’AWS plutôt que d’une capture d’écran fournie par le fournisseur. L’appel à GetVectors sur l’index d’un autre locataire renvoie AccessDenied, ce qui constitue une nouvelle fois le test d’isolation.

Pour quitter complètement un locataire, il suffit d’utiliser DeleteIndex. Deux remarques opérationnelles : un vecteur supprimé disparaît immédiatement des résultats des requêtes, bien qu’il puisse encore compter dans la taille de l’index, et donc dans le calcul des frais de scan, pendant environ un jour. De plus, lorsqu’une nouvelle version d’un document est intégrée, les clés de la version précédente doivent être supprimées par ce même chemin, et non via une tâche de nettoyage distincte.

Audit : Événements de données CloudTrail par ARN

La création et la suppression d’index (CreateIndex, DeleteIndex) sont des événements de gestion que CloudTrail enregistre par défaut. Les enregistrements des requêtes individuelles sont des événements de données : ils doivent être activés, et chacun d’eux est facturé. En sautant cette étape, toute prétention à l’auditable n’a aucun sens.

// CDK
new cloudtrail.Trail(this, "Trail", { sendToCloudWatchLogs: false })
  .addEventSelector(cloudtrail.DataResourceType.S3_VECTORS_INDEX, [
    `arn:aws:s3vectors:eu-west-1:${account}:bucket/kb-eu-west-1/index/*`,
  ]);
// Check the CDK enum/name for the S3 Vectors index resource type; the underlying resource type is AWS::S3Vectors::Index.

Un événement de requête, simplifié :

{
  "eventSource": "s3vectors.amazonaws.com",
  "eventName": "QueryVectors",
  "eventTime": "2026-09-09T10:41:07Z",
  "userIdentity": { "type": "AssumedRole", "arn": "arn:aws:sts::123456789012:assumed-role/kb-query/q-b" },
  "resources": [{ "type": "AWS::S3Vectors::Index",
                  "ARN": "arn:aws:s3vectors:eu-west-1:123456789012:bucket/kb-eu-west-1/index/org-b" }],
  "errorCode": null
}

« Qui a lu les données du locataire A ? » devient un prédicat dans CloudTrail Lake ou Athena :

SELECT eventTime, eventName, userIdentity.arn, errorCode
FROM   cloudtrail_events
WHERE  eventSource = 's3vectors.amazonaws.com'
  AND  element_at(resources, 1).arn LIKE '%/index/org-a'
ORDER  BY eventTime DESC;

AWS crée l’enregistrement dès l’arrivée de l’appel, avant même que le gestionnaire de l’application ne s’en occupe, et l’opérateur de la plateforme n’a aucun moyen de le modifier. En accordant à l’auditeur des droits de lecture sur ces traces ou en exportant les événements de ce locataire, celui-ci peut répondre à la question sans aide extérieure. C’est ce qui change concernant les exigences d’audit : l’auditeur effectue la vérification, et personne n’a besoin de se fier aux dires du fournisseur.

Quatre ajouts compatibles avec le modèle

Une clé KMS par index. Les buckets Vector peuvent utiliser SSE-KMS, de sorte que l’index de chaque locataire peut être chiffré avec une clé gérée par le client et dédiée à ce locataire. Chaque clé possède un ARN ainsi qu’une région, ce qui constitue un autre élément lié à la résidence des données. En la désactivant, tout ce qui est chiffré avec elle devient illisible, y compris les copies, ce qui offre un effacement plus complet que celui fourni uniquement par la méthode DeleteIndex.

Exportation. ListVectors peut transmettre un index complet, y compris le texte non filtrable, sur jusqu’à 16 segments parallèles. Grâce à des identifiants restreints à son propre ARN, un client peut obtenir l’intégralité de sa base de connaissances. Cela répond aux exigences de portabilité prévues à l’article 20 du RGPD et élimine tout risque de dépendance technique avec une seule requête.

Un chemin réseau privé. Envoyez le trafic S3 Vectors via un point d’accès VPC afin que les requêtes ne traversent pas Internet public. Les responsables de la sécurité des clients soumis à la norme NIS2 ou à un code de sécurité sectoriel exigeront précisément cela.

Coût par client. Grâce aux étiquettes d’affectation des coûts sur chaque index, la couche vectorielle apparaît sur la facture AWS sous forme d’une ligne distincte pour chaque client, et non comme une estimation basée sur des modèles. Ces mêmes étiquettes permettent également l’application de politiques de session de type ABAC.

Ce que les limites de l’index ne résolvent pas

Toutes les réponses ci-dessus indiquent quel utilisateur peut accéder à quels données, et AWS en assure le respect. Les éléments suivants ne sont pas couverts par cette règle, et c’est là que la majeure partie du temps d’ingénierie a été consacrée.

Rôles au sein d’un utilisateur. Le filtre de classification s’applique à cela. Deux règles sont importantes : appliquer le filtre au sein de la requête et non après, ainsi que prendre le rang du rôle auprès de l’autoriseur, jamais auprès du client.

Fuite d’informations liée à l’existence. Les équipes du secteur public se heurtent à un mode de défaillance que d’autres ne rencontrent que rarement : le modèle ne doit même pas révéler l’existence d’un document restreint, car le fait de dire « Je ne peux pas vous montrer le rapport sur le bien-être de l’enfant concernant cette famille » constitue déjà une divulgation en soi. Ainsi, la récupération des données ne restitue jamais de fragment que l’utilisateur ne puisse pas voir, ce que le filtre garantit, et la demande ne laisse jamais supposer qu’il y a des informations cachées. Le modèle répond en se basant sur ce qu’il possède ou admet ne rien avoir. Cela relève de la récupération des données et non de la formulation de la demande, car la récupération peut être testée, tandis que la façon dont le modèle s’exprime ne le peut pas.

Similitude inter-langues. Un corpus mélangeant une langue locale avec des textes juridiques en anglais obtient des scores nettement différents d’un modèle d’embedding unique pour des passages tout aussi pertinents. Un seuil de cosinus fixé en fonction d’une langue entraîne alors la perte de bons résultats dans l’autre langue sans aucun avertissement. La solution a été de remplacer le seuil absolu par une fenêtre relative au meilleur résultat dans chaque ensemble de résultats. Les utilisateurs multilingues devraient mesurer ce paramètre en premier, car la valeur idéale dépend du corpus.

Aucune recherche par mots-clés. Les identifiants tels que les numéros de section ou les IDs d’affaires devraient figurer dans des métadonnées filtrables et être recherchés de manière exacte, plutôt que d’être intégrés de façon aléatoire. Si une véritable recherche par mots-clés est nécessaire, utilisez OpenSearch en premier et laissez S3 Vectors servir de moteur de stockage.

Limites des blocs. La découverte la moins spectaculaire : deux semaines passées à déterminer où diviser les documents ont amélioré la qualité des réponses plus que des mois de travail sur l’architecture de recherche. Il convient de diviser les documents en fonction de leur structure, c’est-à-dire en sections et en clauses, et non uniquement en fonction du nombre de tokens.

Changement du modèle d’incorporation. La dimension est fixée lors de la création de l’index, donc changer de modèle signifie créer un nouvel index pour chaque utilisateur. Il faut recalculer les embeddings à partir des documents originaux encore stockés dans S3, modifier la configuration, puis exécuter DeleteIndex sur l’ancien index. Grâce à cette organisation par utilisateur, la mise à jour s’effectue progressivement, et la suppression elle-même montre que la copie ancienne a disparu.

Bases de connaissances Bedrock. Elles peuvent stocker des vecteurs dans S3 Vectors et constituent un choix par défaut fiable. Mettre en place sa propre procédure d’ingestion s’avère avantageuse pour une raison précise : les bases de connaissances choisissent elles-mêmes les clés vectorielles, et la preuve d’effacement décrite précédemment dépend du fait que vous les choisissiez vous-même.

Examen des exigences

Cinq des six exigences découlent du lieu où se trouvent les données et de ceux qui peuvent y accéder, et non du code écrit par l’équipe. C’était l’objectif, et c’est pourquoi une telle plateforme peut être proposée en toute sécurité à quelqu’un qui ne fait pas confiance au fournisseur.

Le code présenté ici est à titre illustratif. Vérifiez-le par rapport à la version actuelle du AWS SDK v3 et du CDK, car l’événement CloudTrail montré est abrégé, et assurez-vous que la prise en charge des points d’entrée VPC ainsi que l’étiquetage des index pour S3 Vectors sont mentionnés dans la documentation la plus récente. Les termes « locataires », « documents », « régions » et « identifiants de compte » sont des placeholders, et aucun volume de production ni seuil d’évaluation n’est indiqué.