DEV Community

Amit
Amit

Posted on • Originally published at artificialcuriositylabs.ai

What Actually Gets Stored When You Put a Vector in S3 Vectors

TL;DR

  • A vector store does not have a "document" field. Each record is three parts: a key, the vector (an array of floats), and metadata. Your readable text is stored as metadata, not as a first-class body.
  • One document becomes many records. A knowledge base splits each file into chunks, embeds each chunk into its own vector, and writes one record per chunk. Document-level facts like tags get copied onto every chunk of that document.
  • S3 Vectors splits metadata into filterable and non-filterable, with a hard 2 KB cap on the filterable part. Chunk text is far larger than 2 KB, so it must be stored non-filterable — miss that and every write fails with a 400.
  • Filterable metadata is what lets you narrow the corpus before the vector math runs. The same query, filtered to one tag, searches a different slice and returns different results.

A vector store keeps three things per record. The readable document is split across many records, and the text sits inside a metadata field. I learned the exact shape of this by loading a 74-post blog into an S3 Vectors index and then reading back what was actually written. Once you see how the record is really built, the common ingestion failures explain themselves.

Three Components, No Document

Pull a single record back out of an S3 Vectors index and it has exactly three parts:

{
  "key":      "cf4f6331-8869-4d4b-acf1-e1bdf7660fb4",
  "data":     { "float32": [-0.1117, 0.004, -0.007, ...] },
  "metadata": { ... }
}
Enter fullscreen mode Exit fullscreen mode

The key is a unique ID for the record. The data is the vector — in this index, an array of 512 floats, because the embedding model produces 512 dimensions. This array is the only thing similarity search compares. It is also not human-readable: you cannot recover the sentence from those 512 numbers.

So where is the sentence? In the metadata. When a knowledge base writes a record, it stores the chunk's readable text in a metadata key. There is no separate document body in the store — the text is metadata riding alongside the vector:

  one record
    ├─ key   : unique ID
    ├─ data  : 512 floats  (the vector — searchable, not readable)
    └─ metadata : key-value bag
         ├─ chunk text          (the readable sentence)
         └─ tags, year, date    (filter fields)
Enter fullscreen mode Exit fullscreen mode

That single fact reframes everything else: a vector store is a similarity index over float arrays, plus a bag of key-value pairs per record, and your content lives in the bag.

One Document Becomes Many Records

Here is what happens under the hood. I loaded 74 posts. The index holds 488 records. One post became 18 separate records on its own.

The pipeline splits each file into chunks — roughly 300 tokens each — embeds every chunk into its own vector, and writes one record per chunk:

  one post (.md file)
    ├─ chunk 1  (~300 tokens) ──▶ record: key + 512 floats + metadata
    ├─ chunk 2             ────▶ record: key + 512 floats + metadata
    ├─ chunk 3             ────▶ record: key + 512 floats + metadata
    └─ ... chunk 18         ────▶ record: key + 512 floats + metadata
Enter fullscreen mode Exit fullscreen mode

Retrieval searches across all 488 chunk-vectors and returns individual chunks, which is why a single query can return two different passages from the same post: they are two different records that both scored well.

This changes how document-level information has to be stored. A post's tags belong to the whole post, but there is no "post" record to attach them to — only 18 chunk records. So document-level metadata gets copied onto every chunk of that document. All 18 chunks carry the same tags, the same year, the same source URI. The redundancy is deliberate. It is what makes the next part work.

Filterable and Non-Filterable Metadata

S3 Vectors splits metadata into two classes, and the split is the part that breaks builds.

Filterable metadata can be used in query filters. You narrow a search with it: only records where tags contains agents, only records where year equals 2026. It is capped at 2 KB per record, because filtering has to stay fast.

Non-filterable metadata cannot be filtered on, but it is returned with results and has room — it shares a 40 KB-per-record total. It is meant for the large payloads: the chunk text, long descriptions.

