,

RAG Metadata Design for S3 Vectors: Filters at Scale

Blueprint of a hierarchical metadata tree feeding vector cubes into an S3 Vectors bucket for RAG metadata design

RAG metadata design is the part of a retrieval system nobody budgets time for, and the part that decides whether your filters work at all. With S3 Vectors now pre-filtering on ENHANCED indexes, a filter is no longer a hopeful hint to the ANN search. It is a hard scope, which means a sloppy schema now fails loudly instead of quietly. Here is how to design the metadata so it fits the budgets, filters cleanly, and survives the migration off CLASSIC.

Know the budgets before you design anything

The S3 Vectors limits page gives you the box to design inside:

  • 2 KB filterable metadata per vector. Keys and values count. This is the tight one.
  • 40 KB total metadata per vector, filterable plus non-filterable.
  • 50 metadata keys per vector, and at most 10 non-filterable keys per index.
  • 100 filter constraints per query on ENHANCED indexes. Per the filtering docs, each evaluated value counts, so an $in with 40 values spends 40.

Every key is filterable by default. That default is friendly for prototypes and bad for production, because the first engineer who stuffs a 1.5 KB chunk of text into metadata has eaten your filter budget. Non-filterable keys must be declared at index creation and can’t be flipped back later, so decide up front.

RAG metadata design: what to make filterable

A field earns a filterable slot only if some query will scope by it. Everything you merely want returned alongside results goes non-filterable. A sane split for a document RAG index:

{
  "filterable": {
    "tenant_id":   "t-10428",
    "path":        "acme/legal/matter-4417/exhibits/",
    "doc_type":    "contract",
    "acl_groups":  ["legal-core", "matter-4417"],
    "effective_d": 20260915,
    "lang":        "en",
    "is_current":  true
  },
  "non_filterable": ["source_text", "title", "source_uri", "chunk_meta"]
}

Rules of thumb:

  • Short keys, short values. effective_d beats document_effective_date_utc when every byte counts against 2 KB. Key names cap at 63 characters anyway.
  • Use lists for many-to-many. $eq against a list field matches if any element matches, so acl_groups lets one query check “user is in group X” without a giant $or.
  • Booleans for lifecycle state. is_current is cheaper than filtering on a version string and keeps superseded chunks out of answers.
  • Enforce it in the ingestion path, not in a wiki page nobody reads. If Bedrock Knowledge Bases does your ingestion, the same discipline applies to its metadata files.
import json

FILTERABLE = {"tenant_id", "path", "doc_type", "acl_groups",
              "effective_d", "lang", "is_current"}
LIMIT = 2048

def validate(meta: dict) -> dict:
    unknown = set(meta) - FILTERABLE - {"source_text", "title",
                                        "source_uri", "chunk_meta"}
    if unknown:
        raise ValueError(f"unregistered metadata keys: {unknown}")
    f = {k: v for k, v in meta.items() if k in FILTERABLE}
    size = len(json.dumps(f, separators=(",", ":")).encode())
    if size > LIMIT * 0.8:  # keep 20% headroom for schema growth
        raise ValueError(f"filterable metadata {size}B exceeds budget")
    return meta

The JSON byte count is an approximation of how the service measures size, which is why the check keeps headroom instead of cutting it to the last byte.

Hierarchies with $startsWith instead of giant $in lists

The new $startsWith operator, which only works on ENHANCED indexes, is the best thing in this release for schema design. Encode organizational structure as a path and one prefix constraint replaces dozens of enumerated IDs:

# 1 constraint: everything under a matter
{"path": {"$startsWith": "acme/legal/matter-4417/"}}

# 34 constraints: the same scope, enumerated (and stale the moment
# someone adds a sub-folder)
{"folder_id": {"$in": ["f-001", "f-002", "...", "f-034"]}}

# Combine: matter scope + current docs + contracts = 3 constraints
{"$and": [
  {"path": {"$startsWith": "acme/legal/matter-4417/"}},
  {"is_current": true},
  {"doc_type": "contract"}
]}

