Data Grounding & Provenance¶
Overview¶
Provenance maps every graph node back to where in the source document its data came from: the chunk(s), the page(s), and (optionally) the exact character span of the identifier that was found. It is fully deterministic — grounding never involves an LLM call, never changes a prompt, and never changes what gets extracted. It runs as bookkeeping alongside extraction and is bound to the graph after conversion.
Enabled by default (provenance="standard"), it works with both extraction contracts (direct and dense) and needs no changes to your Pydantic templates.
What you get:
- A
__provenance__attribute on every entity node in the graph, with the resolved location. - A standalone
provenance.jsonledger next tograph.json, containing the full chunk index (including chunk text) and per-node lineage — self-contained, so you can go from a node straight to the source snippet and page without re-running the pipeline.
Quick example¶
from docling_graph import PipelineConfig, run_pipeline
config = PipelineConfig(
source="research_paper.pdf",
template="templates.ScholarlyRheologyPaper",
extraction_contract="dense",
provenance="standard", # default — can be omitted
)
context = run_pipeline(config)
graph = context.knowledge_graph
for node_id, data in graph.nodes(data=True):
prov = data.get("__provenance__", {})
print(node_id, "->", prov.get("match"), prov.get("pages"))
SlurryComponent_a1b2c3 -> verbatim [1, 2, 3]
SlurryRheologyStudy_9f8e -> observed [1, 2]
ScholarlyRheologyPaper_44de -> None # scope: document (see below)
The __provenance__ node attribute¶
Every entity node (see Entities vs Components) gets exactly one __provenance__ view, in one of four shapes. The name is a dunder (__provenance__) precisely so it can never collide with a template field — Pydantic forbids dunder field names.
1. Verbatim — exact location¶
The node's identifier was found literally in the document text via a deterministic string scan:
{
"document_id": "9f1a2b3c4d5e6f70",
"match": "verbatim",
"chunks": [10],
"pages": [4],
"refs": ["#/texts/57"]
}
2. Approximate — batch-level (dense only)¶
No verbatim match, but the dense skeleton phase saw the node while reading a known set of chunks:
{
"document_id": "9f1a2b3c4d5e6f70",
"match": "observed",
"chunks": [3, 4],
"pages": [2],
"refs": ["#/texts/12", "#/tables/1"],
"approximate": true
}
approximate: true is the honesty signal — treat this as "somewhere in these chunks," not an exact cite.
refs lists the Docling document elements (self_ref paths like #/texts/1) backing the anchored chunks, so a graph node links straight to the structural elements of the converted document (docling/document.json / document.dclg). It appears whenever the source format exposes structure (anything Docling converts, including DocLang input) and is omitted for plain-text input.
3. Document scope¶
The whole document is the fallback answer for the root node and, for the direct contract, any node whose identifier can't be located verbatim:
This is a fallback, not a fixed fate: before settling for document scope, the binder also tries the node's other short, distinctive str fields (not just its identity). A root whose id is generic (document_number) but whose assureur/title field is a distinctive phrase found verbatim in the text is pinned to that exact chunk instead — same locator, same guards (below), applied to a wider set of fields. Document scope only survives when nothing on the node is distinctive enough to locate.
4. Unresolved (dense only)¶
No verbatim match and no skeleton observation exists for this node. This is the deliberate failure mode — an absent or wrong location is worse than a vague one, so the pipeline never guesses:
Fail-empty, never fail-wrong
The binder resolution ladder is: verbatim (exact) → observed (approximate) → document scope (direct only) → unresolved (dense only). Nothing ever fabricates a location; a node's view only gets more precise across a pipeline run, never less honest.
Capping and detail¶
chunks and refs are capped at 8 entries by default; when more chunks match, a chunks_omitted count is added. The full, uncapped anchor and ref lists always live in provenance.json — the node attribute is intentionally small so it doesn't bloat the graph export.
With provenance="detailed", verbatim views also carry spans (character offsets, capped at 4):
{
"document_id": "9f1a2b3c4d5e6f70",
"match": "verbatim",
"chunks": [10],
"pages": [4],
"spans": [{"chunk": 10, "start": 128, "end": 138}]
}
provenance.json — the full ledger¶
Written next to graph.json (i.e. docling_graph/provenance.json) whenever provenance is not "off". It is the source of truth the node attribute is a compact view of, and it is self-contained: every referenced chunk's text is stored inline, so you never need to re-run extraction to trace a node back to its source.
{
"version": 1,
"document": {
"document_id": "9f1a2b3c4d5e6f70",
"source": "research_paper.pdf",
"input_type": "document",
"converted_at": "2026-07-03T15:49:20",
"page_count": 8,
"template_name": "ScholarlyRheologyPaper",
"template_schema_hash": "3a7c..."
},
"resolution": "span",
"node_level": true,
"chunks": {
"10": {
"chunk_id": 10,
"batch_index": 4,
"page_numbers": [4],
"doc_item_refs": ["#/texts/57"],
"item_geometry": [
{"ref": "#/texts/57", "page_no": 4, "bbox": [90, 280, 506, 306], "page_width": 612, "page_height": 792, "dclg_location": [75, 181, 423, 198]}
],
"headings": ["3. Results"],
"token_count": 118,
"text_hash": "1c2d3e4f5a6b7c8d",
"char_length": 612,
"text": "3. Results\nUsing the coarse NMC powder, the slurries exhibit ...",
"resplit_of": null
}
},
"nodes": {
"studies[].experiments[].slurry_batch.formulation.components[]|material_name=lifepo4": {
"identity_key": "studies[].experiments[].slurry_batch.formulation.components[]|material_name=lifepo4",
"catalog_path": "studies[].experiments[].slurry_batch.formulation.components[]",
"node_type": "SlurryComponent",
"ids": {"material_name": "LiFePO4"},
"anchors": [
{"document_id": "", "chunk_id": 0, "kind": "verbatim", "span": [42, 49]},
{"document_id": "", "chunk_id": 10, "kind": "observed", "span": null}
],
"merged_from": [],
"synthetic": false,
"dropped": false,
"fill_batches": [0],
"notes": []
}
},
"bind_stats": {
"nodes_seen": 16,
"bound_verbatim": 7,
"bound_observed": 8,
"bound_document": 1,
"unresolved": 0
}
}
Field reference¶
| Field | Meaning |
|---|---|
document |
Source identity: a content hash (document_id, stable across runs for the same input bytes), path/URL, detected input type, page count, template name and a hash of its JSON schema (for reproducibility). |
resolution |
The precision this run actually achieved: "document", "chunk", or "span" (upgraded to "span" once at least one node is verbatim-located). |
node_level |
true for dense (per-node skeleton entries exist), false for direct (chunk index only, no per-node entries — see Dense vs. direct grounding). |
chunks |
Every chunk the document was split into, keyed by chunk_id, each with its text, page numbers, docling item refs (doc_item_refs), per-item geometry (item_geometry: page + bounding box [l, t, r, b] in whole page pixels, always top-left origin, with page_width/page_height, plus dclg_location — the same box on the DocLang 512-grid, matching the <location> values in docling/document.dclg exactly; empty when the source format has no geometry, e.g. markdown input), heading trail, and a content hash (text_hash) for drift detection. |
nodes |
One entry per grounded identity, keyed by a canonical identity_key ("{catalog_path}|{field}={canonical_value},..."). Holds the anchor list, dedup/reconciliation lineage (merged_from), and audit flags (synthetic, dropped). |
bind_stats |
Coverage counters from the last binding pass — how many nodes landed in each resolution tier. |
The same chunks records are also written standalone to docling/chunks.json (a flat list ordered by chunk_id), so you can inspect exactly what the chunker produced without digging through provenance.json — handy for comparing a node's chunks: [10] reference against the chunk text side by side.
Anchor kinds¶
Each entry in anchors carries a kind, ordered by evidence strength (strongest first):
| Kind | Meaning | Produced by |
|---|---|---|
verbatim |
The identifier value was found literally in this chunk's text (span set). |
Binder-side deterministic scan (anchor_scan.locate_values) |
observed |
The dense skeleton phase asserted this node while reading this chunk. | Phase 1 skeleton bookkeeping |
reconciled |
Inherited when the skeleton reconciliation LLM call merged an alias into this node; the absorbed identity stays visible via merged_from. |
Skeleton dedupe reconciliation |
derived |
A synthetic parent (rescue-ladder placeholder/bucket) inheriting its children's anchors. | merge_filled_into_root orphan rescue |
Dense vs. direct grounding¶
Both contracts use the same binder and the same deterministic verbatim scan; they differ only in what raw material feeds it.
Dense builds a node-level ledger (node_level: true): the Phase 1 skeleton records which chunk batch a node was observed in, and the fill phase's per-node identifiers are the ones the binder scans for verbatim matches. Because Phase 2 often refines a rough skeleton placeholder into the real value (e.g. skeleton "SlurryComponent" → filled "LiFePO4"), the ledger entry is re-keyed to the final filled identifier before binding, so grounding survives that refinement. A node that is neither verbatim-locatable nor skeleton-observed is unresolved.
Direct (single-call, whole-document extraction) has no skeleton, so it builds a chunk index only (node_level: false) — the document is chunked purely to give the binder something to scan (this does not change what gets extracted; the LLM call is unchanged). Every extracted node is verbatim-located the same way; a node whose identifier can't be found falls back to document scope instead of unresolved, since a direct call has no per-node signal to fall back to.
Direct-mode entities are frequently given a synthesized identifier (e.g. EXP-LFP-PVDF-5WT-761) that never appears verbatim in the source, which used to mean an automatic drop to document scope. The locator now also tries the entity's other short, distinctive str fields (a description, a name) when the identity value doesn't locate — the same fallback used for the root (see Document scope above) — so direct-mode nodes are verbatim-grounded far more often in practice, even though their id is invented.
| Dense | Direct | |
|---|---|---|
node_level |
true |
false |
| Approximate fallback | observed (skeleton-scoped) |
— |
| No-match fallback | unresolved |
scope: "document" |
| Chunking | Required for extraction | Added only to build the provenance index |
Merge safety¶
Provenance survives every graph-building step without affecting node identity:
- Skeleton dedup / reconciliation (dense): when two skeleton entries collapse into one, their anchors union and the absorbed identity is recorded in
merged_from— never silently dropped. - Orphan rescue ladder (dense): a synthetic placeholder/bucket parent inherits its rescued children's anchors as
derivedevidence. - Graph cleanup: when
GraphCleanermerges two content-identical nodes, their__provenance__views are unioned rather than one being discarded — and__provenance__is excluded from the content hash that drives dedup, so two entities with identical data but different anchors still merge correctly.
Provenance never contributes to node identity (graph_id_fields) or content hashing — attaching or unioning a __provenance__ view can never fork or merge a node that wouldn't otherwise fork or merge.
Configuration¶
One field on PipelineConfig, top-level (not nested under dense-specific settings, since it applies to both contracts):
config = PipelineConfig(
source="document.pdf",
template="templates.MyTemplate",
provenance="standard", # "off" | "standard" | "detailed" (default: "standard")
)
| Value | Behavior |
|---|---|
"off" |
No ledger, no __provenance__ attribute, no provenance.json. Output is byte-identical to grounding never having existed. |
"standard" (default) |
__provenance__ on every entity node + provenance.json, with verbatim locations where possible and approximate/document-scope fallbacks otherwise. |
"detailed" |
Everything in standard, plus character spans embedded in the node attribute (spans are always in provenance.json regardless of this setting). |
CLI flag: --provenance {off|standard|detailed}. See convert command.
Token cost: zero. Grounding never touches a prompt, a schema, or an LLM response — it is pure post-processing over data the pipeline already produced, so smaller models are completely unaffected.
Reading provenance programmatically¶
import json
from pathlib import Path
output_dir = Path("outputs/research_paper_pdf_20260703_154920/docling_graph")
# Node-level view (already on the graph)
graph_data = json.loads((output_dir / "graph.json").read_text())
for node in graph_data["nodes"]:
prov = node.get("__provenance__", {})
if prov.get("match") == "verbatim":
print(f"{node['id']}: pages {prov['pages']}")
# Full ledger — resolve a node's grounding down to source text
ledger = json.loads((output_dir / "provenance.json").read_text())
for entry in ledger["nodes"].values():
if entry["node_type"] != "SlurryComponent":
continue
for anchor in entry["anchors"]:
if anchor["kind"] != "verbatim":
continue
chunk = ledger["chunks"][str(anchor["chunk_id"])]
start, end = anchor["span"]
print(entry["ids"], "->", chunk["text"][start:end], f"(page {chunk['page_numbers']})")
See 15_provenance_grounding.py for a complete runnable script.
In exported formats¶
- JSON (
graph.json):__provenance__is a native nested object on each node. - CSV (
nodes.csv):__provenance__is serialized as a JSON string column — parse it withjson.loads(row["__provenance__"]). - Cypher (
graph.cypher):__provenance__is a string node property containing escaped JSON.
See Export Formats for details.
Related¶
- Dense Extraction — the skeleton-then-flesh contract that produces node-level grounding
- Graph Conversion — where the provenance binder runs relative to node/edge creation and cleanup
- Export Formats — how
__provenance__is serialized per format - Configuration reference — the
provenancefield - Provenance API reference —
ProvenanceLedger,NodeProvenance,ChunkRecordand related types