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.
13 KiB
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:
pdfplumberbó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ữ. Xemdocs-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)
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
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
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
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
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 theochunk_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
- Manifest guard. Nạp corpus có
sha256khá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. - 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 đổiaiService.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ườngunsupported_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
# 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ểuorigin/master:path→ phảiexport 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-1nê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
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
# 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
- Đừ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).
- Đừng sinh câu hỏi eval từ chính chunk mà nó kiểm — điểm sẽ đẹp giả.
context_precisionchỉ 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.- 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.pymớ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. - 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:
reasoncode không cho biết tầng nào hỏng.unsupported_claimtrông như lỗi grounding, mở trace ra mới thấy lỗi ở truy hồi.evidence_insufficienttrô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.