Docs·Get started
Concepts
How XERJ Reader answers, when it declines, and what the numbers in a response mean. One example for each idea.
The examples on this page use the shipment email from the inbox example. Confidences in the examples are for illustration.
Answers are spans #
XERJ Reader does not write an answer. It marks where the answer starts and ends in your document, and the text is copied from there.
{"type": "span", "text": "48,200 EUR", "start": 533, "end": 543, "confidence": 0.98}This holds for every span answer: text == document[start:end]. The model has no way to produce text that is not in your document, so it cannot invent a value.
Three things follow:
- Values keep the document's formatting. You get
48,200 EURand7 November, not48200and2025-11-07. Parse them in your own code when you need numbers or dates. - An answer is one continuous piece of text. If the answer is spread over two places, you get the best single piece, or no answer.
- Nothing comes from outside the document. Ask “Who is the CEO of Acme Retail?” about an email that does not say, and the answer is
none.
Abstention and min_confidence #
Every answer comes with a confidence between 0 and 1. min_confidence is the bar an answer must clear:
- confidence above the bar: you get the answer;
- confidence at or below the bar: you get
"type": "none"and, if there was a candidate, thebest_guess.
Yes/No answers follow the same rule when the bar is above 0.5. See Yes and No for lower bars.
The bar is yours to set, per request. It must be between 0 and 1 (not 0, not 1). The default is 0.5, the tuned default.
The trade-off, plainly #
Two numbers describe how answers behave on your documents:
- Coverage: how many of your questions get an answer.
- Precision: how many of the answers you get are right.
A higher bar gives fewer answers, and more of them are right. A lower bar gives more answers, and more of them are wrong. You cannot have the most of both, so you choose.
Here is the same email, the same three questions, and three bars:
| Question | Example confidence | Bar 0.2 | Bar 0.5 (default) | Bar 0.9 |
|---|---|---|---|---|
| How much is invoice INV-20931? | 0.98 | 48,200 EUR |
48,200 EUR |
48,200 EUR |
| Do both shipments leave Rotterdam on the same day? | 0.88 | No |
No |
none |
| Who is the CEO of Acme Retail? | 0.21, best guess Tom Baker |
Tom Baker |
none | none |
- At 0.2, every question gets an answer, and one is wrong: Tom Baker works at Acme Retail, but the email never says he is the CEO.
- At 0.5, the wrong answer becomes “not in this document”, and the right ones stay.
- At 0.9, a right answer is lost too: the Rotterdam question becomes “not in this document”.
How to choose your bar #
- Collect 50 to 100 real questions on your own documents, with the right answers. Include some questions whose answer is not in the document.
- Run them at a few bars, for example 0.3, 0.5, 0.7 and 0.9.
- For each bar, count the answers given and the wrong answers.
- Pick the lowest bar whose wrong answers you can accept.
- Keep the questions. Run them again when your documents change.
As a rule of thumb: raise the bar when a program acts on the answer with no person in the loop, such as paying an invoice. Lower it when a person reviews every answer anyway.
The confidence is a score to compare with a bar, not a promise. A 0.95 answer can still be wrong, which is one more reason to show start and end to the person who checks it.
Best guess #
When the answer is none, best_guess holds the closest candidate the model found, if there was one:
{
"answer": {"type": "none", "text": null, "start": null, "end": null, "confidence": 0.21},
"best_guess": {"text": "Tom Baker", "start": 62, "end": 71, "confidence": 0.21}
}- Do show it as “closest passage”, send it to a review queue, or log it.
- Do not fill a field with it or act on it. It is below your bar for a reason.
When there is no candidate at all, best_guess is null. For none answers, answer.confidence is the confidence of the best guess.
Yes and No #
For yes/no questions, such as “Is…”, “Do…”, “Has…”, the answer can be yes or no:
{"type": "no", "text": "No", "start": 496, "end": 507, "confidence": 0.88}textis"Yes"or"No".confidenceis how sure the model is of the Yes or the No.startandendmark a supporting span when the model found one above your bar. Heredocument[496:507]is17 November, the day the second shipment leaves. Otherwise both arenull.- With a bar above 0.5, a Yes or No whose confidence is not above the bar becomes
none. At the default bar of 0.5 or lower, a question the model reads as a yes/no question always gets Yes or No; check itsconfidence.
In /v1/extract, a field with "type": "boolean" turns Yes and No into true and false.
Offsets are characters #
start is the position of the first character of the answer. end is the position just after the last one. Both count characters from the start of the document, starting at 0.
Characters are Unicode code points, the way Python indexes strings. In Python, document[start:end] is the answer, always.
JavaScript strings index UTF-16 units. For most text this is the same. It differs after characters outside the Basic Multilingual Plane, such as most emoji, which take two UTF-16 units:
doc = "Paid 👍 48,200 EUR"
doc[7:17] # '48,200 EUR' (the API returns start 7, end 17)const doc = "Paid 👍 48,200 EUR";
doc.slice(7, 17); // " 48,200 EU" wrong: the emoji counts as 2 in JavaScriptConvert the offsets before you slice:
// Code-point offsets from the API -> JavaScript string indexes.
function toIndex(text, offset) {
let i = 0;
for (let n = 0; n < offset && i < text.length; n++) {
i += text.codePointAt(i) > 0xffff ? 2 : 1;
}
return i;
}
doc.slice(toIndex(doc, 7), toIndex(doc, 17)); // "48,200 EUR"The JavaScript SDK exports codePointSlice(text, start, end), which slices the way the API counts.
Offsets always refer to the exact text you sent. A line break \n is one character and \r\n is two. If you clean the text before sending it, keep the cleaned version for highlighting. After /v1/convert, offsets refer to the converted text.
Tokens #
You pay per token. A token is a piece of a word. In English, one token is about 4 characters on average. Other languages, tables and code often use more tokens per character.
- Input tokens = the tokens of the document + the tokens of the question, for each question read.
- Output tokens = the tokens of the answer text. Output is free.
Example: the shipment email in the inbox example has 888 characters, so one question about it is about 230 input tokens. At $0.01 per 1M input tokens, 1,000 such questions cost about $0.0023.
Every response has the exact counts:
"usage": {"input_tokens": 230, "output_tokens": 3, "total_tokens": 233}Your Usage page and GET /v1/usage add them up. Pricing has a worked bill.
Long documents #
One request reads up to 1,000,000 characters. The whole document is read for every question, so ten questions about a 100-page contract are ten reads of the contract.
To keep long documents fast and cheap:
- Send the part that can hold the answer, when you know it: one section of a contract, one message of a thread.
- Search first. XERJ search finds the right documents and passages; XERJ Reader then answers from them, with the offsets.
- Ask many fields in one call with
/v1/extract. It is billed as one read per field, the same as separate questions, but it is one round trip. - Convert files once. Run
/v1/convertonce per file, keep the text, and ask as many questions as you need.