Docsfra
API Documentation
v1

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
ReturnsList of chunk resultsSynthesized answer text + source list
Mode selectionmode: hybrid | semanticAlways hybrid
CostLower (retrieval only)Higher (retrieval + LLM)
WhenBuilding your own RAG flowA ready-made, synthesized answer is enough

How it works (retrieve -> generate)

  1. Retrievequery is sent to the same hybrid (vector + keyword, RRF) engine as /v1/search; top_k (default 5, maximum 20) chunks are fetched. If document_id is given, the search is scoped to a single document; if batch_id is given instead, it's scoped to every document in that batch (the two are mutually exclusive — see Request below).
  2. 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.
  3. 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/completions endpoint (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, a RagError is thrown (→ 503).
  4. Answer — the text produced by the LLM is returned in the answer field. 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 in citations[], each carrying its source_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
}
FieldRequiredDescription
queryyesThe question being asked (cannot be empty)
top_knoNumber of chunks to fetch for context — default 5, maximum 20
document_idnoIf given, the answer is scoped to a single document
batch_idnoIf given, the answer is scoped to every document in a batch (b_...). Cannot be combined with document_id400 invalid_request
langnoLanguage 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

HTTPcodeDescription
400invalid_requestquery missing/invalid, document_id/batch_id is an invalid opaque id, or both document_id and batch_id were given together
401unauthorizedMissing, invalid, or revoked key
402insufficient_creditBalance insufficient for the per-query charge — /ask costs more than /search (embed + LLM)
404not_foundbatch_id doesn't belong to your tenant
429rate_limitedtenant_ask throttle scope (60/min) exceeded
503rag_unavailableRAG 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 = value line 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 supporting quote) 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. ground records how its surface was verified in the document: exact, tolerant, relocated (found elsewhere) or unverified.
  • 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 marked ground: "refuted" and recomputed carries the correct value. If the model writes only a result without showing the work, no computed step 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 queuedrunningcompleted (or failed). Poll this endpoint (e.g. every few seconds) until it leaves queued/runningGET 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

HTTPcodeDescription
400invalid_requestFewer than 2 document_ids, or an invalid opaque id — returned immediately on POST
402insufficient_creditBalance insufficient for the query charge — returned immediately on POST
404document_not_foundOne of the ids doesn't belong to your tenant — returned immediately on POST
422reasoning_not_availableOne 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).