structured-data-extraction 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structured_data_extraction-0.2.1/PKG-INFO +265 -0
- structured_data_extraction-0.2.1/README.md +233 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/pyproject.toml +2 -2
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/config.py +11 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/extract.py +21 -2
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/main.py +15 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/rf_classifier.joblib +1 -1
- structured_data_extraction-0.2.1/src/structured_data_extraction.egg-info/PKG-INFO +265 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_extract.py +31 -0
- structured_data_extraction-0.2.0/PKG-INFO +0 -293
- structured_data_extraction-0.2.0/README.md +0 -261
- structured_data_extraction-0.2.0/src/structured_data_extraction.egg-info/PKG-INFO +0 -293
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/LICENSE +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/setup.cfg +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/__init__.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/classify.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/convert.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/embed.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/features.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/filter_records.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/matching.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/docling_cats.joblib +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/tfidf_char_vectorizer.joblib +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/tfidf_vectorizer.joblib +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/paths.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/rater.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/records.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/search.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/tables.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/templates/extraction.j2 +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/templates/schema_discovery.j2 +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/vocab.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/SOURCES.txt +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/dependency_links.txt +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/entry_points.txt +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/requires.txt +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/top_level.txt +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_convert.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_embed.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_features.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_matching.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_rater.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_records.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_search.py +0 -0
- {structured_data_extraction-0.2.0 → structured_data_extraction-0.2.1}/tests/test_tables.py +0 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structured-data-extraction
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Pipeline for extracting structured data (tables, sections, key/value fields) from PDF documents using docling, embeddings, and LLMs.
|
|
5
|
+
Author-email: Dimitrios Avellas <dimitrisavellas8@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/dimitrisavellas/structured-extraction.git
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: docling==2.82.0
|
|
12
|
+
Requires-Dist: docling-core==2.70.2
|
|
13
|
+
Requires-Dist: docling-parse==5.6.1
|
|
14
|
+
Requires-Dist: docling-ibm-models==3.13.0
|
|
15
|
+
Requires-Dist: yake
|
|
16
|
+
Requires-Dist: sentence-transformers
|
|
17
|
+
Requires-Dist: numpy
|
|
18
|
+
Requires-Dist: pandas
|
|
19
|
+
Requires-Dist: pydantic
|
|
20
|
+
Requires-Dist: instructor
|
|
21
|
+
Requires-Dist: openai
|
|
22
|
+
Requires-Dist: jinja2
|
|
23
|
+
Requires-Dist: scikit-learn==1.8.0
|
|
24
|
+
Requires-Dist: joblib
|
|
25
|
+
Requires-Dist: pymupdf
|
|
26
|
+
Requires-Dist: einops
|
|
27
|
+
Requires-Dist: rapidfuzz
|
|
28
|
+
Requires-Dist: python-dotenv
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# Structured Data Extraction
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/structured-data-extraction/)
|
|
36
|
+
|
|
37
|
+
Extract user-specified fields from arbitrary PDFs as structured JSON with provenance.
|
|
38
|
+
|
|
39
|
+
The pipeline is **domain-agnostic**: vocabulary, acronyms and field shapes are derived from each document at runtime, so the same code handles a chip datasheet or an earnings report without changes. It runs against any OpenAI-compatible endpoint, from cloud models to small local models on a laptop.
|
|
40
|
+
|
|
41
|
+
## Contents
|
|
42
|
+
|
|
43
|
+
- [How it works](#how-it-works)
|
|
44
|
+
- [Installation](#installation)
|
|
45
|
+
- [Configuration](#configuration)
|
|
46
|
+
- [Usage](#usage)
|
|
47
|
+
- [Running on local models](#running-on-local-models)
|
|
48
|
+
- [Outputs](#outputs)
|
|
49
|
+
- [Development](#development)
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
Given a PDF and a list of keys, the pipeline converts the PDF to text, learns the document's vocabulary, classifies and embeds its sections, retrieves the passages closest to each key, repairs messy tables among them, and asks an LLM to return validated, structured values.
|
|
54
|
+
|
|
55
|
+
| Step | Module | Job |
|
|
56
|
+
|------|--------|-----|
|
|
57
|
+
| Convert | `convert.py` | Docling to markdown + `items.json` (label, page, bbox). |
|
|
58
|
+
| Vocab | `vocab.py` | Per-document vocabulary (YAKE, up to 4-grams); one-word keys are expanded with related terms. |
|
|
59
|
+
| Classify | `classify.py` | Pre-trained RandomForest tags each item as header, list, table, text or title. |
|
|
60
|
+
| Tables | `tables.py` | Scores table complexity; matched complex tables are rebuilt by a vision LLM. |
|
|
61
|
+
| Embed | `embed.py` | Sentence-transformer embeddings of sections and of each table row with its group header. |
|
|
62
|
+
| Search | `search.py` | Cosine similarity, keeping hits above `SIMILARITY_THRESHOLD` (at most `SEARCH_TOP_K`). |
|
|
63
|
+
| Extract | `extract.py` | Schema discovery, a Pydantic model built on the fly, [Instructor](https://python.useinstructor.com/)-validated extraction, dedup. |
|
|
64
|
+
| Rate | `rater.py` | Clusters records across models, attaches a consensus confidence, scores agreement (Cohen's kappa). |
|
|
65
|
+
| Package | `records.py` | Wraps the fused result into one provenance-stamped record per document. |
|
|
66
|
+
|
|
67
|
+
Design choices worth knowing:
|
|
68
|
+
|
|
69
|
+
- **Semantic search.** "CMRR" and "common-mode rejection ratio" share no letters but sit close in embedding space.
|
|
70
|
+
- **Lazy vision.** Only tables that matched a key are sent to the vision model, so cost tracks the request, not the document size.
|
|
71
|
+
- **Constrained output.** The LLM fills a Pydantic model; free text is rejected and retried.
|
|
72
|
+
- **Optional voting.** List several extraction models to get per-record confidence (`models agreeing / total models`).
|
|
73
|
+
- **Caching.** Every step writes to disk and is skipped on re-run, so iterating on a prompt does not re-run docling.
|
|
74
|
+
|
|
75
|
+
Each module's docstring explains its non-obvious design decisions.
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
Requires Python 3.11+ and an OpenAI-compatible LLM endpoint (the defaults assume [Ollama](https://ollama.com)). The embedding model is downloaded from Hugging Face on first run.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 -m venv venv && source venv/bin/activate
|
|
83
|
+
pip install structured-data-extraction
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For development, install an editable checkout:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git clone https://github.com/dimitrisavellas/structured-extraction.git
|
|
90
|
+
cd structured-extraction
|
|
91
|
+
pip install -e ".[dev]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
Create a `.env` in the directory you run the tool from (parent directories are searched too). Shell environment variables override it.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Provider: any OpenAI-compatible endpoint
|
|
100
|
+
LLM_BASE_URL=http://localhost:11434/v1
|
|
101
|
+
LLM_API_KEY=ollama
|
|
102
|
+
|
|
103
|
+
# Models
|
|
104
|
+
SCHEMA_MODEL=glm-5.1:cloud
|
|
105
|
+
EXTRACTION_MODELS=gemma4:31b-cloud # comma-separated for multi-model voting
|
|
106
|
+
VISION_MODEL=kimi-k2.7-code:cloud # must support images
|
|
107
|
+
EMBEDDING_MODEL=nomic-ai/nomic-embed-text-v1.5 # Hugging Face model ID
|
|
108
|
+
|
|
109
|
+
# Runtime
|
|
110
|
+
EMBED_BATCH_SIZE=4 # raise on bigger GPUs
|
|
111
|
+
MAX_LLM_RETRIES=2 # retries per extraction model
|
|
112
|
+
EXTRACTION_WORKERS=4 # parallel workers across extraction models
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Optional settings (defaults shown):
|
|
116
|
+
|
|
117
|
+
| Variable | Default | Purpose |
|
|
118
|
+
|----------|---------|---------|
|
|
119
|
+
| `SIMILARITY_THRESHOLD` | `0.55` | Cosine floor for a section or row to count as a match. |
|
|
120
|
+
| `SEARCH_TOP_K` | `25` | Maximum matches kept per key. |
|
|
121
|
+
| `VISION_COMPLEXITY_THRESHOLD` | `0.15` | Minimum table complexity sent to the vision model. |
|
|
122
|
+
| `VISION_PARALLELISM` | `4` | Parallel vision calls. |
|
|
123
|
+
| `VOCAB_TOP_N` / `VOCAB_MAX_NGRAM` | `50` / `4` | Vocabulary size and longest phrase. |
|
|
124
|
+
| `EXPAND_KEYS` | `true` | Expand keys with related document vocabulary before search. |
|
|
125
|
+
| `REASONING_EFFORT` | unset | Sent as `reasoning_effort` on schema and extraction calls; `none` disables thinking. |
|
|
126
|
+
| `LLM_TIMEOUT` | `600` | Seconds per schema or extraction request. |
|
|
127
|
+
| `EXTRACT_PER_SECTION` | `false` | One extraction call per matched section instead of one for all. |
|
|
128
|
+
|
|
129
|
+
The first model in `EXTRACTION_MODELS` is the primary, used wherever a single model is needed. Every named model must exist on your provider.
|
|
130
|
+
|
|
131
|
+
## Usage
|
|
132
|
+
|
|
133
|
+
### Command line
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
structured-extract report.pdf --keys revenue "net income" # one PDF
|
|
137
|
+
structured-extract pdfs/ --keys gain CMRR # a directory
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Quote multi-word keys. Without `--keys` you are prompted for them.
|
|
141
|
+
|
|
142
|
+
| Flag | Effect |
|
|
143
|
+
|------|--------|
|
|
144
|
+
| `--schema FILE` | Use your own schema and skip schema discovery (see below). |
|
|
145
|
+
| `--local` | Settings for small local models (see [Running on local models](#running-on-local-models)). |
|
|
146
|
+
| `--levels 1.0 0.75` | Keep only these confidence tiers (`all` keeps everything); multi-model runs prompt otherwise. |
|
|
147
|
+
| `--force` | Recompute every step, ignoring cached files. |
|
|
148
|
+
| `--output DIR` | Output directory (default `output/`). |
|
|
149
|
+
| `--dump-templates DIR` | Write the prompt templates to `DIR` for editing; point `TEMPLATES_DIR` at it. |
|
|
150
|
+
|
|
151
|
+
### Python
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from structured_data_extraction.main import process_single_pdf, process_batch, finalize_outputs
|
|
155
|
+
|
|
156
|
+
process_single_pdf("report.pdf", ["revenue", "net income"]) # or process_batch("pdfs/", keys)
|
|
157
|
+
finalize_outputs(levels=["all"])
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Custom schema
|
|
161
|
+
|
|
162
|
+
`--schema` takes a JSON file in the same format as `intermediate/*_schema.json` and skips schema discovery; keys are derived from it. Setting `"fixed"` on a sub-field turns it into a `Literal`, so the model can only emit that value. This is useful for slicing a table down to one variant or column:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"document_type": "operational amplifier datasheet",
|
|
167
|
+
"schema": {
|
|
168
|
+
"quiescent_current_s_typ": {
|
|
169
|
+
"type": "list",
|
|
170
|
+
"description": "Quiescent current typical value for the AD840S variant",
|
|
171
|
+
"fields": {
|
|
172
|
+
"differentiator": { "type": "string", "description": "Product variant", "fixed": "AD840S" },
|
|
173
|
+
"qualifier": { "type": "string", "description": "Column label: min, typ, or max", "fixed": "Typ" },
|
|
174
|
+
"condition": { "type": "string", "description": "Operating condition" },
|
|
175
|
+
"value": { "type": "number", "description": "Quiescent current value", "unit": "mA" },
|
|
176
|
+
"metadata": { "type": "object", "description": "Source and provenance" }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Single stages
|
|
184
|
+
|
|
185
|
+
Each step is also a console script, run from the same working directory:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
structured-extract-convert --input pdf/example.pdf
|
|
189
|
+
structured-extract-vocab --input markdown/example.md
|
|
190
|
+
structured-extract-classify --stem example
|
|
191
|
+
structured-extract-embed --stem example --keys gain CMRR
|
|
192
|
+
structured-extract-search --stem example --original-keys gain CMRR
|
|
193
|
+
structured-extract-extract --stem example --keys gain CMRR
|
|
194
|
+
structured-extract-rate # fuse models, score agreement
|
|
195
|
+
structured-extract-records # wrap fused.json into records.json
|
|
196
|
+
structured-extract-filter --levels 1.0 0.75 # keep selected confidence tiers
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Running on local models
|
|
200
|
+
|
|
201
|
+
The pipeline runs fully offline with small models (tested with 9B models on a 16 GB MacBook Air via Ollama). Pass `--local`:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
structured-extract report.pdf --keys "net income" --local
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`--local` applies three settings that small models need:
|
|
208
|
+
|
|
209
|
+
- **One extraction call per section** (`EXTRACT_PER_SECTION`). When several tables repeat the same rows in one prompt, 9B models at temperature 0 tend to loop on them indefinitely.
|
|
210
|
+
- **Thinking off** (`REASONING_EFFORT=none`), unless you set `REASONING_EFFORT` yourself. Thinking at temperature 0 can run away.
|
|
211
|
+
- **30-minute request timeout** (`LLM_TIMEOUT`), unless you set a longer one.
|
|
212
|
+
|
|
213
|
+
Ollama loads models with a small context window by default (4096 tokens on machines with less than about 24 GB of GPU memory) and **silently truncates** longer prompts. The schema-discovery prompt can reach about 17k tokens, so start Ollama with a larger window:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
OLLAMA_CONTEXT_LENGTH=40960 ollama serve
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Passing `--schema` skips discovery. With `--local`, each extraction prompt holds one section (under 7k tokens in testing), so a 16k window is usually enough.
|
|
220
|
+
|
|
221
|
+
Expect lower precision than with large models: 9B models occasionally invent records. Listing two or more extraction models and filtering with `--levels` mitigates this.
|
|
222
|
+
|
|
223
|
+
## Outputs
|
|
224
|
+
|
|
225
|
+
Steps cache their results in `intermediate/`; deliverables go to `output/`.
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
output/<stem>_extraction_<model>.json raw records, one file per extraction model
|
|
229
|
+
fused.json every fact, confidence-sorted per key
|
|
230
|
+
records.json fused.json in a provenance envelope, one entry per document
|
|
231
|
+
rating_report.json agreement statistics and pairwise Cohen's kappa
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
A `records.json` entry pairs provenance with the extracted fields, each a list of records sorted by confidence:
|
|
235
|
+
|
|
236
|
+
```jsonc
|
|
237
|
+
{
|
|
238
|
+
"0150200051": {
|
|
239
|
+
"type": "ExtractionResult",
|
|
240
|
+
"provenance": {
|
|
241
|
+
"source_file": "0150200051.pdf",
|
|
242
|
+
"document_type": "cable connector product datasheet",
|
|
243
|
+
"models": { "schema_discovery": "glm-5.1:cloud", "vision": "kimi-k2.7-code:cloud",
|
|
244
|
+
"embedding": "nomic-ai/nomic-embed-text-v1.5" },
|
|
245
|
+
"user_keys": ["product family", "Voltage"]
|
|
246
|
+
},
|
|
247
|
+
"voltage": [
|
|
248
|
+
{ "confidence": 1.0, "models": ["deepseek-v4-flash:cloud", "gemma4:31b-cloud"],
|
|
249
|
+
"qualifier": "Maximum", "value": 60.0, "value_unit": "V AC",
|
|
250
|
+
"metadata": { "section_type": "text", "specified": true } }
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
pip install -e ".[dev]"
|
|
260
|
+
pytest
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Structured Data Extraction
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/structured-data-extraction/)
|
|
4
|
+
|
|
5
|
+
Extract user-specified fields from arbitrary PDFs as structured JSON with provenance.
|
|
6
|
+
|
|
7
|
+
The pipeline is **domain-agnostic**: vocabulary, acronyms and field shapes are derived from each document at runtime, so the same code handles a chip datasheet or an earnings report without changes. It runs against any OpenAI-compatible endpoint, from cloud models to small local models on a laptop.
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [How it works](#how-it-works)
|
|
12
|
+
- [Installation](#installation)
|
|
13
|
+
- [Configuration](#configuration)
|
|
14
|
+
- [Usage](#usage)
|
|
15
|
+
- [Running on local models](#running-on-local-models)
|
|
16
|
+
- [Outputs](#outputs)
|
|
17
|
+
- [Development](#development)
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
Given a PDF and a list of keys, the pipeline converts the PDF to text, learns the document's vocabulary, classifies and embeds its sections, retrieves the passages closest to each key, repairs messy tables among them, and asks an LLM to return validated, structured values.
|
|
22
|
+
|
|
23
|
+
| Step | Module | Job |
|
|
24
|
+
|------|--------|-----|
|
|
25
|
+
| Convert | `convert.py` | Docling to markdown + `items.json` (label, page, bbox). |
|
|
26
|
+
| Vocab | `vocab.py` | Per-document vocabulary (YAKE, up to 4-grams); one-word keys are expanded with related terms. |
|
|
27
|
+
| Classify | `classify.py` | Pre-trained RandomForest tags each item as header, list, table, text or title. |
|
|
28
|
+
| Tables | `tables.py` | Scores table complexity; matched complex tables are rebuilt by a vision LLM. |
|
|
29
|
+
| Embed | `embed.py` | Sentence-transformer embeddings of sections and of each table row with its group header. |
|
|
30
|
+
| Search | `search.py` | Cosine similarity, keeping hits above `SIMILARITY_THRESHOLD` (at most `SEARCH_TOP_K`). |
|
|
31
|
+
| Extract | `extract.py` | Schema discovery, a Pydantic model built on the fly, [Instructor](https://python.useinstructor.com/)-validated extraction, dedup. |
|
|
32
|
+
| Rate | `rater.py` | Clusters records across models, attaches a consensus confidence, scores agreement (Cohen's kappa). |
|
|
33
|
+
| Package | `records.py` | Wraps the fused result into one provenance-stamped record per document. |
|
|
34
|
+
|
|
35
|
+
Design choices worth knowing:
|
|
36
|
+
|
|
37
|
+
- **Semantic search.** "CMRR" and "common-mode rejection ratio" share no letters but sit close in embedding space.
|
|
38
|
+
- **Lazy vision.** Only tables that matched a key are sent to the vision model, so cost tracks the request, not the document size.
|
|
39
|
+
- **Constrained output.** The LLM fills a Pydantic model; free text is rejected and retried.
|
|
40
|
+
- **Optional voting.** List several extraction models to get per-record confidence (`models agreeing / total models`).
|
|
41
|
+
- **Caching.** Every step writes to disk and is skipped on re-run, so iterating on a prompt does not re-run docling.
|
|
42
|
+
|
|
43
|
+
Each module's docstring explains its non-obvious design decisions.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Requires Python 3.11+ and an OpenAI-compatible LLM endpoint (the defaults assume [Ollama](https://ollama.com)). The embedding model is downloaded from Hugging Face on first run.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 -m venv venv && source venv/bin/activate
|
|
51
|
+
pip install structured-data-extraction
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For development, install an editable checkout:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/dimitrisavellas/structured-extraction.git
|
|
58
|
+
cd structured-extraction
|
|
59
|
+
pip install -e ".[dev]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
Create a `.env` in the directory you run the tool from (parent directories are searched too). Shell environment variables override it.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Provider: any OpenAI-compatible endpoint
|
|
68
|
+
LLM_BASE_URL=http://localhost:11434/v1
|
|
69
|
+
LLM_API_KEY=ollama
|
|
70
|
+
|
|
71
|
+
# Models
|
|
72
|
+
SCHEMA_MODEL=glm-5.1:cloud
|
|
73
|
+
EXTRACTION_MODELS=gemma4:31b-cloud # comma-separated for multi-model voting
|
|
74
|
+
VISION_MODEL=kimi-k2.7-code:cloud # must support images
|
|
75
|
+
EMBEDDING_MODEL=nomic-ai/nomic-embed-text-v1.5 # Hugging Face model ID
|
|
76
|
+
|
|
77
|
+
# Runtime
|
|
78
|
+
EMBED_BATCH_SIZE=4 # raise on bigger GPUs
|
|
79
|
+
MAX_LLM_RETRIES=2 # retries per extraction model
|
|
80
|
+
EXTRACTION_WORKERS=4 # parallel workers across extraction models
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Optional settings (defaults shown):
|
|
84
|
+
|
|
85
|
+
| Variable | Default | Purpose |
|
|
86
|
+
|----------|---------|---------|
|
|
87
|
+
| `SIMILARITY_THRESHOLD` | `0.55` | Cosine floor for a section or row to count as a match. |
|
|
88
|
+
| `SEARCH_TOP_K` | `25` | Maximum matches kept per key. |
|
|
89
|
+
| `VISION_COMPLEXITY_THRESHOLD` | `0.15` | Minimum table complexity sent to the vision model. |
|
|
90
|
+
| `VISION_PARALLELISM` | `4` | Parallel vision calls. |
|
|
91
|
+
| `VOCAB_TOP_N` / `VOCAB_MAX_NGRAM` | `50` / `4` | Vocabulary size and longest phrase. |
|
|
92
|
+
| `EXPAND_KEYS` | `true` | Expand keys with related document vocabulary before search. |
|
|
93
|
+
| `REASONING_EFFORT` | unset | Sent as `reasoning_effort` on schema and extraction calls; `none` disables thinking. |
|
|
94
|
+
| `LLM_TIMEOUT` | `600` | Seconds per schema or extraction request. |
|
|
95
|
+
| `EXTRACT_PER_SECTION` | `false` | One extraction call per matched section instead of one for all. |
|
|
96
|
+
|
|
97
|
+
The first model in `EXTRACTION_MODELS` is the primary, used wherever a single model is needed. Every named model must exist on your provider.
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
### Command line
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
structured-extract report.pdf --keys revenue "net income" # one PDF
|
|
105
|
+
structured-extract pdfs/ --keys gain CMRR # a directory
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Quote multi-word keys. Without `--keys` you are prompted for them.
|
|
109
|
+
|
|
110
|
+
| Flag | Effect |
|
|
111
|
+
|------|--------|
|
|
112
|
+
| `--schema FILE` | Use your own schema and skip schema discovery (see below). |
|
|
113
|
+
| `--local` | Settings for small local models (see [Running on local models](#running-on-local-models)). |
|
|
114
|
+
| `--levels 1.0 0.75` | Keep only these confidence tiers (`all` keeps everything); multi-model runs prompt otherwise. |
|
|
115
|
+
| `--force` | Recompute every step, ignoring cached files. |
|
|
116
|
+
| `--output DIR` | Output directory (default `output/`). |
|
|
117
|
+
| `--dump-templates DIR` | Write the prompt templates to `DIR` for editing; point `TEMPLATES_DIR` at it. |
|
|
118
|
+
|
|
119
|
+
### Python
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from structured_data_extraction.main import process_single_pdf, process_batch, finalize_outputs
|
|
123
|
+
|
|
124
|
+
process_single_pdf("report.pdf", ["revenue", "net income"]) # or process_batch("pdfs/", keys)
|
|
125
|
+
finalize_outputs(levels=["all"])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Custom schema
|
|
129
|
+
|
|
130
|
+
`--schema` takes a JSON file in the same format as `intermediate/*_schema.json` and skips schema discovery; keys are derived from it. Setting `"fixed"` on a sub-field turns it into a `Literal`, so the model can only emit that value. This is useful for slicing a table down to one variant or column:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"document_type": "operational amplifier datasheet",
|
|
135
|
+
"schema": {
|
|
136
|
+
"quiescent_current_s_typ": {
|
|
137
|
+
"type": "list",
|
|
138
|
+
"description": "Quiescent current typical value for the AD840S variant",
|
|
139
|
+
"fields": {
|
|
140
|
+
"differentiator": { "type": "string", "description": "Product variant", "fixed": "AD840S" },
|
|
141
|
+
"qualifier": { "type": "string", "description": "Column label: min, typ, or max", "fixed": "Typ" },
|
|
142
|
+
"condition": { "type": "string", "description": "Operating condition" },
|
|
143
|
+
"value": { "type": "number", "description": "Quiescent current value", "unit": "mA" },
|
|
144
|
+
"metadata": { "type": "object", "description": "Source and provenance" }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Single stages
|
|
152
|
+
|
|
153
|
+
Each step is also a console script, run from the same working directory:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
structured-extract-convert --input pdf/example.pdf
|
|
157
|
+
structured-extract-vocab --input markdown/example.md
|
|
158
|
+
structured-extract-classify --stem example
|
|
159
|
+
structured-extract-embed --stem example --keys gain CMRR
|
|
160
|
+
structured-extract-search --stem example --original-keys gain CMRR
|
|
161
|
+
structured-extract-extract --stem example --keys gain CMRR
|
|
162
|
+
structured-extract-rate # fuse models, score agreement
|
|
163
|
+
structured-extract-records # wrap fused.json into records.json
|
|
164
|
+
structured-extract-filter --levels 1.0 0.75 # keep selected confidence tiers
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Running on local models
|
|
168
|
+
|
|
169
|
+
The pipeline runs fully offline with small models (tested with 9B models on a 16 GB MacBook Air via Ollama). Pass `--local`:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
structured-extract report.pdf --keys "net income" --local
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`--local` applies three settings that small models need:
|
|
176
|
+
|
|
177
|
+
- **One extraction call per section** (`EXTRACT_PER_SECTION`). When several tables repeat the same rows in one prompt, 9B models at temperature 0 tend to loop on them indefinitely.
|
|
178
|
+
- **Thinking off** (`REASONING_EFFORT=none`), unless you set `REASONING_EFFORT` yourself. Thinking at temperature 0 can run away.
|
|
179
|
+
- **30-minute request timeout** (`LLM_TIMEOUT`), unless you set a longer one.
|
|
180
|
+
|
|
181
|
+
Ollama loads models with a small context window by default (4096 tokens on machines with less than about 24 GB of GPU memory) and **silently truncates** longer prompts. The schema-discovery prompt can reach about 17k tokens, so start Ollama with a larger window:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
OLLAMA_CONTEXT_LENGTH=40960 ollama serve
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Passing `--schema` skips discovery. With `--local`, each extraction prompt holds one section (under 7k tokens in testing), so a 16k window is usually enough.
|
|
188
|
+
|
|
189
|
+
Expect lower precision than with large models: 9B models occasionally invent records. Listing two or more extraction models and filtering with `--levels` mitigates this.
|
|
190
|
+
|
|
191
|
+
## Outputs
|
|
192
|
+
|
|
193
|
+
Steps cache their results in `intermediate/`; deliverables go to `output/`.
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
output/<stem>_extraction_<model>.json raw records, one file per extraction model
|
|
197
|
+
fused.json every fact, confidence-sorted per key
|
|
198
|
+
records.json fused.json in a provenance envelope, one entry per document
|
|
199
|
+
rating_report.json agreement statistics and pairwise Cohen's kappa
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
A `records.json` entry pairs provenance with the extracted fields, each a list of records sorted by confidence:
|
|
203
|
+
|
|
204
|
+
```jsonc
|
|
205
|
+
{
|
|
206
|
+
"0150200051": {
|
|
207
|
+
"type": "ExtractionResult",
|
|
208
|
+
"provenance": {
|
|
209
|
+
"source_file": "0150200051.pdf",
|
|
210
|
+
"document_type": "cable connector product datasheet",
|
|
211
|
+
"models": { "schema_discovery": "glm-5.1:cloud", "vision": "kimi-k2.7-code:cloud",
|
|
212
|
+
"embedding": "nomic-ai/nomic-embed-text-v1.5" },
|
|
213
|
+
"user_keys": ["product family", "Voltage"]
|
|
214
|
+
},
|
|
215
|
+
"voltage": [
|
|
216
|
+
{ "confidence": 1.0, "models": ["deepseek-v4-flash:cloud", "gemma4:31b-cloud"],
|
|
217
|
+
"qualifier": "Maximum", "value": 60.0, "value_unit": "V AC",
|
|
218
|
+
"metadata": { "section_type": "text", "specified": true } }
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pip install -e ".[dev]"
|
|
228
|
+
pytest
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "structured-data-extraction"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Pipeline for extracting structured data (tables, sections, key/value fields) from PDF documents using docling, embeddings, and LLMs."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -51,7 +51,7 @@ structured-extract-records = "structured_data_extraction.records:main"
|
|
|
51
51
|
structured-extract-filter = "structured_data_extraction.filter_records:main"
|
|
52
52
|
|
|
53
53
|
[project.urls]
|
|
54
|
-
Repository = "https://
|
|
54
|
+
Repository = "https://github.com/dimitrisavellas/structured-extraction.git"
|
|
55
55
|
|
|
56
56
|
[tool.setuptools.packages.find]
|
|
57
57
|
where = ["src"]
|
|
@@ -42,6 +42,14 @@ EMBEDDING_DEVICE = os.environ.get("EMBEDDING_DEVICE") or _auto_device()
|
|
|
42
42
|
EXTRACTION_MODELS = [m.strip() for m in os.environ["EXTRACTION_MODELS"].split(",") if m.strip()]
|
|
43
43
|
assert EXTRACTION_MODELS, "EXTRACTION_MODELS in .env must list at least one model"
|
|
44
44
|
|
|
45
|
+
# Optional thinking control for schema/extraction calls (e.g. "none" stops local thinking models
|
|
46
|
+
# from looping). Unset sends nothing, so providers that reject the field are unaffected.
|
|
47
|
+
LLM_EXTRA = {"reasoning_effort": v} if (v := os.environ.get("REASONING_EFFORT")) else {}
|
|
48
|
+
|
|
49
|
+
# Seconds per LLM request; 600 is the openai client default. Raise it for slow local models
|
|
50
|
+
# whose full extraction (tens of records) takes longer than 10 minutes to generate.
|
|
51
|
+
LLM_TIMEOUT = float(os.environ.get("LLM_TIMEOUT", "600"))
|
|
52
|
+
|
|
45
53
|
# When True (set by main.py --force), every cached pipeline step recomputes instead of skipping.
|
|
46
54
|
FORCE = False
|
|
47
55
|
|
|
@@ -60,3 +68,6 @@ SEARCH_TOP_K = int(os.environ.get("SEARCH_TOP_K", 25))
|
|
|
60
68
|
VISION_COMPLEXITY_THRESHOLD = float(os.environ.get("VISION_COMPLEXITY_THRESHOLD", 0.15))
|
|
61
69
|
VISION_PARALLELISM = int(os.environ.get("VISION_PARALLELISM", 4))
|
|
62
70
|
EXPAND_KEYS = _env_bool("EXPAND_KEYS", True)
|
|
71
|
+
# One extraction call per matched section instead of one call for all of them. Slower, but
|
|
72
|
+
# stops small local models from looping on rows repeated across tables.
|
|
73
|
+
EXTRACT_PER_SECTION = _env_bool("EXTRACT_PER_SECTION", False)
|
|
@@ -62,7 +62,8 @@ _raw = None
|
|
|
62
62
|
def _raw_client():
|
|
63
63
|
global _raw
|
|
64
64
|
if _raw is None:
|
|
65
|
-
_raw = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"]
|
|
65
|
+
_raw = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"],
|
|
66
|
+
timeout=config.LLM_TIMEOUT)
|
|
66
67
|
return _raw
|
|
67
68
|
|
|
68
69
|
|
|
@@ -195,6 +196,7 @@ def discover_schema(markdown_text, matched_sections, vocab, user_keys):
|
|
|
195
196
|
model=os.environ["SCHEMA_MODEL"],
|
|
196
197
|
messages=[{"role": "user", "content": prompt}],
|
|
197
198
|
temperature=0,
|
|
199
|
+
**config.LLM_EXTRA,
|
|
198
200
|
)
|
|
199
201
|
raw = strip_code_fence(resp.choices[0].message.content.strip())
|
|
200
202
|
|
|
@@ -213,7 +215,23 @@ def discover_schema(markdown_text, matched_sections, vocab, user_keys):
|
|
|
213
215
|
def extract_values(client, matched_sections, vocab, user_keys, schema,
|
|
214
216
|
document_type="unknown", source_file="", model=None):
|
|
215
217
|
"""Run one extraction LLM through Instructor and return {field: [record dicts]}.
|
|
216
|
-
Retries up to MAX_LLM_RETRIES; returns {} if every attempt fails.
|
|
218
|
+
Retries up to MAX_LLM_RETRIES; returns {} if every attempt fails.
|
|
219
|
+
With EXTRACT_PER_SECTION, makes one call per section and concatenates the records."""
|
|
220
|
+
if config.EXTRACT_PER_SECTION and len(matched_sections) > 1:
|
|
221
|
+
# small local models loop when several tables repeat the same rows in one prompt
|
|
222
|
+
merged, failed = {}, 0
|
|
223
|
+
for section in matched_sections:
|
|
224
|
+
part = extract_values(client, [section], vocab, user_keys, schema,
|
|
225
|
+
document_type=document_type, source_file=source_file,
|
|
226
|
+
model=model)
|
|
227
|
+
failed += not part
|
|
228
|
+
for field, records in part.items():
|
|
229
|
+
merged.setdefault(field, []).extend(records)
|
|
230
|
+
if failed:
|
|
231
|
+
# the merged result is still cached, so say which run is incomplete
|
|
232
|
+
logger.warning(f"[{model}] {failed}/{len(matched_sections)} sections failed extraction")
|
|
233
|
+
return merged
|
|
234
|
+
|
|
217
235
|
model = model or config.EXTRACTION_MODELS[0]
|
|
218
236
|
template = jinja_env.get_template("extraction.j2")
|
|
219
237
|
|
|
@@ -242,6 +260,7 @@ def extract_values(client, matched_sections, vocab, user_keys, schema,
|
|
|
242
260
|
messages=[{"role": "user", "content": prompt}],
|
|
243
261
|
temperature=0.0,
|
|
244
262
|
response_model=extraction_model,
|
|
263
|
+
**config.LLM_EXTRA,
|
|
245
264
|
)
|
|
246
265
|
output = {}
|
|
247
266
|
for field in extraction_model.model_fields:
|
|
@@ -271,6 +271,16 @@ def _dump_templates(dest):
|
|
|
271
271
|
return dest
|
|
272
272
|
|
|
273
273
|
|
|
274
|
+
def use_local_profile() -> None:
|
|
275
|
+
"""Switch config to settings for small local models (main.py --local).
|
|
276
|
+
|
|
277
|
+
Explicit .env values for REASONING_EFFORT and a longer LLM_TIMEOUT are kept.
|
|
278
|
+
"""
|
|
279
|
+
config.EXTRACT_PER_SECTION = True # one prompt with many repeated tables makes 9B models loop
|
|
280
|
+
config.LLM_EXTRA = config.LLM_EXTRA or {"reasoning_effort": "none"} # thinking can run away
|
|
281
|
+
config.LLM_TIMEOUT = max(config.LLM_TIMEOUT, 1800.0) # ~11 tok/s can need > 10 min
|
|
282
|
+
|
|
283
|
+
|
|
274
284
|
def main():
|
|
275
285
|
parser = argparse.ArgumentParser(description="Document extraction pipeline")
|
|
276
286
|
parser.add_argument("path", nargs="?", help="Path to a PDF file or a directory of PDFs")
|
|
@@ -286,6 +296,9 @@ def main():
|
|
|
286
296
|
parser.add_argument("--dump-templates", metavar="DIR",
|
|
287
297
|
help="Write the built-in prompt templates to DIR for editing, then exit; "
|
|
288
298
|
"point TEMPLATES_DIR at that folder to use your edits")
|
|
299
|
+
parser.add_argument("--local", action="store_true",
|
|
300
|
+
help="Settings for small local models (e.g. 9B on Ollama): one "
|
|
301
|
+
"extraction call per section, thinking off, 30-minute timeout")
|
|
289
302
|
args = parser.parse_args()
|
|
290
303
|
|
|
291
304
|
if args.dump_templates:
|
|
@@ -296,6 +309,8 @@ def main():
|
|
|
296
309
|
parser.error("a PDF file or directory is required (unless using --dump-templates)")
|
|
297
310
|
|
|
298
311
|
config.FORCE = args.force
|
|
312
|
+
if args.local:
|
|
313
|
+
use_local_profile()
|
|
299
314
|
|
|
300
315
|
user_schema = None
|
|
301
316
|
if args.schema:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
|
-
name: structured_data_extraction-0.2.
|
|
2
|
+
name: structured_data_extraction-0.2.1/src/structured_data_extraction/models/section_classifier/rf_classifier.joblib
|
|
3
3
|
size: 89587009 bytes
|
|
4
4
|
sha256: ef2849cc3232e1756617f1f4d823e545cb010c070286bc1b0b612f72e95eab7e
|