This is a short tour of the whole package, for readers who already
know the ideas. If you are new to language models, start with
vignette("readgpt"), which explains them;
vignette("ingest") and vignette("readers")
cover two of the decisions below in depth.
Asking a language model a question about a document involves three decisions, and they are usually made for you, invisibly, all at once. This package makes them separate:
- ingest: get text out of the file, and decide what to throw away
- segment: cut that text into pieces small enough to send
- read: decide how the model works through those pieces to an answer
Each is a registry you can list, swap and extend. Any ingest × any segmenter × any reader composes.
Running this vignette without an API key
Everything below runs offline. Two settings make that work, and they are also how you develop against this package without spending anything.
cl <- gr_mock_client(function(messages, params) "Revenue was 45.2 million dollars.")
old <- gr_options(verbose = FALSE, embedder = "lexical")gr_mock_client() is a client whose handler is an
ordinary R function. It goes everywhere a real client goes and records
every prompt it was sent, which is what lets you check that a reading
strategy is doing what you think before you pay for it.
embedder = "lexical" selects hashed bag-of-words vectors
instead of an embeddings endpoint. They measure word overlap rather than
meaning, so they are a poor substitute in production, but they are free,
offline, and deterministic, which is what makes this document
reproducible. gr_embedders() lists what is registered.
A first run
answer_document() binds one ingest, one segmenter and
one reader into a pipeline and runs it. The bundled example is a short
annual report.
ans <- answer_document(readgpt_example(), "What was revenue?", "thorough",
client = cl)
ans$answer
#> [1] "Revenue was 45.2 million dollars.""thorough" names a recipe. gr_recipes()
returns the built-ins, and each is just a binding of the three axes:
do.call(rbind, lapply(names(gr_recipes()), function(n) {
r <- gr_recipes(n)
data.frame(recipe = n, segment = r$segment$method, reader = r$read$reader)
}))
#> recipe segment reader
#> 1 fast paragraph stuff
#> 2 precise sentence skim
#> 3 needle semantic retrieve
#> 4 thorough paragraph map_reduce
#> 5 survey structural hierarchical
#> 6 narrative paragraph refine
#> 7 scanned page rerank
#> 8 research structural iterative
#> 9 consensus recursive ensemble
#> 10 legacy paragraph map_reducegr_recipes("thorough") prints one in full, showing every
setting on all three axes. A recipe is only a starting point: any of its
settings can be overridden in the call, and gr_recipe()
builds one from scratch.
Reading the answer
The answer is not a string. It is an object that says how it was
arrived at, and the first thing to look at is never
$answer:
ans$partial
#> [1] FALSEpartial is TRUE whenever anything degraded:
a call failed, chunks were dropped for space, a budget stopped the run
early, or an embedding fell back to lexical vectors. Check it
before trusting the answer. When it is TRUE,
$notes says what happened.
names(ans$notes)
#> [1] "chunks" "answered" "failed_calls" "merge_levels" "merge_ok"$evidence is what the answer rests on. What
text holds depends on the reader: verbatim chunk text for
stuff, retrieve, rerank and
iterative; model-extracted passages for skim;
per-chunk model answers for map_reduce.
ans$evidence[, c("chunk_id", "page", "section")]
#> chunk_id page section
#> 1 1 NA NAAnd $trace records every prompt, response and token
count from that one run, not from a second run made to explain the
first.
gr_trace_summary(ans$trace)[, c("calls", "cached", "tokens_in", "tokens_out")]
#> calls cached tokens_in tokens_out
#> 1 1 0 595 13Did the model quote the document, or invent the quote?
For most readers $evidence is verbatim chunk text and is
true by construction: the package put it there. For skim it
is not. skim asks the model to extract the passages that
bear on the question, and what comes back is whatever the model chose to
write, presented as a quotation, with nothing checking that it was
one.
That is the worst gap to leave. A fabricated citation is more convincing than a fabricated answer, because it looks like the thing that would let you check.
doc <- "Revenue rose to 45.2 million dollars.\n\nHeadcount grew to 1,204."
ch <- gr_segment(gr_ingest(doc), list(method = "paragraph", max_tokens = 40))
extracting <- function(quote) gr_mock_client(function(messages, params) {
if (grepl("You extract evidence", messages[[1]]$content, fixed = TRUE)) return(quote)
"Revenue was 45.2 million dollars."
})
faithful <- gr_read(ch, "What was revenue?",
extracting("Revenue rose to 45.2 million dollars."), "skim")
gr_verify_evidence(faithful)[, c("kind", "verified", "match")]
#> kind verified match
#> 1 extracted TRUE 1Now a model that invents one. The span is fluent, on topic, attributed to a real chunk, and not in the document. You cannot catch this case by eye:
invented <- gr_read(ch, "What was revenue?",
extracting("Revenue rose to 88.9 billion dollars on record demand."),
"skim")
gr_verify_evidence(invented)[, c("kind", "verified", "match")]
#> kind verified match
#> 1 extracted FALSE 0.333
invented$partial
#> [1] TRUE
invented$notes$unverified_evidence
#> [1] 1The comparison is forgiving about typography and unforgiving about
content. Whitespace, curly quotes, dashes, case and the punctuation a
model wraps a quote in are all folded away, because none of that is
fabrication and flagging it would make partial stop meaning
anything. A changed number is not folded away. Below an exact match,
match is the fraction of the span carried by its longest
consecutive run in the source. It uses a run rather than word overlap,
because overlap cannot tell a quotation from a paraphrase built from the
same words.
Citations get the same treatment, for every reader. An answer citing
a chunk that was never sent to it sets notes$cited_unknown
and is partial.
Axis 2: segmentation is free to experiment with
Chunking decides what the model can possibly see together. It involves no model calls at all, so you can compare strategies for nothing before spending anything on reading:
doc <- gr_ingest(readgpt_example())
do.call(rbind, lapply(c("fixed", "paragraph", "sentence", "structural"), function(m)
gr_chunk_stats(gr_segment(doc, list(method = m, max_tokens = 120)))))
#> method n total_tokens min median mean max over_cap
#> 1 fixed 5 528 49 120.0 105.6 120 0
#> 2 paragraph 6 532 47 90.0 88.7 116 0
#> 3 sentence 6 532 47 92.5 88.7 106 0
#> 4 structural 8 562 31 75.0 70.2 101 0over_cap is the column to watch: a chunk over the cap is
one the reader will have to truncate or drop.
gr_segmenters() lists all nine, with which of them need a
client (the semantic one embeds).
Overlap matters too. Without it, an answer straddling a boundary is lost by both chunks:
do.call(rbind, lapply(c(0, 40), function(ov)
gr_chunk_stats(gr_segment(doc, list(method = "sentence", max_tokens = 120,
overlap_tokens = ov)))))
#> method n total_tokens min median mean max over_cap
#> 1 sentence 6 532 47 92.5 88.7 106 0
#> 2 sentence 8 752 79 97.0 94.0 106 0Axis 3: each reader works differently
Twelve strategies, each declaring a traversal signature: how it selects chunks, how many calls it makes, and what state it carries between them:
gr_readers()[, c("name", "signature", "cost_calls")]
#> name signature cost_calls
#> 1 ensemble ensemble|sum+1|none sum of members + 1
#> 2 extract all|N+conflicts|none N + one per disagreeing field
#> 3 hierarchical all|N+tree+1|tree N + fan-in levels + 1
#> 4 iterative topk|rounds*2|forward up to 2 x max_rounds
#> 5 map_reduce all|N+logN|tree N + merges
#> 6 preview planned|1+s+1|none 1 + skimmed sections + 1
#> 7 refine all|N|forward N
#> 8 rerank topk|m+1|none m + 1
#> 9 retrieve topk|1|none 1 + embeddings
#> 10 screen head|1|none 1
#> 11 skim all|N+1|none N + 1
#> 12 stuff all|1|none 1The built-in readers all have different signatures, and
ensemble refuses members that share one. Two recipes that
resolve to the same ingestion, the same segmentation and the same read
settings are the same work, and gr_compare() refuses to
bill you twice for them.
cmp <- gr_compare(readgpt_example(), "What was revenue?",
c("fast", "precise", "thorough"), client = cl)
cmp$summary[, c("recipe", "segmenter", "chunks", "reader", "chunks_used", "not_found")]
#> recipe segmenter chunks reader chunks_used not_found
#> 1 fast paragraph 1 stuff 1 FALSE
#> 2 precise sentence 2 skim 2 FALSE
#> 3 thorough paragraph 1 map_reduce 1 FALSEExtraction is shared across recipes and segmentation is shared between recipes whose segment specs match, so comparing three readers over one chunking costs one chunking. One trace covers the whole comparison:
gr_trace_summary(cmp$trace)[, c("calls", "cached", "tokens_in")]
#> calls cached tokens_in
#> 1 5 0 2024not_found distinguishes “the document does not say” from
a failure. So does is_not_found() on any answer. A model
that invents an answer is a worse outcome than one that admits the
document is silent, and the two must not look alike.
Choosing which chunks, and where to put them
Top-k by similarity answers “which chunks are most like the question”, which is not quite the question you wanted answered. If three paragraphs say the same thing, all three score highly and all three go in the prompt.
redundant <- paste(c(
"Revenue was 45.2 million dollars in fiscal 2024.",
"Total revenue reached 45.2 million dollars in the 2024 fiscal year.",
"In fiscal 2024 the company recorded revenue of 45.2 million dollars.",
"Headcount grew to 1,204 employees across nine clinical sites.",
"The board approved a dividend of 0.42 dollars per share in March."),
collapse = "\n\n")
ch <- gr_segment(gr_ingest(redundant), list(method = "paragraph", max_tokens = 40))
picked <- function(m) {
gr_read(ch, "What was revenue?", cl,
list(reader = "retrieve", top_k = 3, mmr = m))$chunks_used
}
rbind("mmr = 1 (top-k)" = picked(1), "mmr = 0.3" = picked(0.3))
#> [,1] [,2] [,3]
#> mmr = 1 (top-k) 1 3 2
#> mmr = 0.3 1 4 5mmr below 1 trades relevance against redundancy, so the
second and third slots go to chunks that add something. It costs
nothing, since the vectors are already computed.
context_order is a separate decision about
placement: transformers attend better to the beginning and end
of a long context than to its middle, so "edges" puts the
strongest chunk first and the second-strongest last. It never changes
which chunks were selected, only where they sit.
Rails, before you spend anything
Two caps are on by default. Both are checked before the reading step makes its first request and again before every request after it, and the cost cap is checked against what the run has spent so far. A parallel read that sends batches, which cannot be stopped part way, is held to its worst case before it starts:
unlist(gr_options()[c("max_cost_usd", "max_calls")])
#> max_cost_usd max_calls
#> 5 400You can also ask what a run would cost before making it:
gr_estimate_cost("gpt-4o", input_tokens = 120000, output_tokens = 4000)
#> [1] 0.34And what one did cost afterwards, counting only the calls issued, which is not the same as the tokens it moved:
gr_trace_cost(ans$trace)[, c("model", "calls", "paid_calls", "usd")]
#> model calls paid_calls usd
#> 1 gpt-5.6-terra 1 1 0.001346Making a re-run free, and a result checkable
Every stage of this package except the model call is a pure function of its input. The model call is the only step that costs money, the only one that can die halfway through a long run, and above a temperature of zero the only one that does not return the same thing twice. Two features follow from that.
A cache stores each successful response against the exact request, so a repeat is free and identical:
cache <- gr_cache(dir = file.path(tempdir(), "readgpt-vignette-cache"))
cached_cl <- gr_cache_client(cl, cache)
first <- answer_document(readgpt_example(), "What was revenue?", "fast",
client = cached_cl)
second <- answer_document(readgpt_example(), "What was revenue?", "fast",
client = cached_cl)
gr_trace_summary(second$trace)[, c("calls", "cached")]
#> calls cached
#> 1 1 1calls - cached is what a run paid for. Failures are
never cached: a rate limit is a property of the moment, and storing one
would make a blip permanent.
A replay goes further. A trace already holds every prompt and every response, so with the document it is enough to reproduce the run exactly, with no key, no network and no spend:
f <- file.path(tempdir(), "run.json")
gr_trace_save(first$trace, f)
replayed <- answer_document(readgpt_example(), "What was revenue?", "fast",
client = gr_replay_client(f))
identical(replayed$answer, first$answer)
#> [1] TRUEThat is the difference between a result someone has to trust and one
they can check. A prompt with no recorded response raises
gr_replay_miss rather than inventing an answer, because a
result that looks like the original and is not is worse than no replay
at all.
Embeddings are not model calls and are not in the trace, so a replay
reproduces chunk ranking only when the recording used a
deterministic embedder and the replay uses the same one. That is why
this vignette set embedder = "lexical" at the top.
Many documents
gr_compare() runs several recipes over one document.
gr_read_many() runs one recipe over many, and gives you a
row per document:
folder <- file.path(tempdir(), "reports")
dir.create(folder, showWarnings = FALSE)
writeLines("Revenue was 45.2 million dollars in fiscal 2024.",
file.path(folder, "north.txt"))
writeLines("Revenue was 51.8 million dollars in fiscal 2025.",
file.path(folder, "south.txt"))
out <- gr_read_many(folder, "What was revenue?", "fast", client = cl)
out$summary[, c("document", "not_found", "chunks_used", "calls", "status")]
#> document not_found chunks_used calls status
#> 1 north.txt FALSE 1 1 ok
#> 2 south.txt FALSE 1 1 okOne unreadable file is one "failed" row, not a dead run.
Each document gets its own budget, so one enormous file cannot starve
the rest. The run itself therefore needs its own ceiling, and there are
two: max_total_calls, checked before each document, and
max_total_usd, checked after one. store =
makes the run resumable.
Passing the same trace = to gr_screen(),
gr_extract() and gr_synthesise() accumulates
the whole review in one place. It is a parent: each stage still
keeps its own trace, which is what $trace returns and what
gr_options(max_calls =) is measured against, so the
screening cannot spend the write-up’s ceiling and the audit’s per-stage
costs still add up to the total.
From a folder to a review
The three axes answer a question. A corpus job usually wants a table, and a review wants a table plus the account of it. Four functions cover that, and they compose in one direction:
gr_protocol() → gr_screen() →
gr_extract() → gr_synthesise()
A protocol is what you fix before reading anything: which documents count, what to collect from the ones that do, and what the write-up has to cover. Fixing it first is the point: a criterion invented while reading is a criterion fitted to what was found.
protocol <- gr_protocol(
"revenue-review",
question = "How did revenue change across the regional reports?",
include = "Reports a revenue figure",
exclude = "Is a forecast rather than a result",
fields = gr_fields(
region = "The region the report covers",
revenue = gr_field("Revenue in millions of dollars", type = "number"),
year = gr_field("Fiscal year reported", type = "integer")
),
outline = c("Findings" = "How revenue compares across regions")
)
protocol
#> <gr_protocol 'revenue-review'>
#> question : How did revenue change across the regional reports?
#> include : Reports a revenue figure
#> exclude : Is a forecast rather than a result
#> fields : region, revenue, year
#> outline : Findings
#> recipe : researchgr_protocols() lists four templates to start from, and
gr_protocol_save()/gr_protocol_read()
round-trip one through a JSON file so it can be shared and cited
alongside the results.
Screening is one call per document. Every document gets a decision
and a reason, and nothing is dropped on the way. A file that could not
be read has no decision rather than a silent exclusion, and
"unclear" is an answer rather than a forced guess.
screener <- gr_mock_client(function(messages, params) {
seen <- paste(vapply(messages, function(m) as.character(m$content), character(1)),
collapse = " ")
line <- regmatches(seen, regexpr("Revenue was [0-9.]+ million dollars in fiscal [0-9]+\\.", seen))
sprintf('{"decision":"include","reason":"Reports a revenue figure.",
"criterion":"Reports a revenue figure","quote":"%s"}', line)
})
screened <- gr_screen(folder, protocol, client = screener)
screened$table[, c("document", "decision", "criterion", "verified")]
#> document decision criterion verified
#> 1 north.txt include Reports a revenue figure TRUE
#> 2 south.txt include Reports a revenue figure TRUEExtraction fills the schema from every chunk of each included document, then reconciles. The result is one typed row per document, and every filled cell carries the sentence it came from, checked against the chunk it was attributed to.
extractor <- gr_mock_client(function(messages, params) {
seen <- paste(vapply(messages, function(m) as.character(m$content), character(1)),
collapse = " ")
n <- if (grepl("45.2", seen, fixed = TRUE)) "45.2" else "51.8"
yr <- if (grepl("2024", seen, fixed = TRUE)) 2024 else 2025
rg <- if (grepl("45.2", seen, fixed = TRUE)) "north" else "south"
line <- sprintf("Revenue was %s million dollars in fiscal %d.", n, yr)
sprintf('{"region":"%s","revenue":%s,"year":%d,
"region__quote":"%s","revenue__quote":"%s","year__quote":"%s"}',
rg, n, yr, line, line, line)
})
table <- gr_extract(screened, protocol, client = extractor, recipe = "fast")
table$table[, c("document", "region", "revenue", "year", "n_unverified")]
#> document region revenue year n_unverified
#> 1 north.txt north 45.2 2024 0
#> 2 south.txt south 51.8 2025 0n_unverified is the column to look at before believing a
row: zero means every value in it can be pointed at in the document.
table$evidence is the long form: one row per supported
cell, with the quote, the page it is on, and whether the quote appears
there.
table$evidence[, c("document_id", "field", "quote", "verified")]
#> document_id field quote
#> 1 f0651bef6287278d region Revenue was 45.2 million dollars in fiscal 2024.
#> 2 f0651bef6287278d revenue Revenue was 45.2 million dollars in fiscal 2024.
#> 3 f0651bef6287278d year Revenue was 45.2 million dollars in fiscal 2024.
#> 4 e472ebfef45d9a3c region Revenue was 51.8 million dollars in fiscal 2025.
#> 5 e472ebfef45d9a3c revenue Revenue was 51.8 million dollars in fiscal 2025.
#> 6 e472ebfef45d9a3c year Revenue was 51.8 million dollars in fiscal 2025.
#> verified
#> 1 TRUE
#> 2 TRUE
#> 3 TRUE
#> 4 TRUE
#> 5 TRUE
#> 6 TRUEFinally the write-up, one call per section of the outline, citing the rows it rests on:
writer <- gr_mock_client(function(messages, params) {
"Revenue was higher in the southern region [study 2] than the northern [study 1]."
})
review <- gr_synthesise(table, protocol, client = writer)
review$citations
#> section study document document_id
#> 1 Findings 2 south.txt e472ebfef45d9a3c
#> 2 Findings 1 north.txt f0651bef6287278d
cat(review$text)
#> ## Findings
#>
#> Revenue was higher in the southern region [study 2] than the northern [study 1].
#>
#> ## References
#>
#> 1. (2024).
#> 2. (2025).Writing from claims instead of from rows
That write-up was drafted from the table, one section at a time, and the prose shows it: the model walks the rows. The structure shows it too: the outline was fixed before anything was read, so the shape of the review is a hypothesis rather than a finding.
gr_claims() computes the relations between studies
first. Each claim names what supports it, what contradicts it, and which
field distinguishes the two:
claim_writer <- gr_mock_client(function(messages, params) {
sys <- messages[[1]]$content
if (grepl("turn a table of studies", sys, fixed = TRUE)) {
return(paste0('{"claims":[{"claim":"Revenue is higher in the south than the north.",',
'"kind":"finding","supported_by":[2],"contradicted_by":[1],',
'"moderator":"region","scope":"one report per region"},',
'{"claim":"The northern region reported for fiscal 2024.",',
'"kind":"finding","supported_by":[1],"contradicted_by":[],',
'"moderator":null,"scope":"one report"}]}'))
}
if (grepl("sections of a review", sys, fixed = TRUE)) {
return(paste0('{"sections":[{"heading":"Where revenue is higher","brief":"the ',
'regional comparison","claims":[1,2],"rationale":"one comparison"}]}'))
}
"Revenue was higher in the south [study 2] than in the north [study 1]."
})
cm <- gr_claims(table, question = protocol$question, client = claim_writer)
cm$claims[, c("claim", "moderator", "n_support", "n_contradict")]
#> claim moderator n_support
#> 1 Revenue is higher in the south than the north. region 1
#> 2 The northern region reported for fiscal 2024. <NA> 1
#> n_contradict
#> 1 1
#> 2 0Every study number in that table was checked against the extraction
before it got there. A number that is not in the table is dropped, a
claim left with no supporting study is dropped entirely, and a
moderator naming a column that does not exist is cleared.
cm$dropped says what went and why, so a thin claims table
can be told apart from a thin literature.
gr_outline() then derives the sections from the claims,
and gr_gaps() computes what the corpus does not contain. It
makes no model call, so the gap list is something you can check by
counting:
outline <- gr_outline(cm, client = claim_writer)
names(outline)
#> [1] "Where revenue is higher" "What is missing"
gr_gaps(cm, extraction = table)[, c("kind", "dimension", "detail")]
#> kind dimension detail
#> 1 unreplicated claim 2 The northern region reported for fiscal 2024.
from_claims <- gr_synthesise(table, outline = outline, question = protocol$question,
client = claim_writer, claims = cm,
gaps = gr_gaps(cm, extraction = table))
from_claims$sections[, c("section", "n_claims", "claims_missed", "partial")]
#> section n_claims claims_missed partial
#> 1 Where revenue is higher 2 0 FALSE
#> 2 What is missing 0 0 FALSEEach section now argues its own claims and is shown only the studies
those claims rest on. claims_missed is the citation check
run backwards: a section handed a claim and not writing it up did not do
what the outline promised, and says so.
gr_audit_report() writes the whole chain out as one
self-contained HTML file for somebody who did not run it:
gr_audit_report("audit.html", screening = screened, extraction = table,
synthesis = review, protocol = protocol)Every [study n] is parsed back out and checked against
the rows that exist; one pointing at a row that is not there is reported
and marks the section partial. That completes the chain: a sentence
cites a study, the study’s row cites a quote, and the quote was checked
against the page it is attributed to. None of it proves the sentence is
true. It makes every step of the way back to the document short enough
to walk.
Using a real model
Everything above used a mock. In production you need a client:
# An OpenAI-compatible endpoint:
cl <- gr_client(model = "gpt-4o") # key from OPENAI_API_KEY
# Or any provider ellmer speaks to, such as Anthropic, Google, Bedrock or Ollama:
cl <- gr_ellmer_client(ellmer::chat_anthropic())
# Or anything at all:
cl <- gr_backend_client(function(messages, params) my_provider(messages))Behind a company gateway, base_url is usually not enough
on its own. It is when the gateway speaks the OpenAI shape and takes
Authorization: Bearer; most do not. Azure OpenAI
authenticates with api-key, API Management adds a
subscription key, and many require a cost-centre or correlation id.
headers covers those. Naming any header makes the API key
optional, so a gateway with its own scheme needs no
OPENAI_API_KEY set at all:
cl <- gr_client(
base_url = "https://gateway.example.com/openai/v1", api = "chat",
headers = c("api-key" = Sys.getenv("GATEWAY_KEY"),
"X-Cost-Centre" = "1234",
# NA suppresses a header rather than sending it. Without this,
# an OPENAI_API_KEY left set for another client in the same
# session would be sent to the gateway as well.
Authorization = NA))gr_options(api_headers = ...) sets the same thing once
for every client, which is the form that belongs in a project’s
.Rprofile. Headers are excluded from the cache key, like
the API key: a rotating token or a per-request correlation id would
otherwise make every lookup a miss.
Without a key, a run stops before it reads the document, with an
error (gr_auth_error) that says how to set one. A client
that authenticates through headers needs no key.
Extending it
Every axis is a registry, and an addition behaves exactly like a
built-in. It gets the same token caps, provenance and reporting, and it
can be named in a recipe or put in an ensemble.
gr_register_segmenter("by_bullet", description = "one chunk per bullet",
fn = function(doc, spec, client, trace) {
units <- unlist(strsplit(doc$text, "\n(?=[-*])", perl = TRUE))
new_chunks(units, "by_bullet", spec)
})
"by_bullet" %in% gr_segmenters()$name
#> [1] TRUEgr_register_extractor(),
gr_register_cleaner(), gr_register_reader(),
gr_register_embedder() and gr_register_model()
work the same way. Each has a worked example in its help page.