Skip to main content

Query, Streaming Query, and Search APIs

Use these endpoints to build cited chat and raw retrieval clients:

EndpointPurposeResponse type
POST /queryRun the full answer pipeline and return one cited answer as JSON.application/json
POST /query/streamRun the same answer pipeline, then reveal the already-guarded result as Server-Sent Events.text/event-stream
POST /searchReturn ranked source chunks only, with no answer synthesis.application/json

All routes are mounted at the API root. The public review-staging Caddy edge proxies the complete FastAPI HTTP surface, including /healthz, /openapi.json, and the non-production /docs UI. This page documents the three query and retrieval endpoints; see the other API reference pages for conversations, memory, ingestion, and document operations. There is no Redoc route and no WebSocket query route.

Authentication and browser integration

Every route on this page requires the configured API-key header. The default header name is X-API-Key.

X-API-Key: <server-side API key>

Principal-bearing deployments may also receive trusted identity headers from an upstream gateway or backend:

HeaderTypeMeaning
X-User-SubjectstringStable authenticated user subject.
X-User-RolestringAuthenticated role. Defaults to user only when the trusted resolver accepts fallback behavior.
X-User-UnitstringOptional organization unit for access filtering.
X-User-Levelinteger stringOptional position level for access filtering.

Browser applications must not embed a shared API key and must not let users forge X-User-* identity headers. Put these calls behind a same-origin backend-for-frontend or an API gateway that injects the key and identity headers server-side. The API does not add app-level CORS behavior for browser cross-origin calls.

For the review-staging edge, clients send only X-API-Key. Caddy removes every caller-supplied X-User-* header and injects the fixed staging identity before proxying the request. Set the URL and read the credential without storing it in shell history before running the curl examples on this page:

export API_BASE='https://rag-aceh-ai-eng.reviewstagepro.com'
read -rsp 'Staging API key: ' API_KEY && export API_KEY && printf '\n'

Native EventSource cannot send X-API-Key or trusted identity headers, so streaming clients must use fetch() plus ReadableStream parsing, as shown below. Clear the staging credential when testing is complete with unset API_KEY API_BASE.

Shared concepts

Collection scoping

/query, /query/stream, and /search accept collection_ids to narrow retrieval to partner collections.

ValueBehavior
Field omittedSearch all collections the caller may access.
nullSame as omitted.
[]Same as omitted.
String arrayTrim each entry, drop blank entries, deduplicate the remaining IDs in first-seen order, then restrict retrieval to the intersection of those IDs and the caller's access predicate.
Array containing only blank stringsSame as omitted: after normalization there is no collection scope, so all accessible collections are searched.
Nonblank unknown or forbidden IDProduces no matching rows for that normalized ID. It is not a 403 and does not reveal whether the collection exists.

Collection IDs are opaque stable strings. Examples include partner collection names such as JDIH, PPID, SatuData, and OpenData, but clients should treat them as configured IDs, not as an exhaustive enum.

Shared citation object

Every citation has the base fields below. The conditional access-scope fields appear only when that source document is restricted.

FieldTypeRequiredMeaning
document_idintegerYesSource document ID.
document_titlestringYesSource document title.
chunk_idintegerYesSource chunk ID.
chunk_ordinalintegerYesChunk ordinal within the document.
page_numberinteger or nullYesSource page number when known.
section_pathstring arrayYesHierarchical section path for the chunk. Empty when no section path is known.
legal_referencestring or nullYesLegal citation/reference extracted for the chunk when known.
char_startinteger or nullYesStart character offset within the source text when known.
char_endinteger or nullYesEnd character offset within the source text when known.
confidentialtrueConditionalPresent only when the source document is confidential. Omitted for non-confidential sources.
owning_org_unitstringConditionalPresent only when the source has an owning organization unit restriction.
min_position_levelintegerConditionalPresent only when the source requires a minimum position level.

Example citation:

{
"document_id": 42,
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": 991,
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088,
"confidential": true,
"owning_org_unit": "Bappeda",
"min_position_level": 3
}

POST /query JSON answer

POST /query runs the full cited answer flow and returns one JSON object. It creates a new conversation when conversation_id is omitted. When conversation_id is present, the new user turn is appended to that existing conversation and recent conversation context is used to answer follow-up questions. Unknown conversations and conversations owned by another subject return 404.

A Government Policy Guard refusal is still a successful 200 JSON response: answer contains the refusal text, and citations may be empty or contain the sources used before the refusal decision. Use /query/stream when the UI needs a distinct guard.blocked event.

Answering figure and count questions

