OPEA Microservices

Architecture of the OPEA overlay layer — chatqna, retriever, reranker, dataprep, embedding, textgen — and the inter-service contract surface between them.

OPEA Microservices

The OPEA overlay layer (genie-ai-overlay/) is where the RAG pipeline actually runs. This page is the architectural reference for the layer: which microservice owns which concern, what they exchange with each other and with the backend, and where the per-service contract surfaces live.

Audience: Integrators extending the RAG pipeline, deployers debugging inter-service issues, and reviewers tracing a failure through the stack. For tuning knobs and operator-facing config, see the RAG Pipeline section.


1. Service Map

graph TB
    BE[Backend / BFF] -->|Bearer token + traceparent| CHATQNA[chatqna<br/>Orchestrator]
    BE -->|admin client_credentials| DATAPREP[dataprep<br/>Ingestion]
    CHATQNA -->|Bearer token + traceparent| RETRIEVER[retriever<br/>Vector + graph search]
    CHATQNA -->|traceparent| RERANKER[reranker<br/>TEI cross-encoder]
    CHATQNA -->|traceparent| VLLM[vLLM<br/>LLM inference]
    CHATQNA -->|traceparent| TRANS[Translation<br/>vLLM-based]
    CHATQNA -->|traceparent| EMB[embedding<br/>TEI wrapper]
    RETRIEVER --> ADB[(ArangoDB)]
    EMB --> TEI_EMB[TEI Embedding]
    RERANKER --> TEI_RERANK[TEI Reranking]
    DATAPREP --> ADB
    DATAPREP --> DOC[Docling<br/>Content extraction]
    DATAPREP -->|LLM labeling| VLLM
    BE --> ADB
    DR[Document Repository] --> DATAPREP
ServiceSourceOwnsKey contract surface
chatqnagenie-ai-overlay/chatqna/genieai_chatqna.pyOrchestrates retrieval → rerank → generation. Validates Bearer token via JWKS. Emits the user-facing SSE stream + confidence_score.POST /v1/chatqna (called by backend), reads/writes source_documents field, emits confidence_score metadata event
retrievergenie-ai-overlay/retriever/Dense + BM25 (lexical) hybrid retrieval against ArangoDB. Optional graph traversal.RETRIEVER_ARANGO_* (k, fetch_k, score_threshold, traversal, hybrid, distance, search_mode), label-filter data contract encoded in search_start
rerankergenie-ai-overlay/reranker/genieai_tei_reranker.pyTEI cross-encoder rescoring + strategy selection (slice, threshold, slice_threshold, knee_threshold, adaptive).RERANKING_STRATEGY, RERANKER_TOP_N, RERANKING_THRESHOLD, NOVELTY_SIGMOID_*, CONTEXT_DECAY_FACTOR, MIN_VALUE_THRESHOLD
embeddinggenie-ai-overlay/embedding/TEI embedding microservice wrapper — single endpoint shared by dataprep (ingest) and chatqna (query).EMBEDDING_SERVER_ENDPOINT, EMBEDDING_MODEL_ID, EMBEDDING_SERVICE_URL
textgengenie-ai-overlay/textgen/vLLM wrapper service — chat, translation, and labeling all call into vLLM through this.VLLM_ENDPOINT, VLLM_LLM_MODEL_ID, VLLM_API_KEY
dataprepgenie-ai-overlay/dataprep/genieai_dataprep_arangodb.pyDocling parse → dynamic-size chunk → contextual prefix (optional) → LLM labeling → vector embedding → ArangoDB write. Emits the ingestion_log to the backend.POST /v1/dataprep/ingest_file, POST /v1/dataprep/retract_file (called by Document Repository via admin service account)
translationseparate OPEA microserviceTarget-language chat translation (streaming or post-generation).TRANSLATION_BACKEND, VLLM_TRANSLATION_*, STREAMING_TRANSLATION_ENABLED

