# 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 --pages 202 # mực không span nào giải thích python -m ingestion.cli coverage --pdf # span đi đâu về đâu python -m ingestion.cli validate --pdf # đối chiếu mục lục cuối sách python -m ingestion.cli chunk-ready --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= ``` 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) /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.