a85b0ccac8
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.
300 lines
13 KiB
Markdown
300 lines
13 KiB
Markdown
# 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 99–1496** đượ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) | ~5–6s |
|
||
| `routing` | Chọn nhánh theo `turn_type` | ~2–3s |
|
||
| `retrieval` | Embed câu hỏi → tìm trong Qdrant | ~0,2–0,3s |
|
||
| `rerank` | Cohere rerank | ~0,14s |
|
||
| `generation` | Sinh câu trả lời có trích dẫn | ~1,5–3s |
|
||
| `entailment` | **Đối chiếu từng mệnh đề với nguồn** | ~0,7–1,8s |
|
||
|
||
Tổng một lượt bình thường: **13–17 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.
|