M06 · Document ingestion and chunking for RAG06-0323 min read
Lesson 39 of 106 · Module 7 of 14 · Week 3
Threads:The measurement threadThe infrastructure thread
Metadata in RAG: what to embed versus what to return
RAG metadata is everything you know about a chunk beyond its text, and the decision that matters is which of it goes into the embedded string and which is stored alongside as a field. Embedded text influences what the chunk matches; stored fields let you filter before searching and cite after answering. Get the split wrong and you either build an index you cannot filter or bury real content under a header of boilerplate that every vector now shares.
What RAG metadata is
RAG metadata is a set of key-value fields stored with each chunk in the index, next to that chunk's vector and its text. Concretely, an indexed record looks less like a bare vector and more like this:
record
id doc-4471#chunk-12
vector [0.0182, -0.0431, ... ] ← what similarity search compares
text "Study leave is granted at the manager's discretion ..."
← what the model reads
metadata
source_file hr-policy-2024-v3.pdf
page 41
section "7.4 Study and examination leave"
doc_title "Employee Handbook 2024"
doc_type policy
effective_date 2024-04-01
version 3
supersedes 2
department hr
acl ["employee", "manager"]
chunk_index 12
chunk_of 58
ingested_at 2024-05-02
Illustrative record shape, constructed for teaching.
Three of those items do three genuinely different things and it is worth separating them before anything else:
- The vector decides whether the chunk is retrieved, by similarity to the query.
- The metadata fields decide which chunks are eligible to be retrieved (filtering), and what the reader is told about the chunk afterwards (citation, provenance, freshness).
- The text decides what the model actually reads and can therefore say.
The embed-versus-return decision is the question of which facts should participate in the first of those and which in the second. It is not a question of which facts to keep — you keep all of them.
How RAG metadata works: embedded text, filterable fields, and returned provenance
L1 — Two channels, one record
Think of every chunk as having two channels into the retrieval system.
The semantic channel is the embedded string. Whatever text you embed is what the vector represents, so anything in that string can pull the chunk towards a query. Prepend the section heading 7.4 Study and examination leave to a chunk body and a query about "study leave" now matches on the heading as well as the body — a real improvement when the body uses different words than the reader does.
The structured channel is the metadata fields. These are not embedded and contribute nothing to similarity. They are exact, filterable, sortable, and returnable. A query restricted to doc_type = policy AND effective_date >= 2024-01-01 never even considers chunks that fail the predicate, however similar their vectors are.
The reason both channels exist is that embeddings are bad at exactly the things structured fields are good at. Vector similarity cannot reliably represent "after March 2024", cannot represent "not superseded", cannot represent "only if the user is in the finance group", and cannot represent negation at all — a limit taken up properly in 07-03. Embedding a date as text does not give you date comparison; it gives you a vector that is faintly more similar to other text containing similar-looking digits. That is not a filter. It is a rumour of a filter.
L2 — Metadata filtering and how it interacts with the index
Filtering on metadata happens in one of three places, and knowing which is a genuine architecture question:
| Filtering approach | Mechanism | Consequence |
|---|---|---|
| Pre-filter | Restrict the candidate set to records passing the predicate, then run similarity search over that subset | Correct top-k within the allowed set; may be slow or may bypass the approximate index depending on the store |
| Post-filter | Run similarity search over everything, then discard results failing the predicate | Fast, but you can ask for 10 and receive 2, because 8 were filtered away after ranking |
| Partitioned index | Keep separate indexes or namespaces per filter value | Exact and fast for a small number of stable partitions; unmanageable for high-cardinality fields |
Post-filtering is the trap. It looks equivalent and is not: it silently reduces your result count and, in the worst case, returns nothing for a query whose answer is sitting in the index behind a filter the ranker never knew about. Pre-filtering costs more and gives the right answer. Approximate-nearest-neighbour structures such as HNSW and IVF interact with filtering in ways that matter here, which is why the vector-database lesson 07-04 follows this one rather than preceding it.
L3 — Enrichment: metadata that becomes text
The advanced move is deliberate context enrichment: constructing the embedded string from the chunk body plus a small amount of structural context, while keeping the same facts as fields too.
Chunk body as cut by the chunker
"The limit rises to 90 days for employees in this category."
Embedded string after enrichment
"Employee Handbook 2024 — 7.4 Study and examination leave
The limit rises to 90 days for employees in this category."
Metadata fields, unchanged, still stored separately
doc_title, section, page, effective_date, version, acl ...
Constructed example.
The same facts appear twice on purpose, in two channels, for two jobs. The heading in the embedded string makes the chunk findable by someone searching for study leave. The heading in the metadata field makes the citation renderable and the results filterable by section. Duplication between the channels is not redundancy to be eliminated; it is the design.
The discipline is knowing when to stop. Enrichment is additive to a fixed-length vector, so every token of prepended context is a token of the body's influence diluted. Prepend a document title and a section heading and you have improved the vector. Prepend the title, the section, the subsection, the date, the version, the department, the author, and a confidentiality banner, and the body of a 300-token chunk is now a minority of its own embedded string — and worse, every chunk in the document now shares a large identical prefix, so all their vectors have been pulled towards each other. You have reduced the index's ability to distinguish its own contents. That is the boilerplate mechanism from 06-04, arriving through a door you opened yourself.
Embedded metadata vs stored metadata vs returned metadata
| Embedded in the vector | Stored as a filterable field | Returned to the reader | |
|---|---|---|---|
| Purpose | Change what the chunk matches | Restrict which chunks are eligible | Let a human verify and judge the answer |
| Mechanism | Text is concatenated into the string sent to the embedding model | Exact predicate evaluated by the vector store | Passed through with the retrieved result into the response |
| Good for | Section headings, document titles, short descriptive labels | Dates, versions, document type, department, ACLs, numeric ranges, source ids | Filename, page, section, date, version, link |
| Bad for | Dates, ids, ACLs, long boilerplate, anything needing exact comparison | Anything that should influence semantic match | Anything a reader cannot act on or interpret |
| Failure when misused | Vectors converge on a shared prefix; body signal diluted; still no real filtering | The field is exact and correct but never influences what gets found | Nothing renders in the citation; the answer is unverifiable |
| Changing it later | Requires re-embedding the affected chunks | Usually an in-place field update | Presentation-layer change only |
| Cost | Vector-space real estate | Storage plus index support for the predicate | Effectively nothing |
The changing it later row is the practical one. Metadata that lives in the embedded string is baked into the vector, so correcting it means re-embedding. Metadata that lives in fields can usually be updated in place. That asymmetry alone argues for keeping the embedded string minimal and the field set generous: fields are cheap to fix and vectors are not.
Worked example: the same chunk indexed three ways
A constructed example. Take one chunk of an HR handbook and index it three ways, then ask what each version can and cannot do. Constructed scenario.
The chunk body, exactly as the chunker produced it:
"The limit rises to 90 days for employees in this category, subject to
approval by the department head. Applications must be submitted at least
six weeks before the leave begins."
Version A — body only, no metadata at all.
embedded string: the body above
metadata: none
query "how much study leave can a manager approve?"
→ the chunk contains neither "study" nor "leave" nor "manager"
→ similarity is weak; the chunk does not enter the top 10
answer: the system reports it cannot find the policy
citation: impossible — nothing records which file or page this came from
The chunk is in the corpus, it is the correct answer, and it is unreachable. This is a decontextualisation failure of exactly the kind 06-02 predicted, and no chunking parameter fixes it because the words the reader searches for are not in the span.
Version B — everything crammed into the embedded string.
embedded string:
"hr-policy-2024-v3.pdf | page 41 | Employee Handbook 2024 |
7.4 Study and examination leave | policy | effective 2024-04-01 |
version 3 | department hr | acl employee,manager | CONFIDENTIAL —
INTERNAL USE ONLY — DO NOT DISTRIBUTE |
The limit rises to 90 days for employees in this category, ..."
metadata: none (it is all in the text)
query "how much study leave can a manager approve?"
→ now matches on "Study and examination leave" and "manager" — better
but:
→ every chunk of every 2024 policy shares ~40 tokens of identical prefix,
so all their vectors have been dragged towards a common point
→ the confidentiality banner is now part of the semantic content of every
chunk in the corpus, and a query about confidentiality retrieves
everything, ranked essentially arbitrarily
→ "effective 2024-04-01" as text supports no date comparison whatsoever;
a query for "current policy" cannot exclude the superseded version 2
→ "acl employee,manager" is a string in a vector, not an access control;
it restricts nothing and is trivially retrievable by anyone
Constructed illustration.
Version B is what "add metadata to improve retrieval" degenerates into when the two channels are not distinguished. It improved one thing and broke three, and its ACL is security theatre.
Version C — split across the two channels deliberately.
embedded string:
"Employee Handbook 2024 — 7.4 Study and examination leave
The limit rises to 90 days for employees in this category, subject to
approval by the department head. Applications must be submitted at least
six weeks before the leave begins."
metadata fields:
source_file hr-policy-2024-v3.pdf page 41
doc_type policy section "7.4 Study and examination leave"
effective_date 2024-04-01 version 3 supersedes 2
department hr acl ["employee","manager"]
chunk_index 12 chunk_of 58
query "how much study leave can a manager approve?"
filter: doc_type = policy AND version = current AND acl ∋ caller's roles
→ superseded version 2 chunks are excluded before search
→ chunks the caller may not see are excluded before search
→ similarity search runs over the eligible set and matches on the heading
plus the body
→ answer returned with citation: Employee Handbook 2024, §7.4, page 41,
effective 1 April 2024
Constructed illustration.
Version C costs one extra design decision and buys three capabilities Version B cannot have at any price: real date and version filtering, a real permission boundary, and a renderable citation. Note also what stayed out of the embedded string — the filename, the page number, the date, the version, the ACL. None of them helps a reader's query match, and all of them help after the match is made.
Decision table: which metadata goes where
| Metadata | Embed it? | Store it as a field? | Return it? | Reasoning |
|---|---|---|---|---|
| Document title | Yes, usually | Yes | Yes | Short, descriptive, genuinely improves match for topic-scoped queries |
| Section / subsection heading | Yes — the highest-value enrichment there is | Yes | Yes | The author's own summary of the span, in the reader's vocabulary |
| Source filename | No | Yes | Yes | Not query vocabulary; essential for citation |
| Page number | No | Yes | Yes | Meaningless to an embedding, indispensable to a verifier |
| Effective date / publication date | No | Yes | Yes | Dates need comparison, and embeddings cannot compare |
| Version and supersedes | No | Yes | Yes | Freshness is a filter, not a similarity |
| Document type (policy, FAQ, contract) | Sometimes, as a single word | Yes | Yes | Useful as a filter; mildly useful as a semantic hint |
| Department / business unit | Rarely | Yes | Optionally | Usually a scoping filter, not a query term |
| Author | No | Yes | Optionally | Authority signals belong in ranking and display, not in the vector |
| Access-control labels (ACL, group, clearance) | Never | Yes | No | Embedded text is not enforcement; and see the security note below |
| Confidentiality banners, disclaimers, legal footers | Never | Optionally | No | The purest boilerplate; embedding it converges every vector in the corpus |
| Chunk index and total chunks | No | Yes | Optionally | Enables retrieving neighbouring chunks to widen context |
| Language | No | Yes | Optionally | A filter; and mixed-language corpora need it |
| Ingestion timestamp / pipeline version | No | Yes | No | Operational lineage: which run produced this vector, so you can invalidate it |
| Preceding/following chunk ids | No | Yes | No | Lets you expand a hit into its neighbourhood without re-searching |
The never embed an ACL row is the one to carry away as a rule rather than a preference. Text in an embedded string is a contributor to a similarity score. It is not a gate, it is not checked, and it cannot deny anything. A chunk whose embedded text says acl: finance-only is retrievable by every caller, and the string itself makes it more likely to surface for a query mentioning finance. Access control has to be enforced by the retriever as a filter applied before results are returned, and it depends on how the index was built — which is precisely why the course teaches it as its own lesson after vector databases. 07-05 is where that argument is completed.
Why RAG metadata is on the NCA-GENL exam
Metadata serves objective 1.4, "Curate and embed content datasets for RAGs" — the phrase "curate and embed" is doing double duty, and the embed-versus-store split is the most concrete form of that distinction in the whole pipeline. It serves objective 1.6, "Familiarity with the capabilities of Python natural language packages (spaCy, NumPy, vector databases, etc.)," because metadata filtering is a named capability of a vector database and knowing that a vector store holds filterable fields alongside vectors is exactly the level of familiarity the objective asks for. It serves objective 1.3 on building RAG use cases, and objective 4.4 on identifying required components — "we need date filtering, therefore the store must support metadata predicates" is that objective in one sentence. [OFFICIAL] on objective wording.
There is also a trustworthy-AI thread here that pays off in module 13. NVIDIA's published account of RAG makes citing sources part of what RAG is: the model retrieves, synthesises, and cites. [NVIDIA-DOC] Citation is not a feature bolted on at the end — it is only possible if provenance metadata was captured at ingestion and carried through retrieval. Transparency is one of NVIDIA's named trustworthy-AI pillars, and a RAG answer with no source is an answer with no transparency. [NVIDIA-DOC] The decision that makes citation possible is made here, three stages before anyone asks for it.
Question phrasings to expect:
- "Which of the following should be stored as metadata rather than embedded in the chunk text?" — dates, ids, filenames, ACLs. Anything requiring exact comparison or enforcement.
- "A RAG system must answer only from documents published after a given date. What capability is required?" — metadata filtering in the vector store, not a better embedding model.
- "Why can a vector search not reliably restrict results to the most recent policy version?" — embeddings represent similarity, not recency or ordering.
07-03is the full treatment. - "What must be captured during ingestion to allow a RAG system to cite its sources?" — document identity and location: source file, page, section.
- "A team adds the document title and section heading to each chunk before embedding. What is the likely effect?" — improved matching for topic-scoped queries, with a risk of vector convergence if the prefix grows long or is shared by every chunk.
- "Where should per-user access control be enforced in a RAG pipeline?" — at retrieval, as a filter on indexed permission metadata; never as text in the embedded chunk.
Distractor families:
| Family | The option | Why it fails |
|---|---|---|
| Embed-everything | "Include all document metadata in the embedded text so the model can use it" | Conflates the semantic channel with the structured one; dilutes the body and converges vectors, and still provides no filtering |
| Filter-by-similarity | "Use the embedding to filter by date or permission" | Embeddings score similarity. They do not compare, order, negate, or authorise |
| ACL-as-text | "Add the permission label to the chunk text" | Text is not enforcement. The chunk remains retrievable by everyone |
| Post-filter-is-equivalent | "Retrieve top-k then drop results the user cannot see" | Silently returns fewer results than requested, and can return none while the answer sits in the index |
| Metadata-is-optional | "Metadata is a nice-to-have for display" | Citation, freshness, and permissions all depend on it; without it a RAG system cannot be audited, governed, or trusted |
| Wrong-stage | "Add metadata at query time" | Metadata is captured at parse and ingestion time. What is not captured then does not exist later |
Common mistakes with RAG metadata
| Mistake | Symptom | Cause | Fix |
|---|---|---|---|
| Embedding the entire metadata block | Retrieval quality drops across the board; unrelated chunks score similarly | Every chunk shares a long identical prefix, so vectors converge | Embed only short descriptive context — title and section heading — and keep the rest as fields |
| Embedding dates and expecting recency filtering | The system happily cites a policy superseded three years ago | A date in text is not a comparable value | Store the date as a field and pre-filter on it |
| Putting permissions in the embedded text | A user receives a chunk they are not entitled to see, and the ACL string made it more likely to surface | Text contributes to a score; it does not gate | Enforce as a pre-filter on indexed permission fields (07-05) |
| Discarding provenance at chunk time | Answers cannot be cited or verified; a wrong answer cannot be traced to its source | Parser captured file and page, and the chunker dropped them | Thread provenance from parse through chunk into the index record as a first-class field |
| No section heading on the chunk | Chunks with pronouns and bare references never match; the corpus looks worse than it is | The heading was outside the cut span | Prepend the heading to the embedded string and store it as a field |
| Post-filtering instead of pre-filtering | A top-10 request returns two results, or none, with no error | Filter applied after ranking removes already-ranked hits | Pre-filter, or partition the index by the filter value |
| Free-text metadata values | Filters match nothing because the field holds HR, hr, Human Resources, and H.R. | No controlled vocabulary at ingestion | Normalise metadata values to a fixed vocabulary as an ingestion step, and assert on unknown values |
| Metadata that drifts from the document | The citation names the wrong version or date | Fields were set once and the document was re-ingested without updating them | Derive metadata from the document at every ingestion, and stamp the record with the pipeline version and run timestamp |
| No chunk-neighbourhood links | Answers are correct but clipped mid-argument, and there is no way to widen the window | Chunk position was never recorded | Store chunk_index, chunk_of, and neighbour ids so a hit can be expanded |
| Assuming the vector store supports the filter you need | The design assumes date-range pre-filtering; the store only post-filters | Capability assumed, not verified | Check filtering semantics before designing around them; this is objective 4.4 in practice |
What is the difference between metadata filtering and semantic search?
They answer different questions and they compose rather than compete.
| Metadata filtering | Semantic search | |
|---|---|---|
| The question it answers | Is this chunk eligible? | Is this chunk relevant? |
| Logic | Exact, boolean, deterministic | Graded similarity, approximate |
| Handles negation | Yes — NOT doc_type = draft is trivial | Poorly to not at all |
| Handles ranges and ordering | Yes — dates, versions, numbers | No |
| Handles paraphrase and synonyms | No | Yes, that is its whole purpose |
| Enforces permissions | Yes, when applied as a pre-filter | Never |
| Fails by | Returning nothing when the predicate is too tight or the vocabulary is inconsistent | Returning plausible but wrong chunks with high confidence |
The right composition is filter first, then rank. Narrow to what the caller is allowed to see and what is current and in scope, then let similarity choose among those. Doing it the other way — rank the world, then drop the ineligible — is the post-filter mistake, and it converts a governance requirement into a silently degraded result count.
Should I put the section heading in the embedded chunk text?
Usually yes, and it is the single highest-return metadata decision available at ingestion, for a reason worth stating plainly: a section heading is the document author's own summary of the span, written in the vocabulary a reader is likely to search with. A body paragraph says "the limit rises to 90 days". The heading says "Study and examination leave". A user searching for study leave matches the heading, and the heading is free — it already exists, it was written by someone who understood the content, and it costs a handful of tokens.
Three cautions keep it from becoming Version B of the worked example. First, keep it short: heading, optionally the document title, and stop. Second, watch for shared-prefix convergence — if every chunk in a 4,000-page corpus is prefixed with the same corporate document title, that prefix contributes nothing distinguishing and a lot of dilution; prefer the most specific heading available over the most general. Third, keep the field copy: the embedded prefix is for matching and the field is for filtering and display, and you want both.
For deeply nested documents, a breadcrumb of the two or three nearest heading levels usually beats either the full path or the leaf alone. Employee Handbook — 7 Leave — 7.4 Study and examination leave carries useful hierarchy; a nine-level path carries mostly punctuation.
What metadata does a RAG system need to cite its sources?
At minimum, four fields, and each one answers a question the reader will ask:
| Field | The reader's question |
|---|---|
| Source document identity — filename, title, or stable id | What is this from? |
| Location within it — page, section number, or heading | Where exactly? |
| Date and version — effective date, publication date, version number | Is this current, and which edition is it? |
| A resolvable pointer — URL, document management id, or file path | Can I go and read it myself? |
Two additions turn a citation into something auditable rather than merely decorative. Ingestion lineage — which pipeline run and which parser and chunker version produced this record — lets you answer "why does this chunk look wrong?" without re-deriving the corpus. And the retrieval trace — which chunks were returned, with what scores, under what filters — is what turns a bad answer into a diagnosable one, the discipline 07-10 is built on.
All of it has to be captured upstream. There is no query-time recovery of a page number the parser knew and the chunker dropped: that fact was available for a few milliseconds during ingestion and, once discarded, it is gone until you re-ingest. This is why provenance is an ingestion concern and not a presentation concern, and why it appears in this module rather than in the module about answering.
Glossary recap: the terms this lesson introduced
| Term | Definition |
|---|---|
| RAG metadata | Structured fields stored with a chunk beyond its body text — provenance, dates, versions, types, permissions, position |
| Embedded string | The exact text sent to the embedding model, which determines what the chunk's vector represents |
| Semantic channel | The embedded text, which influences similarity and therefore whether a chunk is retrieved |
| Structured channel | The metadata fields, which are exact, filterable, and returnable but never contribute to similarity |
| Metadata filtering | Restricting retrieval to chunks satisfying an exact predicate on their fields |
| Pre-filter | Applying the predicate before similarity search, so top-k is computed within the eligible set |
| Post-filter | Applying the predicate after ranking, which silently reduces the result count |
| Context enrichment | Prepending short structural context — document title, section heading — to the chunk body before embedding |
| Shared-prefix convergence | The vector-space effect of every chunk carrying the same long prefix, pulling all their embeddings together |
| Provenance | The record of where a chunk came from — file, page, section, version — that makes citation and auditing possible |
| Ingestion lineage | Which pipeline run, parser version, and chunker version produced a given index record |
| Controlled vocabulary | A fixed, normalised set of permitted values for a metadata field, without which filters silently match nothing |
Key takeaways on RAG metadata
- Metadata has two jobs, and they use two different channels. Embedded text changes what a chunk matches. Stored fields decide what is eligible and what gets shown.
- Embed short descriptive context only — document title and section heading. The heading is the author's own summary in the reader's vocabulary and is the highest-return enrichment available.
- Never embed dates, ids, filenames, or permissions. They need exact comparison or enforcement, and an embedding provides neither.
- Never treat embedded text as access control. A chunk whose text names its ACL is readable by everyone and is more likely to surface for queries mentioning it.
- Pre-filter, do not post-filter. Post-filtering silently returns fewer results than requested and can return none while the answer sits in the index.
- Long shared prefixes converge vectors. Enrichment is additive to a fixed-length representation, so every prepended token dilutes the body's influence and pulls sibling chunks together.
- Fields are cheap to fix; vectors are not. Correcting embedded metadata means re-embedding, which is a strong argument for a minimal embedded string and a generous field set.
- Citation is an ingestion decision. Source, location, date, version, and a resolvable pointer must be captured at parse and chunk time, or the answer can never be verified — and NVIDIA's own framing makes citing sources part of what RAG is. [NVIDIA-DOC]
Next: what happens when the same text appears four thousand times
You now have chunks that carry their context and their provenance. What you do not yet have is a corpus where each distinct fact appears once. Real corpora are full of repetition you did not intend: the running footer the parser extracted from every page, the confidentiality banner on every slide, the standard clause pasted into 300 contracts, the same policy PDF sitting in four shared drives under four filenames, the quoted reply chain repeated down an email thread, and the near-duplicate chunks you manufactured yourself by setting a generous overlap in 06-02. Repetition is not merely wasted storage. A short string that appears on every page of a 4,000-page corpus becomes, in embedding space, a dense cluster sitting near the centre of everything — and a cluster near the centre of everything is close to every query.
Next: 06-04 Deduplication and corpus cleaning for RAG — why a repeated footer can become the nearest neighbour of every query, and how to find and remove it before it does.