llmground
Your schema validated. The value was still made up.
Structured output libraries guarantee the shape of extracted data. None of them check whether the values are actually in the document. llmground runs after extraction and tells you which fields are real.
from llmground import verify
report = verify(extracted, source_document)
if report.has_hallucinations:
route_to_human_review(report.ungrounded)
No API calls. No model. No cost. Zero dependencies.
The bug this catches
extracted = {
"vendor": "ACME Manufacturing Ltd.",
"total": 2846.25, # the invoice says 2,486.25
}
Right types. Passes any JSON schema. Passes Pydantic. The number is a transposition the model invented, and it goes straight into your accounting system. Shape validation cannot see this, because there is nothing wrong with the shape.
GROUNDED vendor = 'ACME Manufacturing Ltd.'
found: ACME Manufacturing Ltd. 14 Sundar Industrial Estate...
UNGROUNDED total = 2846.25
nearest number in source is '$2,486.25'
6/8 fields grounded (75%), 0 weak, 2 ungrounded
Install
pip install llmground
It works with what you already use
llmground doesn't replace Instructor, Outlines, or PydanticAI. It runs after them. Pydantic models, dataclasses, and plain dicts all work, and Pydantic is optional rather than required.
import instructor
from llmground import verify
invoice = client.chat.completions.create(response_model=Invoice, ...)
report = verify(invoice, source_pdf_text)
if report.grounding_rate < 0.9:
escalate(report)
Why matching values is harder than it looks
The naive version of this check is str(value) in source, and it produces so
many false alarms that people switch it off within a day. Real documents don't
write values the way models return them.
| Model returns | Document says | Naive check | llmground |
|---|---|---|---|
4500.0 |
$4,500.00 |
miss | matched |
"2026-01-05" |
5 January 2026 |
miss | matched |
"O'Brien Ltd" |
O'Brien Ltd (smart quote) |
miss | matched |
1234.56 |
1.234,56 (European) |
miss | matched |
-1234.00 |
(1,234.00) (accounting) |
miss | matched |
"ACME Inc" |
ACME, Inc. |
miss | matched |
Every one of these is handled deterministically, with no model in the loop.
Verdicts
| Verdict | Meaning |
|---|---|
grounded |
Found in the source |
weak |
Found only by fuzzy match. A human should look. |
ungrounded |
Not in the source. Likely fabricated. |
skipped |
Not verifiable: booleans, nulls, values like "1" or "yes" |
Fuzzy matches are weak, not grounded, by default. A 90%-similar string
is a different string, and on a financial or medical field that difference is
the entire problem. Opt in with accept_fuzzy=True when you're handling OCR
noise and know what you're trading away.
Skipped fields are excluded from the grounding rate. Counting booleans as grounded would inflate the score and hide real problems.
Spans, for human review
Every match reports character offsets into the source, so you can highlight the evidence rather than just flagging a field.
for r in report.grounded:
span = r.match.spans[0]
highlight(source, span.start, span.end)
That's the difference between telling a reviewer "this field is suspicious" and showing them why.
CLI
llmground extracted.json --source invoice.txt --fail-under 0.9
Exits non-zero below the threshold, so it drops into CI as a quality gate.
Options
verify(
extracted,
source,
accept_fuzzy=False, # count fuzzy matches as grounded
fuzzy_threshold=0.85, # similarity floor for reporting a fuzzy match
number_tolerance=0.01, # allowed numeric difference
day_first=True, # how to read 03/04/2026
ignore=["metadata.run_id"], # skip computed fields
)
Limitations
- Extractive only. It verifies values that should appear in the source. A
field that's legitimately inferred rather than copied (a category label, a
computed total) will read as ungrounded. Use
ignorefor those. - Ambiguous dates are not guessed.
03/04/2026returns no match unless you passday_first, because a silently wrong date is worse than an unverified one. - Grounded is not correct. It confirms a value appears in the document, not that the model pulled it from the right place. A total that matches a different line item's amount will pass.
- Text sources only. PDFs and images need to be through OCR first.
Development
pip install -e ".[dev]"
pytest
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file llmground-0.1.0.tar.gz.
File metadata
- Download URL: llmground-0.1.0.tar.gz
- Upload date:
- Size: 21.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
17149cae253ede3f0c3dbc8e09532beeea7bab804ef999202072a250f971d8e8
|
|
| MD5 |
f0c665245e6a823d4292fb9caa713bb7
|
|
| BLAKE2b-256 |
5a089609cf24ea02d8e5fc51c6e357fc011520d471df305a39b929fcc27147f5
|
File details
Details for the file llmground-0.1.0-py3-none-any.whl.
File metadata
- Download URL: llmground-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cd1f335a00e8a3de5eee6e1452cac54660cbc2792f98a7d94337e594050f1646
|
|
| MD5 |
24a9c9aa055db0ef01694c241a550cb0
|
|
| BLAKE2b-256 |
8313a2d3a96eb5a1f2df671ff1aad3f53432fd5dd756869573a00ff2165ffecb
|