Головна / Статті

Створення RAG з кількома орендарями на Amazon S3 Vectors: конвеєр обробки даних

Багатокористувацький RAG на Amazon S3 Vectors: один індекс на користувача, обмежені облікові дані STS, видалення за ключем, аудит CloudTrail та код ізоляції, який все ще належить вам.

Видавець: REACTAPP.TOP11 хв читання2524 слів

Джерела: Multi-Tenant RAG on Amazon S3 Vectors - Part 3: The Pipeline

Надання кожному орендарю сервісу RAG власного адресованого ресурсу замість спільного індексу з тегом орендаря робить ізоляцію тим, що AWS забезпечує, а аудитор клієнта може перевірити. Структура така: кожна вимога відповідає виклику API, політиці чи рядку журналу, після чого йдуть елементи, які все ще знаходяться у коді додатку. Основна ідея полягає у тому, що ізоляція належить до шару зберігання, а не до речення WHERE tenant = ?, про яке хтось може забути.

Макет

Одна облікова запис AWS містить один бакет векторів на кожний обслуговуваний регіон, і всередині кожного бакета знаходиться один індекс на кожного орендаря. Уривки є ілюстративними прикладами TypeScript з використанням @aws-sdk/client-s3vectors та @aws-sdk/client-bedrock-runtime; тому перевіряйте назви параметрів уідносно поточного 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

Спільний каталог регуляторних актів (закони, правила, рекомендації) копіюється до кожного індексу орендаря. Його копіювання коштує недорого, і це забезпечує автономність даних кожного орендаря.

Створення індексу

Процес інтеграції орендаря полягає у одному дзвінку, під час якого приймаються два незворотні рішення: щодо розміру вбудовування та списку ключів метаданих, які не підлягають фільтрації.

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

Новий індекс отримує ARN, який формується з назви сервісу, регіону (eu-west-1), облікового запису, бакета kb-eu-west-1 та нарешті index/org-b. Регіон безпосередньо міститься у цьому рядку, тож перелік індексів кожного бакета дозволяє безкоштовно отримати звіт про резиденцію даних.

Завантаження даних: обирайте ключі самі

Серед усього, що є в потоці обробки, найважливішим є вибір векторного ключа. Якщо дозволити сховищу генерувати ключі, команда „видалити цей документ“ перетворюється на пошук його фрагментів з подальшим видаленням того, що знайдено, а непряма пошук ніколи не може гарантувати, що нічого не залишилося. Краще отримувати ключ з ідентифікатора документа — тоді видалення стає простим пошуком.

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
}

Зверніть увагу, що не враховують метадані: орендаря. У жодному фрагменті немає напису "org-b". Фрагмент знаходиться в org-b, і саме це робить його B. Додавання тега орендаря також призведе до слабкостей дизайну, заснованого на тегах, коли неправильний тег виглядає точно як правильний, без жодної користі.

PutVectors забезпечує високу послідовність, і кожен виклик оплачується як принаймні 128 КБ, тому групуйте операції запису в пакети.

Щодо вбудовування, для повноти картини:

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

Обробка запитів відбувається у регіоні, який ви обрали, і Bedrock не зберігає запити та не використовує їх для навчання, що є частиною концепції резидентства.

Запити з обліковими даними, які вказують конкретний індекс

У цій архітектурі межі тенанта не вказуються у запиті як фільтр. Це є місцем призначення запиту — єдиним місцем, яке дозволено відкрити за допомогою облікових даних користувача. API створює ці облікові дані заново для кожного запиту.

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

Політика сеансу може лише обмежувати функції ролі та ніколи не надавати більше повноважень, ніж передбачено нею. Креденціали діють кілька хвилин та ніколи не виходять за межі API. У масштабних системах контроль доступу на основі атрибутів досягає того ж ефекту за допомогою тега в індексі, який порівнюється з тегом користувача, тож політика не зростає разом із списком орендарів.

Далі йде сам запит, причому контроль всередині орендаря виражається у вигляді фільтра:

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
}

Отже, існують два кордони та два механізми. IAM на ARN визначає, до якого тенанта можна отримати доступ. Фільтр метаданих, який застосовується під час самого пошуку, а не після нього, визначає, яку класифікацію буде видно всередині цього тенанта. Фільтрація після пошуку, тобто отримання десяти результатів та видалення того, що користувач не може бачити, тихо псує можливість знаходження інформації для обмежених ролей, причому про це ніколи не повідомляється. Фільтрація під час пошуку повертає десять результатів, які користувачу дозволено бачити. Дозволені класифікації обчислюються на основі тверджень авторизатора, а не з тіла запиту.

