Inicio / Artículos

Construcción de RAG multi-inquilino en vectores de Amazon S3: La pipeline

RAG multiinquilino en Amazon S3 Vectors: un índice por inquilino, credenciales STS con alcance limitado, eliminación por clave, auditoría de CloudTrail y el código de aislamiento que aún posees.

Editor: REACTAPP.TOP11 min de lectura2524 palabras

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

Al proporcionar a cada inquilino de un servicio RAG su propio recurso direccionable, en lugar de un índice compartido con una etiqueta de inquilino, se convierte la aislación en algo que AWS impone y que el auditor del cliente puede verificar. Así es como se estructura: cada requisito se relaciona con una llamada a la API, una política o una línea de registro, seguida de las partes que aún se encuentran en el código de la aplicación. La premisa es que la aislación pertenece a la capa de almacenamiento, no a una cláusula WHERE tenant = ? que alguien podría olvidar.

Disposición

Una cuenta de AWS alberga un bucket de vectores por región atendida, y dentro de cada bucket hay un índice por inquilino. Los ejemplos son fragmentos en TypeScript que utilizan @aws-sdk/client-s3vectors y @aws-sdk/client-bedrock-runtime; por lo tanto, verifique los nombres de los parámetros según la versión actual del 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

El catálogo regulatorio compartido (leyes, regulaciones, directrices) se copia en cada índice de inquilino. Duplicarlo cuesta poco y mantiene los datos de cada inquilino de forma autónoma.

Creación de un índice

La incorporación de un inquilino se realiza mediante una sola llamada, que incluye dos decisiones irreversibles: la dimensión de incrustación y la lista de claves de metadatos no 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
  },
}));

El nuevo índice recibe un ARN formado a partir del nombre del servicio, la región (eu-west-1), la cuenta, el bucket kb-eu-west-1 y, finalmente, index/org-b. La región aparece directamente en la cadena, lo que significa que al listar los índices de cada bucket se obtiene gratuitamente el informe de residencia de datos.

Ingestión: elija las claves usted mismo

De todo lo que hay en el pipeline, la elección de la clave vectorial es lo más importante. Si se deja que el almacén genere las claves, la acción de “eliminar este documento” se convierte en una búsqueda de sus fragmentos seguida de la eliminación de todo lo que encuentre la búsqueda, y la búsqueda aproximada nunca puede demostrar que no queda nada atrás. Obtenga la clave a partir del ID del documento, ya que la eliminación es una búsqueda sencilla.

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
}

Observe qué omite la metadatos: al arrendatario. No hay nada en el fragmento que indique “org-b”. El fragmento se encuentra en org-b, y eso es lo que lo convierte en B. Incluir también una etiqueta de arrendatario volvería a presentar la debilidad de los diseños basados en etiquetas, donde una etiqueta incorrecta se parece mucho a una correcta, sin ningún beneficio.

Las escrituras con PutVectors son altamente consistentes, y cada llamada se cobra como mínimo 128 KB, por lo que se deben agrupar las escrituras en lotes.

Para completar la información, respecto al incrustado:

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;
}

Las inferencias se ejecutan en la región a la que te conectas, y Bedrock no conserva los prompts ni los utiliza para el entrenamiento, lo cual forma parte de los principios de residencia.

Consultas con credenciales que especifican un índice

En este diseño, los límites del inquilino no se definen como filtro en la consulta. Son el destino de la consulta, el único destino al que se permite acceder con las credenciales del solicitante. La API crea esas credenciales nuevas para cada solicitud.

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!;
}

Una política de sesión solo puede restringir un rol y nunca otorgar más allá de él. Las credenciales son válidas durante unos minutos y nunca salen de la API. A gran escala, el control de acceso basado en atributos logra lo mismo mediante una etiqueta en el índice que se compara con una etiqueta del principal, de modo que la política no crece junto con la lista de inquilinos.

A continuación está la consulta en sí, donde el control intra-inquilino se expresa como un filtro:

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
}

Por lo tanto, existen dos límites y dos mecanismos. IAM en el ARN decide a qué inquilino se puede acceder. Un filtro de metadatos, aplicado dentro de la búsqueda misma y no después, determina qué clasificación se muestra dentro de ese inquilino. Filtrar después, es decir, obtener diez resultados y descartar lo que el usuario no puede ver, daña silenciosamente la capacidad de recuperación para los roles restringidos sin indicarlo. Filtrar dentro de la búsqueda devuelve los diez resultados que el usuario tiene permiso para ver. Las clasificaciones permitidas se calculan a partir de las afirmaciones del autorizador y nunca a partir del cuerpo de la solicitud.

