Budowanie RAG wielu najemców na Amazon S3 Vectors: cały proces
Multi-tenant RAG na Amazon S3 Vectors: jeden indeks na użytkownika, uprawnienia STS z ograniczonym zakresem, usuwanie według klucza, audyt CloudTrail oraz kod izolacji, który nadal posiadasz.
Źródła: Multi-Tenant RAG on Amazon S3 Vectors - Part 3: The Pipeline
Przydzielanie każdemu najemcy usługi RAG własnego zasobu, do którego można uzyskać dostęp, zamiast wspólnego indeksu z tagiem najemcy, sprawia, że izolacja staje się czymś, co AWS egzekwuje, a co audytor klienta może sprawdzić. Tak wygląda struktura: każde wymaganie jest powiązane z wywołaniem API, polityką lub wierszem z dziennika, a następnie następują elementy, które nadal znajdują się w kodzie aplikacji. Założeniem jest, że izolacja należy do warstwy przechowywania, a nie do klauzuli WHERE tenant = ?, o której ktoś mógłby zapomnieć.
Struktura
Jedno konto AWS zawiera jeden bucket wektorowy na każdy obsługiwany region, a w każdym tym bucket znajduje się jeden indeks na każdego najemcę. Przykłady są ilustracyjne i oparte na TypeScript z użyciem bibliotek @aws-sdk/client-s3vectors oraz @aws-sdk/client-bedrock-runtime; dlatego sprawdź nazwy parametrów w zgodzie z aktualną wersją 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
Katalog regulacyjny (prawa, rozporządzenia, wytyczne) jest kopiowany do każdego indeksu najemcy. Jego kopiowanie nie jest kosztowne i zapewnia, że dane każdego najemcy pozostają zamknięte w sobie.
Tworzenie indeksu
Proces włączania najemcy do systemu polega na jednym połączeniu telefonicznym, w trakcie którego podejmowane są dwie nieodwracalne decyzje: dotyczące wymiaru wplecenia oraz listy kluczy metadanych, które nie mogą być filtrowane.
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
},
}));
Nowy indeks otrzymuje ARN utworzone na podstawie nazwy usługi, regionu (eu-west-1), konta, bufora kb-eu-west-1 oraz wreszcie index/org-b. Region znajduje się bezpośrednio w tym ciągu znaków, co oznacza, że wyświetlenie indeksów każdego bufora pozwala bezpłatnie uzyskać raport dotyczący lokalizacji danych.
Przyjmowanie danych: sam wybierz klucze
Ze wszystkich elementów w tym procesie najważniejszy jest wybór klucza wektorowego. Jeśli pozwolisz magazynowi generować klucze, opcja „usuń ten dokument” zamieni się w wyszukiwanie jego fragmentów, a następnie usunięcie tego, co zostanie znalezione, przy czym wyszukiwanie przybliżone nigdy nie może potwierdzić, że nic nie pozostało. Klucz należy wywnioskować z identyfikatora dokumentu, a usunięcie danych to prosta operacja wyszukiwania.
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
}
Zwróć uwagę na to, co pomija metadane: najemcę. W żadnym fragmencie nie ma napisu „org-b”. Fragment znajduje się w org-b, i to właśnie sprawia, że należy do kategorii B. Dodanie tagu najemcy przywróciłoby słabość projektów opartych na tagach, w których błędny tag wygląda dokładnie tak samo jak poprawny, bez żadnej korzyści.
Zapisy przy użyciu PutVectors są w pełni spójne, a każdy wywołanie jest naliczane jako co najmniej 128 KB, dlatego grupuj zapisy w partie.
Aby mieć pełny obraz: embedding.
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;
}
Procesy inferencji odbywają się w regionie, który sam wybierasz, a Bedrock nie przechowuje zapytań ani nie wykorzystuje ich do szkolenia, co stanowi część koncepcji rezydencji.
Zapytania z uprawnieniami wskazującymi konkretny indeks
W tym projekcie granica użytkownika nie występuje jako filtr w zapytaniu. Jest to cel zapytania – jedyny cel, do którego uprawnione są uprawnienia osoby wysyłającej zapytanie. API tworzy te uprawnienia od nowa dla każdej prośby.
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!;
}
Polityka sesji może jedynie ograniczyć rolę i nigdy nie przyznać uprawnień wykraczających poza nią. Dane uwierzytelniające są ważne przez kilka minut i nigdy nie opuszczają API. W skali większej kontrola dostępu oparta na atrybutach osiąga ten sam efekt dzięki tagowi w indeksie, który jest porównywany z tagiem podmiotu, dzięki czemu polityka nie rośnie wraz z listą najemców.
Następnie przychodzi sama zapytanie, przy czym kontrola wewnątrz najemcy jest wyrażana jako filtr:
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
}
Istnieją zatem dwa ograniczenia oraz dwa mechanizmy. IAM w ARN decyduje, do którego użytkownika można uzyskać dostęp. Filtr metadanych, stosowany w trakcie samego wyszukiwania, a nie później, określa, jaką klasifikację można zobaczyć w ramach danego użytkownika. Filtrowanie później, czyli pobranie dziesięciu wyników i odrzucenie tych, których użytkownik nie może zobaczyć, w sposób pośredni psuje możliwość odnalezienia informacji dla ról ograniczonych, przy czym o tym w ogóle nie informuje. Filtrowanie w trakcie wyszukiwania zwraca dziesięć wyników, które użytkownik ma prawo zobaczyć. Dozwolone klasifikacje są obliczane na podstawie informacji dostarczonych przez autoryzatora, a nie na podstawie treści żądania.
Informacje o źródłach i cytatach nie znajdują się w strukturze odpowiedzi. Model otrzymuje jedynie chunk_text zwróconych wektorów, a każda cytat to identyfikator dokumentu oraz jego wersja, pobrane z tych samych metadanych. To, co jest cytowane, to dokładnie to, co przeczytał model, ponieważ oba elementy składają się z tych samych bajtów.
Celowy błąd
Istnieje test, który odróżnia projekt, w którym aplikacja kontroluje granice, od takiego, w którym to platforma je kontroluje. Skieruj aplikację na niewłaściwy indeks, gdy posiada ona dane uwierzytelniające specyficzne dla użytkownika:
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
Nic nie wraca. W projekcie opartym na filtrowaniu aplikacji ten sam błąd spowodowałby zwrócenie danych A z kodem 200. Zachowaj to jako test integracyjny, ponieważ jest to najtańszy dowód na to, że problem leży poza twoim kodem. Odrzucona próba połączenia pojawia się również w dzienniku audytowym z errorCode: AccessDenied w odniesieniu do index/org-a.
Usuwanie według klucza, weryfikacja według klucza
Usuwanie odbywa się na podstawie manifestu utworzonego podczas importu, bez konieczności przeprowadzania żadnych wyszukiwań.
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() });
}
Biegły audytorski może ponownie uruchomić drugi pętlę, używając własnych uprawnień tylko do odczytu ograniczonych do ARN indeksu danego użytkownika. Wywołanie jest identyczne, różni się jedynie podmiotem, a wynik pochodzi od AWS, a nie z zrzutu ekranu dostarczonego przez dostawcę. Wywołanie GetVectors w odniesieniu do indeksu innego użytkownika zwraca AccessDenied, co ponownie stanowi test izolacji.
Aby usunąć całego użytkownika, wystarczy wywołać DeleteIndex. Dwie uwagi operacyjne: usunięty wektor natychmiast znika z wyników zapytań, chociaż może nadal wpływać na rozmiar indeksu, a tym samym na rachunki za skanowanie, przez około dzień. Ponadto, gdy jest wgrywana nowa wersja dokumentu, klucze poprzedniej wersji powinny być usuwane tą samą ścieżką, a nie poprzez oddzielne zadanie czyszczenia.
Audyt: Zdarzenia danych CloudTrail według ARN
Tworzenie i usuwanie indeksów (CreateIndex, DeleteIndex) to zdarzenia zarządzania, które CloudTrail rejestruje automatycznie. Zapisy poszczególnych zapytań to zdarzenia danych: muszą być włączone, a za każde z nich pobierana jest opłata. Jeśli pominie się ten krok, wszelkie twierdzenia o możliwości audytu okazują się bezpodstawne.
// 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.
Zdarzenie zapytania, w wersji skróconej:
{
"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
}
„Kto odczytał dane najemcy A?” staje się jednym predykatem w CloudTrail Lake lub 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 tworzy zapis w momencie przybycia połączenia, przed obsługą realizowaną przez samą aplikację, a operator platformy nie ma możliwości jego edycji. Udzielając audytorowi uprawnień do odczytu zapisów lub eksportując wydarzenia danego użytkownika, może on samodzielnie odpowiedzieć na pytanie. To właśnie zmienia wymagania audytowe: audytor dokonuje weryfikacji, więc nikt nie musi wierzyć słowom dostawcy.
Cztery ulepszenia pasujące do modelu
Jedny klucz KMS na indeks. Kosze Vector mogą korzystać z SSE-KMS, więc indeks każdego użytkownika może być szyfrowany kluczem zarządzanym przez klienta, przeznaczonym wyłącznie dla tego użytkownika. Każdy klucz posiada ARN oraz region, co stanowi kolejny element związany z lokalizacją danych. Wyłączenie tego mechanizmu sprawia, że wszystko szyfrowane pod jego wpływem staje się nieczytelne, włączając kopie, co zapewnia silniejsze usunięcie danych niż samo DeleteIndex.
Eksport. Funkcja ListVectors może przesyłać cały indeks, włączając tekst, który nie daje się filtrować, przez nawet 16 równoległych kanałów. Dzięki uprawnieniom skonfigurowanym pod własny ARN, klient może zabrać ze sobą całą swoją bazę wiedzy. To spełnia wymogi dotyczące przenoszenia danych zgodnie z artykułem 20 RODO i eliminuje ryzyko przywiązania do konkretnego rozwiązania przy jednym wywołaniu.
Prywatna ścieżka sieciowa. Należy przesyłać ruch S3 Vectors przez punkt końcowy VPC, aby zapytania nie przechodziły przez publiczną sieć Internet. Specjaliści ds. bezpieczeństwa w firmach podlegających regulacjom NIS2 lub odpowiednim przepisom branżowym będą dokładnie o to prosić.
Koszt na klienta. Dzięki tagom przypisującym koszty do każdego indeksu, warstwa wektorowa pojawia się na rachunku AWS jako osobny pozycja dla każdego klienta, a nie jako szacunkowa wartość. Te same tagi umożliwiają stosowanie mechanizmu ABAC w polityce sesji.
Co granice indeksu nie rozwiązują
Wszystkie powyższe odpowiedzi dotyczą tego, do jakich danych może uzyskać dostęp dany użytkownik, a AWS to kontroluje. Poniższe elementy nie podlegają temu ograniczeniu i to właśnie na nich skupiła się większość czasu poświęconego na pracę inżynierską.
Rolи w ramach jednego użytkownika. Filtr klasyfikacji obejmuje to zagadnienie. Istotne są dwie zasady: filtr należy zastosować wewnątrz zapytania, a nie po nim, oraz rangę roli należy pobierać od autoryzatora, a nie od klienta.
Ucieczka informacji. Zespoły sektora publicznego napotykają sposób awarii, z którym rzadko spotykają się inni: model nie powinien nawet zdradzać, że istnieje dokument ograniczony w dostępie, ponieważ stwierdzenie „Nie mogę pokazać panu raportu o dobrostanie dzieci tej rodziny” samo w sobie stanowi ujawnienie informacji. Dlatego mechanizm wyszukiwania nigdy nie zwraca fragmentu, którego użytkownik nie może zobaczyć, co gwarantuje filtr, a prompt nigdy nie zasugerowuje, że coś zostało ukryte. Model odpowiada na podstawie tego, co posiada, albo przyznaje, że nie ma nic. To dotyczy mechanizmu wyszukiwania, a nie promptu, ponieważ mechanizm wyszukiwania można przetestować, natomiast sformułowania modelu – nie.
Podobieństwo międzyjęzykowe. Korpus łączący język lokalny z angielskimi tekstami prawnymi daje znacznie różne wyniki dla równie istotnych fragmentów przy użyciu tego samego modelu embeddingu. Stała wartość progu kosinowego dostosowana do jednego języka powoduje gorsze rezultaty w drugim bez żadnego ostrzeżenia. Rozwiązaniem było zastąpienie absolutnego progu oknem względnym do najlepszego wyniku w każdej grupie rezultatów. Właściciele systemów wielojęzycznych powinni najpierw to sprawdzić, ponieważ odpowiednia wartość zależy od korpusu.
Brak wyszukiwania według słów kluczowych. Identyfikatory takie jak numery sekcji czy identyfikatory spraw powinny znajdować się w metadanych, które można filtrować, i być dopasowywane dokładnie, a nie przetwarzane przez modele embeddingu i pozostawiane przypadkowi. Jeśli potrzebne jest rzeczywiste wyszukiwanie według słów kluczowych, należy użyć OpenSearch jako warstwy wstępnej, a S3 Vectors jako silnika przechowywania.
Granice fragmentów. Najmniej imponujący wynik: dwa tygodnie poświęcone na ustalenie miejsc, w których dzielone są dokumenty, poprawiły jakość odpowiedzi bardziej niż miesiące pracy nad architekturą wyszukiwania. Fragmentowanie powinno odbywać się na podstawie struktury dokumentu, czyli sekcji i zdań, a nie wyłącznie na podstawie liczby tokenów.
Zmiana modelu embeddingu. Wymiary są ustalone przy tworzeniu indeksu, więc zmiana modelu oznacza konieczność stworzenia nowego indeksu dla każdego użytkownika. Należy ponownie obliczyć embeddingi z oryginalnych dokumentów nadal przechowywanych w S3, zmienić konfigurację, a następnie wykonać polecenie DeleteIndex w odniesieniu do starego indeksu. Dzięki takiej strukturze dostosowywanej indywidualnie dla każdego użytkownika zmiany wprowadzane są stopniowo, a sama operacja usuwania pokazuje, że stara kopia zniknęła.
Bazy wiedzy Bedrock. Mogą one przechowywać wektory w S3 Vectors i stanowią rozsądny standard domyślny. Uruchomienie własnego mechanizmu pobierania danych opłaca się z jednego konkretnego powodu: bazy wiedzy same wybierają klucze wektorów, a opisany wcześniej dowód usunięcia zależy od tego, czy to my je wybierzemy.
Przegląd wymagań
Pięć z sześciu wymagań wynika z miejsca przechowywania danych i tego, kto może do nich uzyskać dostęp, a nie z kodu napisanego przez zespół. Taki był cel, i dlatego platformę tę można bezpiecznie oferować osobom, które nie ufają dostawcy.
Kod przedstawiony tutaj ma charakter ilustracyjny. Należy go porównać z aktualną wersją AWS SDK v3 i CDK, ponieważ pokazane zdarzenie CloudTrail jest skrócone, a także sprawdzić w najnowszej dokumentacji obsługę punktów końcowych VPC oraz tagowanie indeksów w S3 Vectors. Nazwy najemców, dokumentów, regionów i identyfikatory kont są tylko przykładami, a nie ma tu informacji o objętościach danych w produkcji ani prógach oceny.
Podobne artykuły
- Hybrydowe wyszukiwanie w RAG: praktyczne zastosowanie BM25, wektorów i fuzji ranku recyprokalnego — Dlaczego RAG oparte wyłącznie na wektorach traci dokładne identyfikatory, jak RRF łączy BM25 z embeddingami, w jakich przypadkach hybrydowe wyszukiwanie podaje błędne wyniki i dlaczego filtry metadanych powinny być stosowane przed fuzją.
- Rozwiązywanie problemów w całym procesie, a nie tylko w modelu LLM: model mentalny do radzenia sobie z awariami w RAG — Gdy bot RAG podaje błędne odpowiedzi, rzadko jest to wina modelu. Śledź błędy na etapach pobierania danych, wyszukiwania, generowania, oceny i pętli agencyjnych, aby znaleźć uszkodzony element.
- Przyjmowanie danych w modelu RAG z uwzględnieniem jakości: kierowanie dokumentami według ich czytelności — Deklaratywny schemat pipeline’ów do przetwarzania dokumentów, który ocenia język, duplikaty oraz poziom czytelności według skali Flescha, a następnie wysyła dokumenty do dwóch indeksów OpenSearch przeznaczonych do wyszukiwania przez klientów i programistów.
- Izolacja wynajmujących w modelu RAG na Amazon S3 Vectors: dlaczego ARN ma znaczenie — Dlaczego indeks dedykowany każdemu wynajmującemu z własnym ARN jest lepszy od filtrów metadanych pod względem izolacji, lokalizacji przechowywania, usuwania danych, audytu oraz śledzenia pochodzenia informacji w modelu RAG na Amazon S3 Vectors zgodnie z wymogami GDPR.