LlamaIndex: Get the Source Document From a Response
To get the source document from a LlamaIndex response, loop over response.source_nodes and read node.metadata (file_name, page_label) plus node.ref_doc_id, which points to the parent Document that each retrieved chunk came from.
- Every retrieved chunk is in response.source_nodes as a NodeWithScore, and node.metadata already holds file_name and page_label for PDFs loaded with SimpleDirectoryReader.
- node.node_id is the chunk id and node.ref_doc_id is the parent Document id; both are random UUIDs unless you set stable ids at load time.
- Pass filename_as_id=True to SimpleDirectoryReader so doc ids become file paths and re-ingestion, refresh and deletion become deterministic.
- Metadata is embedded and sent to the LLM by default, so exclude noisy keys like file_path with excluded_embed_metadata_keys and excluded_llm_metadata_keys.
- Dedupe by file and sort by score to build a citation list; use CitationQueryEngine if you need inline [1], [2] markers.
How do I use LlamaIndex to get the source document from a response?
A LlamaIndex query returns a Response object whose source_nodes attribute lists every chunk the retriever passed to the LLM. Each item is a NodeWithScore with .node (the chunk), .score (retriever similarity) and .text (the chunk text). The file name and page are not top-level fields; they live in node.metadata.
Before you run the snippet: it uses no configuration, so LlamaIndex falls back to the default OpenAI embedding model and LLM. You must set the OPENAI_API_KEY environment variable, or configure your own Settings.embed_model and Settings.llm (for example a local or other hosted model).
Indexing and querying with the defaults incur OpenAI API charges. Every file is embedded at indexing time, and every query sends the retrieved chunks to the LLM. Test on a few small files first.
This snippet works with the current package layout (llama_index.core) and a folder of PDFs. It prints the file name, page and score for each answer.
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
# Requires OPENAI_API_KEY (or Settings.embed_model / Settings.llm configured)
docs = SimpleDirectoryReader("./pdfs").load_data()
index = VectorStoreIndex.from_documents(docs)
query_engine = index.as_query_engine(similarity_top_k=4)
response = query_engine.query("What is the refund policy?")
print(response)
for n in response.source_nodes:
meta = n.node.metadata
print(meta.get("file_name"), meta.get("page_label"), round(n.score or 0, 3))
print(n.text[:120].replace("\n", " "))Why do the IDs in the response look random?
LlamaIndex has two levels. A Document is what a reader produces (for PDFs, SimpleDirectoryReader yields one Document per page). A Node is a chunk cut from a Document by a node parser such as SentenceSplitter, and that chunk is what gets embedded and retrieved.
Each Node has node_id (its own id) and a relationship back to its parent. node.ref_doc_id and node.source_node.node_id both give the parent Document id. If you never assign ids, LlamaIndex generates UUID4 strings, so they carry no meaning and change every time you reload the files.
That is why printing node_id tells you nothing. The useful information is the metadata copied onto the chunk, or a stable doc id you chose yourself.
| Field | Level | Meaning | Stable by default? |
|---|---|---|---|
| node.node_id | Node (chunk) | Id of the retrieved chunk | No, random UUID |
| node.ref_doc_id | Node to Document | Id of the parent Document | No, random UUID |
| node.source_node.node_id | Node to Document | Same value as ref_doc_id, via the SOURCE relationship | No |
| document.doc_id | Document | Read-only alias for the Document's id_; set it with id_=... or filename_as_id=True | Only if you set it |
| node.metadata | Node | Copy of the parent Document metadata | Yes, if the reader set it |
What metadata does SimpleDirectoryReader attach automatically?
For each file, SimpleDirectoryReader sets file_path, file_name, file_type, file_size, creation_date and last_modified_date. The PDF reader adds page_label, and because it returns one Document per page, every page-level chunk carries its own page number.
Non-PDF files such as .txt or .docx usually yield a single Document, so page_label is missing. Always use meta.get('page_label') rather than meta['page_label'] to avoid a KeyError.
Metadata is empty when you build Documents yourself from raw strings, for example Document(text=raw). In that case nothing was recorded, and you must pass metadata explicitly.
How do I map a ref_doc_id back to the original Document?
If you only have an id (from logs, a vector store payload or an old index), resolve it through the index. The docstore holds the stored nodes and index.ref_doc_info maps each parent id to its chunk ids and metadata.
Note that with the default in-memory setup the docstore stores chunk nodes, and with some vector stores (those that set store_text=True such as Chroma or Qdrant) the docstore can be empty, so rely on ref_doc_info or node metadata first.
ref_id = response.source_nodes[0].node.ref_doc_id
# Option 1: ref_doc_info (id -> RefDocInfo)
info = index.ref_doc_info[ref_id]
print(info.metadata.get("file_name"), len(info.node_ids), "chunks")
# Option 2: the docstore
doc = index.docstore.get_document(ref_id, raise_error=False)
if doc:
print(doc.metadata)
# Option 3: just read the chunk itself
print(response.source_nodes[0].node.metadata) How do I set stable IDs and custom metadata at ingestion?
Stable ids make citations, refreshes and deletions deterministic. With filename_as_id=True the doc id is derived from the file path, so reloading the same file produces the same id. Combine it with refresh_ref_docs to update only changed files.
Custom metadata goes in through file_metadata, a callable that receives the path and returns a dict. For documents you build yourself, pass id_ and metadata directly. Current LlamaIndex releases name the constructor field id_; doc_id_ is not a valid keyword, and doc_id is only a property on the object.
Cost warning: this example uses the same defaults as the first snippet, so it needs OPENAI_API_KEY set (or Settings.embed_model and Settings.llm configured). from_documents embeds every file, and refresh_ref_docs re-embeds every changed one, so each run bills API usage per file.
- Load with filename_as_id=True and a file_metadata function.
- Exclude noisy keys from the embedding and LLM text.
- Build or refresh the index.
- Persist the StorageContext so ids and metadata survive restarts.
from pathlib import Path
from llama_index.core import (
SimpleDirectoryReader, VectorStoreIndex, Document,
StorageContext, load_index_from_storage,
)
# Requires OPENAI_API_KEY (or Settings.embed_model / Settings.llm configured)
def meta(path: str) -> dict:
return {"source": Path(path).name, "collection": "policies"}
docs = SimpleDirectoryReader(
"./pdfs", filename_as_id=True, file_metadata=meta
).load_data()
for d in docs:
d.excluded_embed_metadata_keys = ["file_path", "file_size", "creation_date", "last_modified_date"]
d.excluded_llm_metadata_keys = ["file_path", "file_size", "creation_date", "last_modified_date"]
print(docs[0].doc_id) # check how your version formats the id
index = VectorStoreIndex.from_documents(docs)
index.storage_context.persist("./storage")
# later
index = load_index_from_storage(StorageContext.from_defaults(persist_dir="./storage"))
index.refresh_ref_docs(docs) # re-embeds only changed docs
index.delete_ref_doc(docs[0].doc_id, delete_from_docstore=True)
# hand-built document: the keyword is id_, not doc_id_
d = Document(text="...", id_="faq-001", metadata={"source": "faq.md"})
print(d.doc_id) # "faq-001"Why does metadata affect embeddings, chunk size and errors?
By default metadata is prepended to the text both when the chunk is embedded and when it is shown to the LLM. That helps retrieval for fields like title, but a long file_path adds tokens to every chunk and can bias similarity toward chunks from similarly named paths. Use excluded_embed_metadata_keys and excluded_llm_metadata_keys to hide keys from either side while still keeping them on node.metadata for citations.
Node parsers such as SentenceSplitter copy the parent metadata to every chunk. The metadata string counts against chunk_size, so if it is close to or longer than chunk_size you get a ValueError about metadata length being larger than the chunk size, or you end up with tiny text per chunk. Shorten values, exclude keys, or raise chunk_size (default 1024 tokens) and set chunk_overlap sensibly (default 200).
How do I build a clean, deduplicated citation list?
Several chunks often come from the same page. Group by file and page, keep the best score, and sort descending. response.get_formatted_sources() gives a quick printable string, and response.metadata is a dict keyed by node id with each node's metadata, which is handy for debugging.
Do not treat score as truth: it is a similarity value that depends on the embedding model and retriever, not a probability the answer is correct. Use it only for ordering, or apply a SimilarityPostprocessor cutoff before generation.
def citations(response, min_score=0.0):
best = {}
for n in response.source_nodes:
m = n.node.metadata
key = (m.get("file_name", "unknown"), m.get("page_label"))
s = n.score or 0.0
if s >= min_score and s > best.get(key, -1):
best[key] = s
ranked = sorted(best.items(), key=lambda kv: kv[1], reverse=True)
return [
f"[{i}] {f}" + (f", p. {p}" if p else "") + f" (score {s:.2f})"
for i, ((f, p), s) in enumerate(ranked, 1)
]
print("\n".join(citations(response)))
print(response.get_formatted_sources(length=150)) What changed between old and current LlamaIndex versions?
Many online answers use the pre-0.10 layout. The package was split into llama_index.core plus integration packages, and imports changed accordingly. Use the right names for your installed version.
The CitationQueryEngine example below also uses the default OpenAI embedding model and LLM, so it needs OPENAI_API_KEY set (or Settings.embed_model and Settings.llm configured). It reuses the index from earlier, but each query still calls the LLM and incurs API charges.
| Topic | Old (0.5 to 0.8) | Current |
|---|---|---|
| Imports | from llama_index import SimpleDirectoryReader, VectorStoreIndex | from llama_index.core import SimpleDirectoryReader, VectorStoreIndex |
| Sources | response.source_nodes with node.doc_id, plus node_sources | response.source_nodes, node.ref_doc_id, node.node_id |
| Formatted sources | response.get_formatted_sources() | Same method, still available |
| Chat engine | Sources on the response object | chat_engine.chat() returns AgentChatResponse with .source_nodes and .sources (tool outputs) |
| Inline citations | Not available | CitationQueryEngine in llama_index.core.query_engine |
from llama_index.core.query_engine import CitationQueryEngine
# Requires OPENAI_API_KEY (or Settings.llm configured); each query bills LLM usage
engine = CitationQueryEngine.from_args(index, similarity_top_k=4, citation_chunk_size=512)
resp = engine.query("What is the refund policy?")
print(resp) # text contains [1], [2] markers
for i, n in enumerate(resp.source_nodes, 1):
print(i, n.node.metadata.get("file_name"), n.node.metadata.get("page_label"))Common questions
Why is node.metadata empty in my response?
The Documents were most likely created from raw strings or a custom loader without metadata, so there was nothing to copy to the chunks. Pass metadata=... when constructing Document or use file_metadata with SimpleDirectoryReader, then rebuild the index.
Why is page_label missing for some files?
Only readers that split by page, mainly the PDF reader, set page_label. Plain text, Markdown and most DOCX files load as one Document, so use .get('page_label') and fall back to file_name alone.
Where are the sources when I use a chat engine?
chat_engine.chat() returns an AgentChatResponse. Retrieved chunks appear in response.source_nodes, and tool call outputs appear in response.sources. If you see an empty list, check that the engine mode actually retrieves on each turn.
Can I filter retrieval by file name?
Yes. Build MetadataFilters with an ExactMatchFilter or MetadataFilter on file_name and pass filters=... to index.as_retriever or as_query_engine. This only works if the metadata was stored at ingestion and the vector store supports filtering.
Does ref_doc_id equal doc_id?
For a Document, doc_id is its own id (the id_ field). For a chunk, ref_doc_id is the doc_id of the Document it was cut from, so ref_doc_id on the retrieved node equals doc_id on the original Document.