Ask (RAG)
POST /v1/ask is a convenience layer that combines retrieval and LLM
synthesis in a single call: it takes your query, finds the relevant
chunks with /v1/search's same hybrid (RRF) engine, and returns a
synthesized, page-cited answer generated only from those chunks.
Difference from Search
/v1/search is a retrieval primitive — it returns a list of
chunks, it does not synthesize; it's the building block when you want
to build your own RAG flow (with your own prompt/LLM).
/v1/ask uses the same retrieval layer but also performs the
synthesis step for you: you get a ready-made, source-cited answer
text.
/v1/search | /v1/ask | |
|---|---|---|
| Returns | List of chunk results | Synthesized answer text + source list |
| Mode selection | mode: hybrid | semantic | Always hybrid |
| Cost | Lower (retrieval only) | Higher (retrieval + LLM) |
| When | Building your own RAG flow | A ready-made, synthesized answer is enough |
How it works (retrieve -> generate)
- Retrieve —
queryis sent to the same hybrid (vector + keyword, RRF) engine as/v1/search;top_k(default 5, maximum 20) chunks are fetched. Ifdocument_idis given, the search is scoped to a single document; ifbatch_idis given instead, it's scoped to every document in that batch (the two are mutually exclusive — see Request below). - No context — if no chunks are found at all, the LLM is
never called; a fixed, honest answer is returned:
"No answer to this question was found in the documents."This call still counts as successful and is billed normally. - Generate — if chunks were found, each is turned into a context
block headed
[Source: document=..., page=..., section=...](blocks separated by---) and sent to an engine-agnostic, OpenAI-compatible/chat/completionsendpoint (low temperature,temperature=0.1). Transient errors (5xx/timeout) are retried with exponential backoff; on persistent errors (4xx) or if RAG is not configured server-side, aRagErroris thrown (→503). - Answer — the text produced by the LLM is returned in the
answerfield. Passages are numbered ([S1],[S2], ...) and the model cites each claim inline with its source number; only the sources actually cited in the answer are returned incitations[], each carrying itssource_no.
Auth
Like all M2M endpoints, requires an Authorization: Bearer <api_key>
header (dip_live_... or dip_test_..., see Getting Started and
Auth).
Request
Content-Type: application/json body:
{
"query": "what are the invoice payment terms?",
"top_k": 5,
"document_id": null,
"batch_id": null,
"lang": null
}
| Field | Required | Description |
|---|---|---|
query | yes | The question being asked (cannot be empty) |
top_k | no | Number of chunks to fetch for context — default 5, maximum 20 |
document_id | no | If given, the answer is scoped to a single document |
batch_id | no | If given, the answer is scoped to every document in a batch (b_...). Cannot be combined with document_id — 400 invalid_request |
lang | no | Language to generate the answer in (ISO 639-1 code, e.g. en, de, fr) — if omitted, falls back to the tenant's default set in Console → Models, then to tr. An invalid/unsupported code does not error, it's silently dropped (see Output language below) |
Response
{
"query": "what are the invoice payment terms?",
"answer": "...[document/page 3]...",
"evidence_trail": {
"identity": true,
"passages": 2,
"entities": 1,
"tables": 0,
"claims": 1
},
"truncated": false,
"proof": {
"verdict": "verified",
"weakest_link": "exact",
"holes": [],
"stats": { "total": 3, "observed": 2, "computed": 1, "rule": 0,
"computed_refuted": 0, "holes": 0 },
"steps": [ ... ]
},
"citations": [
{
"document_id": "...",
"external_item_ref": "...",
"page_no": 3,
"section": "Payment Terms",
"chunk_id": "...",
"snippet": "...",
"kind": "passage"
}
]
}
citations[] contains only the sources the answer actually cites
(matching the inline [S#] markers one-to-one via source_no) —
retrieved-but-unused chunks are no longer presented as sources. When
the honest answer is "not found in the documents", citations is
empty. Retrieval also applies a reranker relevance floor
(RERANK_MIN_SCORE): candidates the cross-encoder scores below the
floor never reach synthesis, so an unanswerable question doesn't
surface best-of-bad "sources". snippet is the first 200 characters
of the cited chunk's text.
Each citation also carries a kind: "passage" for a regular
retrieved chunk, or "entity" / "table" / "claim" when the source
came from the document's structured representation layers instead of
a raw text chunk (see below). Existing integrations that ignore kind
keep working unchanged — chunk citations still look exactly as before.
Output language
The lang request field controls what language the narrative
text inside answer — the sentences the model writes — is generated
in. Priority order: request lang > the tenant's default set in
Console → Models > the fixed default (tr). An invalid or unsupported
code does not error — it's silently dropped in favor of the tenant
setting, or the default if there isn't one.
lang only covers text the model generates; it never translates the
following: quotes taken from the document (snippet, and the exact
text inside evidence-item citations), entity values (the TYPE: name = value lines inside kind: "entity" citations are returned as found
in the source document), and citation labels ([S1], [S2], ... are
always the same format). This is deliberate: an amount, a date, or a
company name should never be translated in a way that could make it
disagree with the document.
Faithfulness (no hallucination)
The synthesis system prompt enforces these rules on the model: use
only the information in the given context, give an [S#]
style source citation after every significant claim, explicitly state when
the answer isn't in the context ("this information was not found in
the document"), never make things up. If no context (chunks) is
found at all, this rule doesn't even come into play — a fixed, honest
answer is returned without ever calling the LLM.
Credits and billing
/v1/ask costs 0.20 credits/query by default — more expensive
than /v1/search because it includes both the embed and the LLM
step. Balance is checked before the request (402 insufficient_credit if insufficient); if synthesis fails (503) no
credit is deducted, it's only deducted on a successful answer.
dip_test_... test keys are exempt from query charges (usage is
still measured, but not deducted from balance).
Errors
| HTTP | code | Description |
|---|---|---|
| 400 | invalid_request | query missing/invalid, document_id/batch_id is an invalid opaque id, or both document_id and batch_id were given together |
| 401 | unauthorized | Missing, invalid, or revoked key |
| 402 | insufficient_credit | Balance insufficient for the per-query charge — /ask costs more than /search (embed + LLM) |
| 404 | not_found | batch_id doesn't belong to your tenant |
| 429 | rate_limited | tenant_ask throttle scope (60/min) exceeded |
| 503 | rag_unavailable | RAG not configured (missing server-side config) or the LLM returned a persistent error — no credit is deducted in this case |
Notes
/v1/ask is a convenience endpoint that compresses the
retrieve+generate steps into a single call; if you want to use your
own prompt/LLM, you can build your own RAG flow yourself with
/v1/search.
Evidence trail: beyond chunks
Retrieval no longer only returns text chunks. If the target
document(s) already have a structured representation (entities,
tables, verified reasoning claims — see Extraction), /v1/ask also
pulls a small, deterministic (LLM-free) set of evidence items from
those layers and blends them into the same numbered [S#] source list
the chunks use — an evidence item can be cited exactly like a passage,
with the same faithfulness rules:
- Entity — a
TYPE: name = valueline matched against the query by canonical name/aliases. - Table — up to 2 markdown tables matched against a table-shaped question (e.g. "what quantities...").
- Claim — a verified reasoning claim (
statement+ its exact supportingquote) matched against the query. - Line item — invoice/declaration/waybill rows, read cell by cell
from the document's own table (
KALEM: line no=..., description=..., quantity=..., amount=...). Triggered by item/quantity/breakdown questions, together with a summary evidence item carrying the document's REAL item count, its total, and whether the line items add up to that total. The summary is independent of the display cap — if 40 of 72 items are shown, "how many items" is still answered 72. - Rule — a condition the document itself imposes, together with its
deterministic evaluation (
VIOLATED/satisfied/NOT EVALUABLE) and the exact supporting quote. A violation that was found is stated explicitly in the answer even if you didn't ask about it; the model does not evaluate the rule, it relays the engine's verdict.
These layers are capped (entities ≤ 6, tables ≤ 2, claims ≤ 4, rules ≤ 4,
all evidence combined ≤ 14; when the line-item layer is active its rows are
added ON TOP of that cap rather than displacing other evidence) and are
additive to the usual chunk retrieval
— when nothing matches, evidence contributes nothing and /v1/ask
behaves exactly as before.
The response's evidence_trail object reports, for the sources
actually cited in the answer: identity (whether the document(s)
have a representation available at all), and the count of cited
passages, entities, tables and claims. It's purely
informational — evidence_trail is additive and safe to ignore.
Page scope
When the question names a page explicitly ("the items on page 2", "page 5")
and a single document is in scope, that page's text is pulled into the
context directly and passages from other pages are not shown. A page is not
a ranking signal — it is a scope constraint, like document_id.
Relative phrasings ("the last page", "the first page") and "all pages" deliberately do not narrow anything.
Truncated answers: truncated
Questions that ask for long enumerations ("list every line item") can hit
the model's output limit. When that happens Docsfra first continues the
answer from where it stopped (one continuation call); if it still can't
finish, the response carries truncated: true and an explicit warning
is appended to the answer text.
A truncated answer is never presented as a complete one — even a client that ignores the field sees the warning in the text.
The output budget is chosen per question: a narrow question ("who is
the shipper") and a 105-item breakdown do not share a cap. For very long
enumerations prefer the Extraction endpoint over /v1/ask — line
items are produced deterministically there, without depending on the
model to copy each row.
Proof chain: the steps, not just the answer
A citation ([S1]) shows where a claim came from. The response's
proof object goes one step further and gives you the answer's
steps — each one either a value read from the document, a
deterministic rule verdict, or an arithmetic recomputed in Python:
"proof": {
"verdict": "verified",
"weakest_link": "exact",
"holes": [],
"stats": { "total": 3, "observed": 2, "computed": 1, "rule": 0,
"computed_refuted": 0, "holes": 0 },
"steps": [
{ "no": 1, "kind": "observed", "ground": "exact", "source_no": 1,
"page_no": 2, "layer": "entity", "statement": "..." },
{ "no": 2, "kind": "computed", "ground": "exact",
"expression": "40,470.00 + 2,100.00 = 42,570.00",
"inputs": ["40,470.00", "2,100.00"], "result": "42,570.00",
"terms": 2, "recomputed": "42570.00", "arithmetic_ok": true,
"inputs_grounded": true, "source_nos": [1, 2],
"engine": "deterministic" }
]
}
Step kinds:
observed— a value read from the document.groundrecords how its surface was verified in the document:exact,tolerant,relocated(found elsewhere) orunverified.computed— a calculation the answer shows explicitly. The model's arithmetic is not trusted: it is redone in Python. Chains may have any number of terms (a + b + c = total) and are applied left to right. If it doesn't hold, the step is markedground: "refuted"andrecomputedcarries the correct value. If the model writes only a result without showing the work, nocomputedstep is created — transparency is a precondition.rule— the rule engine's deterministic verdict (engine: "deterministic").
verdict derives from the chain's weakest link, not an average:
verified (every step is grounded), partial (the chain has holes) or
broken (a calculation failed verification). holes lists numeric
surfaces that appear in the answer but could not be found in the
documents — they are never hidden.
proof is additive and nullable: when no chain can be built (no
citations and no shown calculation) it returns null, and it is safe to
ignore.
Cross-check: contradictions between documents
POST /v1/cross-check compares the verified reasoning claims of two
or more processed documents (no upper limit — with many documents each
contributes its most important claims within a shared budget) against each other and returns cross-document
findings — contradictions ("document A says X, document B proves not-X")
and support pairs (an obligation in one document, its fulfilment
evidence in another).
Depending on how many documents (and claims) are involved, a cross-check
can take anywhere from under a minute to several minutes, so it runs as a
background job: POST only queues the job and returns right away;
you fetch the actual result with a follow-up GET.
1. Start the job — POST /v1/cross-check
{ "document_ids": ["d_...", "d_..."], "lang": null }
The optional lang field (ISO 639-1 code) follows the exact same
priority chain as /v1/ask (request lang > the tenant's Console →
Models setting > tr, see Output language above), and only affects
each finding's explanation field (the model-written narrative) —
the statement/quote/page_no fields on each side of a finding are
always the original values recorded at extraction time, never
regenerated by cross-check, so they're unaffected by lang.
Validation errors are returned immediately on this call (see Errors
below). Otherwise the response is 202 Accepted:
{
"id": "xc_...",
"status": "queued",
"documents": [{ "document_id": "d_...", "label": "..." }]
}
2. Poll for the result — GET /v1/cross-check/{id}
status moves through queued → running → completed (or
failed). Poll this endpoint (e.g. every few seconds) until it leaves
queued/running — GET calls are never billed. While in progress the
response only carries id, status and documents:
{
"id": "xc_...",
"status": "running",
"documents": [{ "document_id": "d_...", "label": "..." }]
}
Once status is completed, the response gains findings, stats,
version and cost:
{
"id": "xc_...",
"status": "completed",
"documents": [{ "document_id": "d_...", "label": "..." }],
"version": "xchk-v4",
"findings": [
{
"kind": "contradiction",
"explanation": "…",
"a": { "document_id": "d_...", "node_id": "g2-c2", "statement": "…", "quote": "…", "page_no": 4, "span": {"start": 120, "end": 180} },
"b": { "document_id": "d_...", "node_id": "g7-r1", "statement": "…", "quote": "…", "page_no": 27, "span": {"start": 40, "end": 130} }
}
]
}
If status is failed, the response instead gains an error object
({"code", "message"}) describing what went wrong.
The mechanism inherits the reasoning layer's hallucination shield: the
claims being compared were already verified against document text at
extraction time (each carries a page number and an exact quote), and
every finding the model proposes is validated deterministically — it
may only reference known claim ids, both sides must come from
different documents, and each side's statement/quote/page in the
response is filled from the server's own records, never from the
model's echo. An empty findings array is a valid answer; the model
is instructed not to force pairs it is unsure about.
Coverage is systematic, not attention-limited: candidate pairs are first
generated deterministically — every claim is embedded (on Docsfra's own
infrastructure) and matched against the most similar claims of the other
documents, plus any pair sharing a salient token (reference number, amount,
date) — and each candidate is then judged individually by the model
(contradiction / support / none). A separate free-scanning pass catches
rule-vs-violation pairs that similarity cannot surface (e.g. an
"all documents must be in English" clause vs a German sentence). When
the documents' representation includes an entity graph, a third candidate
channel matches same-type entities across documents (by canonical name/
alias overlap) and pairs up the claims that mention them — prioritizing
pairs where the matched entities carry different values, the strongest
natural-contradiction signal. All channels merge by pair; results are
stable across calls and returned in a deterministic order (contradictions
first). The completed response's stats object reports how many claims
and candidate pairs were examined, including entity_candidates (pairs
found via the entity channel).
Errors
| HTTP | code | Description |
|---|---|---|
| 400 | invalid_request | Fewer than 2 document_ids, or an invalid opaque id — returned immediately on POST |
| 402 | insufficient_credit | Balance insufficient for the query charge — returned immediately on POST |
| 404 | document_not_found | One of the ids doesn't belong to your tenant — returned immediately on POST |
| 422 | reasoning_not_available | One of the documents hasn't finished generating its reasoning layer yet — returned immediately on POST |
A failure that happens after the job has started doesn't produce an
HTTP error on POST — it surfaces as status: "failed" with an error
object on a later GET.
Billing
The call is billed as one ask query (same rate, same throttle
scope), charged once when the job is created by POST — polling with
GET is always free, and a job that ends in status: "failed" is not
charged. Also available in the Console under Çapraz Denetim, and as
the cross_check MCP tool (which polls internally and hands back the
finished findings directly).