Once a document is cut into chunks, something has to decide how the model works through them. Send everything at once? Ask each chunk separately and combine the answers? Find the few chunks most like the question and ignore the rest? These are reading strategies, which readgpt calls readers. They decide most of the cost of a run and much of the quality of its answer. The same chunks read two ways can take one request or dozens, and can find a fact or miss it.
This guide explains each reader in plain terms, shows how they differ
on the same document, and gives a way to choose. New to readgpt? Start
with vignette("readgpt").
Setting up
The examples use a stand-in for a real model, so they run for free. This one answers each kind of request the way a model would: it scores an excerpt when asked to score and quotes when asked to quote, so every reader can do its normal work. You do not need to follow its details; it plays the part of the model.
library(readgpt)
old <- gr_options(verbose = FALSE, embedder = "lexical")
stand_in <- function(messages, params) {
instructions <- messages[[1]]$content
everything <- paste(vapply(messages, function(m) m$content, character(1)),
collapse = "\n")
task <- if (is.null(params$schema_name)) "" else params$schema_name
if (task == "relevance") { # rerank: score one excerpt from 0 to 10
excerpt <- messages[[length(messages)]]$content
if (grepl("revenue", excerpt, ignore.case = TRUE)) {
return('{"score": 9, "reason": "States revenue."}')
}
return('{"score": 2, "reason": "About something else."}')
}
if (task == "iterative_step") { # iterative: can I answer yet?
return('{"can_answer": true, "answer": "Revenue was 45.2 million dollars.", "next_query": ""}')
}
if (task == "reading_plan") { # preview: which sections to read
return('{"sections": [{"id": 1, "treatment": "read", "reason": "States revenue."}]}')
}
if (grepl("You extract evidence", instructions, fixed = TRUE)) { # skim: quote
quote <- regmatches(everything,
regexpr("[^.\n]*revenue of [0-9.]+ million dollars[^.\n]*", everything))
return(if (length(quote)) quote else "NONE")
}
"Revenue was 45.2 million dollars."
}embedder = "lexical" makes the readers that search for
similar text use simple word matching instead of a paid embedding
service; more on that below.
Now the document, cut into chunks along its section headings:
chunks <- gr_segment(gr_ingest(readgpt_example()),
list(method = "structural", max_tokens = 120))
gr_chunk_stats(chunks)
#> method n total_tokens min median mean max over_cap
#> 1 structural 8 562 31 75 70.2 101 0Eight chunks. A real report would have hundreds, and the differences below would be correspondingly larger.
Listing the readers
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 1cost_calls is how many requests a reader makes, as a
formula in N, the number of chunks. A reader whose
count grows with N makes a hundred times as many requests on a document
a hundred times longer. But you pay for tokens, not requests, and a
reader that looks at every chunk sends every token: stuff
makes one request whatever the length, yet that request carries the
whole document, so its cost grows with the document too. Each request
also repeats the instructions and writes its own reply, so among readers
that see everything, more requests cost more. The readers that look at a
selection of chunks (the topk ones below) cost roughly the
same however long the document is.
signature describes how a reader works, in three parts
separated by |:
-
which chunks it looks at:
allof them; thetopkmost relevant; thehead(opening) of the document; the chunks aplannedreading chose; or, for anensemble, whatever its members looked at; - how many requests it makes, the same formula in short;
-
what carries over between requests:
none(each request stands alone),tree(answers are combined in stages), orforward(each request builds on the one before).
The built-in readers all have different signatures, and
ensemble refuses members that share one: two readers with
the same signature would be the same method under two names.
Every reader on one document
Here is the same question put to the same eight chunks by each
general-purpose reader. extract and screen are
left out: they are built for literature reviews and need a schema or
criteria.
read_with <- function(reader) {
model <- gr_mock_client(stand_in)
ans <- gr_read(chunks, "What was revenue in 2024?", model, reader)
data.frame(reader = reader, requests = length(model$calls()),
chunks_used = length(ans$chunks_used), partial = ans$partial)
}
readers <- c("stuff", "map_reduce", "refine", "skim", "hierarchical",
"retrieve", "rerank", "iterative", "preview", "ensemble")
do.call(rbind, lapply(readers, read_with))
#> reader requests chunks_used partial
#> 1 stuff 1 8 FALSE
#> 2 map_reduce 9 8 FALSE
#> 3 refine 8 8 FALSE
#> 4 skim 9 1 FALSE
#> 5 hierarchical 9 8 FALSE
#> 6 retrieve 1 6 FALSE
#> 7 rerank 9 2 FALSE
#> 8 iterative 1 6 FALSE
#> 9 preview 2 8 FALSE
#> 10 ensemble 11 8 FALSEThe stand-in gives every reader the same answer, and none of the runs
degraded (partial is FALSE). What differs is
the work: from one request to eleven for eight chunks, and from reading
everything to relying on a single passage. On a long document the gap
widens: the number of requests grows with the document for some readers
and not at all for others, and the cost grows for every reader that
reads the whole document.
The readers
stuff: everything in one request
Puts the whole document into a single prompt. It is the simplest
reader, and usually the cheapest way to have the model read all of a
document, with one set of instructions and one reply. Its cost still
grows with the document’s length. The model sees everything at once, so
it can connect facts from different places. The limit is the context
window: if the document does not fit, stuff sends what
fits, warns, and marks the answer partial
(on_overflow = "error" makes it stop instead). Very long
prompts also dilute attention; details in the middle of a long prompt
are more easily missed.
Use it for documents comfortably inside the model’s
context window. Recipe: "fast", which
answer_document() uses by default for a document of up to
50,000 tokens, or less on a model with a small context window.
map_reduce: every chunk separately, then combined
Asks the question of each chunk on its own (“map”), then combines the answers in stages (“reduce”). Nothing is skipped, each request is small, and the per-chunk requests can run in parallel. It costs N requests plus the combining steps, and a fact that only makes sense across two chunks can be missed, because no single request sees both.
Use it for questions where every mention matters:
“list every risk named in this report”. Recipe:
"thorough", which answer_document() uses by
default for a longer document.
refine: a draft revised chunk by chunk
Reads the chunks in order, keeping a running answer and revising it with each new chunk. Later text can correct earlier conclusions, which suits documents whose argument develops. It costs N requests that cannot run in parallel, and the running answer can drift.
Use it for narratives and arguments that build.
Recipe: "narrative".
skim: quote first, then answer
For each chunk, asks the model to copy out any passages that bear on
the question, then answers once from those passages alone. The final
answer rests on quotations, which readgpt checks against the text (see
below). It costs N + 1 requests; the per-chunk step can use a cheaper
model through skim_model.
Use it for questions where you want the answer tied
to exact wording. Recipe: "precise".
hierarchical: summaries of summaries
Summarises each chunk, then summarises groups of summaries
(fan_in at a time), level by level, until everything fits
in one prompt, and answers from that. It suits questions about a whole
long document, such as “what is this report’s overall argument?”, at the
price of detail lost in summarising. summary_model can use
a cheaper model for the summaries; max_levels caps the
depth.
Use it for long, structured documents where you want
an overview. Recipe: "survey".
retrieve: only the most relevant chunks
Turns the question and every chunk into embeddings (lists of
numbers that place similar text close together) and sends only the
top_k chunks closest to the question, in a single request.
The cost of the answer does not grow with the document, which makes it
the usual choice for finding one fact in something long. The risk: a
chunk that answers the question but does not look like it is
never sent.
Use it for a specific fact in a long document.
Recipe: "needle".
rerank: shortlist, then let the model judge
Makes a quick word-matching shortlist (rerank_candidates
chunks, 20 by default), asks the model to score each for relevance from
0 to 10, and answers from the chunks scoring at least
rerank_min_score. The model’s judgement is better than word
matching, at the price of one scoring request per shortlisted chunk.
skim_model can do the scoring more cheaply.
If no chunk could be scored, for example because the provider does not support the structured replies the scoring needs, it falls back to the word-matching order and warns. If only some chunks were scored, the answer is marked partial.
Use it for long documents where word matching alone
picks badly, including scanned ones. Recipe:
"scanned".
iterative: look, decide what is missing, look
again
Retrieves a few chunks and asks the model whether it can answer yet,
and if not, what it should look for next. Then it retrieves again, up to
max_rounds times. It suits questions needing several facts
from different places (“how did the drop in margin affect the
dividend?”). ans$notes$queries records what it searched for
in each round, and ans$notes$stop_reason why it
stopped.
Use it for multi-step questions.
Recipe: "research".
preview: plan, then read
Shows the model an outline of the document (section headings, sizes,
and short excerpts) and asks which sections to read in full, which to
skim, and which to skip. Then it reads accordingly. A section the plan
does not mention is read, never skipped, so silence cannot lose part of
the document; the plan is recorded in ans$notes$plan, so
“sections 3 and 5 were not read” is visible. If no usable plan comes
back, it reads everything and marks the answer partial.
Use it for long documents with clear sections, when most of them are irrelevant to the question.
ensemble: several readers, compared
Runs two or more different readers (members; by default
retrieve and map_reduce) and asks the model to
reconcile their answers. It costs the sum of its members plus one
request, and pays off when a wrong answer is expensive. Members must
have different signatures, because running the same method twice is not
a second opinion.
Use it for high-stakes questions.
Recipe: "consensus".
extract and screen
extract fills in a table of details (a schema)
from every chunk, and screen decides whether a document
meets a set of criteria. Both belong to the literature-review workflow,
shown in vignette("tour").
Choosing a reader
| your situation | reader | cost grows with document length? |
|---|---|---|
| the document fits comfortably in one request | stuff |
yes, though it is one request |
| one specific fact, long document | retrieve |
no (embeddings aside) |
| one fact, and word matching picks badly | rerank |
no, fixed by rerank_candidates
|
| every mention must be found | map_reduce |
yes |
| the answer must rest on exact quotations | skim |
yes |
| an overview of a long document | hierarchical |
yes |
| an argument that develops through the text | refine |
yes |
| several facts combined from different places | iterative |
no, up to max_rounds (embeddings aside) |
| long document, most sections irrelevant | preview |
depends on the plan |
| a wrong answer would be costly | ensemble |
as its members |
If unsure, run two or three on a document where you already know the answer and compare (see “Trying readers on your own documents” below).
Choosing and placing chunks
retrieve, rerank and iterative
choose which chunks the model sees. These settings control the choice
and where the chosen chunks go; each says which readers it applies
to.
top_k (retrieve,
rerank, iterative) is how many chunks to send,
6 by default. For iterative it is how many each round adds:
the chunks gathered in earlier rounds stay in the prompt as long as they
fit. Too few and the answer can be missed; too many and you pay for text
that does not help.
min_score (retrieve) drops
chunks less similar to the question than a threshold, though the single
best chunk is always kept.
mmr (retrieve,
iterative) stops near-duplicates crowding each other out.
Plain top-k picks the chunks most like the question; if three paragraphs
say the same thing, all three get in. Below 1, mmr balances
relevance against similarity to what is already picked (0.7 is a
reasonable start):
repetitive <- 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")
pieces <- gr_segment(gr_ingest(repetitive), list(method = "paragraph", max_tokens = 40))
model <- gr_mock_client(stand_in)
picked <- function(mmr) {
gr_read(pieces, "What was revenue?", model,
list(reader = "retrieve", top_k = 3, mmr = mmr))$chunks_used
}
rbind("top-k (mmr = 1)" = picked(1), "mmr = 0.3" = picked(0.3))
#> [,1] [,2] [,3]
#> top-k (mmr = 1) 1 3 2
#> mmr = 0.3 1 4 5Plain top-k spent all three places on the same fact;
mmr = 0.3 kept the best one and used the other two for
something different. It costs nothing extra.
context_order (retrieve,
rerank, iterative) decides where the chosen
chunks sit in the prompt. Models attend best to the beginning and end of
a long prompt, so "edges" puts the best chunk first and the
second-best last; "document" keeps the order they appear in
the document, which reads more naturally when chunks are consecutive;
the default "relevance" puts the best first.
restate (every reader but
extract and screen) repeats the question
before the excerpts as well as after them, because a question asked
once, after several thousand tokens of text, is a long way from the
start of the prompt. "auto" does this when the text is
long, "always" and "never" override.
Whether mmr, context_order or
restate help depends on your documents and model; test them
rather than assume.
Embeddings
retrieve, iterative and the
semantic way of cutting chunks need embeddings. By
default readgpt asks the model provider for them, which costs a little
and needs an internet connection.
gr_options(embedder = "lexical") uses word-matching vectors
computed locally instead: free, offline and exactly repeatable, but they
match words rather than meaning, so “turnover” will not find “revenue”.
Use lexical vectors for practice and testing, and real embeddings for
real work.
gr_embedders()[, c("name", "deterministic", "description")]
#> name deterministic
#> 1 api FALSE
#> 2 lexical TRUE
#> description
#> 1 Embeddings endpoint on the client's base URL
#> 2 Hashed bag-of-words; free, offline, word overlap not meaningAsking for citations
With cite = TRUE the model is asked to cite the chunks
it used, as [chunk 3]. Each chunk id leads back to a page
and section through ans$evidence:
citing <- gr_mock_client(function(messages, params)
"Revenue was 45.2 million dollars [chunk 2].")
ans <- gr_read(chunks, "What was revenue in 2024?", citing,
list(reader = "stuff", cite = TRUE))
ans$answer
#> [1] "Revenue was 45.2 million dollars [chunk 2]."
ans$evidence[ans$evidence$chunk_id == 2, c("chunk_id", "section")]
#> chunk_id section
#> 2 2 SummaryA citation to a chunk the model was never sent is caught and recorded
in ans$notes$cited_unknown, and marks the answer partial.
hierarchical does not support citations, because it answers
from summaries rather than chunks.
Checking what an answer rests on
ans$evidence holds different things depending on the
reader:
| reader |
evidence$text holds |
|---|---|
stuff, retrieve, rerank,
iterative
|
the chunks, word for word |
skim |
passages the model copied out |
map_reduce |
each chunk’s answer |
refine, hierarchical
|
nothing (the answer is built from drafts or summaries) |
Chunks sent word for word are true copies of the document by
construction. The passages skim collects are what the model
says the document contains, so readgpt checks every one against
the chunk it came from:
skimmed <- gr_read(chunks, "What was revenue in 2024?", gr_mock_client(stand_in), "skim")
gr_verify_evidence(skimmed)[, c("chunk_id", "verified", "match")]
#> chunk_id verified match
#> 1 2 TRUE 1match is 1 for an exact quotation (ignoring spacing,
capitals and quote marks). A quotation that is not in the document, such
as a changed figure or an invented sentence, scores lower and is marked
verified = FALSE.
ans$notes$unverified_evidence counts such quotations, and
any one of them makes the answer partial.
Trying readers on your own documents
The best way to choose is to test on a few documents where you
already know the answer. gr_compare() runs several
configurations on one document, extracting the text once and sharing
chunks between configurations that cut them the same way:
cmp <- gr_compare(readgpt_example(), "What was revenue in 2024?", list(
gr_recipe("narrow", segment = list(method = "structural", max_tokens = 120),
read = list(reader = "retrieve", top_k = 2)),
gr_recipe("wide", segment = list(method = "structural", max_tokens = 120),
read = list(reader = "retrieve", top_k = 6)),
gr_recipe("all", segment = list(method = "structural", max_tokens = 120),
read = list(reader = "map_reduce"))),
client = gr_mock_client(stand_in))
cmp$summary[, c("recipe", "reader", "settings", "chunks_used", "not_found", "partial")]
#> recipe reader settings chunks_used not_found partial
#> 1 narrow retrieve top_k=2, max_tokens=120 2 FALSE FALSE
#> 2 wide retrieve max_tokens=120 6 FALSE FALSE
#> 3 all map_reduce max_tokens=120 8 FALSE FALSEsettings names whatever each configuration changed from
the defaults, so rows that differ only in a number can be told apart.
Two configurations that would do exactly the same work are run once,
with a warning, rather than paid for twice.
When a provider cannot do structured replies
rerank, iterative and preview
ask the model to reply in a fixed structure (JSON), which most current
providers support. Where the structure is not supported or the reply
cannot be read, each one falls back, warns, and marks the answer
partial: rerank to word-matching order,
iterative to a single retrieval, preview to
reading everything. The answer is still produced; partial
tells you it came by the fallback.
Next
-
vignette("ingest"): getting the text out of files in the first place. -
vignette("tour"): the ways of cutting documents into chunks, caching and replaying runs, reading many documents, and literature reviews. -
?gr_read_spec: every reading setting, with its default and allowed range.