Scan large document corpora for researcher-defined features using a local LLM.
docfeatures is a command-line tool that feeds text documents through a locally-hosted language model to identify specific features — clinical findings, metadata, document characteristics — and stores the results in MySQL for analysis. It's designed for medical/clinical research workflows where a researcher needs to triage thousands of documents before manual review.
- You define the features you're looking for in a YAML config file.
- docfeatures reads each document, sends it to a local LLM with a generated prompt, and parses the structured JSON response.
- Results are stored in MySQL, one row per feature per document.
- You query the results with SQL to find documents matching your criteria.
Large documents are automatically chunked at structural boundaries (HTML headers, markdown headers, paragraph breaks) and results are merged across chunks.
pip install -r requirements.txt
# Configure database credentials
cp env.example .env
# Edit .env with your MySQL host, user, password, database
# Create a database (one per corpus)
mysql -e "CREATE DATABASE my_corpus"
# Test on 10 documents
python docfeatures.py \
--config my_features.yaml \
--corpus /path/to/documents/ \
--run-name v1 \
--host http://your-llm-server:11434 \
--limit 10
# Check results, tweak config, iterate...
python docfeatures.py --config my_features.yaml --corpus /path/to/documents/ --run-name v2 --limit 10
# Happy with the output — full run
python docfeatures.py --config my_features.yaml --corpus /path/to/documents/ --run-name v2_finalDefine features in a YAML config file. Four types are supported:
Is this feature present? true / false.
Chunks merge with OR: if any chunk says true, the document is true.
lung_consolidation:
type: boolean
description: >
Consolidation or airspace opacity. Indicated by air bronchograms,
lobar or segmental opacification, or explicit mention of consolidation.Which category does this document fall into? Exactly one of the listed options.
Options are ordered weakest → strongest. Chunks merge by taking the strongest (rightmost) value.
malignancy_likelihood:
type: enum
options: [none, possible, probable, definite]
description: >
Overall suspicion for malignancy based on report language.Free text extraction — summaries, names, descriptions.
For chunked documents, set strategy to control merging:
first-chunk— use the first non-empty value (good for header metadata like author names)last-chunk— use the last non-empty value (default; good for summaries and conclusions)concatenate— join all non-empty values with spaces
Optional: max_length gives the LLM a character limit hint.
signing_radiologist:
type: text
strategy: first-chunk
description: >
Full name of the signing radiologist, or null if not found.
document_summary:
type: text
strategy: last-chunk
max_length: 200
description: >
One to two sentence summary of primary findings.Numeric extraction — counts, measurements. Null means "not applicable."
Chunks merge by taking the MAX non-null value.
Use with caution. LLMs are unreliable counters. Best used as triage approximations, not precise measurements. Consider using a boolean for detection, then reviewing matching documents manually.
enlarged_node_count:
type: integer
description: >
Number of enlarged lymph nodes, or null if not examined.After a broad scan, you can run a second pass that extracts additional features only from documents matching specific criteria. Add a filter section to the YAML config:
filter:
from_run: "lung_v3"
require:
lung_consolidation: true
malignancy_likelihood: [probable, definite]
exclude:
pneumothorax: true
features:
consolidation_location:
type: enum
options: [upper_lobe, middle_lobe, lower_lobe, multilobar, unspecified]
description: ...When a filter is present, --corpus is optional — the file list comes from the source run's completed documents. If --corpus is also specified, results are intersected.
Filter logic uses JOIN-based queries suitable for corpora with hundreds of millions of documents.
docfeatures communicates via the OpenAI-compatible /v1/chat/completions endpoint, which is supported by all major local LLM serving tools:
| Backend | Default Port | Notes |
|---|---|---|
| ollama | 11434 | Simplest setup. Set num_ctx for large documents. |
| vLLM | 8000 | Best for multi-user concurrency (continuous batching). |
| llama.cpp (llama-server) | 8080 | Lightweight, single-user. |
# ollama
python docfeatures.py --host http://server:11434 -m qwen3.5:35b ...
# vLLM
python docfeatures.py --host http://server:8000 -m Qwen/Qwen3.5-35B-A3B-GPTQ-Int4 ...
# llama-server
python docfeatures.py --host http://server:8080 ...The --host and --model flags can also be set in the YAML config under the llm section to avoid repeating them:
llm:
host: "http://my-server:11434"
model: "qwen3.5-35b-long"Model recommendations change rapidly. Some general guidance as of early 2026:
Disable reasoning/thinking mode. Models like Qwen3, DeepSeek-R1, and other "reasoning" models generate an internal chain-of-thought before answering. This can produce thousands of wasted tokens per document and occasionally degenerate into infinite loops. For structured extraction tasks, disable thinking at the server level or via prompt tags (e.g., /no_think for Qwen3). docfeatures benefits from fast, direct answers — not deliberation.
Instruction following matters more than model size. A well-tuned 8B model that reliably produces clean JSON is more useful than a 70B model that occasionally returns malformed output or ignores the schema. Test with --limit 10 and inspect the raw chunk_results table to verify output quality before committing to a large run.
LLMs are extractors, not oracles. When the model is pulling information from text you provide (feature extraction, classification, name extraction), accuracy is generally good. When the model must generate facts from memory (medical knowledge, dates, statistics), hallucination rates are high. Design your features to extract from the document, not to quiz the model.
usage: docfeatures.py [-h] [-c CONFIG] [--corpus CORPUS] [-r RUN_NAME]
[-n LIMIT] [--retry-errors] [--cooldown SECS]
[--host HOST] [-m MODEL]
[--list-runs] [--purge-run NAME]
Processing:
-c, --config YAML feature config file
--corpus Path to document directory or single file
-r, --run-name Name for this run (used for resume and comparison)
-n, --limit Stop after N documents
--retry-errors Re-process documents that errored previously
--cooldown SECS Pause between documents (thermal mitigation)
LLM overrides:
--host LLM server URL (overrides config file)
-m, --model Model name (overrides config file)
Management:
--list-runs Show all runs in the database
--purge-run NAME Delete a run and all its results
Runs resume automatically. Re-running with the same --run-name skips completed documents and picks up where it left off. Documents interrupted mid-processing are cleaned up and retried.
The intended workflow for iterating on feature definitions:
# Test
python docfeatures.py -c features.yaml --corpus /data/ -r v1 -n 10
# Tweak config...
python docfeatures.py -c features.yaml --corpus /data/ -r v2 -n 10
# Compare v1 and v2 in MySQL, tweak again...
python docfeatures.py -c features.yaml --corpus /data/ -r v3 -n 10
# Satisfied — full run
python docfeatures.py -c features.yaml --corpus /data/ -r v3_final
# Clean up test runs
python docfeatures.py --purge-run v1
python docfeatures.py --purge-run v2
python docfeatures.py --purge-run v3Ctrl+C stops gracefully after the current document, prints throughput statistics, and estimates time remaining for the rest of the corpus.
docfeatures handles LLM server instability:
- 503/502 (server restarting): Retries up to 12 times with 15-second pauses (~3 minutes). If the server comes back, processing continues seamlessly.
- Connection refused (server down): Halts the run immediately. Resume with the same
--run-nameonce the server is back. Change HALT_ON_CONN_FAILURE internally to False to treat as if 503/502. - Thermal throttling (compact hardware): Use
--cooldown 3to inject pauses between documents, reducing sustained heat load on devices like the NVIDIA DGX Spark.
docfeatures uses MySQL. Configure credentials via .env file (see .env.example). Use one database per document corpus.
Tables are created automatically on first run:
runs— run metadata, config YAML, model infodocuments— per-document status, file path, hash, processing timechunk_results— raw JSON from each LLM call (per-chunk)document_features— merged feature values (sparse: only positive/non-default values stored)
Only positive/non-default values are stored in document_features:
falsebooleans are omitted- The first (lowest) enum option is omitted
nulltext and integer values are omitted
A document with status='complete' and no feature rows means all features were negative/default. This dramatically reduces storage for large corpora where most features are absent from most documents.
pymysql
pyyaml
requests
python-dotenv
Python 3.8+.
MIT License
