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_ACCOUNTin.env. Provision only under that account. - NUA key = account-level ops only (create/list KBs). Put it in
.envasARAG_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). NoteGET /api/v1/userdoes NOT work for NUA keys (always 403 "not valid in the global API") - a 403 there proves nothing. - Hosts: retrieval/
/askuse 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 .../askwithanswer_json_schema(OpenAI-function style:name+parametersas JSON Schema) -> schema-conformantanswer_jsongrounded in real content. Never sendcitations:trueANDanswer_json_schemain the same/askcall - the backend answers 500 or 503. For provenance in schema mode readretrieval_results.resources(populated even withcitations:false) or fire a second citations-only/ask. - REMi (answer-quality score):
POST {kb}/predict/remiwith the KB service-account token (NOT the NUA key). Score against the FULL retrieved context (all paragraphs/askgrounded 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}.nameis the type enum:labeler | llm-graph | synthetic-questions | ask | .... Always pinparameters.llm.modelto the KB's own model - unpinned tasks return 200 then fail silently during execution. DA output is stored underda-<destination>-f-<fieldId>, not the plain destination key - read the resource's real field list before reading it back. Only ONElabeler-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-Strategyheader (e.g. table-aware for schedules, visual/OCR for scanned docs). - Search configurations:
GET/POST /kb/{id}/search_configurations- named configs with a baked-infilter_expression; wire each surface to its config rather than filtering ad hoc. - Reading extracted text: needs
show=extracted&extracted=textTOGETHER (show=valuesalone 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. /askignores unknown top-level keys silently (zzz_probe: 1returns 200 with a normal stream, verified 2026-09-04), so atemperature,seedorgeneration_paramsfield 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)./askhonours a request-leveltop_kover the stored configuration's (verified 2026-09-05 onportal-intent-review: 20 paragraphs over 15 resources by default, 60 over 21 withtop_k: 60). Aprequeriesentry is a full find request, so each pass carries its owntop_k,resource_filtersandweight- the portal gives a pinned paper a 20-paragraph pass at weight 2 and a 10-paragraph pass per question clause, and never sendsfull_resourcebeside a wide budget (whole papers and sixty paragraphs do not fit one context).- Top-level
resource_filterson/askis 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 withresource_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/findis 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 torag_strategies: [{name: 'full_resource'}], and it is dropped if a request-leveltop_kis also sent. Pair it withresourceId/resource_filtersand notop_kfor a rescue read of a paper whose top passages the first pass already saw./graphcapstop_kat 500 (422less_than_equalabove 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./entitiesgroupsinlines 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 combinedsummaryplus per-resource summaries. Missing uids are silently ignored. - Reranker: both
/findand/askaccept a top-levelrerankerfield -'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 comparingscore_typeon retrieved paragraphs (RERANKERby default and withreranker:'predict'explicit;BM25/VECTOR/BOTHonly withreranker:'noop'). A namedsearch_configurationdoes not override an explicit request-levelreranker(unlikefeatures- see below) and its ownconfigobject also accepts arerankerkey. 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 fieldname) 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, eachrequestis a full FindRequest.full_resourceandpage_image/tables(underrag_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?}whereidentis theNuclia-Learning-Idresponse 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 APIPATCH /account/{acct}/kb/{kbid}{hidden_resources_enabled:true}(NUA key) accepts it even though the field is undocumented on that endpoint - verified live./catalogreturns hidden resources unless you passhidden=false;/find//askexclude 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_configurationoverrides the request'sfeatures- passing a named config plusfeatures: ['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./findparagraph 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/graphwith PERSON/DATE/LOC paths and the curated llm-graph relations fall outsidetop_k.
Clean architecture for this portal (so ARAG is swappable)#
- Put retrieval behind a
RetrievalProviderinterface on the server (methods likeask(),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).