The request and response shape is unchanged, but the answer path handles figure-, count-, and quantity-shaped questions more directly than before:

  • The faithfulness check that runs before an answer is returned is figure-aware (#209, #216). Numeric answers — short numbers, dates, and figures that appear differently across languages — are no longer dropped by a naive word-overlap check, so a grounded figure question is answered and cited rather than over-refused.
  • The Planner selects the read-only SQL Query tool only when a structured data source is actually configured for the deployment (#223). When no structured source is configured, a quantity-shaped question is answered from the document corpus by hybrid retrieval instead of being routed to an unavailable tool — so it returns a cited answer rather than a refusal.
  • Open Data figures are read query-specifically — each lookup is scoped to the specific question rather than pulling a whole dataset (#226) — and this live-portal path now covers Satu Data sources in addition to Open Data (#198).

These are answer-quality behaviors inside the pipeline. They do not add or rename any request or response field, and every returned figure is still grounded in a retrieved source and carries the usual citations.

Request fields

FieldTypeRequiredConstraintsMeaning
querystringYesMinimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400.User question.
user_idstringNoMinimum length 1. Default anonymous.Development fallback subject only when trusted auth is not required. Do not use it as browser-controlled identity in production.
conversation_idinteger or nullNoMust be greater than 0 when set.Existing conversation to resume. Omit or set null to start a fresh conversation.
collection_idsstring array or nullNoOmitted, null, [], or an array that normalizes to no nonblank IDs means all accessible collections; nonblank IDs are trimmed and deduplicated before scoping.Optional retrieval scope.

Request JSON

{
"query": "Ringkas aturan transparansi anggaran Aceh dan beri sumbernya.",
"user_id": "frontend-dev-user",
"conversation_id": null,
"collection_ids": ["JDIH", "PPID"]
}

Response fields

FieldTypeMeaning
answerstringFinal guarded answer or guard refusal text.
conversation_idintegerConversation that now contains the user turn and assistant message. Store it for follow-up turns.
message_idintegerStored assistant message ID.
citationscitation arraySource citations for the answer. See Shared citation object.

Response JSON

{
"answer": "Aturan transparansi anggaran mewajibkan publikasi informasi anggaran dan pelaporan yang dapat diakses masyarakat [chunk:991].",
"conversation_id": 123,
"message_id": 456,
"citations": [
{
"document_id": 42,
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": 991,
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088
}
]
}

curl

curl --fail-with-body \
--request POST \
"$API_BASE/query" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'

POST /query/stream Server-Sent Events

POST /query/stream accepts the same request body as /query and has the same setup status/error behavior. The answer pipeline completes before the response body starts. Only after the final answer has passed guard checks does the API reveal content as SSE frames.

This is a buffer-then-reveal stream:

  • No unapproved draft text is streamed.
  • answer.delta frames are emitted only after the guard passes.
  • If the guard blocks, no answer.delta frame is emitted.
  • There are no heartbeat frames. The stream is a fast reveal after pre-stream processing finishes.
  • Setup failures return normal HTTP JSON errors before the text/event-stream body starts.

Request fields and JSON

The request schema is identical to POST /query.

{
"query": "Apa ketentuan lanjutan dari aturan itu?",
"user_id": "frontend-dev-user",
"conversation_id": 123,
"collection_ids": ["JDIH"]
}

Response headers

Successful streams return 200 with Content-Type: text/event-stream. The server also sets streaming-friendly headers including Cache-Control: no-cache, X-Accel-Buffering: no, and Connection: keep-alive.

Huawei APIG or any other gateway in front of the API must be configured not to buffer text/event-stream responses. Because work happens before streaming starts, gateway request/response timeouts must also be long enough for the full multi-step answer pipeline. Heartbeats would not protect this pre-stream window.

SSE frame format

Each event is framed as:

event: <event-name>
data: <single-line JSON object>

The data: payload is JSON. Clients should parse events in wire order and should not reorder citations or deltas.

Event order and terminal semantics

A successful stream follows exactly one of these paths:

message.start
citation zero or more times
answer.delta zero or more times
done

or, when the Government Policy Guard blocks the generated answer:

message.start
citation zero or more times
guard.blocked
done

A terminal error event is reserved for an unexpected framing fault after streaming has started. In that case, error replaces done; treat it as terminal and stop reading.

EventData shapeWhen it appearsClient behavior
message.start{"conversation_id": integer, "message_id": integer}First event.Store IDs for the current assistant message and future resume.
citationCitation objectZero or more times after message.start and before answer or block events.Attach citations to the pending assistant message in received order.
answer.delta{"text": string}Zero or more times only when the guard passes.Append text to the visible answer buffer.
guard.blocked{"reason": string}Instead of answer.delta when the guard blocks.Show a refusal/block state using reason; do not render an answer buffer.
done{"finish_reason": "stop"} or {"finish_reason": "guard_blocked"}Normal terminal event.Finalize the message.
error{"code": "stream_failed", "message": string}Terminal event only on mid-stream framing fault.Surface an error and stop; do not wait for done.

Normal SSE transcript

event: message.start
data: {"conversation_id":123,"message_id":457}

event: citation
data: {"document_id":42,"document_title":"Qanun Aceh Budget Transparency","chunk_id":991,"chunk_ordinal":7,"page_number":12,"section_path":["Bab III","Pasal 8"],"legal_reference":"Qanun Aceh No. 1 Tahun 2024 Pasal 8","char_start":1534,"char_end":2088}

event: answer.delta
data: {"text":"Aturan transparansi anggaran mewajibkan publikasi informasi anggaran"}

event: answer.delta
data: {"text":" dan pelaporan yang dapat diakses masyarakat [chunk:991]."}

event: done
data: {"finish_reason":"stop"}

Guard-blocked SSE transcript

event: message.start
data: {"conversation_id":123,"message_id":458}

event: guard.blocked
data: {"reason":"Permintaan tidak dapat dijawab karena melanggar kebijakan."}

event: done
data: {"finish_reason":"guard_blocked"}

curl

Use curl --no-buffer to avoid client-side output buffering.

curl --no-buffer --fail-with-body \
--request POST \
"$API_BASE/query/stream" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--header "Accept: text/event-stream" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'

POST /search raw retrieval

POST /search returns ranked source chunks. It does not synthesize an answer, does not apply the generated-answer guard, and should not be shown to end users as if it were a chat answer. Use it for retrieval inspection, source pickers, citation previews, and debugging search relevance.

No matches is a successful 200 with results: [].

Request fields

FieldTypeRequiredConstraintsMeaning
querystringYesMinimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400.Search text.
modestringNoOne of hybrid, semantic, lexical. Default hybrid.Retrieval strategy.
top_kintegerNoDefault 5; must be 1 through 50.Maximum number of chunks returned after ranking.
collection_idsstring array or nullNoOmitted, null, [], or an array that normalizes to no nonblank IDs means all accessible collections; nonblank IDs are trimmed and deduplicated before scoping.Optional retrieval scope.

Search modes

ModeBehaviorUse when
hybridCombines semantic vector retrieval and lexical PostgreSQL full-text retrieval, then ranks the fused candidates.Default for user-facing retrieval quality.
semanticUses only semantic vector retrieval.You need meaning-based matches without lexical fusion.
lexicalUses only full-text lexical retrieval.You need exact term/statute/string matching behavior.

Request JSON

{
"query": "transparansi anggaran",
"mode": "hybrid",
"top_k": 5,
"collection_ids": ["JDIH", "PPID"]
}

Response fields

FieldTypeMeaning
modestringThe mode used for the request.
resultssearch result arrayRanked chunks. Empty when there are no visible matches.

Search result object:

FieldTypeMeaning
chunk_idintegerSource chunk ID. Mirrors citation.chunk_id.
document_idintegerSource document ID. Mirrors citation.document_id.
ordinalintegerChunk ordinal within the document.
textstringRetrieved chunk text.
scorenumberRetrieval score for ranking. Higher-ranked results appear earlier. Do not compare scores across different modes as a stable API contract.
citationcitation objectFull citation metadata. See Shared citation object.

Response JSON

{
"mode": "hybrid",
"results": [
{
"chunk_id": 991,
"document_id": 42,
"ordinal": 7,
"text": "Pemerintah Aceh mempublikasikan informasi anggaran kepada masyarakat...",
"score": 0.8732,
"citation": {
"document_id": 42,
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": 991,
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088
}
}
]
}

curl

curl --fail-with-body \
--request POST \
"$API_BASE/search" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'

Status and error behavior

Standard error responses are JSON objects:

{"detail":"Invalid or missing API key"}

FastAPI request-validation errors use the standard 422 shape with detail as an array of validation items.

{
"detail": [
{
"type": "greater_than",
"loc": ["body", "top_k"],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {"gt": 0}
}
]
}
StatusApplies toMeaningBody shape
200/queryAnswer returned. A guard refusal is still 200 with the refusal in answer.QueryResponse
200/query/streamSSE stream started after setup completed. The stream terminates with done or error.text/event-stream
200/searchSearch completed, including no matches.SearchResponse with results: [] when empty
400All three routesNon-empty query string is whitespace-only after parsing.{"detail":"Query must not be empty"}
401All three routesAPI key missing/invalid, required principal missing, or malformed principal when trusted auth is required.{"detail":"Invalid or missing API key"}, {"detail":"Authenticated principal required"}, or {"detail":"Invalid authenticated principal"}
404/query, /query/streamConversation ID is unknown or belongs to another subject; or no indexed content is available to answer.{"detail":"..."}
413/query, /query/streamThe answer context would exceed the model context window.{"detail":"..."}
422All three routesJSON schema validation failed, such as query: "", invalid enum, conversation_id ≤ 0, top_k outside 1..50, missing query, or extra fields on /search.{"detail":[...]}
502/query, /query/streamModel or orchestration dependency failed.{"detail":"..."}
502/searchModel dependency failed, such as an embedding or reranker ModelClientError. Unhandled repository faults are not mapped by this handler.{"detail":"..."} for model dependency failures

For /query/stream, setup failures (400, 401, 404, 413, 422, 502) are returned before the SSE body starts. Once a 200 text/event-stream body starts, normal completion is represented by done; a late framing fault is represented by terminal error instead of done.

Production TypeScript client

These helpers are intended for a trusted server, BFF, or same-origin route that can read the API key from server-side configuration. Do not ship the shared API key to browser JavaScript.

type SearchMode = "hybrid" | "semantic" | "lexical";

type Citation = {
document_id: number;
document_title: string;
chunk_id: number;
chunk_ordinal: number;
page_number: number | null;
section_path: string[];
legal_reference: string | null;
char_start: number | null;
char_end: number | null;
confidential?: true;
owning_org_unit?: string;
min_position_level?: number;
};

type QueryRequest = {
query: string;
user_id?: string;
conversation_id?: number | null;
collection_ids?: string[] | null;
};

type QueryResponse = {
answer: string;
conversation_id: number;
message_id: number;
citations: Citation[];
};

type SearchRequest = {
query: string;
mode?: SearchMode;
top_k?: number;
collection_ids?: string[] | null;
};

type SearchResult = {
chunk_id: number;
document_id: number;
ordinal: number;
text: string;
score: number;
citation: Citation;
};

type SearchResponse = {
mode: SearchMode;
results: SearchResult[];
};

type ApiValidationItem = {
type?: string;
loc?: Array<string | number>;
msg?: string;
input?: unknown;
ctx?: Record<string, unknown>;
};

type ApiErrorPayload = { detail: string | ApiValidationItem[] };

class AcehApiError extends Error {
readonly status: number;
readonly detail: string | ApiValidationItem[] | undefined;

constructor(status: number, detail: string | ApiValidationItem[] | undefined) {
super(renderApiDetail(status, detail));
this.name = "AcehApiError";
this.status = status;
this.detail = detail;
}
}

type ClientOptions = {
baseUrl: string;
apiKey: string;
apiKeyHeader?: string;
principalHeaders?: {
subject: string;
role?: string;
unit?: string;
level?: number;
};
fetchImpl?: typeof fetch;
};

function apiHeaders(options: ClientOptions, accept?: string): Headers {
const headers = new Headers();
headers.set("Content-Type", "application/json");
if (accept) headers.set("Accept", accept);
headers.set(options.apiKeyHeader ?? "X-API-Key", options.apiKey);

const principal = options.principalHeaders;
if (principal) {
headers.set("X-User-Subject", principal.subject);
if (principal.role) headers.set("X-User-Role", principal.role);
if (principal.unit) headers.set("X-User-Unit", principal.unit);
if (principal.level !== undefined) headers.set("X-User-Level", String(principal.level));
}

return headers;
}

function endpoint(baseUrl: string, path: string): string {
return new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
}

async function readError(response: Response): Promise<AcehApiError> {
let detail: ApiErrorPayload["detail"] | undefined;
try {
const payload = (await response.json()) as Partial<ApiErrorPayload>;
detail = payload.detail;
} catch {
detail = response.statusText || undefined;
}
return new AcehApiError(response.status, detail);
}

function renderApiDetail(status: number, detail: ApiErrorPayload["detail"] | undefined): string {
if (typeof detail === "string" && detail.trim()) return `API ${status}: ${detail}`;
if (Array.isArray(detail)) return `API ${status}: ${JSON.stringify(detail)}`;
return `API ${status}`;
}

async function postJson<TResponse>(
options: ClientOptions,
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<TResponse> {
const fetcher = options.fetchImpl ?? fetch;
const response = await fetcher(endpoint(options.baseUrl, path), {
method: "POST",
headers: apiHeaders(options),
body: JSON.stringify(body),
signal,
});

if (!response.ok) throw await readError(response);
return (await response.json()) as TResponse;
}

export async function queryJson(
options: ClientOptions,
request: QueryRequest,
signal?: AbortSignal,
): Promise<QueryResponse> {
return postJson<QueryResponse>(options, "/query", request, signal);
}

export async function searchRaw(
options: ClientOptions,
request: SearchRequest,
signal?: AbortSignal,
): Promise<SearchResponse> {
return postJson<SearchResponse>(options, "/search", request, signal);
}

TypeScript SSE parsing with fetch() and ReadableStream

type QueryStreamEvent =
| { event: "message.start"; data: { conversation_id: number; message_id: number } }
| { event: "citation"; data: Citation }
| { event: "answer.delta"; data: { text: string } }
| { event: "guard.blocked"; data: { reason: string } }
| { event: "done"; data: { finish_reason: "stop" | "guard_blocked" } }
| { event: "error"; data: { code: "stream_failed"; message: string } };

type QueryStreamCallbacks = {
onStart?: (data: { conversation_id: number; message_id: number }) => void;
onCitation?: (citation: Citation) => void;
onDelta?: (text: string) => void;
onBlocked?: (reason: string) => void;
onDone?: (finishReason: "stop" | "guard_blocked") => void;
onError?: (error: Error) => void;
signal?: AbortSignal;
};

export async function streamQuery(
options: ClientOptions,
request: QueryRequest,
callbacks: QueryStreamCallbacks,
): Promise<{ conversationId: number; messageId: number; answer: string; citations: Citation[]; blockedReason?: string }> {
const fetcher = options.fetchImpl ?? fetch;
const response = await fetcher(endpoint(options.baseUrl, "/query/stream"), {
method: "POST",
headers: apiHeaders(options, "text/event-stream"),
body: JSON.stringify(request),
signal: callbacks.signal,
});

if (!response.ok) throw await readError(response);
if (!response.body) throw new Error("Streaming response did not include a body");

const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("text/event-stream")) {
throw new Error(`Expected text/event-stream but received ${contentType || "no content-type"}`);
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let conversationId: number | undefined;
let messageId: number | undefined;
let answer = "";
let blockedReason: string | undefined;
const citations: Citation[] = [];

const handleEvent = (frame: QueryStreamEvent): boolean => {
switch (frame.event) {
case "message.start":
conversationId = frame.data.conversation_id;
messageId = frame.data.message_id;
callbacks.onStart?.(frame.data);
return false;
case "citation":
citations.push(frame.data);
callbacks.onCitation?.(frame.data);
return false;
case "answer.delta":
answer += frame.data.text;
callbacks.onDelta?.(frame.data.text);
return false;
case "guard.blocked":
blockedReason = frame.data.reason;
callbacks.onBlocked?.(frame.data.reason);
return false;
case "done":
callbacks.onDone?.(frame.data.finish_reason);
return true;
case "error": {
const error = new Error(`${frame.data.code}: ${frame.data.message}`);
callbacks.onError?.(error);
throw error;
}
}
};

try {
for (;;) {
const { value, done } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
buffer = buffer.replace(/\r\n/g, "\n");

let boundary: number;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const rawFrame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
if (!rawFrame.trim() || rawFrame.startsWith(":")) continue;

const parsed = parseSseFrame(rawFrame);
if (!parsed) continue;
const terminal = handleEvent(parsed);
if (terminal) {
await reader.cancel().catch(() => undefined);
if (conversationId === undefined || messageId === undefined) {
throw new Error("Stream completed without message.start");
}
return { conversationId, messageId, answer, citations, blockedReason };
}
}

if (done) break;
}
} finally {
reader.releaseLock();
}

throw new Error("SSE stream ended before done or error");
}

function parseSseFrame(rawFrame: string): QueryStreamEvent | null {
let eventName = "message";
const dataLines: string[] = [];

for (const line of rawFrame.split("\n")) {
if (!line || line.startsWith(":")) continue;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}

if (dataLines.length === 0) return null;
const data = JSON.parse(dataLines.join("\n")) as unknown;

switch (eventName) {
case "message.start":
case "citation":
case "answer.delta":
case "guard.blocked":
case "done":
case "error":
return { event: eventName, data } as QueryStreamEvent;
default:
throw new Error(`Unexpected SSE event: ${eventName}`);
}
}

A chat UI can render cited streaming answers by collecting citation events, appending answer.delta.text, replacing the answer area with guard.blocked.reason on a block, and treating either done or error as terminal.