La fundamentación y las citaciones no forman parte de la estructura de la respuesta. El modelo solo recibe el chunk_text de los vectores devueltos, y cada cita es el ID y la versión del documento extraídos de esos mismos metadatos. Lo que se cita es exactamente lo que el modelo leyó, ya que ambos son los mismos bytes.

El error intencionado

Existe una prueba que distingue entre un diseño en el que la aplicación controla los límites y uno en el que lo hace la plataforma. Dirija la aplicación al índice incorrecto mientras esta posee credenciales específicas del cliente:

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

Nada vuelve. En un diseño que depende del filtrado de aplicaciones, el mismo error devolvería los datos de A con un código 200. Considérelo como una prueba de integración, ya que es la evidencia más económica de que el problema no está en su código. La llamada rechazada también aparece en el registro de auditoría con errorCode: AccessDenied relacionado con index/org-a.

Borrado por clave, verificado por clave

La eliminación utiliza el manifiesto escrito al ingresar los datos, y no se realiza ninguna búsqueda.

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 auditor puede volver a ejecutar el segundo bucle con sus propias credenciales de solo lectura, limitadas al ARN del índice de ese inquilino. La llamada es idéntica, solo difiere el principal, y el resultado proviene de AWS en lugar de una captura de pantalla proporcionada por el proveedor. Llamar a GetVectors contra el índice de otro inquilino devuelve AccessDenied, lo que constituye una vez más la prueba de aislamiento.

Para que un inquilino completo se retire basta con ejecutar DeleteIndex. Dos notas operativas: un vector eliminado desaparece de inmediato de los resultados de las consultas, aunque aún puede contabilizarse en el tamaño del índice y, por lo tanto, en la facturación por escaneo, durante un día como máximo. Además, cuando se ingiere una nueva versión de un documento, las claves de la versión anterior deben eliminarse a través de esta misma ruta y no mediante una tarea de limpieza separada.

Auditoría: eventos de datos de CloudTrail por ARN

La creación y eliminación de índices (CreateIndex, DeleteIndex) son eventos de gestión y CloudTrail los registra por defecto. Los registros de las consultas individuales son eventos de datos: es necesario activarlos, y cada uno se cobra por separado. Si se omite ese paso, cualquier afirmación de auditoría carecerá de validez.

// 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 evento de consulta, simplificado:

{
  "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én leyó los datos del inquilino A?” se convierte en un predicado en CloudTrail Lake o 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 genera el registro en el momento en que llega la llamada, antes incluso de que lo haga el propio manejador de la aplicación, y el operador de la plataforma no tiene forma de editarlo. Si se le otorga al auditor acceso de lectura al historial o se exportan los eventos de ese inquilino, podrá responder a la pregunta por sí mismo. Eso es lo que cambia en cuanto al requisito de auditoría: el auditor verifica la información y nadie necesita creerse las palabras del proveedor.

Four additions that fit the model

Una clave KMS por índice. Los buckets de Vector pueden utilizar SSE-KMS, por lo que el índice de cada inquilino puede ser encriptado con una clave gestionada por el cliente dedicada exclusivamente a ese inquilino. Cada clave lleva un ARN y una región, lo cual constituye otro elemento relacionado con la residencia de los datos. Al desactivarla, todo lo encriptado bajo ella queda ilegible, incluidas las copias, lo que representa una eliminación más segura que la que ofrece únicamente DeleteIndex.

Exportación. ListVectors puede transmitir todo un índice, incluido el texto que no es filtrable, a través de hasta 16 segmentos paralelos. Con credenciales vinculadas a su propio ARN, un cliente puede llevarse consigo toda su base de conocimientos. Esto cumple con los requisitos de portabilidad del Artículo 20 del GDPR y elimina la dependencia con una sola llamada.

Ruta de red privada. Envíe el tráfico de S3 Vectors a través de un punto de extremo de VPC para que las consultas no crucen Internet público. Los responsables de seguridad de los clientes sujetos a NIS2 o a un código de seguridad sectorial solicitarán exactamente esto.

Costo por cliente. Gracias a las etiquetas de asignación de costos en cada índice, la capa de vectores se muestra en la factura de AWS por cliente como un elemento separado y no como una estimación basada en modelos. Las mismas etiquetas permiten la implementación de políticas de sesión basadas en ABAC.

Lo que los límites del índice no resuelven

Todas las respuestas anteriores indican qué tenant puede acceder a qué datos, y AWS se encarga de hacerlo cumplir. Los siguientes elementos no están cubiertos por ese límite, y es ahí donde se invirtió la mayor parte del tiempo de ingeniería.

Roles dentro de un tenant. El filtro de clasificación lo abarca. Dos reglas son importantes: aplicar el filtro dentro de la consulta y no después de ella, y obtener el rango del rol del autorizador, nunca del cliente.

Fuga de información sobre la existencia. Los equipos del sector público se enfrentan a un modo de fallo que otros rara vez experimentan: el modelo no debe revelar siquiera que existe un documento restringido, ya que decir “No puedo mostrarte el informe de bienestar infantil de esta familia” ya constituye una divulgación en sí mismo. Por lo tanto, la recuperación de información nunca devuelve fragmentos que el usuario no pueda ver, algo que el filtro garantiza, y la solicitud nunca insinúa que se haya ocultado algo. El modelo responde con lo que tiene o admite no tener nada. Esto se maneja en la recuperación de información y no en la solicitud, porque la recuperación puede ser probada, mientras que la redacción del modelo no puede.

Similitud entre lenguas. Un corpus que mezcla un idioma local con textos legales en inglés obtiene puntuaciones notablemente diferentes de un modelo de embedding único para pasajes que son igualmente relevantes. Un umbral de coseno fijo ajustado para un idioma determinado provoca la pérdida de buenos resultados en el otro sin ninguna advertencia. La solución fue reemplazar el umbral absoluto por un rango relativo al mejor resultado en cada conjunto de resultados. Los usuarios multilingües deben medir esto primero, ya que el valor adecuado depende del corpus.

Sin búsqueda por palabras clave. Identificadores como números de sección o IDs de casos deben incluirse en metadatos filtrables y compararse de forma exacta, en lugar de ser incrustados y dejados al azar. Si se necesita una verdadera búsqueda por palabras clave, utilice OpenSearch como herramienta principal y deje que S3 Vectors funcione como motor de almacenamiento.

Límites de los fragmentos. El hallazgo menos llamativo: pasar dos semanas determinando dónde se dividen los documentos mejoró la calidad de las respuestas más que meses trabajando en la arquitectura de recuperación. Los fragmentos deben basarse en la estructura del documento, es decir, en secciones y cláusulas, y no únicamente en el conteo de tokens.

Cambiar el modelo de incrustación. La dimensión se fija al crear el índice, por lo que cambiar de modelo implica crear otro índice para cada usuario. Es necesario volver a calcular las incrustaciones a partir de los documentos originales que aún se encuentran en S3, cambiar la configuración y luego ejecutar DeleteIndex en el índice antiguo. Gracias al diseño por usuario, la implementación se realiza de forma gradual, y la eliminación en sí misma demuestra que la copia antigua ya no existe.

Bases de conocimiento Bedrock. Pueden almacenar vectores en S3 Vectors y constituyen una opción por defecto adecuada. Tener tu propio sistema de ingesta tiene sentido por una razón concreta: las bases de conocimiento eligen ellas mismas las claves de los vectores, y la prueba de eliminación descrita anteriormente depende de que tú las elijas.

Requisitos revisados

Cinco de los seis requisitos se deben a dónde se almacenan los datos y a quién puede acceder a ellos, y no al código escrito por el equipo. Ese era el objetivo, y por eso se puede ofrecer de forma segura una plataforma de este tipo a quienes no confían en el proveedor.

El código aquí presentado es ilustrativo. Compáralo con la versión actual del AWS SDK v3 y CDK, ya que el evento de CloudTrail mostrado está abreviado, y verifica el soporte para puntos de extremo VPC y el etiquetado de índices en S3 Vectors en la documentación más reciente. Los nombres de inquilinos, documentos, regiones e IDs de cuenta son placeholders, y no se muestran volúmenes de producción ni umbrales de evaluación.

Leer más