2. Request Flow (ChatQnA Pipeline)

sequenceDiagram
    participant BE as Backend
    participant CQ as ChatQnA
    participant Em as Embedding
    participant Ret as Retriever
    participant Re as Reranker
    participant LLM as vLLM (textgen)
    participant TR as Translation
    participant ADB as ArangoDB

    BE->>CQ: POST /v1/chatqna (Bearer + traceparent + query + history)
    CQ->>CQ: Validate JWT (JWKS) + extract user
    CQ->>BE: GET /api/me/context (user profile for prompt enrichment)
    BE-->>CQ: { name, role, emailVerified }
    CQ->>Em: encode (query) — single batched call including history turns
    Em-->>CQ: { query_emb, history_embs }
    CQ->>Ret: search (embedding + filter labels + history blend)
    Ret->>ADB: ArangoSearch (hybrid: dense + BM25, optional graph traversal)
    ADB-->>Ret: ranked candidates
    Ret-->>CQ: top-N candidates with metadata
    CQ->>Re: rerank (candidates, query, history)
    Re-->>CQ: strategy-selected chunks with scores
    CQ->>CQ: assemble source_documents + compute confidence_score
    CQ->>LLM: chat (system + context + history + query, stream=True)
    LLM-->>CQ: SSE chunks
    CQ->>CQ: strip internal markers, collapse markdown blanks
    opt STREAMING_TRANSLATION_ENABLED=1
      CQ->>TR: stream-translate per markdown unit
      TR-->>CQ: translated unit
    end
    CQ-->>BE: SSE: data chunks + metadata event + [DONE]

What the backend sends to ChatQnA

FieldRequiredSource
messagesyesTrimmed conversation history (MULTI_TURN_HISTORY_TURNS default 1)
queryyesCurrent user message
streamyesMirrors OPEA_STREAMING; backend reads OPEA_STREAMING from env
top_n, reranking_strategy, reranking_threshold, traversal_max_depth, traversal_max_returned, traversal_score_threshold, distance_threshold, lambda_mult, score_thresholdnoTop-level per-request override fields (no wrapper block) — see §5
Bearer tokenyesForwarded from the user’s request, validated independently in ChatQnA
traceparentyesW3C trace context propagated from backend

What ChatQnA returns to the backend

Two modes, controlled by OPEA_STREAMING:

Non-streaming (OPEA_STREAMING=false):

{
  "text": "<final answer, internal markers stripped>",
  "source_documents": [
    { "document_id": "<doc_key>", "score": 0.91, "metadata": { ... } },
    ...
  ],
  "metadata": {
    "retrieval_confidence_score": 0.78,
    "confidence_score": 0.82,
    "is_grounded": true,
    "self_confidence": 67   // only when LLM_SELF_CONFIDENCE_ENABLED=1
  }
}

Streaming (OPEA_STREAMING=true):

  • A sequence of SSE events. Each non-terminal event is data: {"text":"<delta>"}.
  • A single metadata event (typically just before [DONE]) carries the source_documents and confidence metadata.
  • Terminal: data: [DONE].

Internal-marker stripping. ChatQnA scrubs |<-MSG->|, USER:, ASSISTANT: markers and collapses excess blank runs before the text reaches the user or the translation pipeline. This is essential to prevent prompt-template leakage.


3. Trust Boundaries (Microservice Level)

Each OPEA microservice that touches the network has its own authentication posture:

