Production agentic retrieval-augmented generation system.
React chat UI → FastAPI → LangGraph (memory → retrieve → rerank → augment) → GPT-5.6 Luna token stream. Optional OpenAI web search. Embeddings: Alibaba tongyi-embedding-vision-flash (768-d). Rerank: qwen3-rerank. State in PostgreSQL. Vectors in Qdrant. Deploy on AWS ECS Fargate.
- High-level architecture
- Component map
- Request path
- LangGraph agent
- RAG pipeline
- Memory model
- Context window
- Auth
- Database design
- Vector store
- Ingest
- Streaming contract
- Frontend UX
- Local vs AWS
- Harness
- API
- Repo layout
- Run it
- Env
flowchart LR
U[User] --> FE[React UI<br/>Vite + Nginx]
FE -->|HTTPS / JWT| ALB[ALB]
ALB -->|/ | FE
ALB -->|/api /health /ready| API[FastAPI]
API --> PG[(PostgreSQL<br/>users chats messages<br/>documents memories)]
API --> QD[(Qdrant<br/>768-d cosine)]
API --> OA[OpenAI<br/>gpt-5.6 + web_search]
API --> DS[Alibaba Model Studio<br/>embed + rerank]
API --> S3[(S3 / local disk)]
Two data planes:
| Plane | Store | What lives there |
|---|---|---|
| Relational | PostgreSQL | Identity, chat history (STM source), document metadata, extracted LTM rows |
| Vector | Qdrant | Chunk embeddings, memory embeddings, ANN + similar-passage search |
flowchart TB
subgraph Client
SB[Sidebar<br/>new chat + history + files]
PB[Prompt box<br/>+ file/image + web search]
CV[Chat view<br/>markdown + SSE cursor]
PS[Passages panel<br/>ranked + similar]
end
subgraph API["backend/app"]
AUTH[auth routes]
CHAT[chats + SSE]
DOC[documents]
SRCH[semantic / ranked / similar]
MW[ASGI middleware<br/>request-id · rate limit · headers]
end
subgraph Agent["LangGraph"]
M[memory]
R[retrieve]
A[augment]
G[generate / stream]
end
subgraph RAG
P[parser]
C[chunker]
E[embed]
RK[rerank]
CTX[context packer]
end
SB --> AUTH
PB --> CHAT
PB --> DOC
CV --> CHAT
PS --> SRCH
CHAT --> Agent
DOC --> P --> C --> E
M --> PG[(Postgres)]
M --> QD[(Qdrant)]
R --> E
R --> QD
R --> RK
A --> CTX
G --> OA[GPT-5.6 Luna]
Chat turn from the browser to tokens on screen.
sequenceDiagram
autonumber
actor User
participant UI as React
participant API as FastAPI
participant PG as PostgreSQL
participant LG as LangGraph
participant QD as Qdrant
participant DS as DashScope
participant OA as OpenAI Luna
User->>UI: type + optional files + web search
UI->>API: POST /api/documents (if files)
API->>DS: embed chunks / image
API->>QD: upsert points
UI->>API: POST /api/chats/{id}/stream JWT
API->>PG: insert user message
API-->>UI: SSE status=retrieving
API->>LG: memory → retrieve → augment
LG->>PG: last N messages (STM)
LG->>DS: query embedding
LG->>QD: ANN search user_id filter
LG->>DS: qwen3-rerank
LG-->>API: passages + packed context
API-->>UI: SSE passages
API->>OA: Responses stream + optional web_search
OA-->>UI: SSE token / tool / citation
API->>PG: insert assistant message
API->>OA: extract durable facts
API->>QD: upsert LTM vectors
API-->>UI: SSE done
Graph is compiled per request (closes over the DB session).
stateDiagram-v2
[*] --> memory
memory --> retrieve
retrieve --> augment
augment --> [*]
| Node | Job |
|---|---|
memory |
STM = last STM_MESSAGE_LIMIT messages. LTM = ANN over memories collection |
retrieve |
Query embed → Qdrant top-k → qwen3-rerank top-n. Fail-open if embed/rerank is down |
augment |
Pack LTM + STM + passages + question into CONTEXT_WINDOW_TOKENS |
Generation is outside the graph so FastAPI can stream SSE without buffering the whole answer.
flowchart LR
Q[User question] --> SAN[sanitize / injection guard]
SAN --> MEM[memory node]
MEM --> RET[retrieve + rank]
RET --> AUG[augment / budget]
AUG --> GEN[GPT-5.6 stream]
GEN --> PERSIST[save message]
PERSIST --> LTM[maybe extract LTM]
Classic parse → chunk → embed → retrieve → rank → augment → generate.
flowchart TB
F[PDF / DOCX / text / image] --> PARSE[parser]
PARSE -->|text| CHUNK[recursive chunker<br/>size 800 · overlap 120]
PARSE -->|image| VEMB[vision embed<br/>tongyi-embedding-vision-flash]
CHUNK --> TEMB[document embed<br/>text_type=document]
TEMB --> QD[(Qdrant documents)]
VEMB --> QD
UQ[User query] --> QEMB[query embed<br/>text_type=query]
QEMB --> ANN[cosine ANN top-k=20]
QD --> ANN
ANN --> RR[qwen3-rerank top-n=6]
RR --> PACK[context window packer]
STM[short-term messages] --> PACK
LTM[long-term facts] --> PACK
PACK --> LLM[GPT-5.6 Luna]
WEB{web_search on?} -->|yes| TOOL[hosted web_search]
TOOL --> LLM
LLM --> OUT[SSE tokens + citations]
| Step | Model / code | Notes |
|---|---|---|
| Parse | rag/parser.py |
PDF pages, DOCX, text, images |
| Chunk | rag/chunker.py |
Headings / paragraphs, then windows |
| Embed | tongyi-embedding-vision-flash |
768-d, independent multimodal vectors |
| Retrieve | Qdrant cosine | Filter user_id (+ optional document_id) |
| Rank | qwen3-rerank |
Compatible /reranks API |
| Augment | rag/augmenter.py |
Priority pack into token budget |
| Generate | gpt-5.6 |
Responses API, optional web_search |
Semantic search vs similarity:
flowchart LR
subgraph Semantic
Q1[question text] --> E1[query vector] --> S1[ANN vs all chunks]
end
subgraph Similarity
P[passage id] --> V[stored vector] --> S2[ANN neighbors]
end
flowchart TB
subgraph STM["Short-term · working context"]
MSG[messages table<br/>last 16 in this chat]
end
subgraph LTM["Long-term · user-scoped"]
ROW[memories table<br/>kind + content]
VEC[Qdrant memories<br/>same 768-d space]
end
TURN[finished turn] --> X[LLM extract 0-3 facts]
X --> ROW
X --> VEC
NEXT[next question] --> STM
NEXT --> ANN2[ANN over memories]
ANN2 --> VEC
| Kind | Lifetime | Scope | Store |
|---|---|---|---|
| STM | This chat | chat_id |
PostgreSQL messages |
| LTM | Across chats | user_id |
PostgreSQL memories + Qdrant memories |
Extracted kinds: fact | preference | entity.
Packer is greedy by priority, then token estimate (chars / 4).
flowchart TB
P100["100 · User question"] --> BUDGET[remaining tokens]
P80["80 · Retrieved passages 1..n"] --> BUDGET
P40["40 · Conversation STM"] --> BUDGET
P30["30 · Long-term memory"] --> BUDGET
BUDGET --> CTX[single context string]
CTX --> LLM
Default budget: CONTEXT_WINDOW_TOKENS=24000 (retrieved + memory, not the full model window).
sequenceDiagram
participant U as User
participant UI as React
participant API as FastAPI
participant PG as users
U->>UI: register / login
UI->>API: POST /api/auth/register|login
API->>PG: bcrypt hash / verify
API-->>UI: access JWT + refresh JWT
UI->>API: Authorization Bearer access
Note over API: HS256 · type=access|refresh
API-->>UI: 401
UI->>API: POST /api/auth/refresh
API-->>UI: new pair
Passwords are bcrypt (72-byte cap). Production refuses default SECRET_KEY.
PostgreSQL via SQLAlchemy + Alembic (001_initial, 002_indexes).
erDiagram
users ||--o{ chats : owns
users ||--o{ documents : uploads
users ||--o{ memories : remembers
chats ||--o{ messages : contains
users {
uuid id PK
varchar email UK
varchar name
varchar password_hash
timestamptz created_at
timestamptz updated_at
}
chats {
uuid id PK
uuid user_id FK
varchar title
timestamptz created_at
timestamptz updated_at
}
messages {
uuid id PK
uuid chat_id FK
varchar role
text content
jsonb citations
jsonb meta
timestamptz created_at
}
documents {
uuid id PK
uuid user_id FK
varchar filename
varchar content_type
varchar storage_path
varchar status
int chunk_count
text error
timestamptz created_at
}
memories {
uuid id PK
uuid user_id FK
varchar kind
text content
uuid source_chat_id
bool active
timestamptz created_at
}
users - account.
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| varchar(320) UK | lowercased | |
| name | varchar(120) | |
| password_hash | varchar(255) | bcrypt |
| created_at / updated_at | timestamptz |
chats - sidebar history.
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| user_id | UUID FK → users ON DELETE CASCADE | indexed |
| title | varchar(240) | first prompt if still “New chat” |
| created_at / updated_at | timestamptz | index (user_id, updated_at) |
messages - STM source + transcript.
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| chat_id | UUID FK → chats CASCADE | |
| role | varchar(20) | user | assistant |
| content | text | streamed answer stored after complete |
| citations | JSONB | { passages, web } |
| meta | JSONB | context token stats |
| created_at | timestamptz | index (chat_id, created_at) |
documents - ingest metadata (bytes live on disk or S3).
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | also Qdrant document_id payload |
| user_id | UUID FK | |
| filename | varchar(512) | sanitized basename |
| content_type | varchar(128) | |
| storage_path | varchar(1024) | local path or s3://bucket/key |
| status | varchar(32) | pending → processing → ready | failed |
| chunk_count | int | |
| error | text | truncated on failure |
memories - durable facts (vector twin in Qdrant, id aligned).
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | Qdrant point id |
| user_id | UUID FK | |
| kind | varchar(32) | fact / preference / entity |
| content | text | |
| source_chat_id | UUID | optional |
| active | bool |
flowchart LR
U1[user A] --> C1[A chats]
U1 --> D1[A documents]
U1 --> V1[Qdrant filter user_id=A]
U2[user B] --> C2[B chats]
U2 --> D2[B documents]
U2 --> V2[Qdrant filter user_id=B]
Every list/get/delete is user_id == current_user. Vectors are never queried without that payload filter.
| Name | Table | Columns |
|---|---|---|
| ix_users_email | users | email unique |
| ix_chats_user_id | chats | user_id |
| ix_chats_user_updated | chats | user_id, updated_at |
| ix_messages_chat_id | messages | chat_id |
| ix_messages_chat_created | messages | chat_id, created_at |
| ix_documents_user_id | documents | user_id |
| ix_documents_user_created | documents | user_id, created_at |
| ix_memories_user_id | memories | user_id |
Two collections, same dimension, cosine.
flowchart TB
subgraph documents
P1[point uuid]
P1 --> VEC1[vector 768]
P1 --> PAY1["user_id · document_id · filename<br/>chunk_index · text · modality"]
end
subgraph memories
P2[point = memory.id]
P2 --> VEC2[vector 768]
P2 --> PAY2["user_id · text · kind"]
end
Payload indexes: user_id, document_id (keyword).
stateDiagram-v2
[*] --> processing: upload
processing --> ready: chunks upserted
processing --> failed: parse / embed error
ready --> [*]: delete file + Qdrant points
failed --> [*]: delete
Allowed: .pdf .docx .txt .md .csv .json .png .jpg .jpeg .bmp .webp. Path traversal stripped. Size cap MAX_UPLOAD_MB.
POST /api/chats/{id}/stream - text/event-stream. ASGI middleware (not BaseHTTPMiddleware) so the body is not buffered.
type |
Meaning |
|---|---|
status |
retrieving / uploading / generating / searching the web |
passages |
reranked chunks for the side panel |
token |
next text delta |
tool |
Luna web_search running / done |
citation |
URL annotation from web search |
error |
fail-open message |
message |
persisted assistant id |
done |
stream closed |
sequenceDiagram
UI->>API: POST stream
API-->>UI: status retrieving
API-->>UI: passages
API-->>UI: status generating
loop tokens
API-->>UI: token
end
API-->>UI: message id
API-->>UI: done
flowchart LR
AUTH[Register / login] --> APP
subgraph APP[Main shell]
SIDE[New chat · history · files]
THREAD[Messages + composer]
RIGHT[Retrieved + similar passages]
end
THREAD --> PLUS[+ file or image]
THREAD --> WEB[Web search chip → Luna]
Mobile: hamburger opens the sidebar over a scrim.
flowchart LR
B[Browser :80 / :5173] --> FE[frontend nginx]
FE --> BE[backend :8000]
BE --> PG[(postgres :5432)]
BE --> QD[(qdrant :6333)]
BE --> DISK[uploads volume]
flowchart TB
IN[Internet] --> ALB[Application Load Balancer]
ALB -->|/ | FE[frontend :80]
ALB -->|/api /health /ready| BE[backend :8000]
subgraph Fargate["ECS Fargate task"]
FE
BE
QD[qdrant sidecar :6333]
end
BE --> RDS[(RDS PostgreSQL 16)]
BE --> S3[(S3 uploads SSE)]
BE --> SM[Secrets Manager]
BE --> CW[CloudWatch logs]
AS[CPU autoscaling 1-4] --> Fargate
ALB path rules: default → frontend; /api/*, /health, /ready → backend.
Reliability around every model/tool call (agent/harness.py).
flowchart LR
CALL[embed / rerank / LLM] --> CB{circuit open?}
CB -->|yes| FAIL[fail-open or 5xx]
CB -->|no| RETRY[tenacity 3x<br/>HTTP / timeout]
RETRY --> TRACE[RunTrace]
Also: token budget, prompt-injection markers, production config gate (APP_ENV=production).
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/auth/register |
no | returns token pair |
| POST | /api/auth/login |
no | |
| POST | /api/auth/refresh |
refresh JWT | |
| GET | /api/auth/me |
access | |
| GET/POST | /api/chats |
access | list / create |
| GET/PATCH/DELETE | /api/chats/{id} |
access | |
| POST | /api/chats/{id}/stream |
access | SSE |
| GET/POST/DELETE | /api/documents |
access | |
| POST | /api/search/semantic |
access | ANN only |
| POST | /api/search/ranked |
access | ANN + rerank |
| GET | /api/search/similar/{id} |
access | neighbors |
| GET | /health |
no | liveness |
| GET | /ready |
no | Postgres + Qdrant |
agentic-rag/
├── frontend/ React (Vite) + nginx
├── backend/
│ ├── app/
│ │ ├── api/routes/ auth, chats, documents, search
│ │ ├── agent/ LangGraph, harness, memory, prompts
│ │ ├── rag/ parse, chunk, retrieve, augment, ingest
│ │ ├── services/ OpenAI, DashScope, Qdrant, S3
│ │ ├── db/ SQLAlchemy models + session
│ │ └── core/ config, JWT, middleware
│ ├── alembic/versions/
│ └── tests/
├── infra/aws/ Terraform ECS + ALB + RDS + S3
├── docker-compose.yml
└── .github/workflows/ci.yml
cp .env.example .env
# set SECRET_KEY, OPENAI_API_KEY, DASHSCOPE_API_KEY
docker compose up --buildhttp://localhost - register, New chat, attach a file, toggle Web search.
Without Compose: Postgres + Qdrant up, then
cd backend && alembic upgrade head && uvicorn app.main:app --reload
cd frontend && npm install && npm run devcd backend && pytest -qAWS
- Push images to ECR.
cd infra/aws && cp terraform.tfvars.example terraform.tfvarsterraform init && terraform apply- Open the ALB DNS.
| Variable | Default | Role |
|---|---|---|
SECRET_KEY |
- | JWT signing |
OPENAI_API_KEY |
- | GPT-5.6 Luna |
OPENAI_MODEL |
gpt-5.6 |
generation + web_search |
DASHSCOPE_API_KEY |
- | embed + rerank |
EMBEDDING_MODEL |
tongyi-embedding-vision-flash |
768-d |
RERANK_MODEL |
qwen3-rerank |
|
DATABASE_URL |
local compose | asyncpg |
QDRANT_URL |
http://qdrant:6333 |
|
CONTEXT_WINDOW_TOKENS |
24000 | pack budget |
STM_MESSAGE_LIMIT |
16 | |
LTM_RECALL_LIMIT |
8 | |
AWS_S3_BUCKET |
empty | local disk if unset |
APP_ENV |
development |
production validates secrets |
Do not commit .env or *.tfvars.