Посилання та цитати не входять до структури відповіді. Модель бачить лише chunk_text повернених векторів, а кожне посилання — це ідентифікатор документа та версія, отримані з тих самих метаданих. Те, що цитується, — це саме те, що прочитала модель, оскільки це однакові байти.

Навмисний баг

Існує тест, який відрізняє дизайн, у якому межі контролює додаток, від дизайну, у якому це робить платформа. Направте додаток на неправильний індекс, коли він має облікові дані, прив’язані до конкретного користувача:

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

Нічого не повертається. У дизайні, який ґрунтується на фільтрації за запитом, той самий баг міг би повернути дані A з кодом статусу 200. Залиште це як тест інтеграції, адже це найдешевший доказ того, що проблема не в вашому коді. Відхилений запит також з’являється у журналі аудиту з кодом помилки errorCode: AccessDenied щодо index/org-a.

Видалення за ключем, перевірка за ключем

Для видалення використовується маніфест, створений під час введення даних, і жодних пошуків не відбувається.

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

Аудитор може знову запустити другий цикл, використовуючи власні обмежені права на читання, які діють лише щодо ідентифікатора індексу цього користувача. Запит залишається ідентичним, змінюється лише суб’єкт, а результат надсилається від AWS, а не у вигляді скріншоту від постачальника. Виклик функції GetVectors щодо індексу іншого користувача призводить до помилки AccessDenied, що знову є тестом ізоляції.

Щоб видалити всього користувача, достатньо використати функцію DeleteIndex. Два оперативні зауваження: видалений вектор негайно зникає з результатів пошуку, проте він все ще може враховуватися у розмірі індексу та, відповідно, у рахунку за сканування, протягом приблизно дня. Крім того, під час додавання нової версії документа ключі попередньої версії слід видалити через той самий шлях, а не за допомогою окремої процедури очищення.

Аудит: події даних CloudTrail за ідентифікатором ARN

Створення та видалення індексів (CreateIndex, DeleteIndex) є подіями керування, і CloudTrail записує їх за замовчуванням. Записи окремих запитів є подіями даних: їх потрібно увімкнути, і за кожен з них стягується плата. Якщо пропустити цей крок, будь-які твердження про можливість аудиту будуть безпідставними.

// 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.

Подія запиту, спрощена:

{
  "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
}

„Хто прочитав дані орендаря A?“ стає одним з пропозицій у CloudTrail Lake або 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 створює запис у момент надходження виклику, ще до того, як це зробить власний обробник додатку, і оператор платформи не має можливості його змінити. Якщо надати аудитору права на читання цих записів або експортувати події цього користувача, вона зможе відповісти на запитання самостійно. Саме це змінює вимоги до аудиту: аудитор перевіряє інформацію, і нікому не потрібно вірити словам постачальника.

Чотири доповнення, які підходять до цієї моделі

Один ключ KMS на кожен індекс. Контейнери Vector можуть використовувати SSE-KMS, тож індекс кожного користувача може бути зашифрований ключем, яким керує сам клієнт та який призначений саме для цього користувача. Кожен ключ має ARN та регіон, що є ще одним артефактом резидентності. Його вимкнення робить усе, що зашифровано ним, непрочитуваним, включаючи копії, що є більш надійним способом видалення, ніж DeleteIndex окремо.

Експорт. Функція ListVectors може передавати весь індекс, включаючи текст, який не піддається фільтрації, через до 16 паралельних каналів. Завдяки обліковим даним, прив’язаним до власного ARN, клієнт може отримати свою повну базу знань. Це задовольняє вимоги статті 20 GDPR щодо портативності та усуває проблему залежності за допомогою одного виклику.

Приватний мережевий шлях. Надсилайте трафік S3 Vectors через кінцеву точку VPC, щоб запити не проходили через публічний Інтернет. Спеціалісти з безпеки у клієнтів, які підпадають під NIS2 або секторальні стандарти безпеки, саме про це і проситимуть.

Витрати на клієнта. Завдяки тегам розподілу витрат для кожного індексу, шар векторів відображається у рахунку AWS окремим пунктом для кожного клієнта, а не як орієнтовна оцінка. Ці ж теги дозволяють використовувати формат ABAC у політиці сеансів.

Що не вирішує межа індексу