ServiceInbound authOutbound authNotes
chatqnaKeycloak JWT (JWKS) — validates Bearer on every requestNone to retriever/reranker/embedding/textgen (internal network)Extracts user identity (sub, iss, roles) from the JWT payload for audit + prompt enrichment
retrieverNone (internal network only)ArangoDB credentials from ARANGO_URL/ARANGO_USER/ARANGO_PASSWORDReads only — no write access
rerankerNoneTEI Reranking HTTP endpointReads only
embeddingNoneTEI Embedding HTTP endpointShared by chatqna (query) and dataprep (ingest)
textgenNonevLLM HTTP endpoint (Bearer VLLM_API_KEY)OpenAI-compatible; also used for labeling and translation
dataprepKeycloak client_credentials — dedicated service account (KC_DATAPREP_CLIENT_ID/SECRET)Same internal endpoints as above, plus backend ingestion-log endpoint for diagnosticsService account is separate from user tokens; permissions scoped to ingestion
translationNonevLLM translation endpoint (Bearer VLLM_API_KEY)Called from ChatQnA only

Failure mode: if ChatQnA’s JWKS call to Keycloak fails (Keycloak down or unreachable), chatqna returns 401/500 — the backend surfaces this as a 5xx to the user. The JWKS cache absorbs short Keycloak hiccups; cache miss on every request is a sign Keycloak itself is misconfigured.

ChatQnA token validation rules

genie-ai-overlay/chatqna/keycloak_token_validator.py (post-commit 2e5cb17b6 — final state) implements a standard OIDC Resource Server posture:

  • No anonymous access. A request without a Bearer token (or with a malformed Authorization header) is rejected with 401 UNAUTHENTICATED before the JWKS call. There is no service-account fallback path for chatqna — every chat call must carry the originating user’s JWT.
  • Per-request token forwarding. ChatQnA receives the user’s Bearer token on every request (forwarded by the backend, which itself received it from Kong). Dataprep, in contrast, uses a dedicated service account (KC_DATAPREP_CLIENT_ID/SECRET) and never sees a user token.
  • JWKS-based signature validation. Tokens are verified against the Keycloak JWKS (no shared secret, no HS256). The validator fetches the JWKS from KEYCLOAK_INTERNAL_URL/realms/<KEYCLOAK_REALM>/protocol/openid-connect/certs and caches it.
  • What is validated. signature (via JWKS), iss (must match the configured realm), exp (rejects expired tokens).
  • What is NOT validated. azp (Authorized Party) is deliberately NOT checked — this aligns ChatQnA with gov-chat-backend and document-repository, which all follow standard OIDC Resource Server semantics where audience verification alone (aud, when present) is enough. The per-flavor KEYCLOAK_CLIENT_ID override was removed because it broke mobile auth (mobile tokens carry a different azp than web tokens).
  • verify_aud: False. Keycloak 26+ uses aud=account on user tokens; ChatQnA trusts iss + exp + signature instead.

Failure mode: an unauthenticated request returns 401; a malformed token returns 401 TOKEN_INVALID; a token whose iss does not match the configured realm returns 401 INVALID_ISSUER. All three are surfaced to the user as 401 from the BFF (the backend does not auto-retry).


4. Telemetry Architecture

All OPEA services emit OTel spans through the shared genie-ai-overlay/tracing.py. The trace context flows via W3C traceparent headers — backend injects on the first hop, every downstream service extracts and propagates.

Dataprep spans (instrumented in genieai_dataprep_arangodb.py)

SpanPhaseKey attributes
dataprep.ingestingest entrydataprep.file_type, dataprep.file_size_bytes, dataprep.file_id
dataprep.retractretract entrydataprep.file_id
dataprep.chunkingdocling + chunkdataprep.chunk_count
dataprep.llm.label_chunksingle-chunk label calldataprep.chunk_index, dataprep.llm_attempt, dataprep.llm_batched=False, dataprep.llm_model, dataprep.labels_suggested, dataprep.llm.completion_tokens
dataprep.llm.label_batchbatched label calldataprep.llm_batched=True, dataprep.llm_batch_size, dataprep.chunk_indices, dataprep.llm_model, dataprep.labels_suggested, dataprep.llm.prompt_tokens

ChatQnA spans