Design the path from broadest to narrowest scope, and always end segments with a delimiter. Without the trailing slash, matter-44 happily matches matter-4417. Put the tenant first if tenants never share data, so a single prefix doubles as an isolation check. If you also filter by tenant through tenant_id, keep both: the explicit field is easier to audit.

S3 Vectors buckets being switched from CLASSIC to ENHANCED index mode in place without reloading vectors

Date encoding for range filters

Range operators ($gt, $gte, $lt, $lte) accept numbers only. ISO strings like "2026-09-15" will not range-filter. Pick one numeric encoding per index and stick to it:

  • Epoch seconds for event timestamps where time of day matters.
  • YYYYMMDD integers (20260915) for business dates. Human-readable in logs, sorts correctly, and compact.

A bounded range costs two constraints: {"effective_d": {"$gte": 20260101, "$lte": 20260930}}. Cheap compared to enumerating months.

Count constraints in your query builder

Filters built from user permissions are where the 100-constraint cap bites. A user in 120 groups produces a 120-value $in and an error. Count before you send, and degrade intentionally: collapse to a path prefix, split into multiple queries and merge, or move the user’s groups into a coarser role.

def count_constraints(f) -> int:
    if isinstance(f, dict):
        n = 0
        for k, v in f.items():
            if k in ("$and", "$or"):
                n += sum(count_constraints(x) for x in v)
            elif isinstance(v, dict):
                n += sum(len(x) if isinstance(x, list) else 1
                         for x in v.values())
            else:
                n += 1
        return n
    return 0

assert count_constraints(query_filter) <= 100

This mirrors the documented rule (each value counts) but treat it as a guardrail, not a spec. Leave margin and log the count so you see creep before it becomes an outage.

One index or many: tenant sharding

Pre-filtering makes a shared multi-tenant index viable for recall. That does not make it the right call everywhere. A bucket holds up to 10,000 indexes, and each index takes up to 1,000 write requests per second, so the tradeoffs are:

  • Shared index, tenant filter: simplest ops, one schema, best for many small tenants. Isolation depends on your filter code being right every time.
  • Index per tenant: physical separation, independent schema evolution, per-tenant deletion is a single delete-index. Better for regulated or very large tenants, and it isolates write-heavy tenants from each other’s throughput.
  • Hybrid: a shared index for the long tail, dedicated indexes for whales and anyone whose contract says “separate.” Route in a thin lookup layer.

Migrating CLASSIC indexes safely

Indexes in buckets created before September 30, 2026 stay CLASSIC until you switch them. The switch itself is in place with no re-ingestion, per the launch post. The risk is in your queries, not your data. Do it in this order:

  1. Inventory buckets and indexes; tag which apps query each.
  2. Replay a day of production filters through count_constraints. Anything over 100 gets fixed first, since the cap applies on ENHANCED.
  3. Remove “fallback to unfiltered search” hacks that existed to paper over thin results.
  4. Flip a low-traffic index, watch errors and latency in CloudWatch, then roll through the rest and set the bucket default.
B=prod-rag
for IDX in $(aws s3vectors list-indexes --vector-bucket-name $B \
               --query 'indexes[].indexName' --output text); do
  aws s3vectors update-index-mode --vector-bucket-name $B \
    --index-name "$IDX" --index-mode ENHANCED && echo "flipped $IDX"
  sleep 300   # soak before the next one
done
aws s3vectors put-vector-bucket-default-index-mode \
  --vector-bucket-name $B --default-index-mode ENHANCED

Schema changes are a different story. If the migration is also your moment to mark source_text non-filterable or rename keys, that needs a new index and a re-put, because non-filterable configuration is fixed at creation. Batch it at 500 vectors per PutVectors call, dual-write during cutover, and switch reads once counts match. Tools like the S3 Vectors Embed CLI help when you’re re-embedding anyway.

At Enkompass (enkompass.net), this is our home turf. We ship production Bedrock RAG and agent workloads on S3 Vectors, and we’ve designed the tenant hierarchies, permission filters and CLASSIC-to-ENHANCED rollouts that keep them correct. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.

Ready when you are

Let’s map your route to AWS

A free 60-minute architecture review with a senior engineer. No slides, no SDR, just a plan.