Создание RAG с несколькими арендаторами на Amazon S3 Vectors: конвейер обработки
Модель RAG с поддержкой нескольких арендаторов на Amazon S3 Vectors: по одному индексу на арендатора, учетные данные STS с ограниченным диапазоном, удаление по ключу, аудит через CloudTrail и код изоляции, который всё ещё находится у вас.
Источники: 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() });
}
Аудитор может повторно запустить второй цикл, используя собственные учетные данные с правами только на чтение, ограниченные ARN индекса данного арендатора. Запрос идентичен, изменяется лишь принципал, а результат поступает от 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: практическое применение BM25, векторов и метода взаимного ранжирования — Почему RAG, основанный только на векторах, теряет точные идентификаторы, как метод RRF объединяет BM25 с эмбеддингами, в каких случаях гибридный поиск дает плохие результаты и почему фильтры метаданных должны применяться до объединения данных.
- Отладка конвейера, а не ИИ-модели: подход к устранению сбоев в RAG — Когда бот на основе RAG дает неверные ответы, проблема редко кроется в самой модели. Необходимо отследить сбои на этапах загрузки данных, поиска, генерации ответов, оценки качества и выполнения действий, чтобы выявить проблемный слой.