SpanPhaseNotes
chatqna.orchestrateorchestrator entryRoot span for the RAG pipeline inside chatqna (genieai_chatqna.py)
chatqna.reranker_selectionreranker strategy selectionWhich strategy was applied to the candidates
retriever.hybrid_searchretriever callEmitted by the retriever service (ArangoDB hybrid vector + BM25 + graph)
reranker.rerankreranker callEmitted by the reranker service
reranker.tei_invokeinner TEI callInner span inside the reranker for the TEI HTTP call

For full per-phase breakdown and an end-to-end debug recipe (fetch a trace by ID, find the bottleneck, decode the traceparent chain), see the Debugging with Tracing & Logs developer KB (.claude/rules/DEBUGGING-TRACING.md in the repo).


5. Per-Request Overrides (ChatQnA)

The request body fields below act as per-request overrides over the deployment defaults (RERANKER_TOP_N, RERANKING_STRATEGY, RETRIEVER_ARANGO_K, RETRIEVER_ARANGO_FETCH_K, RETRIEVER_ARANGO_FILTER_STRATEGY, CONFIDENCE_RANK_DECAY, RERANKER_SCORE_CALIBRATION, RERANKER_SCORE_TEMPERATURE). They are top-level fields on the ChatQnA request (e.g. top_n, reranking_strategy), not nested inside a wrapper block — see genie-ai-overlay/chatqna/genieai_chatqna.py (chat_request.top_n, chat_request.reranking_strategy, GenieaiRetrieverParms, GenieaiRerankerParms). This is how the same chatqna service can serve different retrieval strategies for different user requests without restart.

Request fieldMaps toDefaultNotes
top_nRERANKER_TOP_N3Final chunks forwarded to the LLM
reranking_strategyRERANKING_STRATEGYsliceSee Architecture — §9.3 Reranker Strategies
reranking_thresholdRERANKING_THRESHOLD(env)Threshold used by threshold / slice_threshold only — slice, knee_threshold and adaptive never read it
traversal_max_depthRETRIEVER_TRAVERSAL_MAX_DEPTH(env)Graph traversal depth
traversal_max_returnedRETRIEVER_TRAVERSAL_MAX_RETURNED(env)Graph traversal fan-out
traversal_score_thresholdRETRIEVER_TRAVERSAL_SCORE_THRESHOLD(env)Minimum graph-edge score to follow
distance_thresholdRETRIEVER_DISTANCE_THRESHOLD(env)MMR pool distance cutoff
lambda_multRETRIEVER_LAMBDA_MULT(env)MMR diversity vs relevance weight
score_thresholdRETRIEVER_SCORE_THRESHOLD(env)Minimum retriever score

Safety: per-request overrides cannot weaken global safety knobs (e.g. cannot disable Contextual Retrieval once it is enabled at the service level). The override is a forwarding mechanism, not an authentication-bypass one.


6. The Label-Filter Data Contract

A subtle but load-bearing detail: OPEA’s framework drops custom fields that aren’t part of its known schema. The retriever therefore receives category labels encoded inside the search_start parameter (a string with a ::labels: separator), not as a separate top-level field.

# chatqna → retriever
from core.label_contract import encode_filter_labels

search_start = encode_filter_labels("chunk", ["Agriculture.Crops.Corn", "Agriculture.Crops.Cassava"])
# => "chunk::labels:Agriculture.Crops.Corn,Agriculture.Crops.Cassava"

The retriever parses this back out (decode_filter_labels(search_start) in genie-ai-overlay/core/label_contract.py) and uses the label list to build the AQL FILTER clause before the vector search. If a chunk arrives without an expected label (dataprep returned []), the chunk is still stored but will not surface under a strict label filter at query time.

For the label-driven retrieval behavior (label selector prompt, taxonomy alignment, common pitfalls), see Knowledge Base — Labelling & Taxonomy.


7. Inter-Service Failure Modes

