Files
duocthu/docs/how-this-was-built.md
VuQuangBao a85b0ccac8 Fix the F3 out-of-scope gate, close out the V1 feature audit, and clean up project docs
Also drop .github/ (GitHub-specific CI/CD workflows and ArgoCD
operational scripts) from this mirror -- Gitea auto-picked up
.github/workflows/*.yml as Actions and queued a run against secrets
that don't exist here. Not meaningful outside the GitHub-hosted repo
anyway.
2026-08-25 12:05:00 +07:00

300 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dựng lại dự án này từ đầu
Tài liệu này ghi **toàn bộ quá trình**: từ một file PDF tới một chatbot RAG chạy
trên k3s. Viết cho người chưa từng đụng repo — đọc xong dựng lại được.
Mọi con số ở đây đều **đo được**, không phải ước lượng. Chỗ nào chưa kiểm chứng
thì ghi rõ là chưa.
---
## 0. Bức tranh tổng thể
```
PDF (1.668 trang)
│ ingestion/ — bóc chữ, phân đoạn, chunk
684 chuyên luận → 15.100 chunk
│ embed cohere-v4 (Bedrock), 1024 chiều
Qdrant collection duocthu_v1
apps/ai-service ── hiểu câu hỏi → truy hồi → sinh → đối chiếu → trích dẫn
apps/web (Next.js BFF) → realvuxbaro.me
```
Hạ tầng: k3s + ArgoCD trên một EC2. CI tự build image, đẩy GHCR, trỏ ArgoCD sang
tag mới. Quan sát: OpenTelemetry → Tempo + Langfuse, Prometheus + Grafana.
---
## 1. Nguồn dữ liệu
**Dược thư Quốc gia Việt Nam 2018**, `ingestion/data/raw/*.pdf` (38 MB, 1.668 trang).
Chỉ **Phần 2 (chuyên luận thuốc), trang 991496** được đưa vào corpus. Phần 1
(hướng dẫn chung, ngộ độc, tương tác) và Phần 3 (phụ lục BSA, ATC) **cố ý loại
ra** — mọi câu hỏi rơi vào hai phần đó sẽ bị từ chối, và đó là đúng thiết kế.
Mỗi chuyên luận có tối đa **18 mục** (`ten_chung_quoc_te`, `chi_dinh`,
`chong_chi_dinh`, `lieu_luong_va_cach_dung`, `tuong_tac_thuoc`, …). Danh sách
chuẩn nằm ở `apps/ai-service/rag/sections.py` (`SECTION_ORDER`).
> **Bẫy đã gặp:** `pdfplumber` bóc chữ **hỏng** trên tài liệu này. Một phần chữ
> chỉ tồn tại dưới dạng **vector path**, không extractor nào trả về. Đã xử lý
> bằng cách chuyển 51 đoạn vector-outlined (1.116 ký tự) trở lại luồng chữ.
> Xem `docs-legacy/pdf-parsing-outlier-catalog.md`.
---
## 2. Ingestion — PDF thành chunk
Chạy bằng `python -m ingestion.cli` (trong thư mục `ingestion/`).
### 2.1 Dò bảng trước (chậm, có cache)
```bash
python -m ingestion.cli detect-tables --pdf data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf
# → data/processed/table_regions.json (183 vùng bảng trên 135 trang)
```
Chạy một lần rồi dùng lại. **Đừng xoá file này** — dựng lại rất lâu.
### 2.2 Bóc chữ + phân đoạn thành chuyên luận
```bash
python -m ingestion.cli run \
--pdf data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf \
--tables data/processed/table_regions.json \
--out data/processed/monographs.jsonl
```
Kết quả đo được (2026-08-24): 253.518 span → **684 chuyên luận**, 151 khối bảng
được **tách khỏi văn xuôi và cách ly** (quarantine).
> **Hợp đồng quarantine — bắt buộc phải hiểu:** bảng và công thức bị lấy ra khỏi
> prose. Tầng trả lời **được phép trưng ảnh crop nguồn**, nhưng **tuyệt đối không
> được tự phát biểu một liều lấy từ đó**. Đây là quyết định an toàn, không phải
> giới hạn kỹ thuật.
### 2.3 Các cổng kiểm trước khi chunk
```bash
python -m ingestion.cli residual-ink --pdf <pdf> --pages 202 # mực không span nào giải thích
python -m ingestion.cli coverage --pdf <pdf> # span đi đâu về đâu
python -m ingestion.cli validate --pdf <pdf> # đối chiếu mục lục cuối sách
python -m ingestion.cli chunk-ready --pdf <pdf> # cổng chặn rác đầu vào
```
Nguyên tắc: **cổng có mục tiêu bằng 0**, ví dụ `unclassified = 0`. Nội dung không
dựng lại được thì **cách ly**, không bao giờ âm thầm bỏ đi hoặc âm thầm giữ lại.
### 2.4 Chunk
```bash
python -m ingestion.cli chunk \
--monographs data/processed/monographs.jsonl \
--tables data/processed/table_regions.json \
--pdf data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf \
--out data/processed/chunks.jsonl
```
**15.100 chunk** — 14.949 prose + 151 block_descriptor. 0 chunk vượt trần 800
token. Tổng 4.105.382 token (cl100k_base).
> **Đã kiểm 2026-08-24:** dựng lại từ PDF cho ra **681/684 chuyên luận giống hệt
> từng byte**. Pipeline có tính tất định.
---
## 3. Embed và nạp vào Qdrant
```bash
python -m ingestion.load.run \
--chunks data/processed/chunks.jsonl \
--cache data/processed/embeddings \
--provider cohere-v4 \
--collection duocthu_v1 \
--qdrant-url http://localhost:6333
```
- Model: **cohere-v4** qua Bedrock, **1024 chiều, Cosine**
- Cache khoá theo `(model_id, input_kind, text_sha256)`**không** theo
`chunk_id`. Nên sửa id không làm mất cache; sửa *chữ* mới làm mất.
- Point id sinh từ `chunk_id` → nạp lại cùng corpus thì **hội tụ**, không nhân đôi
### Hai chốt an toàn phải biết
1. **Manifest guard.** Nạp corpus có `sha256` khác vào collection đang có dữ liệu
sẽ **bị từ chối TRƯỚC khi ghi** (`CorpusMismatch`). Production không thể hỏng
vì ai đó chạy nhầm lệnh nạp.
2. **Count gate.** Số điểm trong collection phải khớp số chunk, lệch là FAIL.
> **Muốn đổi corpus trên production: đừng ghi đè.** Nạp vào collection **mới**
> (`duocthu_v2`) rồi đổi `aiService.config.qdrantCollection`. Lý do: ArgoCD roll
> back được **cấu hình**, **không** roll back được **dữ liệu**. Cách này biến một
> cuộc di trú dữ liệu thành một thay đổi cấu hình.
---
## 4. ai-service — đường đi của một câu hỏi
`POST /v1/rag/query``apps/ai-service/rag/agent.py`:
| Chặng | Làm gì | Thời gian thật (production) |
|---|---|---|
| `understanding` | LLM sinh `QueryFrame` (turn_type, thuốc, mục, bối cảnh bệnh nhân) | ~56s |
| `routing` | Chọn nhánh theo `turn_type` | ~23s |
| `retrieval` | Embed câu hỏi → tìm trong Qdrant | ~0,20,3s |
| `rerank` | Cohere rerank | ~0,14s |
| `generation` | Sinh câu trả lời có trích dẫn | ~1,53s |
| `entailment` | **Đối chiếu từng mệnh đề với nguồn** | ~0,71,8s |
Tổng một lượt bình thường: **1317 giây**.
### Điểm quan trọng nhất về kiến trúc
Sau khi LLM sinh `QueryFrame`, có **một chuỗi hàm vá hậu kỳ** ghi đè kết quả dựa
trên **danh sách chuỗi tiếng Việt cứng** (`_apply_condition_candidate_cue`,
`_apply_named_drug_cues`, …).
**Đây là điểm yếu lớn nhất của hệ thống.** Nó khiến hệ trả lời đúng khi người dùng
gõ đúng câu trong bộ eval, và khác đi khi gõ cách khác. Đã chứng minh: cùng một ý
hỏi 4 cách → 3 quyết định khác nhau.
> **Luật khi sửa:** thêm cue là **nuôi bệnh**. Chuyển tri thức vào
> **prompt + schema + một cổng tất định**, để lớp cue **teo đi**. Ví dụ mẫu:
> commit `1a6e6eb` (sửa lỗi phạm vi bằng trường `unsupported_request`).
### Entailment là lưới an toàn thật sự
Đã cứu ít nhất một lần thật: câu *"Bệnh nhân sốt cao dùng thuốc gì?"* truy hồi
nhầm ra `dantrolen`/`halothan` (thuốc của **sốt cao ác tính**). Generation dựng
câu trả lời trên bằng chứng sai, **entailment bác** → abstain.
Không có nó, hệ thống đã bảo bác sĩ dùng dantrolen để hạ sốt.
---
## 5. Chạy local
```bash
# 1. Qdrant
docker run -d --name qdrant -p 6333:6333 qdrant/qdrant:v1.19.0
python -m ingestion.load.run --chunks ... --collection duocthu_v1 --qdrant-url http://localhost:6333
# 2. ai-service (mặc định đã trỏ localhost:6333 / duocthu_v1)
cd apps/ai-service && python -m uvicorn main:app --host 127.0.0.1 --port 8099
```
Cần AWS credentials có quyền gọi Bedrock (`aws sts get-caller-identity` để kiểm).
> **Bẫy Windows:** không dùng `--reload` (server không nạp lại đúng, phải
> **restart tay**). Git Bash làm hỏng đường dẫn kiểu `origin/master:path` → phải
> `export MSYS_NO_PATHCONV=1`. Console tiếng Việt lỗi mã →
> `export PYTHONIOENCODING=utf-8`.
> **Local KHÔNG đo được hiệu năng.** Máy ở Việt Nam gọi Bedrock `us-east-1` nên
> trung vị **44s/câu** so với ngưỡng budget **40s** → nhiều case đỏ vì mạng chứ
> không vì code. Muốn số thật phải chạy trên production.
---
## 6. Triển khai
```
push master (chạm apps/** hoặc packages/**)
→ .github/workflows/build-practice-images.yml
→ build 4 image, đẩy GHCR theo tag = commit SHA
→ sync_practice_argocd.py trỏ Application sang tag mới
→ chờ Synced + Healthy + đúng SHA
→ xác nhận realvuxbaro.me đang phục vụ bản mới
```
Workflow có **`paths:` filter** — sửa tài liệu **không** kích hoạt build.
### Roll back
```bash
gh workflow run rollback-k3s.yml -f target_sha=<sha cũ>
```
Không build lại, chỉ trỏ về image tag cũ đã có trên GHCR. Ba chốt: kiểm image tồn
tại → chờ Synced/Healthy đúng SHA → **xác nhận trang thật đang phục vụ bản đã lùi**.
| Roll back được | Không roll back được |
|---|---|
| Code, prompt, model id, config | **Dữ liệu Qdrant** |
| (đều nằm trong image / Helm values) | **Dữ liệu Postgres** |
---
## 7. Đánh giá chất lượng
```bash
# 90 case bất biến (quyết định, trích dẫn, đúng thuốc)
python scripts/run_all_evals.py --base-url https://realvuxbaro.me --output-dir out/
# chấm chất lượng câu trả lời (venv RIÊNG — ragas phá vỡ deps của service)
<venv_ragas>/python scripts/score_evals_ragas.py ...
# ổn định theo cách diễn đạt — rẻ, không cần LLM judge
python scripts/paraphrase_probe.py --base-url https://realvuxbaro.me
```
**Số chốt 2026-08-24 (production): 84/90**, trung vị 13,8s.
### Bài học về phương pháp đo — đọc trước khi tin bất kỳ con số nào
1. **Đừng để judge trùng model với generator** — nó tự thiên vị. Đã đổi sang Mistral Large 3 (một model độc lập, khác hẳn cả generator lẫn model được thử đầu tiên nhưng không dùng được trên account này).
2. **Đừng sinh câu hỏi eval từ chính chunk mà nó kiểm** — điểm sẽ đẹp giả.
3. **`context_precision` chỉ tái lập được 52% theo từng case** — một judge đơn gần
như tung đồng xu. Chỉ tin khi hai judge đồng thuận.
4. **Bộ 90 case có thể bị "học thuộc"** vì định tuyến chạy trên danh sách chuỗi
cứng. `paraphrase_probe.py` mới là thứ phát hiện được điều đó — và nó đã tìm ra
2 lỗi thật mà bộ 90 case không thấy. **Câu diễn đạt lại phải viết tay**: bảo
model paraphrase thì nó giữ nguyên từ khoá điều khiển định tuyến, đúng thứ cần
phải thay đổi.
5. **Nghiệm thu phải chạy end-to-end qua `/api/chat`**, không bao giờ nghiệm thu
trên tầng understanding tách rời. PR #54 "verify 3/3" trên bản cô lập rồi vẫn
hỏng trên production.
---
## 8. Quan sát
- **Langfuse** (`langfuse.realvuxbaro.me`) — trace từng chặng, kèm điểm Ragas và
thumbs của người dùng. **Đây là chỗ mở ra khi không rõ tầng nào hỏng.**
- **Tempo / Prometheus / Grafana** (`realvuxbaro.me/grafana/`)
> **Bài học lặp lại 2 lần trong một ngày:** `reason` code **không** cho biết tầng
> nào hỏng. `unsupported_claim` trông như lỗi grounding, mở trace ra mới thấy lỗi
> ở **truy hồi**. `evidence_insufficient` trông như không liên quan tới một thay
> đổi về phạm vi, hoá ra chính nó gây ra. **Mở trace, đừng suy từ mã lỗi.**
> **Đừng bao giờ trỏ Pod tới hostname công khai của chính cụm nó** — hairpin
> routing làm trace bị nuốt im lặng. Dùng Service nội bộ.
---
## 9. Những chỗ vẫn đang hỏng (2026-08-24)
| Lỗi | Bản chất |
|---|---|
| 3 chuyên luận `Đ` sai `drug_id` | Slugifier nuốt chữ `Đ`. Code đã sửa, **corpus chưa nạp lại** — đúng 65/15.100 chunk cần đổi |
| Định tuyến lệch theo cách diễn đạt | 4 cách hỏi → 3 quyết định. Bệnh gốc: lớp cue cứng |
| Truy hồi "sốt cao" | Hiểu thành **sốt cao ác tính** → dantrolen/halothan |
| Mục > 2.000 token | 287 mục (2%) làm đổ lượt gọi Bedrock: `read_timeout=20` × 2 lần > budget 40s → `provider_unavailable` |
| F3 chưa đạt 100% | Cổng **tất định**, nhưng **trigger là phán đoán LLM** → ~1 trượt/35 lần. Muốn 100% thì trigger cũng phải tất định |
---
## 10. Nhật ký phát triển
`docs-legacy/progress-log.md` (~326 KB) ghi **vì sao** mọi thứ thành ra như hiện
tại: các số đo, các ngõ cụt, các quyết định bị lật lại. Đọc code không suy ra được
phần đó. Đọc nó trước khi định lật lại một quyết định nào.