rag-gateway 0.4 ships the 🥈 Curl-able RAG milestone: a FastAPI service exposing the full Phase 2 retrieval pipeline as REST endpoints with OpenAPI 3.1 auto-generated docs.
| Route | Purpose |
|---|---|
GET /healthz |
Public liveness probe. |
GET /readyz |
Public readiness probe — wiring completes in the app factory, so ready means fully constructed and routable. |
GET /v1/info |
Public service descriptor (version + endpoint list). |
POST /v1/ingest/document |
Multipart document ingest (Step 1.10). |
POST /v1/query |
Full RAG: understanding → router → optional rerank / pack / LLM generate. |
POST /v1/retrieve |
Retrieval-only subset (no rerank / pack / generate). |
GET /v1/corpora |
List corpora visible to the calling tenant. |
GET /v1/corpora/{id} |
Describe one corpus. |
POST /v1/corpora |
Create a corpus for the calling tenant (admin surface, Step 3.10). |
PATCH /v1/corpora/{id} |
Update a corpus's mutable fields (admin surface). |
DELETE /v1/corpora/{id} |
Delete a corpus owned by the calling tenant (admin surface, Step 3.10). |
GET /openapi.json |
Auto-generated OpenAPI 3.1 spec. |
GET /docs |
Swagger UI. |
GET /redoc |
ReDoc UI. |
# Start the gateway against the dev / noop wiring.
uv run uvicorn rag_gateway.app:app --host 0.0.0.0 --port 8000
# Query it.
curl -s http://localhost:8000/v1/query \
-H 'content-type: application/json' \
-d '{
"tenant_id": "demo",
"principal_id": "curl",
"query": "what is RAG?",
"top_k": 5
}' | jqFull walkthrough — including ragctl ingest then curl /v1/query against a seeded corpus — in docs/guides/curl-quickstart.md.
End-to-end RAG query. Composes QueryUnderstandingPipeline, RetrievalRouter, RerankPipeline, ContextPacker, and (optionally) the wired LLM into a single request.
| Field | Type | Default | Description |
|---|---|---|---|
tenant_id |
string | required | Tenant scope. Every downstream SPI sees this tenant. |
principal_id |
string | required | Acting principal id. Becomes part of the RequestContext. |
query |
string (min_length=1) | required | The user's question or search input. |
corpus_ids |
string[] | [] |
Restrict retrieval to these corpora. Empty = every visible corpus. |
top_k |
int (1..100) | 10 |
Maximum chunks returned from retrieval. |
filters |
object | {} |
Free-form FilterExpr-equivalent dict; translated by the active backend. |
request_id |
string | auto | Caller-supplied request id; auto-generated if absent. Echoed in X-Request-Id response header. |
rerank |
bool | true |
Run the cross-encoder reranker (Step 2.7). |
pack |
bool | true |
Apply ContextPacker budget-fit + reorder (Step 2.8). |
pack_budget |
int (64..128_000) | 2048 |
Token budget for the packer. |
generate |
bool | false |
Call the wired LLM to produce an answer. Default off so the gateway runs creds-free. |
generate_max_tokens |
int (1..32_000) | 1024 |
Cap on output tokens for the LLM call. |
generate_temperature |
float (0..2) | 0.0 |
LLM sampling temperature. |
| Field | Type | Notes |
|---|---|---|
request_id |
string | The id echoed in X-Request-Id. |
query |
string | Echoed input. |
decision |
RoutingDecision |
Which backends ran + per-call weights. |
chunks |
Chunk[] |
Hydrated reranked chunks. Empty when rerank=false. |
chunk_refs |
ChunkRef[] |
Router output. Always populated when retrieval ran. |
citations |
Citation[] |
Per-chunk excerpts ordered by position-in-context. |
packed |
PackedContext | null |
Present iff pack=true. |
answer |
Answer | null |
Present iff generate=true and the LLM call succeeded. See Answer generation. |
timings |
StageTimings |
Wall-time per stage (ms); null for stages that didn't run. |
trace |
TraceContext |
OTel trace info (propagated from traceparent if present). |
| HTTP | Code | Trigger |
|---|---|---|
| 400 | value_error |
Pydantic validation, invalid top_k, etc. |
| 401 | auth_error |
Missing / invalid auth (admin routes; the body-driven routes ignore auth). |
| 403 | acl_denied |
PolicyEngine denied a chunk read or egress. |
| 422 | (FastAPI default) | Request body validation failure with field paths. |
| 429 | rate_limit_error |
Per-tenant rate limit exceeded. |
| 502 | retrieval_error |
Downstream retrieval backend failure. |
| 500 | various | Unhandled error; rare and indicates a bug. |
4xx / 5xx bodies use the GatewayError envelope:
{
"error": {
"code": "retrieval_error",
"message": "vector store unavailable",
"request_id": "abc-123",
"trace_id": "0123456789abcdef...",
"context": {}
}
}When generate=true, the gateway:
- Consults
PolicyEnginewith theegress_textdecision over the packed chunks. Adenyoutcome short-circuits and returnsanswer=null(the chunks + citations are still in the response); atransformoutcome substitutes the transformed chunks before generation. - Builds a numbered context block — each packed chunk gets a 1-based index that matches the citation positions.
- Calls
LLM.completewith a fixed system prompt instructing the model to cite by[1]/[2]/ etc. - Returns an
Answerwith the model output text + citation list (one citation per chunk that was in the prompt) + token-usage metadata.
LLM failures degrade silently: the response still carries chunks + citations + packed context, just with answer=null. The structured-log event gateway.answer.failed records the failure for operator dashboards.
Retrieval-only. Same identifying triple + corpus_ids / top_k / filters as /v1/query; no rerank / pack / generate fields. Returns chunks: ChunkRef[] directly — useful for callers bringing their own reranker.
List corpora visible to the calling tenant. Tenant is read from X-Tenant-Id header in dev mode or extracted from the bearer token in production.
curl -s http://localhost:8000/v1/corpora \
-H 'x-tenant-id: demo' \
-H 'x-principal-id: curl' | jqResponse:
{
"corpora": [
{"id": "docs", "tenant_id": "demo", "display_name": "Docs", ...},
{"id": "wiki", "tenant_id": "demo", "display_name": "Wiki", ...}
],
"total": 2
}Describe one corpus. Cross-tenant lookups return 404, not 403 — this is deliberate. The response shape doesn't distinguish "not found" from "not visible" so callers can't probe for other tenants' corpora.
Create a corpus owned by the calling tenant (the admin write surface behind the console's Corpora page, Step 3.10). The body carries only display fields and embedding metadata — id is minted server-side (corpus_…) and tenant_id is taken from the authenticated request, so a caller can neither choose an id nor create into another tenant. document_count starts at 0 and is maintained by the ingest path. Returns 201 with the created Corpus.
curl -s -X POST http://localhost:8000/v1/corpora \
-H 'content-type: application/json' \
-H 'x-tenant-id: demo' \
-H 'x-principal-id: curl' \
-d '{
"display_name": "Support KB",
"description": "Customer support tickets",
"embedding_model": "text-embedding-3-small",
"embedding_dimension": 1536
}' | jq| Field | Type | Required | Notes |
|---|---|---|---|
display_name |
string | ✓ | Human-readable name (min length 1). |
description |
string | Defaults to "". |
|
embedding_model |
string | null | Informational — documents the model the corpus is embedded with. | |
embedding_dimension |
int | null | Embedding vector dimension (≥ 1). |
|
metadata |
object | Free-form tenant metadata. |
A blank display_name is rejected with 422; a request without tenant / principal credentials is rejected with 401.
Update a corpus's mutable fields — display_name, description, metadata. PATCH semantics: only the fields present in the body change; omitted fields are left untouched. embedding_model / embedding_dimension are immutable (they define the corpus's vector space — changing them would invalidate every embedding already indexed under it), as are id, tenant_id, and document_count. Returns 200 with the updated Corpus. Cross-tenant or unknown ids return 404 — the same invisibility rule as GET, so a caller can neither mutate nor probe for another tenant's corpora.
curl -s -X PATCH http://localhost:8000/v1/corpora/corpus_8f… \
-H 'content-type: application/json' \
-H 'x-tenant-id: demo' \
-H 'x-principal-id: curl' \
-d '{"display_name": "Support KB (EMEA)"}' | jqDelete a corpus owned by the calling tenant. Returns 204 on success. Cross-tenant or unknown ids return 404 — the same invisibility rule as GET, so a caller can neither delete nor probe for another tenant's corpora.
curl -s -X DELETE http://localhost:8000/v1/corpora/corpus_8f… \
-H 'x-tenant-id: demo' \
-H 'x-principal-id: curl' -iThe spec is auto-generated by FastAPI at /openapi.json. Routes carry tags, summary, description (from the route docstring), and responses tables for the documented error codes.
Interactive docs:
GET /docs— Swagger UIGET /redoc— ReDoc
The spec validates against OpenAPI 3.1 as of FastAPI 0.115+.
The gateway middleware (apps/gateway/src/rag_gateway/middleware.py) reads:
| Source | Mode | Notes |
|---|---|---|
Authorization: Bearer <token> |
Production | Resolves to Principal via the wired Auth SPI. |
X-Tenant-Id + X-Principal-Id headers |
Dev / demo | Builds a synthetic Principal with no real verification. |
Request body tenant_id + principal_id |
/v1/query + /v1/retrieve |
These routes accept identity in the body so they're curl-friendly; production deployments should still require Authorization and reject mismatched bodies. |
/v1/corpora reads from headers only (not body). In production, wire a real Auth impl that rejects requests without Authorization:
from rag_gateway.app import build_app
from my_app.auth import ProductionAuth
app = build_app(
auth=ProductionAuth(...),
default_tenant_id=None, # disable dev fallback
)The gateway middleware parses the W3C traceparent header and propagates it into the RequestContext.trace. Each pipeline stage opens its own canonical OTel span:
| Span | Owner |
|---|---|
gateway.query |
The route handler (wraps the per-stage spans). |
gateway.retrieve |
The retrieve route handler. |
query.understand |
QueryUnderstandingPipeline |
retrieve.route |
RetrievalRouter.execute |
rerank |
RerankPipeline |
pack |
ContextPacker |
The gateway.query span carries rag.tenant_id + rag.gateway.{query_chars,rerank,pack,generate,results_n,citations_n,answer_generated,elapsed_ms}.
gateway.query_complete is emitted once per /v1/query call:
{
"event_kind": "gateway.query_complete",
"tenant_id": "demo",
"request_id": "abc-123",
"shape": "semantic",
"chunks_n": 5,
"citations_n": 5,
"answer_generated": true,
"elapsed_ms": 42.1
}Other events: gateway.answer.failed (LLM call failed under generate=true), gateway.answer.policy_denied (PolicyEngine denied the egress check).
For local smoke testing without a live HTTP listener:
ragctl query "what is RAG?" \
--tenant demo \
--top-k 5 \
--no-rerank --no-packDrives the in-process gateway via TestClient against build_app() with the noop wiring. See ragctl.md for the full flag reference.
build_app(...) accepts every pipeline as a keyword argument so production deployments swap noops for real backends:
from rag_gateway.app import build_app
from rag_query.pipeline import QueryUnderstandingPipeline
from rag_retrieval import HybridRetriever, RetrievalRouter, SemanticEmbeddingCache
# ... real backend imports ...
app = build_app(
understanding=QueryUnderstandingPipeline(
rewriter=OpenAIQueryRewriter(api_key=...),
decomposer=LLMQueryDecomposer(...),
hyde=HyDEGenerator(llm=anthropic_llm, embedder=openai_embedder, embedding_cache=SemanticEmbeddingCache(redis_embedding_cache)),
),
retrieval_router=RetrievalRouter(
hybrid=HybridRetriever(
vector_backend=PgVectorStore(...),
keyword_backend=ElasticsearchKeywordStore(...),
graph_backend=Neo4jGraphStore(...),
),
embedder=openai_embedder,
),
reranker=production_reranker,
packer=ContextPacker(),
llm=anthropic_llm,
corpus_store=PostgresCorpusStore(...),
auth=ProductionAuth(...),
policy_engine=ProductionPolicyEngine(...),
default_tenant_id=None,
)See architecture/gateway.md for the design rationale and the dependency injection pattern.
rag_gateway.serve:create_app (v1.0.1) is the production serve entrypoint for containers and Helm — a uvicorn factory that picks its wiring from the environment:
RAG_CONFIG_PATH |
App served |
|---|---|
set to a readable rag.yaml |
build_app_from_config(load(path)) — config-driven backends, corpora, tenants, governance |
| unset / empty | build_app() — noop wiring; every route serves with zero infrastructure |
| set but missing / invalid | fails fast with ConfigError — a typo'd mount crashes the pod loudly instead of silently serving noop wiring in production |
# The gateway image's default CMD — no arguments needed:
uvicorn rag_gateway.serve:create_app --factory --host 0.0.0.0 --port 8000
# Bare container — serves the noop-wired app on :8000:
docker run -p 8000:8000 ghcr.io/officialcodework/agentcontextos/rag-gateway:1.0.1
# Config-driven — mount a rag.yaml and point RAG_CONFIG_PATH at it:
docker run -p 8000:8000 \
-v "$PWD/rag.yaml:/app/config/rag.yaml:ro" \
-e RAG_CONFIG_PATH=/app/config/rag.yaml \
ghcr.io/officialcodework/agentcontextos/rag-gateway:1.0.1The chart wires this end-to-end: it renders .Values.config.ragYaml (or a minimal valid default) into a ConfigMap, mounts it at /app/config/rag.yaml, and sets RAG_CONFIG_PATH to match. ${VAR} / ${VAR:-default} references inside the file are interpolated from the container environment at load time, so secrets stay in Secret-injected env vars (e.g. ${RAG_POSTGRES_PASSWORD}) and never land in the ConfigMap. RagConfig is strict (extra='forbid'): an unknown key fails validation and the pod CrashLoops with the offending field path in the error — by design, so a malformed config is impossible to miss.
Probes: GET /healthz is liveness, GET /readyz is readiness. All wiring happens inside the app factory before uvicorn binds the socket, so a ready response means the app is fully constructed and routable.
- architecture/gateway.md — design + middleware rationale.
- guides/curl-quickstart.md — 5-minute demo walkthrough.
- reference/query.md —
QueryUnderstandingPipeline. - reference/router.md —
RetrievalRouter. - reference/reranker.md —
RerankPipeline. - reference/packer.md —
ContextPacker.