Усі вищезазначені відповіді стосуються того, до яких даних може отримати доступ той чи інший орендар, і AWS це контролює. Наступні елементи не підпадають під ці обмеження, і саме на їх оптимізацію було витрачено більшу частину часу інженерів.

Ролі всередині орендаря. Це покривається фільтром класифікації. Важливі два правила: застосовувати фільтр усередині запиту, а не після нього, та брати ранг ролі з модуля авторизації, а не від клієнта.

Витік інформації про існування. Команди державного сектору стикаються зі ситуацією, з якою інші рідко зустрічаються: модель не повинна навіть вказувати на існування обмеженого документа, адже фраза «Я не можу показати вам звіт про добробут дитини цієї родини» вже є самою по собі розкриттям інформації. Тому система пошуку ніколи не повертає частину даних, яку користувач не може побачити, що гарантує фільтр, а запит ніколи не натякає на те, що щось приховується. Модель відповідає на основі того, що має, або визнає, що у неї нічого немає. Це стосується процесу пошуку, а не запиту, оскільки процес пошуку можна протестувати, тоді як формулювання моделі — ні.

Схожість між мовами. Корпус, який поєднує місцеву мову з англійськими юридичними текстами, дає значно інші оцінки від однієї моделі ембеддингу для уривків, які є однаково релевантними. Фіксований поріг косинуса, налаштований для однієї мови, призводить до поганих результатів у іншій без жодних попереджень. Рішенням було заміна абсолютного порогу на вікно, яке залежить від найкращого результату в кожному наборі даних. Багатомовні користувачі повинні спочатку виміряти це показник, оскільки правильне значення залежить від корпусу.

Відсутність пошуку за ключовими словами. Ідентифікатори, такі як номери розділів чи ідентифікатори справ, повинні потрапляти до фільтруваних метаданих та збігатися точно, а не бути ембеддованими та залишатися на волю випадку. Якщо потрібен справжній пошук за ключовими словами, слід використовувати OpenSearch на початку та дозволити S3 Vectors виконувати функцію двигуна зберігання.

Межі частин документа. Найменш привабливе відкриття: два тижні, витрачені на визначення місць розділення документів, покращили якість відповідей більше, ніж місяці роботи над архітектурою пошуку. Розділяйте документи за структурою, тобто за розділами та пунктами, а не лише за кількістю токенів.

Зміна моделі ембеддингу. Розмір виміру фіксується під час створення індексу, тому зміна моделі означає створення окремого індексу для кожного користувача. Перерахуйте ембеддинги з оригінальних документів, які все ще знаходяться у S3, змініть конфігурацію, а потім виконайте команду DeleteIndex для старого індексу. Завдяки структурі, що передбачає окремі індекси для кожного користувача, зміни впроваджуються поступово, а сам процес видалення показує, що стара копія зникла.

Бази знань Bedrock. Вони можуть зберігати вектори в S3 Vectors, і це є розумним стандартним варіантом. Використання власного механізму введення даних виправдане лише з однієї причини: бази знань самі вибирають ключі векторів, а описаний раніше доказ видалення залежить від того, чи ви самі їх оберете.

Повторний огляд вимог

П’ять із шести вимог зумовлені місцем зберігання даних та тим, хто може до них отримати доступ, а не кодом, написаним командою. Саме це було метою, і саме тому таку платформу можна безпечно запропонувати тим, хто не довіряє постачальнику.

Наведений тут код є ілюстративним. Порівняйте його з поточною версією AWS SDK v3 та CDK, оскільки показаний подія CloudTrail є скороченим, а також перевірте підтримку кінцевих точок VPC та позначкування індексів для S3 Vectors у найновішій документації. Тенанти, документи, регіони та ідентифікатори облікових записів є замінниками, і тут не вказано обсягів даних у продакшені чи порогів оцінки.

Пов’язана література

  • Завантаження документів із урахуванням якості в RAG: маршрутизація документів за рівнем читабельності — це декларативний підхід до створення потоків обробки документів, який оцінює мову, наявність дублікатів та рівень читабельності за шкалою Флеша, а потім надсилає документи до двох індексів OpenSearch для пошуку з боку клієнтів та розробників.
  • Ізоляція тенантів у RAG на Amazon S3 Vectors: чому важливий ARN — чому індекс для кожного тенанта з власним ARN краще за фільтри метаданих для забезпечення ізоляції, резидентності, видалення, аудиту та простежуваності даних на рівні GDPR у системі Amazon S3 Vectors.