Documentation

Developing on Progress Agentic RAG

The credential model, working call shapes and architecture for building a research portal on Progress Agentic RAG, from the CorpusKit project.

Overview#

What building CorpusKit taught us about Progress Agentic RAG (the Nuclia-based platform): the

credential model, the call shapes that work, and the platform behaviours to plan for. Read it

before writing any retrieval or provisioning code.

Mental model#

ARAG = a managed RAG platform. Per Knowledge Box (KB) you: ingest content -> enrich it

with Data-Augmentation (DA) agents (labeler, graph, synthetic-questions, ask/summarize) ->

retrieve with grounded, cited answers (/ask) -> score answer quality with REMi ->

optionally expose tools to an agent over MCP. Extraction, labels, a knowledge graph, and

search configurations are all configurable objects ON the KB, viewable in the admin console.

Credentials (get this right first - most 403s are a wrong-token problem)#

  • Account: all resources live under one account, set as ARAG_ACCOUNT in .env. Provision only under that account.
  • NUA key = account-level ops only (create/list KBs). Put it in .env as ARAG_NUA_KEY (never commit it). It 403s on KB-scoped writes - do NOT use it to write resources/tasks/configs on a KB.
  • KB service-account token (SOWNER) = KB-scoped reads AND writes (ingest, tasks, /ask, REMi). Header: X-NUCLIA-SERVICEACCOUNT: Bearer <token>. Mint one per KB after creating it.
  • Verify a NUA key is scoped to the right account with GET /api/v1/account/{ACCOUNT_ID}/kbs (200 + KB list = good; 403 = wrong account). Note GET /api/v1/user does NOT work for NUA keys (always 403 "not valid in the global API") - a 403 there proves nothing.
  • Hosts: retrieval//ask use the rag-host ({region}.rag.progress.cloud); DA tasks use the dp-host (same, with .rag. swapped for .dp.). A key provisions only in the zones its account allows; the account CorpusKit is developed on provisions in the Australian zone (aws-ap-southeast-2-1).

