# 03 — Data flow
Two flows exist. They meet only at the Qdrant collection.
## Flow A — document ingestion (offline)
```mermaid
flowchart TD
PDF[/"data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf
1,668 pages"/]
SPANS["extract_spans (PyMuPDF)
+ merge_outlined_runs"]
GLYPH["scan_glyph_order / scan_reading_order
sanity gate, reports only"]
REG["_region_index:
table_regions.json + formula_regions_2d.json"]
ASM["segment.assemble
monograph + section detection,
table lift-out, quarantine"]
MONO[/"data/processed/monographs.jsonl
684 monographs"/]
PMAP["build_page_map
physical → printed folio"]
CHUNK["chunk_all
section → chunk, 800-token ceiling"]
CHUNKS[/"data/processed/chunks.jsonl
15,100 chunks, schema v4"/]
GATES["cli chunk-ready
named gates, all must be 0"]
EMBED["load.run: CachingEmbeddingProvider
cohere.embed-v4:0, input_type=search_document"]
CACHE[/"data/processed/embeddings/*.jsonl
keyed by (model, kind, sha256(text))"/]
LOADER["ChunkLoader
uuid5 point ids, batch 256"]
QD[("Qdrant duocthu_v1")]
MAN[("Qdrant duocthu_v1__manifest
corpus sha · model · dims")]
PDF --> SPANS --> ASM
PDF --> GLYPH
REG --> ASM
ASM --> MONO --> CHUNK --> CHUNKS
PDF --> PMAP --> CHUNK
MONO --> GATES
CHUNKS --> GATES
CHUNKS --> EMBED --> CACHE --> LOADER --> QD
LOADER --> MAN
```
Intermediate artifacts are real files that exist on disk today
([04-ingestion-pipeline.md](04-ingestion-pipeline.md) lists their sizes). The
embed step is separable (`--embed-only`) and cached, so an interrupted run
resumes without re-paying Bedrock.
## Flow B — a user question (live)
```mermaid
sequenceDiagram
autonumber
actor U as Clinician
participant W as web (Next.js)
participant MW as middleware.ts
participant API as ai-service /v1/rag/query
participant AG as RagAgent
participant LLM as Bedrock Converse
participant RS as RetrievalService
participant QD as Qdrant
participant GA as GroundedAnswerService
participant PG as PostgreSQL
U->>W: POST /api/chat {content, conversationId}
W->>MW: rate-limit by client IP
MW-->>W: allow (or 429)
W->>API: POST /v1/rag/query
{query, subject_scope:"human", intent:"fact_lookup", conversation_id}
Note over API: resolve_subject_scope() re-derives scope
from the text — the caller's claim cannot widen it
API->>AG: handle(turn, conversation_id)
AG->>PG: recent(conversation_id, 12) — fail-open
AG->>AG: CatalogDrugResolver bounds candidate drug_ids
AG->>LLM: [1] understanding → QueryFrame (JSON)
AG->>AG: _route(): turn_type + deterministic guards
alt clarify / abstain / smalltalk
AG-->>API: AgentReply (no retrieval)
else answerable
AG->>RS: retrieve_framed(drug_id, section_key, query)
RS->>QD: scroll by payload filter (whole section)
QD-->>RS: chunks, re-sorted by part_index
RS->>RS: _decide(): provenance + quarantine gate
AG->>GA: answer_from_result(...)
GA->>LLM: [2] generation → {claims[], evidence_sufficient, ...}
GA->>GA: grounding.verify() — numbers/citations, deterministic
GA->>LLM: [3] entailment → {entailed, unsupported, complete, missing_evidence}
GA-->>AG: GroundedAnswer + citations
end
AG->>PG: append(conversation_id, lines) — fail-open
API->>PG: save(trace) — fail-open
API-->>W: RagQueryResponse (decision, answer, blocks, citations, disclaimer)
W->>W: map reason → Vietnamese; group citations by chunk_id
W-->>U: SendMessageResponse
```
## What is carried at each hop
| Hop | Payload |
|---|---|
| Browser → web | `{content, conversationId}` |
| web → ai-service | `{query, subject_scope, intent, conversation_id}` + `X-Correlation-ID`, optional `traceparent`/`tracestate` |
| understanding LLM | Candidate drug shortlist (drug_id + name), 19 section keys with glosses, prior known-facts block, history, current turn |
| Qdrant | Payload filter only for the section route (`drug_id` + `section_key`); a 1024-d vector for the dense fallback |
| generation LLM | Numbered evidence blocks, each prefixed `(drug_id=…; thuốc=…; mục=…)`, plus a presentation plan and the fenced user question |
| entailment LLM | Each claim paired with only the evidence block(s) it cited, plus the whole selected evidence set |
| ai-service → web | `decision`, `reason`, `answer`, `blocks[]`, `citations[]`, `quick_replies[]`, `answer_plan`, `candidate_assessments[]`, `disclaimer`, `trace_id`, `correlation_id`, `otel_trace_id` |
## Identifier flow
One identifier threads the whole system:
```
chunk_id = "{drug_id}__{section_key}__{part_index}"
```
- **Written** by `ingestion/chunk/chunker.py`
- **Point id** = `uuid5(POINT_NAMESPACE, chunk_id)` — derived, so a re-load
overwrites rather than duplicates (`ingestion/load/models.py`)
- **Filtered on** in Qdrant (`chunk_id` has a keyword index)
- **Returned** as `Citation.chunk_id` and as `AnswerClaim.source_ids`
- **Split** by `answer.py::_section_key` to pick a block title, and by
`web/app/api/chat/route.ts` to recover the drug slug per citation
- **Persisted** in `rag_retrieval_trace.citations` (jsonb)
The block-descriptor variant is
`{drug_id}__{section_key}__block__{table_id}`.
Correlation identifiers: `X-Correlation-ID` (validated against
`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`, regenerated if malformed) and the
OpenTelemetry trace id are both echoed in response headers and stored on the
trace row (`migrations/003`).
## Error / fallback flow
```mermaid
flowchart TD
Q[Turn received] --> SCOPE{looks_non_human?}
SCOPE -->|yes| AB1["abstain: out_of_scope"]
SCOPE -->|no| UND[understanding LLM]
UND -->|provider error| AB2["abstain: understanding_provider_unavailable"]
UND -->|unparseable JSON| AB3["abstain: understanding_malformed_output"]
UND --> ROUTE{route}
ROUTE -->|missing required field| CLR[clarify]
CLR --> BRK{4th consecutive clarify?}
BRK -->|yes| AB4["abstain: clarify_loop_exhausted"]
BRK -->|no| OUT1[return question]
ROUTE --> RET[retrieval]
RET -->|no evidence| AB5["abstain: parent_hydration_failed"]
RET -->|missing source_refs| AB6["abstain: missing_provenance"]
RET -->|quarantined content| VP["verify_pdf: notice + source page"]
RET -->|ok| GEN[generation LLM]
GEN -->|budget out| AB7["abstain: request_budget_exhausted"]
GEN -->|provider error| AB8["abstain: provider_unavailable"]
GEN -->|bad JSON| AB9["abstain: malformed_output"]
GEN -->|insufficient ×2| AB10["abstain: evidence_insufficient"]
GEN --> GR[grounding.verify]
GR -->|number not in cited block| AB11["abstain: ungrounded_number"]
GR -->|marker out of range| AB12["abstain: invalid_citation"]
GR -->|claim with no citation| AB13["abstain: uncited_claim"]
GR --> ENT[entailment LLM]
ENT -->|not entailed| AB14["abstain: unsupported_claim"]
ENT -->|incomplete| REP[repair regeneration]
REP -->|still incomplete| AB15["abstain: incomplete_answer"]
ENT -->|ok| OK[answerable + citations]
```
Every terminal box above is a distinct `reason` string, and every one of them
has an explicit Vietnamese message in
`apps/web/app/api/chat/route.ts::REFUSALS`. That mapping is load-bearing: an
unmapped reason falls through to `GENERIC_REFUSAL`, which reads as "no data in
the formulary" and would misdescribe an outage.