The trap is the default. Every metadata key is filterable unless you explicitly mark it non-filterable at index creation, and that choice is immutable — you cannot change it later without recreating the index. A knowledge base stores chunk text in a metadata key, and a 300-token chunk is well over 2 KB. Left filterable, it blows the budget, and every write fails:

Invalid record for key '...': Filterable metadata must have at
most 2048 bytes (Service: S3Vectors, Status Code: 400)
Enter fullscreen mode Exit fullscreen mode

The fix is to declare the text key non-filterable when you create the index:

aws s3vectors create-index \
  --vector-bucket-name my-vectors \
  --index-name blog-index \
  --data-type float32 \
  --dimension 512 \
  --distance-metric cosine \
  --metadata-configuration \
    '{"nonFilterableMetadataKeys":["AMAZON_BEDROCK_TEXT","AMAZON_BEDROCK_METADATA"]}'
Enter fullscreen mode Exit fullscreen mode

That moves the text out of the 2 KB filterable budget and into the 40 KB total. Because the setting cannot be changed after creation, getting it wrong means deleting the index and starting over. It is the one parameter worth reading twice.

The Budget Is the Design

The metadata limits are the design constraints you build against (full list here):

Limit Value
Total metadata per record 40 KB
Filterable metadata per record 2 KB
Metadata keys per record 50
Non-filterable keys per index 10
Dimensions per vector 1–4096

The 2 KB filterable cap is the one that shapes behavior. Small structured fields — tags, year, date, author — belong in the filterable budget. The big text belongs outside it. Since document-level fields get copied onto every chunk, they need to stay small: multiply a fat filterable field across every chunk of every document and the budget disappears fast.

Filtering Narrows the Corpus Before the Math

The payoff of putting tags in filterable metadata is that a query can narrow the corpus before the vector search runs. Same query, different slice.

Unfiltered, a search for agent cost and pricing returns the pricing posts:

query: "what did I learn about agent cost and pricing"
  0.687  how-to-optimize-agent-subscriptions.md
  0.682  ai-subscriptions-are-secretly-usage-models.md
  0.682  harness-is-where-the-margin-lives.md
Enter fullscreen mode Exit fullscreen mode

Add a filter for tags containing skills, and the identical query searches only the chunks from skills-tagged posts — a different subset of the 488 records — and returns different results:

filter: tags listContains "skills"
  0.607  agent-sprawl-is-a-skills-problem.md
  0.603  skills-as-institutional-memory.md
Enter fullscreen mode Exit fullscreen mode

The filter runs first. It selects which chunk-vectors are even eligible, then similarity search ranks within that subset. This is why the tag lives on every chunk: the filter has to be able to include or exclude each record on its own, without knowing which document it came from. Filters compose, too — year equals 2026 AND tags contains pricing narrows on both fields before a single distance is computed.

What's Missing

Two limits are worth knowing before you lean on this.

The filterable budget is tight. 2 KB per record is enough for tags and dates, not for rich structured metadata. A workload that wants to filter on many fields, or on long field values, will feel the ceiling — and because filterable keys are duplicated across every chunk, the ceiling arrives sooner than the per-record number suggests.

And the immutability is real. Dimension, distance metric, and the non-filterable key list are all fixed at index creation. There is no migration path — only delete and rebuild. For a personal blog that is a minor annoyance. For a large index it is a reason to model the metadata schema carefully before the first write.

So What

A vector store is a similarity index over float arrays, with a metadata bag per record where your text and your filter fields live together under a hard budget. Once that model is clear, the failures stop being mysterious: ingestion breaks because the text was left filterable, filtering returns nothing because the field was never declared, and the same query returns different results because a filter changed which records were eligible. The store rewards understanding its actual shape — three components per record, one document shredded across many of them, and a 2 KB line that decides where your text is allowed to live.

This is the storage layer underneath the tier decision. For why an agent's retrieval workload belongs on this cold tier at all, see Which Tier Does Your Vector Workload Live On?.

Top comments (0)