SymptomLikely causeWhere to look
Chat returns 401 to a logged-in userChatQnA JWKS validation failed — Keycloak unreachable or iss/aud mismatchdocker logs <chatqna> — JWKS errors. Verify KEYCLOAK_URL matches the public URL in the JWT
Chat returns “no relevant documents” for a known-good queryRetriever returns empty — could be label filter too strict, embedding model mismatch, or traversal disabledCheck the retriever.hybrid_search span (emitted by the retriever service); the trace shows the AQL FILTER clause and result count
LLM hallucinates instead of abstainingCHATQNA_ENFORCE_ABSTENTION=false or the system prompt override is too weakSee CHATQNA_ABSTENTION_INSTRUCTIONS and CHATQNA_ENFORCE_ABSTENTION env vars
Translation never appears in the streamSTREAMING_TRANSLATION_ENABLED=0 (default) — output is translated post-generation (English-then-flip), not per-unitSet STREAMING_TRANSLATION_ENABLED=1 for per-unit streaming; verify VLLM_TRANSLATION_ENDPOINT reachable
Ingestion “completes” but chunks are missing labelsLabel selector LLM call failed silently (per-chunk fallback to raw chunk + no context for embedding)Check dataprep.llm.label_chunk spans — look for llm_attempt > 1 or JSONDecodeError. Verify VLLM_LLM_MODEL_ID supports guided JSON (response_format={"type":"json_object"})
All chat requests time outvLLM is down or saturated; the embedding model call hangscurl <VLLM_ENDPOINT>/health; check gpu=true node labels and docker service ls | grep vllm
ChatQnA rejects Bearer tokensWrong aud claim — ChatQnA’s expected audience doesn’t match what Keycloak issuesVerify ChatQnA’s KEYCLOAK_AUDIENCE (or equivalent) and the client config in keycloak-config/

8. Verifying After a Stack Update

After upgrading any OPEA service image, run this verification sequence before opening it to users:

# 1. All OPEA services are healthy in Swarm
docker service ls | grep -E "chatqna|retriever|reranker|dataprep|embedding|textgen"

# 2. Internal health endpoints respond
docker exec $(docker ps --format "{{.Names}}" | grep chatqna | head -1) curl -s http://localhost:8888/health
docker exec $(docker ps --format "{{.Names}}" | grep retriever | head -1) curl -s http://localhost:7000/health
docker exec $(docker ps --format "{{.Names}}" | grep reranker | head -1) curl -s http://localhost:8000/health

# 3. End-to-end smoke: ask a known question, verify a trace lands in VictoriaTraces
# (use the API contracts doc for curl examples with a Keycloak ROPC token)
# Then query VictoriaTraces for the trace:
TRACE_ID=<from response headers>
docker exec $(docker ps --format "{{.Names}}" | grep backend | head -1) \
  curl -s "http://victoriatraces:10428/select/jaeger/api/traces/$TRACE_ID" | jq '.data[0].spans | length'
# Expected: an auto-instrumented backend HTTP server span (named after the route, e.g. `POST /api/chat`) plus chatqna.orchestrate, chatqna.reranker_selection, retriever.hybrid_search, reranker.rerank, and reranker.tei_invoke — at least 6 spans total, more if dataprep runs

A trace that returns fewer spans than expected means one stage silently no-ops — usually a service that’s reachable but misconfigured (e.g. TEI pointing at the wrong embedding model).


9. Where to Go Next

ConcernWhere
End-to-end RAG flow with operator-facing configRAG Pipeline
Ingestion stages (parse, chunk, label, embed)Knowledge Base — Ingestion
Confidence score derivationArchitecture — §9.4 Confidence Scoring
Contextual RetrievalArchitecture — §10.2 Ingestion Flow and Data Labelling Strategy §7
Multi-turn retrievalMulti-Turn Retrieval
HTTP API shapesAPI Contracts — Backend
Live trace debuggingDebugging with Tracing & Logs (developer KB, .claude/rules/DEBUGGING-TRACING.md in the repo)