Working call shapes#

  • Ingest a file: single-call POST /kb/{id}/upload, then PATCH metadata onto the created resource. The two-step create-resource + PUT-file pattern 500s on PDFs on this deployment.
  • A resource's title is NOT searchable body text. Any name that must ground an answer (a person, a vehicle, a product) has to be written into the text field body too.
  • Grounded answer: POST {rag-host}/api/v1/kb/{id}/ask -> grounded, cited answer.
  • Structured JSON answer (query-time): POST .../ask with answer_json_schema (OpenAI-function style: name + parameters as JSON Schema) -> schema-conformant answer_json grounded in real content. Never send citations:true AND answer_json_schema in the same /ask call - the backend answers 500 or 503. For provenance in schema mode read retrieval_results.resources (populated even with citations:false) or fire a second citations-only /ask.
  • REMi (answer-quality score): POST {kb}/predict/remi with the KB service-account token (NOT the NUA key). Score against the FULL retrieved context (all paragraphs /ask grounded on), not just the citation excerpts - thin context makes groundedness swing wildly (0/5 then 5/5 on the same good answer). Full context -> stable 5/5.
  • DA tasks: POST {dp-host}/api/v1/kb/{id}/task/start, body {name, parameters, apply, enabled}. name is the type enum: labeler | llm-graph | synthetic-questions | ask | .... Always pin parameters.llm.model to the KB's own model - unpinned tasks return 200 then fail silently during execution. DA output is stored under da-<destination>-f-<fieldId>, not the plain destination key - read the resource's real field list before reading it back. Only ONE labeler-type task can run at a time (concurrent starts 422 and can leave a stale zombie config; DELETE /kb/{id}/task/{id} clears it) - run labelers sequentially with polling.
  • Extract strategies: register the strategy, then apply it by uploading with an X-Extract-Strategy header (e.g. table-aware for schedules, visual/OCR for scanned docs).
  • Search configurations: GET/POST /kb/{id}/search_configurations - named configs with a baked-in filter_expression; wire each surface to its config rather than filtering ad hoc.
  • Reading extracted text: needs show=extracted&extracted=text TOGETHER (show=values alone returns only the file pointer). The extracted text flattens markdown line breaks into whitespace runs - normalize before rendering or raw ##/| leaks on screen and position-based citation highlighting breaks.
  • /ask ignores unknown top-level keys silently (zzz_probe: 1 returns 200 with a normal stream, verified 2026-09-04), so a temperature, seed or generation_params field can be sent without error and without any evidence it reached the model. Do not rely on one for determinism; cache decisions portal-side instead (the intent classifier does).
  • /ask honours a request-level top_k over the stored configuration's (verified 2026-09-05 on portal-intent-review: 20 paragraphs over 15 resources by default, 60 over 21 with top_k: 60). A prequeries entry is a full find request, so each pass carries its own top_k, resource_filters and weight - the portal gives a pinned paper a 20-paragraph pass at weight 2 and a 10-paragraph pass per question clause, and never sends full_resource beside a wide budget (whole papers and sixty paragraphs do not fit one context).
  • Top-level resource_filters on /ask is the strongest scoping lever there is, and the portal's retrieval pin is built on it (verified live 2026-09-06: "In anti-LGI1 antibody encephalitis, what proportion of patients relapsed" returned the anti-NMDAR paper's 28% and 764 days unfiltered, and the LGI1 paper's own 16 (30%) at 414 days with resource_filters: ["8565cc8f..."], in 9.6 s). Two things to know before relying on it: it is honoured weakly (see the known bug below), so cross-check every citation against the filter server-side and drop the ones outside it; and the relevance scores that come back are reranked within the filtered set, so a score from a filtered /find is not comparable with a score from an unfiltered one - use it to decide whether the filtered papers carry anything for the question, never to compare "inside the pin" against "the whole collection".
  • depth: 'deep' is the way to read one resource whole: it maps to rag_strategies: [{name: 'full_resource'}], and it is dropped if a request-level top_k is also sent. Pair it with resourceId/resource_filters and no top_k for a rescue read of a paper whose top passages the first pass already saw.
  • /graph caps top_k at 500 (422 less_than_equal above it) and pages the path index in no stable order, so one page is a different slice on every call while the index is growing. The path query accepts a group scope, {prop:'path', source:{group:'Gene'}, undirected:true} (verified), so a whole-index read is one page per entity group plus the ungrouped page, unioned and ranked portal-side. /entitiesgroups inlines every entity per custom group.
  • Multi-doc summaries: POST /kb/{id}/summarize {resources: [uids] (REQUIRED), summary_kind: 'simple'|'extended', user_prompt?} - verified 2xx; response has a combined summary plus per-resource summaries. Missing uids are silently ignored.
  • Reranker: both /find and /ask accept a top-level reranker field - 'predict' (cross-encoder rerank pass) or 'noop' (raw BM25/vector/hybrid scores) - verified live by sending an invalid value: the 422 names the exact enum (str-enum[RerankerName], "Input should be 'predict' or 'noop'"). The platform's default when the field is omitted is already 'predict' - confirmed by comparing score_type on retrieved paragraphs (RERANKER by default and with reranker:'predict' explicit; BM25 / VECTOR / BOTH only with reranker:'noop'). A named search_configuration does not override an explicit request-level reranker (unlike features - see below) and its own config object also accepts a reranker key. Pinning it explicitly (in the request body and the stored config) is therefore defensive, not a new capability - it stops a future platform default change from silently un-reranking these paths. An object form ({name:'predict', ...}, discriminator field name) is also accepted but extra keys inside it (top_k, etc.) are silently ignored by validation, so don't rely on them without separately confirming they do something - the plain string form is the only fully-verified shape.
  • Prequeries (deep research): rag_strategies: [{name:'prequeries', queries:[{request: {query, features:['keyword','semantic']}, weight: 1}], ...}] - max 10 queries, each request is a full FindRequest. full_resource and page_image/tables (under rag_images_strategies) are the other verified strategy names.
  • Rephrase: POST /kb/{id}/predict/rephrase {question, user_id} works with the SA token BUT appends a single status digit (0/1) to the rephrased text - strip a trailing [01].
  • Answer feedback: POST /kb/{id}/feedback {ident, good, task:'CHAT', feedback?} where ident is the Nuclia-Learning-Id response header of the original /ask - capture it at stream time or it is gone.
  • Hidden resources: PATCH /resource/{id} {hidden:true} 422s until the KB-level feature is on, and there is NO regional endpoint to enable it. The account-scoped Zone API PATCH /account/{acct}/kb/{kbid} {hidden_resources_enabled:true} (NUA key) accepts it even though the field is undocumented on that endpoint - verified live. /catalog returns hidden resources unless you pass hidden=false; /find//ask exclude them by default.
  • Memory DA task: POST {dp}/task/start {name:'memory', parameters:{name, on:1, operations: [{memory:{ident, prompt?, rules?}}], llm:{model}}, apply, enabled} - verified 200.
  • Activity endpoints (/kb/{id}/activity/*) are 403 to SA tokens (dashboard-user auth only). Ask analytics must be logged app-side at the proxy - which sees every ask anyway.
  • search_configuration overrides the request's features - passing a named config plus features: ['keyword'] still runs the config's own features, making mode switches silently inert. Only attach the config for the default mode; drop it when the caller chooses a specific mode.
  • /find paragraph scores mix scales - semantic about 0 to 1, BM25 unbounded (5-30 typical). Calibrate (logistic squash above 1) instead of normalising to the top hit, or every top result reads "100%".
  • Graph queries: filter to agent-extracted relations with {prop:'generated', by:'data-augmentation'} (combinable via {and:[...]} with a path/node query). Without it, once real PDFs land the built-in NER pipeline floods /graph with PERSON/DATE/LOC paths and the curated llm-graph relations fall outside top_k.

Clean architecture for this portal (so ARAG is swappable)#

  • Put retrieval behind a RetrievalProvider interface on the server (methods like ask(), search(), graph(), provisionTenant()). ARAG is one implementation; a stub/mock is another for local dev without the platform. No ARAG/Nuclia types leak into the UI or components.
  • Server-side only. The KB service-account token NEVER reaches the browser - every ARAG call is proxied through the server. No key in client code, ever.
  • Provisioning engine (this portal's differentiator): "point at a blank KB + a domain brief -> configure everything" maps directly onto the calls above: create KB (NUA key) -> mint SA token -> ingest the starter corpus (/upload) -> register DA tasks (labeler for the domain taxonomy, llm-graph for the entity/relation types, synthetic-questions for suggested questions, ask for summaries) -> register extract strategies + search configs -> done. Build it as an idempotent, resumable pipeline (tasks are async; poll to completion; labelers strictly sequential).