document2graph
Extract hierarchical document graphs and baseline chunks from PDF files. The package parses PDFs using Docling and produces two complementary representations:
- Document graph — text, image, and table nodes connected by weighted structural edges, capturing the layout hierarchy of the document. Every graph is guaranteed to be connected: nodes without a resolvable parent are attached to a synthetic document root node.
- Baseline chunks — flat, semantically merged chunks suitable for retrieval pipelines, with optional context enrichment.
Installation
pip install document2graph
Or from source:
pip install -e .
For development dependencies (pytest):
pip install -e ".[dev]"
Optional extras — none of them needed to build a graph, weight its edges structurally, or run the rule-based need and supply measures:
| extra | for |
|---|---|
tokenizers |
counting a token budget with the retriever's own Hugging Face tokenizer |
surprisal |
the surprisal need estimator (one forward pass per edge) |
embeddings |
semantic similarity backends |
linguistic |
spaCy, an accuracy upgrade for the rule-based need estimators |
neo4j |
writing graphs to a Neo4j instance |
all |
everything above |
Requires Python 3.11+.
Quick start
Configuration
Both extractors share the same ExtractorConfig:
from document2graph.models import ExtractorConfig
from docling.datamodel.pipeline_options import PdfPipelineOptions
config = ExtractorConfig(
pdf_path="data/pdfs/", # directory containing input PDFs
data_path="data/output/", # root directory for all output files
save_json=True, # write results to disk as JSON (default: True)
document_type="report", # arbitrary label stored in document metadata
pdfPipelineOptions=PdfPipelineOptions(), # optional Docling pipeline config
)
Metadata extraction
The metadata_config field controls how document metadata (title, authors, version, etc.) is
located inside each PDF. Each field is described by a search string and an inclusive page range
to search on. Fields set to None are skipped.
from document2graph.models import ExtractorConfig, MetadataExtractionConfig, MetadataFieldConfig
config = ExtractorConfig(
pdf_path="data/pdfs/",
data_path="data/output/",
metadata_config=MetadataExtractionConfig(
title_page=1,
version=MetadataFieldConfig(label="edition", pages=(1, 1)),
authors=MetadataFieldConfig(label="authors", pages=(1, 2)),
institutions=MetadataFieldConfig(label="affiliations", pages=(1, 2)),
bibliography=None, # not present in this document type
correspondence=MetadataFieldConfig(label="contact", pages=(2, 3)),
),
)
The default MetadataExtractionConfig() targets German-language Praxisempfehlungen PDFs
(version on page 1, authors/institutions/bibliography/correspondence on page 2).
Edge weights
Every edge in the document graph carries a weight attribute reflecting the strength of the
structural connection. The defaults can be overridden per category via edge_weights:
from document2graph.models import ExtractorConfig, EdgeWeightConfig
config = ExtractorConfig(
pdf_path="data/pdfs/",
data_path="data/output/",
edge_weights=EdgeWeightConfig(
section=1.0, # heading -> subheading
text=0.8, # heading/body -> body text, subtext, footnotes
list_item=0.6, # anchor text -> grouped list item (bullets)
media=0.9, # referencing text -> image/table (matched via caption)
unreferenced_media=0.3, # fallback heading -> image/table without a caption match
root=0.1, # document root -> otherwise disconnected node
),
)
Edge weight metrics
Which metric produces the weight is EdgeWeightConfig.metric; how its result combines
with the structural weight above is EdgeWeightConfig.combination (replace, mean or
multiply).
| metric | weight of an edge |
|---|---|
structural |
the configured weight of the edge's relation (default; unchanged behaviour) |
uniform |
1.0 everywhere — the baseline any weighting has to beat |
similarity |
parent/child content similarity in [0, 1] |
inverted_similarity |
1 - similarity; the parent is worth attaching when it does not repeat the child |
need_supply |
0.5 * (1 + need x supply - independence): every edge starts neutral, evidence moves it (see below) |
similarity comes from SimilarityConfig.backend:
bm25— lexical: Okapi BM25 over the document tree, clipped at zero and normalized by the largest score across the tree.bm25_scoringcontrols the query/document asymmetry:child_query(default),symmetric_meanorsymmetric_maxembedding— semantic: cosine similarity of sentence embeddings (requires theembeddingsextra:pip install "document2graph[embeddings]")blend—alpha * embedding + (1 - alpha) * bm25
Both polarities are available because both are defensible: a similar parent is topically applicable context, while a dissimilar one supplies what the child does not already carry.
from document2graph.models import EdgeWeightConfig, SimilarityConfig
edge_weights = EdgeWeightConfig(
metric="inverted_similarity",
combination="mean", # combine with the structural weight
similarity=SimilarityConfig(backend="blend", alpha=0.5),
)
Need and supply
need_supply splits the question in two. Need is a property of the child alone — can
this unit be read on its own? Supply is what distinguishes one candidate parent from
another — how much of the missing context does this parent actually deliver?
Three need estimators, selected by NeedConfig.estimator:
reference_density— the share of the child's pointers it cannot resolve by itself. Pronouns with no antecedent inside the unit, demonstratives (diese Dosis), definite descriptions whose entity was never introduced (die Therapieagainsteine Therapie), and acronyms the unit never expands. Scored as a ratio, so a long unit does not look self-sufficient just by being long. Bullets score high without a rule that says they should. Watch for generic definites (Die Niere filtert Blutpoints nowhere but matches the rule) — tolerable noise that inflates prose relative to lists, so compare across unit types with care, or drop the class viapointer_classes.syntactic— grammatical evidence that the unit is not standalone: no finite verb, a fragment rather than a main clause, a leading discourse connective (Jedoch,Zudem), a lowercase start (unusually informative in German, where capitalization is otherwise rigid), and — a property of the edge, not the child — a parent that ends in a colon, which is close to decisive. This measures grammatical incompleteness, which is not the same as interpretive incompleteness: a well-formed sentence can still mean little without the heading naming which patients it applies to. A complement toreference_density, not a replacement.surprisal—dNLL = NLL(child) - NLL(child | parent)from a small causal LM, per token so length cancels. The most general of the three: bullet to paragraph, cell to column header, continuation to predecessor are all the same computation on one scale. Surprisal drops for two reasons that look identical in the number, though — the parent genuinely supplies context, or it merely repeats the child's vocabulary. The control iscounterfactual: condition on a non-ancestor parent from the same document and subtract, which collapses a genuine dependency but leaves shared-domain vocabulary intact. That correction is on by default;counterfactual="none"reports the raw drop.
estimator="blend" takes a weighted mean of several. The rule-based estimators use regex
lexicons for German and English out of the box; installing the linguistic extra
(pip install "document2graph[linguistic]", plus a spaCy model such as
python -m spacy download de_core_news_sm) swaps in POS tags and dependency parses, which
are markedly better at finite verbs and clause structure.
SupplyConfig.metric selects how supply is measured. The default is composite:
| supply metric | what it measures |
|---|---|
antecedent_coverage |
of the pointers the child leaves dangling, the share this parent answers |
complementarity |
IDF-weighted content the parent adds that the child does not already carry |
composite |
max(coverage, complementarity), floored at the relation's structural prior |
uniform |
need alone |
structural, similarity, inverted_similarity, surprisal |
the earlier arms, kept for comparison |
Composing need and supply
NeedSupplyConfig.composition decides how the two become one weight. The default,
"evidence", uses no per-relation priors:
w = 0.5 * (1 + merge_evidence - independence_evidence)
merge_evidence = need x measured_supply
independence_evidence = (1 - need) x n / (n + independence_half_length)
- 0.5 is the edge existing. Construction attached the child to this parent. That is the same structural fact for every relation, so it is one neutral value rather than a table of priors that would need measuring and tuning.
- Merge evidence moves the edge toward 1: the child needs context and this parent measurably delivers it. Supply is the composite without its prior floor, so it spans its full range rather than a 2.5x band above the floor.
- Independence evidence moves the edge toward 0: the child shows no sign of needing
anything, weighted by how much text (
nwords) it had to show that in. A three-word cell with no pointers stays near 0.5; a sixty-word self-contained paragraph drops well below it. Tables and figures get no independence evidence, since the need estimators read grammar and pointers and a table has neither.
The product form scored "nothing could be measured" and "measured to stand alone"
identically, at zero; this keeps them apart. It also gives MergePolicy.min_weight a
reading: below 0.5 it merges everything except children that show they stand alone,
above 0.5 only edges with positive evidence of need. scripts/audit_edge_weights.py --section evidence shows, per relation, which way the edges move and which term moved
them.
composition="product" is the earlier need x supply, with the composite's prior floor
applied. It is kept for comparison with runs made under it. The floor was what kept a
three-word bullet or a table cell -- where both measured components return near zero
by construction -- from reading as "this parent supplies nothing"; the neutral start is
what does that under "evidence".
The priors (used only by "product") are configurable per relation and ship with values
measured from 50 hand-labelled edges (tests/.test-data/edge_labels.json):
from document2graph.models import StructuralPriorConfig, SupplyConfig
SupplyConfig(structural_prior=StructuralPriorConfig(
priors={"heading_list": 0.9, "text_text": 0.3}, # replaces the table wholesale
default=0.5, # any relation absent from it
))
Supply relations are finer than the structural relations: text splits into
heading_text / text_text and list_item into heading_list / text_list,
because a heading and a paragraph do not deliver the same kind of context. The
split is derived from the nodes, so it also covers edges that never existed during
graph construction.
Complementarity should be given a corpus-wide IDF table, without which its scores are not comparable across documents:
from document2graph.edge_weights import MetricDeps, compute_edge_weights, idf_table
deps = MetricDeps(idf=idf_table(every_node_text_in_the_corpus))
weights = compute_edge_weights(ctx, config, deps)
from document2graph.models import EdgeWeightConfig, NeedConfig, SupplyConfig, SimilarityConfig
edge_weights = EdgeWeightConfig(
metric="need_supply",
need=NeedConfig(
estimator="blend",
blend_weights={"reference_density": 0.5, "syntactic": 0.5},
),
supply=SupplyConfig(
metric="inverted_similarity",
similarity=SimilarityConfig(backend="bm25"),
),
)
Registering another metric needs no change to the package:
from document2graph.edge_weights import register_metric
@register_metric("depth_decay")
def depth_decay(ctx, config, deps):
return {edge: 0.5 ** ctx.unit(edge[1]).level for edge in ctx.edges}
RelevancyWeightConfig was removed in 0.2.0. It is replaced by
metric="inverted_similarity" with the matching SimilarityConfig and combination.
Connectivity guarantee
Each graph contains a synthetic root node (#/document-root, labelled with the document
title). Any node whose parent cannot be resolved — as well as any component that would
otherwise be disconnected — is attached to this root with the root edge weight, so every
document graph is a single (weakly) connected component.
Extracted metadata is available on the Document object under the metadata sub-object:
result = extractor.run("my_document.pdf")
doc = result["document_metadata"]
print(doc.title)
print(doc.metadata.authors)
print(doc.metadata.version)
Pipeline flags
Graph construction repairs what the PDF parser leaves ambiguous: reading order, paragraphs split across columns, page regions, unlinked captions. Each step can be switched off individually to measure what it contributes. All of them are applied after parsing, so a sweep over several settings reuses one parse of the corpus.
from document2graph.models import ExtractorConfig, PipelineFlags
config = ExtractorConfig(
pdf_path="./pdfs",
data_path="./data",
flags=PipelineFlags(
repair_reading_order=True, # sort snippets by page block instead of docling's order
stitch_continuations=True, # rejoin a paragraph split across a column/page break
merge_line_fragments=True, # rejoin the lines of a figure/sidebar/masthead block
merge_table_continuations=True, # rejoin a table continued on the next page
assign_regions=True, # body / front_matter / sidebar / figure
recover_captions=True, # find captions docling left unlinked to their figure
filter_decorative_pictures=True, # drop logos and rules
absorb_figure_text=True, # fold a chart's labels into the picture node
figure_label_max_tokens=8, # above this a figure-region node is prose, and stays
level_source="hybrid", # "hybrid" | "typography" | "docling"
),
)
Two of the steps rejoin units the parser cut in half, and both were calibrated against a hand-labelled review of 298 candidate splits across the test corpus:
merge_line_fragments— docling clusters running text into paragraphs but reads a figure's body, a boxed sidebar and the masthead line by line, so one bullet of a flowchart becomes five snippets and five graph nodes. Lines that sit directly under one another in the same column, and read as one continuing text, are merged back into one snippet. Body text is deliberately excluded: there docling's clustering is right, and joining neighbouring bullets would destroy the list. Needsassign_regions.absorb_figure_text— a chart is read line by line, so its axis ticks and data points arrive as one node each:"42","33","51". None of them is retrievable on its own and the picture that gives them meaning has no text at all, so the whole figure is invisible to a retriever. The labels become the picture'sfigure_text, in reading order, and stop being nodes. Across the twenty-document test corpus, 87% of figure-region nodes are five tokens or fewer — in a chart-heavy document that is 86–97% of everything — and folding them in takes the corpus from 12515 units to 8520, while 132 pictures that had no text become retrievable. A figure-region node abovefigure_label_max_tokensis prose rather than a label — a flowchart step, a boxed note — and stays a node of its own, though it still appears in the digest so the figure reads whole. Captions are never absorbed, nor is a node that parents another. Needsassign_regions.merge_table_continuations—docling_doc.tablesholds one item per layout cluster, so a table broken by a page break arrives as two, with half the rows each and the caption on the first only. The continuation's rows are appended to the head, whoseprovenancethen reports both pages and whosecontinuation_refsnames the item it absorbed. Only page breaks are merged: in a two-column guideline layout, two tables side by side are two tables, even when they repeat a header row.
level_source selects how heading levels are derived:
hybrid(default) — headings are grouped into styles by (embedded font, height cluster) and ranked by how they nest in reading ordertypography— font height alone; headings of the same apparent size stay siblingsdocling— docling's ownSectionHeaderItem.level
Parsing once
Parsing a PDF is by far the most expensive step, so the parsed document is cached as
JSON under <data_path>/raw_texts/ and reused on the next run — by both extractors,
so a document is parsed once no matter which representation is built first.
config = ExtractorConfig(
pdf_path="./pdfs",
data_path="./data",
use_docling_cache=True, # default; set False to always re-parse
refresh_docling_cache=False, # True re-parses and overwrites the cache
)
Document graph extraction
Processes every PDF in pdf_path and builds a NetworkX graph per document.
from document2graph.document2graph_extractor import DocumentGraphExtractor
extractor = DocumentGraphExtractor(config)
# Process all PDFs and save graphs + snippets to disk
extractor.generate_snippets()
# Or collect Snippet objects in memory as well
snippets = extractor.generate_snippets(return_snippets=True)
To process a single file and get the raw graph components:
result = extractor.run("my_document.pdf")
text_nodes = result["text_nodes"]
image_nodes = result["image_nodes"]
table_nodes = result["table_nodes"]
edges = result["edges"] # list of (parent_id, child_id, weight) tuples
metadata = result["document_metadata"]
Graphs are saved as .gexf files under <data_path>/nx_graphs/ and can be loaded with NetworkX:
import networkx as nx
G = nx.read_gexf("data/output/nx_graphs/my_document.gexf")
Loading a graph back
GEXF is a view for graph tools: it drops bounding boxes, page numbers, char spans and
the table serializations. The full graph is written next to it as JSON under
<data_path>/graphs/ and can be read back without re-parsing the PDF — which is what
makes it practical to run many retrieval conditions over the same corpus.
from document2graph import load_graph
graph = load_graph("data/output/graphs/my_document_graph.json")
graph.document_id # stable id, shared with the document's baseline chunks
graph.root_id # snippet_id of the document root
for snippet in graph.snippets:
snippet.snippet_id # globally unique across the corpus
snippet.local_ref # the document-local docling ref, e.g. "#/texts/12"
snippet.parent_id # snippet_id of the parent, or None
snippet.provenance # [(page_no, bbox, charspan), ...]
graph.edges # [(parent snippet_id, child snippet_id, weight), ...]
graph.reference_edges # [(mentioning text, media item, weight), ...]
graph.pages # [(page_no, width, height), ...] — the box of every page
graph.page(3) # the geometry of one page, or None if it was not recorded
Every stored bbox is a docling provenance box in PDF points, which says nothing about
where it sits on the page without the page it belongs to. graph.pages is what a
consumer normalizes against — scoring bbox overlap against layout gold, drawing nodes
over a page image — so a reloaded graph needs neither the PDF nor a second parse.
DocumentGraph.from_snippet_graph(graph, document) builds the same object from a
DocumentGraphExtractor.run() result in memory; extractor.run(...)["document_graph"]
returns it directly.
Building a graph from a parsed document
DocumentGraphExtractor owns a corpus directory and writes intermediate files, which is
the wrong shape for a harness that has already parsed the document and wants the graph as
a value. graph_from_docling is the same construction without the filesystem, so an
ablation over PipelineFlags or EdgeWeightConfig reuses one parse:
from docling_core.types.doc.document import DoclingDocument
from docling_parse.pdf_parser import DoclingPdfParser
from document2graph import PipelineFlags, graph_from_docling
docling_doc = DoclingDocument.load_from_json("data/raw_texts/my_document_docling_doc.json")
pdf_doc = DoclingPdfParser().load(path_or_stream="pdfs/my_document.pdf")
graph = graph_from_docling(
docling_doc, pdf_doc, "my_document",
flags=PipelineFlags(stitch_continuations=False),
)
Nothing is written unless save_gexf_to is passed. build_snippet_graph returns the
raw (SnippetGraph, Document) instead, for a caller that wants the node objects.
pdf_doc is still required, and is why a condition sweep needs the PDF next to the
cached parse: graph construction reads the line and character cells behind line heights
and font keys, and the drawn shapes behind page blocks and regions, none of which the
DoclingDocument carries.
Merging graph nodes into retrieval units
A graph node on its own is often not readable: a bullet under a colon, a paragraph whose
subject is named only in its heading, a table cell. Merging ancestors into it fixes that
and costs tokens, and MergePolicy is how you ask which ancestors are worth the tokens.
from document2graph import load_graph, merge_units
from document2graph.models import MergePolicy
graph = load_graph("data/output/graphs/my_document_graph.json")
bare = merge_units(graph, MergePolicy(strategy="none"))
merged = merge_units(graph, MergePolicy(strategy="weighted", min_weight=0.8))
everything = merge_units(graph, MergePolicy(strategy="all_ancestors"))
sections = merge_units(graph, MergePolicy(strategy="subtree"))
| strategy | the unit is |
|---|---|
none |
the bare node |
all_ancestors |
the node plus every ancestor up to the root |
weighted |
the node plus ancestors while the edge weight clears min_weight (and the running product clears min_cumulative) |
subtree |
one unit per heading, holding its whole subtree |
min_tokens is a floor on the other end: a node whose own text falls below it is folded
into the nearest ancestor that is a unit, rather than standing as one. It is a floor and
not a filter — merging only ever walks upwards, so a node simply left out would take its
text out of the corpus with it — and a fragment with no eligible ancestor keeps its own
unit for the same reason. It is 0 by default, and it is the retrieval-time counterpart to
PipelineFlags.absorb_figure_text, which catches the same problem at its source where the
fragments sit inside a figure.
all_ancestors is the baseline the weighted policy has to beat, and it has to beat it at
equal token budget rather than at equal recall: withholding nothing can hardly lose
recall, so the claim can only be the same recall for fewer tokens. On the twenty-document
test corpus, merging everything costs 2.06× the index tokens of merging nothing, and
the weighted dial spans 1.07× to 1.75× of it.
Each unit records what it was built from, so a condition can be audited rather than only scored:
unit.text # ancestors first, the unit's own node last
unit.member_ids # every contributing snippet, in that order
unit.merged_edges # which edges were followed, with weight and running product
unit.breadcrumb # heading ancestors' texts, outermost first — kept out of `text`
unit.provenance # the locations of every member, so a merged unit still places
unit.token_count
The breadcrumb stays out of text on purpose: prepending the heading path is its own
retrieval condition, and one any segmentation can be given, so folding it in silently
would make "structure-aware" and "heading-prepending" impossible to tell apart.
Comparing two weight metrics fairly
min_weight is a threshold on whatever scale the configured metric produces, and those
scales do not agree — running two metrics at 0.5 compares how much each happens to merge
at 0.5. What makes them comparable is fixing the outcome: calibrate each metric's
threshold to the same mean unit size, then compare retrieval at that matched budget.
from document2graph import calibrate_min_weight, mean_unit_tokens
target = mean_unit_tokens(graphs, MergePolicy(strategy="all_ancestors"))
calibration = calibrate_min_weight(graphs, MergePolicy(strategy="weighted"), target)
calibration.policy # ready to pass to merge_units
calibration.achieved_mean_tokens
calibration.relative_error
calibration.within(0.02)
The achieved mean comes back beside the policy because the reachable sizes are a step
function of the distinct edge weights, so a target between two steps is not reachable at
all. The shipped structural weights take five values, so a corpus merged under them can
only land on a handful of means. Check relative_error before reporting two conditions as
budget-matched.
Token counts default to a tokenizer-free estimate, which is fine for exploring but not for a real budget comparison — that has to charge every condition with the same tokenizer the retriever will embed with:
from document2graph.retrieval import huggingface_token_counter
count = huggingface_token_counter("intfloat/multilingual-e5-large")
units = merge_units(graph, policy, count_tokens=count)
Expanding a retrieved set
Merging happens at index time and decides what gets embedded. Expansion happens at query time and decides what gets returned — a different trade, because index-time context is paid for in every unit whether a query needed it or not, while expansion is paid for only on the handful of units a query actually retrieved. The two compose: embed small and precise, return complete.
expand is a pure graph operation over (node_id, score) pairs — no index, no embedding
model, no query — so a retrieval harness keeps its own ranking and asks only what the
graph can answer:
from document2graph import expand, load_graph
from document2graph.models import ExpansionPolicy
graph = load_graph("data/output/graphs/my_document_graph.json")
hits = [(node_id, score), ...] # whatever your retriever ranked
for result in expand(graph, hits, ExpansionPolicy(mode="auto_merge")):
result.unit.text # what to put in the context (a MergedUnit, as merge_units returns)
result.score
result.reason # "hit", or the mode that grew it
result.added_ids # nodes pulled in that were not themselves retrieved
result.absorbed_ids # other hits folded into this unit, not returned separately
result.added_tokens # what this unit's growth charged to the budget
| mode | adds |
|---|---|
none |
nothing |
parent |
each hit's parent |
ancestors |
the whole chain above each hit |
siblings |
the hit's neighbours under the same parent |
subtree |
what hangs below the hit, for when a heading is retrieved |
auto_merge |
where min_siblings retrieved nodes share a parent, the parent replaces them |
reference_edges |
the figure or table a retrieved mention points at, and vice versa |
auto_merge is the one that needs the weights rather than just the shape: a group of
siblings is only worth collapsing if the parent actually holds them together, which is
what min_edge_weight asks. It collapses one level only — walking on up would carry a
section to the document root as soon as two of its paragraphs matched, which says
something about the tree rather than about the query.
Two ceilings, and they answer different questions: max_tokens_per_unit is how large one
returned unit may be, max_added_tokens is the whole result's expansion allowance. Hits
grow in rank order, so a tight allowance grows the top of the result and leaves the tail
bare rather than spreading thinly.
dedupe (on by default) drops a hit that has been absorbed into another returned unit.
Without it a parent and its children both come back and the shared text is charged twice,
which silently understates what expansion costs.
Writing to Neo4j
Requires the neo4j extra. The driver and its credentials belong to the caller.
from neo4j import GraphDatabase
from document2graph import ensure_neo4j_constraints, write_graph_to_neo4j
with GraphDatabase.driver(uri, auth=(user, password)) as driver:
ensure_neo4j_constraints(driver) # once per database
write_graph_to_neo4j(driver, graph)
Documents become (:Document)-[:HAS_ROOT]->(:Snippet), the hierarchy becomes
(:Snippet)-[:HAS_CHILD {weight}]->(:Snippet) and mentions become
[:REFERENCES {weight}]. Writing the same document again updates it in place: every id
is derived deterministically from the document's name.
Identifiers and provenance
Both extractors derive the same document_id for a given PDF, and derive it the same
way on every run, so graph nodes and baseline chunks can be joined and a re-run updates
a corpus instead of duplicating it.
from document2graph import document_id_for
document_id_for("my_document.pdf") # stable uuid5, independent of directory and extension
Every unit — a graph node, a Snippet, a GraphSnippet or a baseline Chunk — exposes
its location in the PDF the same way, as a list of Provenance(page_no, bbox, charspan).
A unit has more than one entry when it spans more than one region: a paragraph stitched
back together across a column break, a table continued on the next page, or a chunk
merged from several document items.
Baseline chunk extraction
Produces flat, retrieval-ready chunks using Docling's hybrid chunker.
from document2graph.baseline_extractor import BaselineExtractor
extractor = BaselineExtractor(config)
# Process all PDFs; saves JSON to disk
extractor.generate_baseline_chunks()
# Or return chunks in memory
all_chunks = extractor.generate_baseline_chunks(return_chunks=True)
# all_chunks["my_document.pdf"]["baseline"] -> List[Chunk]
# all_chunks["my_document.pdf"]["enriched"] -> List[Chunk] (context-enriched)
To process a single file:
chunks = extractor.extract_baseline_chunks(
filename="my_document.pdf",
baseline_description="Q1 report chunks",
enrich=True, # also produce context-enriched variants
)
Chunker settings
max_tokens bounds the size of a baseline chunk and should match the context
window of the model that later embeds it — set it too high and the chunker
emits chunks the retriever silently truncates. tokenizer only determines how
chunk length is measured, so it should be the tokenizer of that same model.
from document2graph.models import ExtractorConfig, ChunkerConfig
config = ExtractorConfig(
pdf_path="./pdfs",
data_path="./data",
chunker=ChunkerConfig(
tokenizer="intfloat/multilingual-e5-large",
max_tokens=512,
merge_peers=True, # merge adjacent chunks under one heading while they fit
),
)
Output structure
<data_path>/
├── raw_texts/ # parsed Docling documents (also the parse cache)
├── nx_graphs/ # .gexf graph files (one per document)
├── graphs/ # <filename>_graph.json (full graph, reloadable with load_graph)
├── snippets/ # <filename>_snippets.json (document graph output)
└── baseline_chunks/ # <filename>_baseline_chunks.json
Data models
| Model | Key fields |
|---|---|
Snippet |
snippet_id, type (text/image/table), document_id, sequence_no, label, level, region (body/front_matter/sidebar/figure), page_no, bbox, charspan, provenance, text |
Chunk |
chunk_id, document_id, filename, text, meta, provenance, baseline_description, embedding |
Provenance |
page_no, bbox, charspan |
PageGeometry |
page_no, width, height (the page box every stored bbox is expressed against) |
MergePolicy |
strategy (none/all_ancestors/subtree/weighted), min_weight, min_cumulative, min_tokens, max_tokens, separator, breadcrumb_separator |
MergedUnit |
unit_id, document_id, text, member_ids, breadcrumb, token_count, provenance, page_no, node_type, merged_edges |
MergedEdge |
parent_id, child_id, weight, cumulative |
ExpansionPolicy |
mode (none/parent/ancestors/siblings/subtree/auto_merge/reference_edges), min_siblings, min_edge_weight, max_added_tokens, max_tokens_per_unit, dedupe, separator |
ExpandedHit |
unit: MergedUnit, score, hit_id, reason, added_ids, absorbed_ids, added_tokens |
Calibration |
policy, target_mean_tokens, achieved_mean_tokens, relative_error, within(tolerance) |
DocumentGraph |
document_id, filename, title, root_id, snippets: list[GraphSnippet], edges, reference_edges, pages: list[PageGeometry], edge_relations |
GraphSnippet |
everything on Snippet plus local_ref, parent_local_ref, snippet_type, line_heights, font_key, level_height, extracted_at. A media snippet's text is what the node is made of: a table is its caption above its markdown, a picture its caption above whatever is printed inside the figure |
Document |
document_id, document_type, filename, title, metadata: DocumentMetadata |
DocumentMetadata |
version, authors, institutions, bibliography, correspondence |
MetadataExtractionConfig |
title_page, version, authors, institutions, bibliography, correspondence (each a MetadataFieldConfig) |
MetadataFieldConfig |
label (search string), pages (inclusive 1-based page range, e.g. (1, 3)) |
ChunkerConfig |
tokenizer (HF tokenizer name), max_tokens, merge_peers |
PipelineFlags |
repair_reading_order, stitch_continuations, merge_line_fragments, merge_table_continuations, assign_regions, recover_captions, filter_decorative_pictures, absorb_figure_text, figure_label_max_tokens, level_source (hybrid/typography/docling) |
EdgeWeightConfig |
section, text, list_item, media, unreferenced_media, reference, root (structural weights by relation), metric (structural/uniform/similarity/inverted_similarity/need_supply), combination (replace/mean/multiply), similarity: SimilarityConfig, need: NeedConfig, supply: SupplyConfig, need_supply: NeedSupplyConfig |
SimilarityConfig |
backend (bm25/embedding/blend), embedding_model, alpha, bm25_k1, bm25_b, bm25_scoring (child_query/symmetric_mean/symmetric_max) |
NeedConfig |
estimator (uniform/reference_density/syntactic/surprisal/blend), blend_weights, reference_density: ReferenceDensityConfig, syntactic: SyntacticConfig, surprisal: SurprisalConfig |
NeedSupplyConfig |
composition (evidence/product), independence_half_length (words at which absent dependency signals count as half the evidence of independence) |
SupplyConfig |
metric (uniform/structural/similarity/inverted_similarity/surprisal/antecedent_coverage/complementarity/composite), combine (max/mean), floor (prior/none), antecedent: AntecedentCoverageConfig, complementarity: ComplementarityConfig, structural_prior: StructuralPriorConfig, similarity: SimilarityConfig, surprisal: SurprisalConfig |
AntecedentCoverageConfig |
pointers: ReferenceDensityConfig (acronyms excluded by default), idf_weighted, default_when_no_pointers |
ComplementarityConfig |
idf_source (document/corpus), top_k_novel_terms, stem_terms, min_idf |
StructuralPriorConfig |
priors (per supply relation; defaults measured), default, enabled. Only the product composition applies them |
ReferenceDensityConfig |
language (de/en/auto), pointer_classes (anaphor/demonstrative/definite/acronym), default_when_no_pointers, use_spacy, spacy_model_de, spacy_model_en |
SyntacticConfig |
feature_weights (no_finite_verb/fragment/leading_connective/lowercase_start/parent_ends_in_colon), language, use_spacy, spacy_model_de, spacy_model_en |
SurprisalConfig |
model, counterfactual (none/sibling_parent), max_context_tokens, max_child_tokens, normalize (max/logistic), logistic_scale, device |
Running tests
pytest
Skip slow tests that download models:
pytest -m "not slow"
Metadata
Release files for document2graph 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| document2graph-0.2.0.tar.gz | 182.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| document2graph-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 322.7 kB
Release files / document2graph-0.2.0.tar.gz
| Download URL | document2graph-0.2.0.tar.gz |
|---|---|
| Size | 182.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
25c16110a841df4dba3fc927a7e1abd339f4e532d9556c5a862f2a4386115cd4
|
|
BLAKE2b-256 checksum How to use checksums |
c580169ab152709493b961c3b28d5d0ac2aff9f45d44cd2b244db53c51e2de02
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / document2graph-0.2.0-py3-none-any.whl
| Download URL | document2graph-0.2.0-py3-none-any.whl |
|---|---|
| Size | 139.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d2e780686f6c14bd46ab9d1aaa922c0b1110372e56f9e928cf69f0b73c144e1d
|
|
BLAKE2b-256 checksum How to use checksums |
580108a1d008109824c950e44c05d84f1698dc080f4b40ce75cc5004e5edb24f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log