在 Amazon S3 Vectors 上构建多租户 RAG:完整流程
基于 Amazon S3 Vectors 的多租户 RAG:每个租户一个索引,受限的 STS 凭证,通过密钥实现数据删除,CloudTrail 审计功能,以及仍由您掌控的隔离代码。
来源: Multi-Tenant RAG on Amazon S3 Vectors - Part 3: The Pipeline
为RAG服务的每个租户提供独立的可寻址资源,而非使用带有租户标签的共享索引,这使得隔离成为AWS强制实施且客户审计员可以核查的内容。其实现方式是:将每个需求映射到一次API调用、一条策略或一行日志记录,其余部分仍保留在应用程序代码中。其核心理念是隔离应属于存储层,而非可能被遗忘的WHERE tenant = ?子句。
结构布局
每个AWS账户在所服务的每个区域中都拥有一个向量存储桶,而每个存储桶内则包含每个租户对应的索引。这些示例代码为使用@aws-sdk/client-s3vectors和@aws-sdk/client-bedrock-runtime编写的TypeScript示例,参数名称请以当前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。区域信息直接包含在字符串中,这意味着只需列出每个桶的索引,就能免费获得数据驻留情况报告。
数据导入:自行选择键
在整个处理流程中,向量键的选择最为重要。如果让存储系统自动生成键,那么“删除此文档”操作就会变成先搜索该文档的各个片段,再删除搜索结果中的内容,而近似搜索则无法确保没有内容被遗留。应当从文档ID中提取键,这样删除操作只需简单查找即可完成。
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 KB计费,因此应将多次写入操作合并为批次处理。
为完整性起见,再补充一下嵌入功能:
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
}
因此存在两个边界和两种机制。ARN上的IAM决定你可以访问哪个租户,而元数据过滤器则在搜索过程中而非之后起作用,用于决定你在该租户中能看到哪些分类。如果在搜索之后再进行过滤,即先获取十个结果再丢弃用户无法查看的内容,这会悄悄破坏受限角色的检索效果,且不会给出任何提示。而在搜索过程中进行过滤,则会返回用户有权查看的十个结果。允许显示的分类是根据授权方的声明来确定的,而非来自请求体。
引用信息并不包含在响应格式中。模型仅能看到返回向量中的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状态码返回。请将此作为集成测试保留,因为这是证明问题出在代码之外最简单的证据。被拒绝的请求也会出现在审计日志中,显示为针对index/org-a的errorCode: AccessDenied。
通过键值删除,再通过键值验证
删除操作会使用数据导入时生成的清单,无需进行任何搜索操作。
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即可。有两点操作注意事项:被删除的向量会立即从查询结果中消失,但仍可能计入索引大小以及扫描计费中,持续时间最长为一天。此外,在导入新文档版本时,应通过相同路径删除旧版本的键,而非通过单独的清理任务。
审计:按ARN查看CloudTrail数据事件
索引的创建与删除(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范围内的凭据,租户能够获取完整的知识库。这既符合GDPR第20条关于数据可移植性的要求,又可通过一次调用避免锁定问题。
私有网络路径。 通过VPC端点发送S3 Vectors流量,这样查询就不会经过公共互联网。那些受NIS2或行业安全规范约束的租户的安全负责人会明确要求这样做。
每租户的成本。 由于每个索引都带有成本分配标签,向量层在AWS账单中会以单独的条目形式显示在每个租户名下,而非作为估算值。同样的标签也让基于ABAC的会话策略成为可能。
索引边界无法解决的难题
以上所有内容都说明了租户能够访问哪些数据,以及AWS是如何实施这一控制的。以下事项不在该范围之内,而实际上大部分工程工作都集中在这些方面。
租户内的角色。分类过滤器可以处理这一问题。有两点需要注意:必须在查询时应用过滤器,而不能在查询之后;角色的等级应从授权方获取,而非从客户端获取。
存在泄露问题。公共部门团队面临一种其他领域很少出现的故障模式:模型甚至不得透露存在受限文档这一事实,因为“我无法向您展示该家庭的儿童福利报告”本身就已构成信息泄露。因此,检索系统绝不会返回用户无法看到的内容,这是过滤机制所保障的,同时提示语也不会暗示有任何内容被隐藏。模型要么基于已有信息作答,要么承认自己没有相关信息。这类问题属于检索环节的缺陷,而非提示语的问题,因为检索机制可以接受测试,而模型的表述方式则无法被测试。
跨语言相似度。当语料库同时包含本地语言与英语法律文本时,对于相关性相同的段落,不同的嵌入模型会给出明显不同的分数。基于某一种语言调整好的余弦阈值,往往会毫无预兆地导致另一种语言下的良好结果消失。解决方法是用相对于每组结果中最佳匹配项的窗口范围来替代绝对阈值。多语言环境下的用户应首先测量这一数值,因为合适的阈值取决于具体的语料库。
无关键词搜索功能。诸如章节编号或案件编号之类的标识符应被纳入可过滤的元数据中,并进行精确匹配,而非通过嵌入处理并依赖随机性。如果确实需要真正的关键词搜索功能,应先使用OpenSearch,再将S3 Vectors作为存储引擎。
分块边界。最不引人注目的发现是:花费两周时间研究文档应如何分割,其对答案质量的提升效果竟优于数月来对检索架构的优化。分块应基于文档结构,即段落和条款,而非仅依据标记数量。
更换嵌入模型。索引创建时其维度是固定的,因此更换模型意味着需要为每个租户创建新的索引。需从仍存储在S3中的原始文档重新计算嵌入值,调整配置后,再对旧索引执行DeleteIndex操作。得益于按租户划分的架构,这一变更可以逐步推进,而且删除操作本身就能表明旧副本已被移除。
基础知识库。它可以将向量存储在S3 Vectors中,这是较为理想的默认选择。自行处理数据导入仅有一个重要优势:知识库会自行选择向量键,而之前提到的删除证明功能则依赖于用户自行选择这些键。
需求再探
六个需求中的五个取决于数据存储的位置以及谁有权访问这些数据,而非团队编写的代码。这正是设计初衷,也是为何这样的平台能够安全地提供给那些不信任供应商的用户。
此处的代码仅用于说明。请将其与当前的AWS SDK v3及CDK进行对比,因为所示的CloudTrail事件已被简化,同时需在最新文档中确认S3 Vectors对VPC端点的支持情况以及索引标记功能。租户、文档、区域和账户ID均为占位符,文中也未提及实际的生产数据量或评估阈值。
相关阅读
- RAG的混合搜索:BM25、向量与互反排名融合的实践应用 —— 为何仅使用向量的RAG会丢失精确标识符,RRF如何将BM25与嵌入向量融合,混合搜索在哪些情况下表现不佳,以及为何元数据过滤应先于融合步骤进行。
- 排查流程而非大语言模型:RAG故障的生产环境处理思路 —— 当RAG机器人给出错误答案时,模型很少是问题所在。通过数据摄取、检索、生成、评估及智能代理循环逐步追踪故障点,找出出问题的环节。