docstore
dbt for unstructured data. Extract once, query forever.
Most tools re-read your documents every time you ask a question. docstore extracts the fields you care about once, caches them locally, and answers subsequent queries from the cache - no LLM calls, no re-reading, no waiting.
Run 1 (cold): 200 invoices → 400 LLM calls → ~$0.40
Run 2 (warm): 200 invoices → 0 LLM calls → $0.00
Query: "which invoices are unpaid?" → 0 LLM calls → <1s
Why this exists
LLMs made it easy to extract structured data from documents. What they did not provide is a layer that persists that extraction and invalidates it automatically when the source file changes. Every existing tool either:
- Re-reads raw documents on every query (expensive, slow)
- Requires a database or vector store (complex, overkill for most teams)
- Stores embeddings for semantic search (wrong abstraction for structured extraction)
docstore treats structured extraction as a cache over your unstructured data. Same insight as dbt applied to SQL - you define the transformation once, the system manages the state.
Benchmark
docstore ships with a reproducible public cache benchmark. It generates a
synthetic invoice corpus, writes ground_truth.jsonl, then measures:
cold_extract: empty cache, every document calls the LLM oncewarm_extract: same corpus and schema, every document is served from cachecached_query: filtered query against cached JSON — zero LLM calls, near-instant
# Synthetic invoices (auto-generated)
uv run python scripts/benchmark.py /tmp/docstore-benchmark --count 30
uv run python scripts/benchmark.py /tmp/docstore-benchmark --count 30 --filter "paid=false"
# Any existing folder of documents
uv run python scripts/benchmark.py ./my-documents \
--no-generate --schema my_schema --ask --glob "*.pdf" --filter "status=active"
Use --provider and --model to run it against a specific vendor. The
benchmark is intended to show cache behavior, not provider quality.
A contract-specific corpus generator is also included for demo purposes:
# Generate 30 synthetic vendor contract PDFs
uv run --extra scripts python scripts/generate_pdf_contracts.py ./sample_contracts
# Then benchmark against them
uv run python scripts/benchmark.py ./sample_contracts \
--no-generate --schema contracts --ask --glob "contract_*.pdf" --filter "status=active"
Installation
pip install lumient-docstore
All four LLM providers (Anthropic, OpenAI, Groq, Gemini) work out of the box - pick one at runtime via --provider.
Or from source:
git clone https://github.com/LumientAI/docstore
cd docstore
pip install -e ".[dev]"
Quickstart
Python API
from pathlib import Path
from docstore import DocStore, ExtractionSchema, create_llm_client
from docstore.agents.orchestrator import run_directory
class InvoiceSchema(ExtractionSchema):
vendor: str
amount: float
currency: str
due_date: str
paid: bool
invoices_dir = Path("./invoices")
# Co-locate the cache with the corpus so the CLI and Python API
# share state (the CLI's path-taking commands default to this).
store = DocStore(root=invoices_dir / ".docstore")
descriptor = InvoiceSchema.to_descriptor()
client = create_llm_client() # defaults to Anthropic; pass provider="openai" etc. to override
results = run_directory(invoices_dir, descriptor, store, client)
# Query without any LLM calls
unpaid = store.query("InvoiceSchema", lambda r: r.data.get("paid") is False)
CLI
# Generate a synthetic invoice corpus for testing (30 .txt files)
python scripts/generate_txt_invoices.py ./sample_invoices --count 30
# Extract - describe fields interactively
docstore shell ./invoices/
# Extract with a named schema
docstore extract ./invoices/ --schema invoice_schema
# Use OpenAI, Groq, or Gemini instead of the default Anthropic provider
docstore extract ./invoices/ --schema invoice_schema --provider openai
docstore extract ./invoices/ --schema invoice_schema --provider groq
docstore extract ./invoices/ --schema invoice_schema --provider gemini
# Override the default model for any provider
docstore extract ./invoices/ --schema invoice_schema --provider gemini --model gemini-2.5-pro
# Query stored results (no LLM)
docstore query invoice_schema --filter "is_paid=false" --store ./invoices/.docstore
# Aggregate: count and sum per vendor (no LLM)
docstore query invoice_schema --group-by vendor --count --sum amount --store ./invoices/.docstore
# Ask in natural language - one LLM call compiles to a filter,
# results come from cache with no per-document re-reads
docstore ask "which unpaid invoices are over $5000?" --schema invoice_schema --store ./invoices/.docstore
# Diff a changed file
docstore diff ./invoices/acme_april.pdf --schema invoice_schema
# Remove cache entries whose source file no longer exists
docstore sync --store ./invoices/.docstore # dry run
docstore sync --store ./invoices/.docstore --yes # delete stale entries
# Wipe the cache (optional --schema X to scope)
docstore clean --store ./invoices/.docstore --yes
# Stats
docstore stats --store ./invoices/.docstore
MCP server (Claude Desktop)
Add to your claude_desktop_config.json:
{
"mcpServers": {
"docstore": {
"command": "docstore-server",
"env": {
"DOCSTORE_DIR": "/path/to/your/.docstore",
"DOCSTORE_PROVIDER": "anthropic",
"ANTHROPIC_API_KEY": "your-key"
}
}
}
}
Claude can then call extract, query, diff, and stats directly.
Supported providers are anthropic (default), openai, groq, and gemini.
Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, or GEMINI_API_KEY
for the provider you choose. Each provider has a default model, and you can
override it with --model on the CLI or DOCSTORE_MODEL for the MCP server.
How it works
┌──────────────────────────────────────────────────────────────────┐
│ docstore pipeline │
│ │
│ document.pdf │
│ │ │
│ ▼ │
│ ┌─────────┐ cache hit? ──────────────────────────────┐ │
│ │ Parser │ ─────────────► .docstore/{key}.json │ │
│ └─────────┘ (no LLM) ──────────────────────────────┘ │
│ │ │
│ │ cache miss │
│ ▼ │
│ ┌───────────┐ │
│ │ Extractor │ 1 LLM call - extract fields against schema │
│ └───────────┘ │
│ │ │
│ │ (opt-in via --validate) │
│ ▼ │
│ ┌╌╌╌╌╌╌╌╌╌╌╌┐ │
│ ╎ Validator ╎ +1 LLM call - sanity-check extracted values │
│ └╌╌╌╌╌╌╌╌╌╌╌┘ │
│ │ │
│ ▼ │
│ .docstore/{file_hash}__{schema}__{version}.json │
└──────────────────────────────────────────────────────────────────┘
The validator is off by default - cold extraction is one LLM call per file. Pass --validate to add a plausibility check (doubles cost; see the CLI reference for trade-offs).
Cache key: sha256(file_bytes)[:16] + schema_name + sha256(json.dumps(fields, sort_keys=True))[:12]
The cache invalidates automatically when:
- The file content changes (file hash changes)
- The schema changes (schema version changes)
- A different schema is applied to the same file (different key)
Schema definition
Two ways to define a schema:
1. Python class (recommended for code)
from docstore import ExtractionSchema
class ContractSchema(ExtractionSchema):
parties: list
start_date: str
end_date: str
obligations: list
auto_renews: bool
2. Natural language via CLI (recommended for ad-hoc use)
docstore shell ./contracts/
# > vendor name, contract start date, expiry date, whether it auto-renews
The orchestrator normalises your description into a canonical schema and shows it to you before running.
Supported file types
PDF, DOCX, TXT, MD, CSV, HTML, JSON
PDF support covers documents with embedded/selectable text. Scanned or image-only PDFs need OCR, which docstore does not support yet.
Limitations vs Lumient
docstore is a single-document extraction cache. It does not:
- Compose records across documents (invoice + Stripe → reconciliation status)
- Trigger automatically when files arrive
- Maintain a queryable entity layer with lineage
- Support multi-step workflow logic (validate, diff, generate)
- Provide governance and audit trails for regulated industries
For cross-document composition and maintained operational records, see Lumient.
Development
# uv (recommended)
uv sync --all-extras
uv run pytest
uv run ruff check .
# Or with pip
pip install -e ".[dev]"
pytest tests/
See AGENTS.md for architectural invariants and CONTRIBUTING.md for the PR process.
License
MIT
No comments yet
Be the first to share your take.