# Pipeline từ PDF Dược thư đến chatbot RAG production > **Tài liệu canonical cho pipeline end-to-end.** Nội dung mô tả logic hiện hành > từ PDF, parsing, chunking và indexing đến query understanding, retrieval, > generation, grounding, citation và response. Khi tài liệu cũ mâu thuẫn với > file này, đối chiếu code runtime; code là nguồn sự thật cuối cùng. > > Mục tiêu của tài liệu là giải thích được toàn bộ chuỗi xử lý: PDF được đọc và > kiểm tra như thế nào, dữ liệu được phân đoạn/chunk/embedding ra sao, chatbot > truy xuất và tạo câu trả lời thế nào, hệ thống chống hallucination bằng gì, > eval từng tầng ra sao, đã đo được gì và tuyệt đối chưa được khẳng định điều gì. ## 0. Tóm tắt trong một phút Hệ thống là một chatbot RAG tra cứu **Dược thư Quốc gia Việt Nam 2018**. Nó không đưa toàn bộ PDF cho LLM và cũng không dùng LLM để “đọc PDF mỗi lần hỏi”. PDF được xử lý **offline một lần** thành dữ liệu có cấu trúc theo: ```text thuốc -> mục chuyên luận -> chunk -> vector + metadata + provenance ``` Các con số của corpus đang dùng: | Hạng mục | Giá trị đã đo | |---|---:| | PDF nguồn | 1.668 trang, 38.795.771 byte | | Phạm vi đưa vào RAG | Phần 2, chuyên luận thuốc, trang in 99–1496 | | Chuyên luận được segment | 684 | | Section | 11.974 | | Ký tự section | 8.213.036 | | Chunk tổng | 15.100 | | Prose chunk | 14.949 | | Block descriptor cho bảng/công thức | 151 | | Tổng token chunk (`cl100k_base`) | 4.105.382 | | Chunk vượt trần 800 token | 0 | | Embedding | Cohere Embed v4, 1.024 chiều | | Vector DB | Qdrant, cosine distance, collection `duocthu_v1` | | Model hiểu câu hỏi/tạo đáp án | Cấu hình qua AWS Bedrock Converse; model production không được suy ra từ file local | | Reranker production | Cohere Rerank 3.5 cho các nhánh cần rerank | Khi có câu hỏi, chatbot ưu tiên **lọc chính xác bằng metadata** nếu đã biết thuốc và section. Vector search chỉ là fallback có giới hạn. LLM chỉ được phép diễn đạt lại evidence đã truy xuất và phải trả claim có citation. Sau đó code kiểm tra citation, số liệu và một lượt semantic entailment; không qua gate thì abstain, không hiển thị đáp án chưa kiểm chứng. Điểm quan trọng nhất khi thuyết trình: > “Embedding giúp tìm candidate, nhưng không phải nguồn chân lý. Metadata, > provenance và grounding mới là các rào chắn quyết định nội dung nào được phép > tới người dùng.” --- ## 1. Phạm vi và tuyên bố trung thực ### 1.1. Nguồn dữ liệu Nguồn hiện tại là file: ```text ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf ``` SHA-256 đã kiểm tra lại: ```text 2aa81c846a5e760f82658c46816ab63174204a7d95b3e2755b6565288e53d0d1 ``` PDF gồm ba phần chính: 1. Phần 1 — các chuyên luận/hướng dẫn chung, trang in 37–98. 2. Phần 2 — các chuyên luận thuốc, trang in 99–1496. 3. Phần 3 — phụ lục, trang in 1497–1528; sau đó là back-index. Corpus production hiện **chỉ bao phủ Phần 2**. Vì vậy câu hỏi về kê đơn chung, ngộ độc, hướng dẫn dùng thuốc ở thai kỳ/suy gan thận ở cấp chương, bảng BSA, pha thuốc tiêm hoặc phụ lục có thể phải abstain dù nội dung có tồn tại ở nơi khác trong cuốn sách. ### 1.2. Giới hạn lâm sàng bắt buộc phải nói rõ `source_manifest.json` ghi nhận: - bản đang dùng là lần xuất bản thứ hai, năm 2018; - đã có lần xuất bản thứ ba năm 2022; - quyền sử dụng production chưa được ghi nhận đầy đủ; - nguồn chưa được đánh dấu đủ điều kiện làm nguồn duy nhất cho clinical production. Do đó website đang chạy production theo nghĩa **hạ tầng/phần mềm đã deploy**, không đồng nghĩa đã được duyệt là một clinical decision support system. Hệ thống phù hợp để demo kỹ thuật, tra cứu có nguồn và hỗ trợ chuyên gia kiểm tra. Nó không được tuyên bố: - thay thế bác sĩ/dược sĩ; - đưa phác đồ điều trị chuẩn hoặc thuốc first-line; - chứng minh thuốc “an toàn/phù hợp” chỉ vì tìm thấy mục chỉ định; - bao phủ đầy đủ Dược thư 2022 hoặc toàn bộ nội dung Dược thư 2018; - đã có đánh giá y khoa toàn corpus bởi hội đồng chuyên gia. --- ## 2. Kiến trúc tổng thể Hệ thống có hai đường hoàn toàn tách nhau. ### 2.1. Đường offline: tạo corpus ```mermaid flowchart LR PDF[PDF 1.668 trang] EX[Extract spans + page map] QA[Repair + PDF QA] SEG[Segment monograph/section] CH[Chunk schema v4] EMB[Bedrock Cohere Embed v4] QD[(Qdrant duocthu_v1)] MF[(Manifest sidecar)] PDF --> EX --> QA --> SEG --> CH --> EMB --> QD CH --> MF EMB --> MF ``` Đường này chạy theo batch. Nó không nằm trong request path của chatbot. Không có chuyện người dùng hỏi rồi server mới parse 1.668 trang PDF. ### 2.2. Đường online: trả lời một câu hỏi ```mermaid flowchart LR U[Browser] C[Caddy TLS] W[Next.js BFF] F[FastAPI /v1/rag/query] Q[Query understanding] R[Routing + retrieval] V[Evidence policy] G[Structured generation] D[Deterministic grounding] E[Semantic entailment] P[(PostgreSQL trace/history)] O[Prometheus + Tempo] U --> C --> W --> F --> Q --> R --> V --> G --> D --> E --> F --> W --> U F --> P F --> O Q --> O R --> O G --> O D --> O E --> O ``` Production hiện tại là một EC2 `t3.large` chạy Docker Compose gồm Postgres, Qdrant, ai-service, web và Caddy. Observability được bổ sung bằng Prometheus, Grafana, Tempo và OpenTelemetry Collector. --- ## 3. Vì sao không thể chỉ `extract_text()` rồi chia đều PDF này có nhiều đặc điểm khiến cách naive dễ tạo lỗi y dược: - không có bookmark/TOC dùng được: `doc.get_toc()` trả 0 entry; - tagged-PDF structure tree rất nông và không bao phủ đủ; - bố cục hai cột; - có trang PyMuPDF trả block cột phải trước cột trái; - title thuốc có nhiều font size khác nhau; - title dài có thể wrap qua nhiều dòng; - header/footer chạy lặp trên gần toàn bộ sách; - bảng và công thức 2D bị sai nghĩa nếu tuyến tính hóa; - có chữ được vẽ bằng vector outline, không tồn tại trong text layer; - có glyph PUA và một số hàng có thứ tự glyph bất thường; - liều người lớn/trẻ em/suy thận có thể nằm sát nhau, nên cắt sai seam có thể gán con số cho sai đối tượng. Ví dụ nguy hiểm đã tìm thấy: công thức Cockcroft–Gault nếu flatten sai có thể đọc thành phép nhân thay vì phép chia. Với dữ liệu liều, đây không phải lỗi format mà là lỗi nội dung có khả năng gây hại. Vì vậy pipeline dùng chiến lược: 1. giữ bbox, page, font và reading order càng lâu càng tốt; 2. chỉ bỏ cấu trúc sau khi đã chuyển nó thành provenance/metadata; 3. không tự tin tuyến tính hóa bảng/công thức chưa xác minh; 4. mọi bước đều có gate fail-closed trước khi embedding. --- ## 4. Bước 1 — Lập bản đồ trang in và trang vật lý PDF có hai khái niệm trang: - **physical page**: index trang trong file, dùng để render/crop bằng PyMuPDF; - **printed page**: số trang in người đọc nhìn thấy trong sách, dùng trong citation. Hai số không được coi là giống nhau. `page_map.py` đọc folio ở header band để xây mapping. Chunk chỉ được phát hành nếu provenance vật lý có thể ánh xạ sang trang in hợp lệ. Tại sao cần cả hai: - UI cần physical page + bbox để cắt đúng ảnh nguồn; - người dùng/mentor cần printed page để mở sách và đối chiếu; - nếu chỉ dùng physical page, citation có thể lệch so với số trang in; - nếu chỉ dùng printed page, code không biết crop tọa độ nào trong file. Gate hiện tại yêu cầu mọi chunk có cả: ```json { "source_page_range": [100, 100], "printed_page_range": [101, 101] } ``` --- ## 5. Bước 2 — Extract text thành luồng span liên tục ### 5.1. Công cụ chính PyMuPDF (`fitz`) là extractor chính vì trên các trang kiểm tra thực tế nó giữ reading order tốt hơn `pdfplumber.extract_text()`. `pdfplumber` chỉ được xem như công cụ table-specific, không dùng cho body text. Mỗi span giữ các trường quan trọng: ```text physical_page, printed_page, column, block, line, span_index, x0, y0, x1, y1, text, font, size ``` Đây là dữ liệu nền cho segmentation, crop và audit sau này. ### 5.2. Sắp xếp hai cột Pipeline không tin mù quáng thứ tự block thô của PyMuPDF. Mỗi block được phân loại thành: - `full_width`; - `left`; - `right`; - `unknown`. Sau đó sort theo thứ tự: ```text full_width header -> cột trái -> cột phải ``` và trong từng nhóm sort theo tọa độ `y`. Lý do là whole-document comparison từng phát hiện 12/1.398 trang trong phạm vi monograph có block cột phải xuất hiện trước cột trái. Nếu để nguyên, section của thuốc sau có thể bị gắn vào thuốc trước. ### 5.3. Luồng cross-page Extractor phát span như một luồng liên tục qua các trang, không biến mỗi trang thành một document độc lập. Điều này cho phép: - merge title wrap qua dòng/trang; - giữ paragraph tiếp nối qua cột/trang; - không cắt nội dung ở page boundary chỉ vì layout in ấn. --- ## 6. Bước 3 — Repair và kiểm tra nội dung PDF ### 6.1. Chuẩn hóa glyph và lỗi reading order Pipeline có các bước kiểm tra/repair riêng cho: - PUA glyph; - replacement character `U+FFFD`; - glyph có x-order đảo; - text bị vẽ bằng vector outline; - reading order cột; - header/footer boilerplate. Các run chữ vector outline được phát hiện bằng drawing path, sau đó dùng bộ transcription đã xác minh để chèn lại thành synthetic span đúng vị trí. Có 51 run kiểu này từng được đọc bằng mắt và transcription, tổng 1.116 ký tự. ### 6.2. Coverage ledger Mỗi span phải rơi vào đúng một trạng thái. Artifact hiện tại có: | Trạng thái | Span | Ký tự | |---|---:|---:| | `normalized_text` | 177.767 | 8.183.293 | | `out_of_scope` | 53.376 | 897.729 | | `heading` | 12.723 | 220.915 | | `boilerplate_excluded` | 4.977 | 47.599 | | `quarantined` | 3.953 | 49.183 | | `structural_excluded` | 3 | 53 | | `unassigned` | 0 | 0 | Ledger chứng minh “mọi span extractor tạo ra đều được định tuyến có tên”. Nó không chứng minh extractor đã nhìn thấy mọi thứ trên trang, nên cần residual ink. ### 6.3. Residual-ink QA Quy trình: 1. render trang thành ảnh; 2. xóa/che mọi pixel nằm trong bbox của text span đã extract; 3. tìm phần mực còn lại; 4. phân loại vùng còn lại. Artifact hiện tại có 3.931 vùng: | Loại | Số vùng | |---|---:| | `header_rule` | 1.649 | | `text_as_vector_outline` | 1.061 | | `table_frame` | 959 | | `antialias_speck` | 220 | | `fraction_bar_candidate` | 23 | | `rule_fragment` | 10 | | `header_band_fragment` | 9 | | `unclassified` | 0 | Điểm cần nói đúng: `unclassified = 0` nghĩa là mọi vùng đã được **đặt tên**, không có nghĩa toàn bộ content đã được người đọc xác nhận đúng 100%. ### 6.4. Vì sao không dùng character-level equality làm chỉ số duy nhất Character count từng tạo kết luận sai vì: - dấu tiếng Việt có thể là nhiều glyph nhưng một ký tự; - normalization hợp nhất span và thay glyph; - một số glyph nằm ngoài page rectangle; - hai extractor có thể cùng bỏ sót một công thức. Do đó eval parser dùng nhiều “instrument” độc lập: span ledger, residual ink, cross-tool comparison, visual census và invariant từ chính cuốn sách. --- ## 7. Bước 4 — Phát hiện bảng và công thức, rồi quarantine ### 7.1. Không flatten nội dung 2D chưa được xác minh Table/formula region được phát hiện, gắn: ```text block_id, kind, shape, physical_page, printed_page, bbox, section_key, quarantined ``` Các shape được khảo sát gồm simple table, multi-level/merged header, cross-page continuation, boxed list và formula 2D. Chỉ 151 block thuộc corpus monograph cuối cùng được phát hành dưới dạng block descriptor. ### 7.2. Block descriptor là gì Thay vì embedding cell text không đáng tin, pipeline tạo một chunk chỉ từ metadata đã xác minh, ví dụ: ```text AMPICILIN VÀ SULBACTAM — Liều lượng và cách dùng — bảng, trang 204. Nội dung chỉ tra cứu được trên ảnh trang gốc, không trích dẫn được dưới dạng văn bản. ``` Descriptor giúp câu hỏi “bảng liều theo chức năng thận” vẫn tìm được vùng nguồn, nhưng model không được suy ra con số từ bảng bị flatten. ### 7.3. Contract an toàn - block text không được rò vào prose chunk; - `header_row` bị embargo vì chưa human-verified; - mỗi block phải có descriptor; - mỗi attachment phải có page + bbox; - chunk có attachment phải báo `has_quarantined_content=true`; - nhánh trả lời gặp block này trả `VERIFY_PDF`, không generation; - UI hiện crop/ảnh nguồn và cảnh báo đối chiếu PDF; - không dùng chunk này để phát biểu liều số. --- ## 8. Bước 5 — Segment thành monograph và section ### 8.1. Phát hiện monograph title Không dùng font size đơn thuần. Rule chính: ```text bold + gần-all-caps + chiều dài ngắn + nằm trong phạm vi monograph ``` Lý do: - title thật xuất hiện cả 10.0pt và 9.5pt; - một threshold `size >= 9.8` từng làm mất khoảng 15% title; - có title thật chứa mixed case như `HMG-CoA`, nên `isupper()` tuyệt đối cũng không đủ; - title nhiều dòng phải merge trước khi match; - part-divider như “CÁC CHUYÊN LUẬN THUỐC” bị loại riêng. Rule hiện cho phép tối đa 10% chữ lowercase trong title candidate để giữ các abbreviation mixed-case hợp lệ. ### 8.2. Phát hiện section Section heading là span bold được so với taxonomy mở, ví dụ: - `ten_chung_quoc_te`; - `ma_atc`; - `loai_thuoc`; - `dang_thuoc_va_ham_luong`; - `chi_dinh`; - `chong_chi_dinh`; - `than_trong`; - `lieu_luong_va_cach_dung`; - `tac_dung_khong_mong_muon`; - `tuong_tac_thuoc`; - `thoi_ky_mang_thai`; - `thoi_ky_cho_con_bu`; - `qua_lieu_va_xu_tri`; - `ten_thuong_mai`. Taxonomy không đóng cứng theo một danh sách sách giáo khoa; nó được mở rộng từ các heading thực sự quan sát thấy trong PDF. ### 8.3. Output của segmentation Một monograph có dạng khái niệm: ```json { "drug_id": "abacavir", "drug_name": "ABACAVIR", "source_page_range": [100, 102], "atc_codes": ["J05AF06"], "sections": { "chi_dinh": { "text": "...", "parts": [ { "kind": "prose", "physical_page": 101, "bbox": [44.0, 100.0, 299.0, 300.0], "source_span_ids": ["p101_b2_l0_s1"] } ] } }, "tables": [] } ``` Điểm quan trọng là section không chỉ có string. `parts` giữ reading order, page, bbox và source span IDs, vì chunking cần provenance chính xác. ### 8.4. Kết quả segmentation hiện tại - 684 monograph; - 11.974 section; - 8.213.036 ký tự section; - 151 table/formula block được quarantine; - catalog entity có 684 entity; - 344 back-index “xem ...” alias được map; - 492 section tên thương mại; - tổng 10.164 alias; - không còn alias back-index unresolved hoặc ambiguous trong artifact đã xác minh. --- ## 9. Bước 6 — Chunking schema v4 ### 9.1. Parent unit: `(drug_id, section_key)` Chunking tôn trọng cấu trúc sách. Parent logic là một section của một thuốc, không phải toàn monograph và không phải window toàn PDF. Ví dụ câu “Chống chỉ định của metformin?” về bản chất đã chỉ rõ: ```text drug_id = metformin section_key = chong_chi_dinh ``` Giữ hai field này trong metadata giúp retrieval lọc đúng mục thay vì mong embedding tự phân biệt “chỉ định” và “chống chỉ định”. ### 9.2. Token budget Các constant hiện tại: ```text CEILING_TOKENS = 800 TARGET_TOKENS = 650 OVERLAP_TOKENS = 65 tokenizer = cl100k_base ``` Rule: - section <= 800 token: giữ nguyên thành một chunk; - section > 800 token: tách thành atom rồi greedy-pack quanh target 650; - không để chunk cuối cùng vượt 800; - overlap khoảng 65 token để giữ continuity. ### 9.3. Atom không phải fixed character window Atom mặc định là câu, được tách theo dấu câu nhưng tránh nhầm: - decimal comma như `0,425`; - numbering; - abbreviation; - label kết thúc bằng dấu `:`. Danh sách tương tác thuốc có thể là một “câu” rất dài ngăn bằng dấu phẩy. Nếu atom đó vượt target, chunker được phép tách theo comma để tránh embedding bị truncate. ### 9.4. Giữ nhãn đối tượng/đường dùng qua seam Đây là phần quan trọng nhất của chunker về safety. Giả sử source có: ```text Đường uống: Người lớn: 500 mg mỗi 8 giờ ... Trẻ em: 15 mg/kg ... ``` Nếu seam rơi trước `500 mg`, chunk tiếp theo phải được lặp lại context label `Đường uống` và `Người lớn`. Chunker theo dõi active label/scope label và đưa nhãn đó vào đầu continuation chunk. Hai field được tách riêng: - `source_text`: span nguồn liên tục, dùng reassembly/provenance; - `text`: nội dung dùng retrieval, có thể prepend context label; - `context_labels`: ghi rõ label nào chỉ được lặp để retrieval an toàn. Nhờ đó không đánh tráo retrieval context với văn bản nguyên bản. ### 9.5. Không để chunk kết thúc bằng label rỗng Chunker không cho một chunk kết thúc kiểu: ```text Người lớn: ``` rồi để dose nằm một mình ở chunk sau. Label được carry sang part mới. Nếu label dài làm chunk pathological vượt mục tiêu, ưu tiên giữ clinical context và để gate oversized phát hiện, thay vì xuất một dose không nhãn. ### 9.6. Metadata của một chunk ```json { "schema_version": 4, "chunk_id": "abacavir__chi_dinh__0", "drug_id": "abacavir", "drug_name": "ABACAVIR", "section_key": "chi_dinh", "section_display_name": "Chỉ định", "text": "...", "source_text": "...", "context_labels": [], "heading_physical_page": 100, "source_page_range": [101, 101], "printed_page_range": [102, 102], "atc_codes": ["J05AF06"], "part_index": 0, "part_count": 1, "est_tokens": 250, "oversized": false, "chunk_kind": "prose", "attachments": [], "has_quarantined_content": false } ``` `chunk_id` ổn định theo `{drug_id}__{section_key}__{part_index}`. Khi một section có nhiều part, Qdrant scroll có thể trả thứ tự UUID ngẫu nhiên nên runtime bắt buộc sort lại theo `part_index`. ### 9.7. Kết quả chunking hiện tại | Chỉ số | Giá trị | |---|---:| | Tổng chunk | 15.100 | | Prose | 14.949 | | Descriptor | 151 | | Tổng token | 4.105.382 | | Token lớn nhất | 800 | | Vượt ceiling | 0 | | Chunk có context label | 1.651 | | Chunk mang quarantine flag | 487 | Raw SHA-256 của `chunks.jsonl`: ```text 8dfae08ae6d9222089c5cdb4207a064fe67989f10f7552b555af0aef6331d9a1 ``` Normalized corpus SHA dùng trong manifest: ```text 04a27166eaa255b516829f8364227e65ad700e51446b569609d18b5efd11189c ``` --- ## 10. Bước 7 — Embedding ### 10.1. Model và vector space Corpus được embed bằng: ```text AWS Bedrock model: cohere.embed-v4:0 output_dimension: 1024 input_type: search_document embedding_type: float truncate: NONE ``` Query lúc runtime dùng cùng model nhưng: ```text input_type: search_query ``` Đây là bất đối xứng có chủ đích của model. Dùng sai `input_type`, sai model hoặc sai dimension vẫn có thể trả nearest neighbours mà không báo lỗi; vì vậy manifest startup gate là bắt buộc. ### 10.2. Vì sao chọn Cohere v4 Lúc benchmark, Titan v2 và Cohere v4 đều trả vector 1.024 chiều và L2 norm đo được là 1.0. Cohere được chọn vì: - hỗ trợ multilingual, phù hợp corpus tiếng Việt; - batch được tối đa 96 text/request; - Titan adapter gửi một text/request, nên full corpus chậm hơn rất nhiều. Full corpus embedding lịch sử tiêu tốn khoảng **0,49 USD** trên tài khoản AWS cá nhân. Đây là số đo lịch sử, không phải bảng giá cam kết cho lần chạy sau. ### 10.3. Không truncate im lặng `truncate="NONE"` khiến input quá dài báo lỗi. Với Dược thư, truncate phần cuối của một danh sách tương tác hoặc liều có thể tạo false negative, nên pipeline phải sửa chunk trước thay vì để provider tự cắt. ### 10.4. Content-addressed cache Cache key: ```text (model_id, input_kind, sha256(exact_text)) ``` Không key chỉ bằng `chunk_id`, vì text thay đổi thì vector cũ phải invalid. Không key thêm metadata không liên quan, vì hai chunk có text giống hệt có thể dùng cùng vector. Cache là JSONL append-only, index in-memory chỉ giữ byte offset. Một run bị gián đoạn có thể resume và chỉ trả tiền cho miss. Artifact cache hiện khoảng 208 MB. ### 10.5. Quy tắc vận hành Không re-embed chỉ để deploy code mới. Qdrant data nằm ở volume riêng. Khi di chuyển server, ưu tiên snapshot/restore collection vì vừa miễn phí vừa giữ đúng vector/corpus identity. --- ## 11. Bước 8 — Load vào Qdrant ### 11.1. Collection ```text name: duocthu_v1 vector size: 1024 distance: Cosine points: 15.100 ``` Payload giữ toàn bộ chunk record để provenance không mất qua stage boundary. Các field có payload index: ```text chunk_id, drug_id, section_key, atc_codes, chunk_kind, has_quarantined_content ``` ### 11.2. Idempotent point ID Point ID không random. Nó là UUID5 từ `chunk_id` với namespace cố định: ```text point_id = uuid5(PROJECT_NAMESPACE, chunk_id) ``` Load lại cùng corpus sẽ overwrite đúng point cũ, không nhân đôi dữ liệu. ### 11.3. Manifest sidecar Collection `duocthu_v1__manifest` lưu: ```text corpus_sha256 chunk_count model_id dimensions input_kind provider distance ``` Khi ai-service khởi động, nó đọc sidecar và so với query embedder. Sai model, sai dimension hoặc thiếu manifest thì service từ chối start. Đây là cách chặn silent mismatch giữa hai vector space. ### 11.4. Load gate Loader kiểm tra: - schema đúng v4; - các field provenance bắt buộc tồn tại; - page range là cặp integer hợp lệ; - vector đúng 1.024 chiều; - manifest tương thích trước khi ghi; - collection count cuối cùng bằng chunk count. Lần load production ghi 15.100 point qua 59 batch và count gate pass. --- ## 12. Bước 9 — Request vào chatbot được hiểu như thế nào ### 12.1. Request contract Browser gọi Next.js BFF `/api/chat`. BFF thêm/gửi: - `conversation_id`; - `X-Correlation-ID`; - W3C `traceparent`/`tracestate` nếu có; - query tới FastAPI `/v1/rag/query`. API giới hạn query 1–4.000 ký tự và conversation ID tối đa 128 ký tự. ### 12.2. Candidate-bound query understanding Một LLM call biến câu hỏi thành `QueryFrame`. LLM không được xem toàn bộ catalog 684 thuốc rồi tự chọn tùy ý. Trước đó, deterministic resolver tạo shortlist drug ID có khả năng xuất hiện trong turn/history. Model chỉ được chọn trong shortlist này. Mục tiêu: - tên thuốc bịa không bị map sang thuốc thật gần giống; - token prompt không tăng theo toàn catalog; - giữ typo correction trong một tập candidate có ràng buộc. ### 12.3. QueryFrame Frame chứa các nhóm field: ```text turn_type drugs / unknown_drugs attribute / section population, age, weight, route condition + condition_relation patient_context standalone_query depends_on_previous_turn needs_clarify + clarify_reason + quick_replies system_error ``` Các `turn_type` chính: - `drug_overview`; - `drug_attribute`; - `drug_to_condition`; - `condition_to_drug`; - `condition_relation`; - `interaction`; - `dosing_calc`; - `smalltalk`; - `out_of_scope`. ### 12.4. Patient context có cấu trúc Nếu người dùng cung cấp, hệ thống giữ: - tuổi, giới, cân nặng; - bệnh chính và bệnh nền; - dị ứng/ADR; - thuốc đang dùng; - thai kỳ/cho con bú; - CKD, eGFR, CrCl, creatinine; - suy gan, Child–Pugh, AST/ALT/bilirubin; - lab khác và điều trị trước đó. Không invent field còn thiếu. Condition normalizer cũng bảo thủ: chỉ normalize alias chắc chắn như `THA -> tăng huyết áp`, `gout -> gút`; condition rộng như “viêm gan” phải hỏi subtype khi subtype làm thay đổi đáng kể kết quả. ### 12.5. Context hội thoại - raw conversation turns được lưu Postgres; - chỉ đọc cửa sổ gần nhất, mặc định 6 turn; - `standalone_query` giải tham chiếu như “thuốc đó”; - normalized last frame hiện vẫn in-memory theo process; - restart/multi-worker có thể mất normalized frame dù raw history còn trong DB; - clarification loop có circuit breaker, tối đa 4 lần liên tiếp. --- ## 13. Bước 10 — Retrieval: không phải câu nào cũng vector search ### 13.1. Route A — biết thuốc và biết section Ví dụ: ```text “Chống chỉ định của metformin?” ``` Sau understanding: ```text drug_id = metformin section_key = chong_chi_dinh ``` Qdrant dùng payload filter và `scroll` toàn bộ section, không dùng vector: ```text filter drug_id == metformin AND section_key == chong_chi_dinh ``` Tại sao scroll toàn section: - top-k có thể làm mất cuối danh sách; - một danh sách chống chỉ định bị cắt vẫn đọc như danh sách hoàn chỉnh; - mọi part được sort lại theo `part_index`. Đây là route chính cho câu hỏi có facet rõ. ### 13.2. Route B — drug overview hoặc câu hỏi tự do về một thuốc - Bare drug name: lấy các section giới thiệu như tên quốc tế, loại thuốc, chỉ định, dược lý/cơ chế. - Free-form question nhưng không resolve được section: lấy monograph prose, rerank và pack evidence trong budget 6.000 token. - Reranker lấy top 6; nếu provider rerank lỗi, fail-open về thứ tự gốc nhưng vẫn giữ size bound. Reranker chỉ là ordering aid. Nó không được quyền biến mất một answer đã có bằng chứng. ### 13.3. Route C — condition/bệnh sang thuốc Luồng hai tầng: ```text condition -> chỉ tìm trong section_key=chi_dinh -> exact/contiguous lexical phrase trước -> nếu không có hit: dense vector fallback trong chi_dinh -> group theo drug_id -> rank ở cấp thuốc -> cap 8 thuốc -> tối đa 2 evidence chunk/thuốc ``` Không search chống chỉ định, ADR, thận trọng hoặc tương tác để sinh candidate điều trị. Điều này chặn lỗi quan hệ kiểu “thuốc gây tăng huyết áp” bị biến thành “thuốc điều trị tăng huyết áp”. Dense fallback dùng Cohere query embedding và Qdrant `query_points()`. Bản production cuối hỗ trợ cả: - client mới: `query_points(query=vector, ...)`; - client cũ: `search(query_vector=vector, ...)`. Hit dense phải qua minimum score 0,12. Vì vector DB luôn có nearest neighbour kể cả cho câu vô nghĩa, “có hit” không tự động nghĩa là có evidence phù hợp. ### 13.4. Patient-specific stage 2 Stage 2 không sinh thuốc mới. Nó chỉ xem top candidate đã có indication và tìm thêm evidence liên quan bệnh nhân: - interaction với current medication; - chống chỉ định/thận trọng; - liều trong suy gan/thận; - thai kỳ/cho con bú; - tuổi. Bound hiện tại: ```text patient candidates: 2 safety hits/section: 1 safety sections/candidate: 4 ``` Không có hit được hiểu là “chưa có evidence”, không phải “an toàn”. Status có thể là supported, supported with caution, requires additional information hoặc insufficient evidence. ### 13.5. Route D — interaction hai thuốc Hệ thống lấy mục tương tác của cả hai thuốc và tổng hợp trên union evidence. Nếu bất kỳ bên nào có bảng/công thức cần visual verification, toàn bộ synthesis bị chặn để không tạo một kết luận phối hợp từ một nguồn đầy đủ và một nguồn bị thiếu. ### 13.6. Những gì chưa phải true hybrid Code có lexical overlap và module RRF, nhưng production chưa dùng native sparse vector/BM25 + dense hybrid đầy đủ. Nhánh condition đang là: ```text lexical phrase first -> dense fallback ``` Không nên trình bày nó như Elasticsearch-style BM25 hoặc production hybrid RRF. --- ## 14. Bước 11 — Evidence policy trước khi gọi LLM Mỗi search hit được chuyển thành `Evidence` với: ```text evidence_id / matched_doc_id drug_id / drug_name section_key / section_title text / score source_refs requires_visual_check ``` Policy quyết định: - không evidence -> `ABSTAIN`; - evidence thiếu provenance -> `ABSTAIN`; - có visual-only evidence -> `VERIFY_PDF`; - còn lại -> `ANSWERABLE`. LLM không tự quyết định các trạng thái này bằng confidence score. --- ## 15. Bước 12 — Generation có cấu trúc ### 15.1. Model và invocation Production sử dụng `deepseek.v3.2` qua AWS Bedrock Converse: ```text temperature = 0 max output tokens = 4096 connect timeout = 5s read timeout = 20s SDK attempts = 2 ``` Converse adapter hiện yêu cầu JSON trong prompt và tự bóc object `{...}` từ response. Schema chưa được provider enforce server-side trong code production này, nên parser luôn fail-closed khi malformed. ### 15.2. Prompt input Prompt nhận: - query đã contextualize; - answer plan nhỏ: verbosity/layout/direct lookup hay synthesis; - numbered evidence blocks; - trusted metadata label `drug_id`, drug name và section; - instruction không dùng kiến thức ngoài evidence; - user text nằm trong fence và được xem là data, không phải instruction. Metadata label cần thiết vì trong monograph, text đôi khi chỉ nói “thuốc kháng vitamin K” mà không lặp lại “warfarin”; tên thuốc đến từ metadata tin cậy. ### 15.3. Output contract Model không trả một blob prose tự do. Nó trả gần dạng: ```json { "evidence_sufficient": true, "claims": [ { "drug_id": "colchicin", "text": "Colchicin được ghi nhận cho đợt gút cấp.", "citations": [1] } ], "clarifying_question": null, "quick_replies": [] } ``` List mode còn có guard: - claim phải có `drug_id`; - drug phải thuộc candidate set từ retrieval; - citation của claim phải trỏ evidence cùng drug; - drug ngoài candidate set làm toàn generation bị reject. ### 15.4. Request budget Một turn có budget chung: ```text wall clock: 40.000 ms LLM calls: tối đa 8 ``` Budget được truyền qua understanding, generation và verification. Code phân biệt content failure với provider/budget failure để trace không gắn nhãn sai. --- ## 16. Bước 13 — Grounding và chống hallucination Prompt không phải guardrail duy nhất. Output phải qua các tầng sau. ### 16.1. Schema validation - JSON parse được; - `claims` là list; - text không rỗng; - citation là integer hợp lệ; - `evidence_sufficient` là boolean thật; - quick replies đúng type, tối đa 4 và không trùng. ### 16.2. Candidate-set validation Đặc biệt cho condition-to-drug: ```text generated drug ⊆ retrieved candidate drugs claim citation -> evidence của chính drug đó ``` ### 16.3. Deterministic numeric grounding Mọi số trong mỗi claim phải tồn tại **character-for-character** trong đúng evidence claim đó cite. Không normalize: - `7,5` thành `7.5`; - `1.500` thành `1500`; - `2 g` thành `2000 mg`. Lý do: conversion/normalization ở liều là nơi lỗi 10x hoặc 1000x dễ xảy ra. Hệ thống chọn refuse thay vì tự diễn giải. ### 16.4. Citation validation - citation index phải tồn tại; - mọi claim có nội dung phải có citation; - số của claim chỉ được tìm trong union các evidence claim đó cite; - số có ở evidence 2 không cứu được claim đang cite evidence 1. ### 16.5. Semantic entailment Regex không hiểu nghĩa. Claim “Metformin chữa ung thư” vẫn có thể có citation hợp lệ và không chứa số. Vì vậy có một LLM judge pass thứ hai: - nhận từng structured claim; - chỉ nhìn evidence mà claim cite; - quyết định claim có entailed hay không; - kiểm tra completeness và phải đưa exact quote nếu báo thiếu. Chỉ chạy một semantic pass. Lặp lại cùng prompt temperature 0 không tạo các vote độc lập mà chỉ tăng latency/correlation. ### 16.6. Completeness repair Nếu claim đúng nhưng còn bỏ sót dữ kiện có quote nguồn xác minh được, service có thể yêu cầu generation lại một lần rồi chạy lại toàn bộ grounding + entailment. Không sửa trực tiếp string đầu ra bằng code. ### 16.7. Fail-closed Các lý do reject riêng: ```text request_budget_exhausted provider_unavailable malformed_output evidence_insufficient unsupported_drug ungrounded_number invalid_citation uncited_claim unsupported_claim incomplete_answer ``` Khi production có generator, generation fail không âm thầm đổi thành một đoạn raw source giả làm câu trả lời chatbot. Hệ thống abstain với reason cụ thể. ### 16.8. Quarantine path `VERIFY_PDF` return trước generation: ```text “Nguồn có bảng hoặc công thức cần đối chiếu trực tiếp với ảnh PDF; không tự động trích số liệu.” ``` Đây là nhánh an toàn nhất cho dữ liệu 2D chưa reconstruct. --- ## 17. Bước 14 — Response, citation và UI API response gồm: - trace/correlation/OTel trace ID; - decision + reason; - answer; - resolved drug ID; - generated/extractive flag; - semantic blocks/claims; - candidate assessments; - citations; - disclaimer cố định. Citation được dựng từ metadata của evidence, không để model tự bịa: ```text chunk_id drug_id / drug_name section_key / section_title source document printed page range physical page block_id / bbox / source_crop exact evidence text ``` Next.js BFF group nhiều source ref cùng chunk thành một evidence card nhưng vẫn giữ attachment ref riêng. UI cho phép xem evidence/crop và gửi feedback helpful/not helpful theo trace. Disclaimer là string do code sở hữu, không do model viết: > Nội dung được trích từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu > chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng. --- ## 18. Eval ingestion/parser Không có một metric duy nhất được gọi là “PDF parse accuracy”. Eval được chia thành ladder. ### 18.1. Back-index validation Back-index cuối sách được dùng như ground truth page/name. Kết quả chạy lại trên artifact hiện tại: ```text detected monographs: 684 ground-truth entries: 705 recall: 96,2% (678/705) precision: 99,1% ``` 27 unmatched ground-truth entry không đồng nghĩa 27 thuốc bị mất. Danh sách còn có chuyên luận chung/phụ lục nằm ngoài scope, ví dụ kê đơn thuốc, ngộ độc, sử dụng thuốc ở trẻ em và pha thuốc tiêm. Phải đọc denominator trước khi biến 96,2% thành tuyên bố chất lượng. ### 18.2. Span routing ledger Mục tiêu: mọi span phải có trạng thái, `unassigned = 0`. ### 18.3. Residual ink Mục tiêu: mọi vùng mực còn lại phải được phân loại; đây là cách phát hiện chữ vector-outline và fraction bar mà text extractor không thấy. ### 18.4. Visual census Các population nhỏ được đọc toàn bộ thay vì sampling: - 23 fraction-bar candidate: 16 thật, 7 false positive, precision 69,6%; - 51 vector-outlined text run: đọc và transcription toàn bộ; - table block chưa có full human census toàn corpus. ### 18.5. Chunk-ready gates Chạy lại ngày 12/08/2026 trên artifact local, toàn bộ gate pass: ```text outlined run not merged = 0 known corruption string = 0 formula fragment in prose = 0 PUA char = 0 replacement char = 0 empty section = 0 section/part without provenance = 0 unflagged quarantine block = 0 duplicate table/drug id = 0 chunk over 800 tokens = 0 chunk without printed page = 0 wrong schema = 0 source text not unique = 0 physical range not exact = 0 section not reassemblable = 0 block text leaked into chunk = 0 descriptor count = block count = 151 ``` Gate reassembly kiểm tra các `source_text` part có thể ghép lại đúng section, loại context label lặp khỏi nguồn. Đây mạnh hơn chỉ kiểm tra “mỗi câu có xuất hiện đâu đó”. ### 18.6. Điều parser eval không chứng minh - mọi cell trong bảng đúng; - recall 100% cho borderless table/bar-less formula; - toàn bộ nội dung đã được chuyên gia y khoa đọc; - bản 2018 phù hợp cho clinical production hiện tại. --- ## 19. Eval embedding và retrieval ### 19.1. Dense baseline 160 case source-derived, 8 section, mỗi section 20 câu: | Metric | Dense embedding thuần | |---|---:| | hit@1 | 0,544 | | hit@3 | 0,663 | | hit@5 | 0,738 | Đặc biệt `chong_chi_dinh` chỉ hit@1 = 0,05 khi để vector tự chọn section. Section `duoc_ly_va_co_che_tac_dung` dài và generic hút nhiều query không liên quan. ### 19.2. Bài học kiến trúc từ eval Embedding không hỏng hoàn toàn; bài toán đã có metadata nhưng retrieval lại bỏ qua metadata. Fix đúng là resolve section và filter, không nhất thiết đổi model. Sau section routing: ```text 160 generated routing cases: hit@1 = 1,000 human-written single-drug cases: 16/16 ``` Phải trình bày caveat: 160 case sinh từ cấu trúc có circularity; 16 câu human written là check bổ sung nhưng sample vẫn nhỏ. ### 19.3. Negative/adversarial eval Eval không chỉ đo recall. Nó phải đo: - tên thuốc bịa/gần giống; - non-human/veterinary; - prompt injection; - wrong relation; - citation sai; - số không có trong source; - provider outage; - Qdrant/Postgres unavailable; - multi-turn context bleed; - stale quick reply; - quarantine path. --- ## 20. Eval answer/grounding ### 20.1. Unit/contract tests Các nhóm test bảo vệ: - query frame/schema parsing; - drug candidate bounding; - condition normalization/relation guard; - patient context extraction; - section ordering; - exact citation binding; - numeric grounding và decimal separator; - structured claim parsing; - unsupported-drug rejection; - semantic entailment/completeness; - timeout/provider error taxonomy; - disclaimer; - trace fail-open; - Qdrant modern/legacy API compatibility. Release record cuối feature condition-to-drug: ```text pytest: 278 passed, 6 skipped live datastore integration: 6 passed ruff: clean shared types tsc: passed Next production build: passed web tsc: passed ``` Ngày 12/08 chỉ chạy lại parser/chunk gate offline. Một lần gọi full ai-service pytest local dừng ở collection vì `localhost:6333` không chạy; nó không gọi production và không làm thay đổi kết quả release record trên. ### 20.2. Golden/live eval lịch sử Một mốc live trước feature condition: - 35 câu golden e2e; - 19/19 câu answerable trả grounded đúng thuốc; - 14 adversarial abstain; - phát hiện hai gap về price và “double dose”. Ngày 11/08 còn có battery 37 live cases cho các fix về pediatric clarification, timeout/citation chips và regression guard. ### 20.3. Condition-to-drug eval Có ba lớp artifact: 1. `condition_to_drug_v1.jsonl`: 20 case contract/diagnostic. 2. `production_manual_60.jsonl`: 60 case HTTP production. 3. `run_manual_battery.py`: recorder + deterministic checker, không dùng một overall LLM judge. 60 case bao phủ: - general/specific condition; - ambiguity; - relation confusion; - bệnh nền; - current medication; - thai kỳ/cho con bú; - renal/hepatic lab; - allergy; - named-drug regression; - interaction; - conversation continuation và new-case reset. Runner kiểm tra: - decision/reason; - answerable phải có citation; - general reverse lookup chỉ cite `chi_dinh`; - patient query phải có candidate assessment; - drug mong đợi xuất hiện; - citation drug không nằm ngoài candidate set; - số thuốc không vượt cap; - interaction citation phải thật sự nhắc current medication; - answer không chứa language unsupported như first-line/lựa chọn tốt nhất/phác đồ chuẩn. ### 20.4. Trạng thái production battery thật Tại handoff cuối ngày 11/08: ```text case 1–20: 20/20 unique pass sau fix/retry case 21–60: chưa chạy ``` Vì vậy không được báo 60/60 hoặc Definition of Done đầy đủ. Hai case gout subtype từng phát hiện lỗi production-only do qdrant-client mới không còn `.search()`. Commit cuối sửa sang `query_points()` và deploy smoke đã bắt regression này. --- ## 21. Observability và auditability ### 21.1. Correlation Correlation ID và W3C trace context đi từ Next.js tới FastAPI. Response trả: - `X-Correlation-ID`; - `X-Trace-ID`; - persisted retrieval trace ID. ### 21.2. OpenTelemetry stages Trace có các stage: ```text receive understanding routing retrieval rerank/evidence generation grounding/entailment persistence response ``` ### 21.3. Prometheus Theo dõi request rate/latency, stage latency, route/reason, abstention, generation rejection, provider failure và trace-write failure. Label được giới hạn để tránh cardinality explosion; không nhét raw patient query vào metric label. ### 21.4. Ba lớp truy vết một câu trả lời 1. UI evidence panel: chunk, page, source text, crop. 2. Grafana/Tempo: stage nào chạy, latency, decision/reason, trace ID. 3. PostgreSQL `rag_retrieval_trace`: query, resolved drug, citations/evidence, decision/reason, correlation và OTel trace ID. Đây là execution/provenance trace, không phải chain-of-thought logging. --- ## 22. Deploy production ### 22.1. Topology ```text Internet -> Caddy :443 -> Next.js web :3000 -> FastAPI ai-service :8000 -> Qdrant -> PostgreSQL -> AWS Bedrock qua EC2 IAM role ``` Không có long-lived AWS key trong repo/env production; Bedrock dùng IAM instance role. ### 22.2. CI/CD Push `master` chạy GitHub Actions: 1. SSH vào EC2; 2. `git fetch` + reset về `origin/master`; 3. Docker Compose build/restart ai-service, web và observability stack; 4. reload/validate Caddy; 5. chạy migration; 6. health/ready/web smoke; 7. condition smoke thật với gout cấp; 8. yêu cầu `decision=answerable` và citation `section_key=chi_dinh`; 9. kiểm tra Prometheus/Tempo/Grafana; 10. tạo request có correlation ID rồi xác nhận exact trace tồn tại trong Tempo. ### 22.3. Bản production cuối ngày 11/08 ```text commit: f4b84fb workflow run: 31471486789 status: success ``` Bản này gồm feature condition-to-drug, condition deploy smoke và compatibility fix Qdrant `query_points()`. ### 22.4. Hạ tầng chưa làm Gateway/auth/user/chat services chưa được build. Frontend hiện gọi thẳng ai-service qua BFF. Gitea + team ArgoCD/k3s vẫn là target nhưng chưa triển khai; production EC2/Compose là interim topology. --- ## 23. Failure modes và cách hệ thống phản ứng | Failure | Phản ứng | |---|---| | Không resolve được thuốc | clarify/abstain, không đoán thuốc gần nhất | | Condition sai quan hệ | abstain `unsupported_reverse_relation` | | Không có indication hit | abstain, không nói “không có thuốc điều trị” | | Query embedding lỗi | abstain/fallback theo route, không giả làm content miss | | Reranker lỗi | giữ thứ tự candidate gốc, vẫn bounded | | Evidence thiếu page | abstain | | Có bảng/công thức quarantine | `VERIFY_PDF`, không generation | | Model JSON malformed | abstain `malformed_output` | | Model nêu số ngoài evidence | reject `ungrounded_number` | | Citation sai/thiếu | reject | | Claim không entailed | reject `unsupported_claim` | | Candidate drug ngoài retrieval | reject `unsupported_drug` | | Budget hết | abstain `request_budget_exhausted` | | Bedrock outage | abstain `provider_unavailable` | | Postgres trace write lỗi | answer an toàn vẫn trả; trace dùng UUID local | | Browser disconnect | BFF propagate abort upstream | --- ## 24. Những hạn chế còn lại 1. Corpus là bản 2018, trong khi đã có bản 2022. 2. Chưa có quyền sử dụng production và clinical release approval đầy đủ. 3. Chỉ ingest Phần 2; Phần 1 và phụ lục chưa vào corpus. 4. 151 bảng/công thức vẫn là crop/descriptor, chưa reconstruct row/column. 5. Provenance tới chunk/page/region, chưa có character span. 6. Ingestion hiện không phát `parent_id`; parent hydration chỉ là compatibility code, chưa phải active hierarchy. 7. True native sparse BM25 + dense RRF chưa live. 8. Condition normalizer cố ý nhỏ, chưa phải terminology/ICD service. 9. Patient stage chỉ xem tối đa 2 candidate để bound latency; không phải clinical ranking đầy đủ. 10. Raw history bền trong Postgres nhưng normalized last frame còn in-memory. 11. Full 60-case production battery mới hoàn thành 20 case. 12. Chưa có tập 200–500 case được bác sĩ/dược sĩ duyệt và chấm content. 13. Rerank/generation/entailment cùng phụ thuộc Bedrock; outage làm tăng abstain. 14. Chưa có streaming claim đã verify; request vẫn synchronous. 15. Không có authentication; rate limit hiện là in-memory theo một web process. 16. BFF có thể che upstream non-2xx thành `upstream_error`, làm diagnostic khó nếu không xem trace/log. --- ## 25. Cách demo cho mentor ### Slide 1 — Bài toán “Một PDF Dược thư dài 1.668 trang, hai cột, nhiều bảng/liều. Mục tiêu không chỉ là semantic search mà là trả lời có provenance và fail-closed.” ### Slide 2 — Pipeline tổng thể Vẽ hai đường offline/online. Nhấn mạnh ingestion không chạy trong request. ### Slide 3 — PDF parsing Nói ba điểm: 1. không có TOC; 2. explicit two-column reading order; 3. bbox/page/font được giữ đến segmentation. ### Slide 4 — PDF QA Nêu coverage ledger + residual ink + visual census. Nhấn mạnh không dùng một metric “99% parsing accuracy” mơ hồ. ### Slide 5 — Chunking Dùng ví dụ người lớn/trẻ em. Nêu 800/650/65, `source_text` so với `text`, và context label propagation. ### Slide 6 — Table/formula quarantine Cho ví dụ công thức flatten sai. Nêu 151 descriptor và crop-only answer. ### Slide 7 — Embedding/Qdrant Nêu Cohere v4 1.024d, `search_document`/`search_query`, cosine, UUID5, manifest sidecar. ### Slide 8 — Retrieval So sánh dense-only hit@1 0,544 với section route 1,000. Đây là slide chứng minh kiến trúc được quyết định bởi eval. ### Slide 9 — Condition-to-drug Nêu `chi_dinh`-only, lexical-first/dense fallback, group by drug, cap 8, patient stage cap 2. ### Slide 10 — Grounding Vẽ: ```text structured claims -> candidate-set check -> number/citation check -> semantic entailment -> serve hoặc abstain ``` ### Slide 11 — Eval Nêu parser gates, retrieval eval, adversarial test, production battery và số thật 20/60. ### Slide 12 — Production và limitations Nêu `f4b84fb`, observability và các giới hạn clinical/source/auth. --- ## 26. Câu hỏi mentor có thể hỏi và câu trả lời ngắn ### “Tại sao không chunk cố định 500 token?” Vì cấu trúc Dược thư đã có drug/section/population/route. Fixed window có thể cắt label khỏi dose. Hệ thống dùng section làm parent, sentence-aware packing, target 650, overlap 65 và lặp context label có ghi dấu. ### “Tại sao vẫn cần embedding nếu đã filter section?” Exact metadata route xử lý câu đã rõ thuốc/section. Embedding cần cho paraphrase, free-form fallback và condition-to-drug khi exact phrase không match. Nó là fallback, không phải router toàn năng. ### “Tại sao Qdrant thay vì chỉ PostgreSQL?” Qdrant hỗ trợ cosine vector search kết hợp payload filter theo drug/section và payload index. Postgres dùng cho trace/history giao dịch; hai workload tách nhau. ### “Rerank để làm gì?” Bi-encoder tạo vector query/document độc lập nên section dài generic dễ hút query. Cross-encoder rerank chấm query-document cùng nhau để sắp lại top candidate. Nó chỉ reorder và fail-open. ### “Làm sao chắc LLM không bịa liều?” Không chỉ prompt. Mỗi claim phải cite evidence; mọi số phải match nguyên văn trong đúng evidence; citation phải hợp lệ; semantic entailment check nội dung; table/formula chưa tin cậy không được đưa vào generation. Fail thì abstain. ### “Tại sao không cho phép đổi 2 g thành 2000 mg?” Vì unit conversion là nơi lỗi liều nguy hiểm. Hệ thống hiện ưu tiên traceability và refuse, không tự chuyển đổi số ngoài source. ### “96,2% recall có nghĩa là mất 27 thuốc?” Không. Denominator 705 của back-index còn chứa nhiều chương/phụ lục ngoài phạm vi Phần 2. Cần đọc unmatched list và phân loại trước khi kết luận missing drug. ### “15.100 chunk có phải 15.100 đoạn độc lập không?” Không hoàn toàn: 14.949 prose và 151 block descriptor. Một section dài có nhiều part và overlap; `source_text` cho phép reassemble chính xác, `part_index` giữ thứ tự. ### “Có dùng BM25/hybrid không?” Có lexical/token-overlap primitives và RRF module, nhưng production chưa có native sparse/BM25 hybrid đầy đủ. Condition path là exact lexical phrase trước, dense fallback sau. ### “Production-ready chưa?” Software/deployment đã live; clinical release thì chưa. Nguồn 2018, quyền sử dụng/chuyên gia phê duyệt chưa đủ, table chưa reconstruct hết, eval production mới 20/60. ### “Nếu Qdrant mới đổi API thì sao?” Bản cuối thêm compatibility helper ưu tiên `query_points()` và fallback `search()`, kèm regression test mô phỏng client production chỉ có API mới. --- ## 27. Các lệnh tái hiện an toàn ### 27.1. Offline/read-only hoặc sinh artifact local Từ `ingestion/`: ```powershell python -m ingestion.cli validate ` --pdf data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf ` --tables data/processed/table_regions.json python -m ingestion.cli chunk-ready ` --monographs data/processed/monographs.jsonl ` --chunks data/processed/chunks.jsonl ``` Các lệnh này đọc PDF/artifact local, không gọi production hoặc Bedrock. ### 27.2. Parser/segment/chunk local ```powershell python -m ingestion.cli run ` --pdf data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf 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 ``` Đây là mutation local artifact; nên giữ SHA để so với canonical corpus. ### 27.3. Lệnh có chi phí — không chạy nếu chưa được duyệt ```powershell python -m ingestion.load.run ` --provider cohere-v4 ` --collection duocthu_v1 ``` Lệnh này có thể gọi AWS Bedrock và ghi Qdrant target. Không dùng URL production, không re-embed và không load lại nếu chưa xác định chính xác quyền, endpoint, corpus SHA, chi phí và phương án rollback. --- ## 28. Nguồn code chính để mở khi thuyết trình - PDF extraction: [`ingestion/ingestion/extract/spans.py`](../ingestion/ingestion/extract/spans.py) - Segmentation: [`ingestion/ingestion/segment/assembler.py`](../ingestion/ingestion/segment/assembler.py) - Title detector: [`ingestion/ingestion/segment/detector.py`](../ingestion/ingestion/segment/detector.py) - Chunker: [`ingestion/ingestion/chunk/chunker.py`](../ingestion/ingestion/chunk/chunker.py) - Chunk schema: [`ingestion/ingestion/chunk/models.py`](../ingestion/ingestion/chunk/models.py) - Readiness gates: [`ingestion/ingestion/validation/readiness.py`](../ingestion/ingestion/validation/readiness.py) - Cohere embedding: [`ingestion/ingestion/embed/bedrock_cohere.py`](../ingestion/ingestion/embed/bedrock_cohere.py) - Loader/manifest: [`ingestion/ingestion/load/run.py`](../ingestion/ingestion/load/run.py) - Query understanding: [`apps/ai-service/rag/understanding.py`](../apps/ai-service/rag/understanding.py) - Agent routing: [`apps/ai-service/rag/agent.py`](../apps/ai-service/rag/agent.py) - Retrieval policy: [`apps/ai-service/rag/service.py`](../apps/ai-service/rag/service.py) - Qdrant adapter: [`apps/ai-service/adapters/qdrant.py`](../apps/ai-service/adapters/qdrant.py) - Answer/grounding: [`apps/ai-service/rag/answer.py`](../apps/ai-service/rag/answer.py) - Deterministic verifier: [`apps/ai-service/rag/grounding.py`](../apps/ai-service/rag/grounding.py) - Production battery: [`apps/ai-service/evals/production_manual_60.jsonl`](../apps/ai-service/evals/production_manual_60.jsonl) - Battery runner: [`apps/ai-service/scripts/run_manual_battery.py`](../apps/ai-service/scripts/run_manual_battery.py) - Deploy workflow: [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) - Production handoff: [`coordination/CODEX_CONDITION_MEDICATION_QA_HANDOFF_2026-08-11.md`](../coordination/CODEX_CONDITION_MEDICATION_QA_HANDOFF_2026-08-11.md) --- ## 29. Kết luận Điểm mạnh của dự án không phải “đã gắn một LLM vào PDF”, mà là biến một PDF y dược phức tạp thành một chuỗi artifact có thể audit: ```text pixel/span -> monograph/section -> source-preserving chunk -> embedding + metadata + manifest -> bounded retrieval -> structured claims -> deterministic + semantic verification -> citation/crop/trace tới người dùng ``` Mỗi tầng đều giữ provenance và có failure mode rõ. Eval đã trực tiếp thay đổi kiến trúc: dense-only đạt hit@1 0,544 nên hệ thống chuyển sang metadata section routing; bảng/công thức flatten sai nên chuyển sang quarantine/crop; Qdrant API khác production/local nên deploy smoke + compatibility test được thêm. Tuyên bố đúng nhất hiện tại là: > Hệ thống đã chạy end-to-end trên production, có corpus 15.100 point, trả lời > grounded với citation/trace và có nhiều gate fail-closed. Tuy nhiên đây vẫn là > bản tra cứu kỹ thuật dựa trên Dược thư 2018, chưa phải sản phẩm lâm sàng được > phê duyệt; còn thiếu nguồn hiện hành, review chuyên gia, table reconstruction > và phần còn lại của production evaluation.