Files
duocthu/docs/how-this-was-built.md
T
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

13 KiB
Raw Blame History

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)

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 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/queryapps/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

# 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

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

  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.