Homus
‹ All talks

Bypassing the Multimodal Tax: Framework-Free Hybrid RAG, Raw SQL RRF, and Live UI Telemetry

AI Engineer World's Fair 2026: Online Track · Video gốc

Chatbot FAQ chạy local: Docling ra Markdown, bốn chunking strategy, hybrid search trong Postgres gộp bằng RRF, guardrail bằng code và trace với Langfuse.

DatabasesContext EngineeringSecurity

1. Hai vấn đề: token mất trước câu hỏi đầu tiên, và quá nhiều tool

Abed Matini mở đầu bằng lời chào và tự giới thiệu: anh là Senior Backend Developer tại Ogilvy. Anh cảm ơn AI Engineer World's Fair 2026 đã cho anh một slot trong Online Track. Hôm nay anh sẽ dẫn người xem đi qua chủ đề "bypassing the multimodal tax": làm sao có một hybrid RAG không cần framework, với RRF viết bằng raw SQL, và live telemetry.

Slide tiêu đề Bypassing the Multimodal Tax, bên phải là chatbot FAQ Assistant đang chạy
Slide tiêu đề: "Bypassing the Multimodal Tax", dòng phụ "Framework-Free Hybrid RAG, Raw SQL RRF, and Live UI Telemetry", kèm tên Abed Matini, Senior Backend Developer, Ogilvy, AI Engineer World's Fair 2026, Online Track. Cột bên phải màn hình là chatbot FAQ Assistant đang chạy local, dùng cho demo suốt talk.

Như người xem thấy, phía bên phải màn hình là live demo của anh. Với mỗi slide anh chuyển qua, nếu có gì liên quan tới phần trình bày thì anh có thể cho xem ngay. Anh cũng mở sẵn vài tab để vừa đi qua slide vừa demo. Rồi anh bắt đầu.

Có hai vấn đề mà talk này cố giải quyết. Vấn đề thứ nhất: khi bắt đầu chat với bất kỳ LLM nào mà cần upload một tài liệu, ta thường chỉ kéo thả một file PDF, một file Word hay một tấm ảnh, rồi bảo chatbot sẵn sàng cho những câu hỏi sẽ tới sau khi upload. Cái khó là ngay khi upload những tài liệu này, ta đã mất một phần token, phần token đáng lẽ chỉ dành để xử lý tài liệu, trong khi chưa hề đặt một câu hỏi nào. Tức là ta đã tiêu token ở đó mà chưa hỏi gì cả. Đó là vấn đề đầu tiên, và cũng là cái "multimodal tax" trong tên talk.

Vấn đề thứ hai: nếu ta đang build một chatbot, muốn có vector database, muốn có cả keyword search lẫn semantic search, rồi lại muốn thêm nhiều tool nữa, thì số tool sẽ quá nhiều khi gộp tất cả lại. Trong một chatbot production, ta sẽ có quá nhiều tool, và việc quản lý chatbot production cho đúng cách sẽ trở nên phức tạp.

2. Ba phần của talk

Abed chia talk thành ba phần chính. Phần một là cách upload tài liệu, đưa tài liệu vào và chuẩn bị chúng sẵn sàng cho chatbot trước khi user bắt đầu chat. Ở phía bên phải màn hình, anh có một chatbot FAQ assistant đang chạy local trên máy. Anh sẽ cho xem dashboard anh tạo cho nó, cách chat với nó, và cách lấy kết quả.

Slide What we will walk through với ba cột: Getting documents in, Search inside Postgres, Observability and safety
"What we will walk through". Cột 1, Getting documents in: biến PDF, PPTX, DOCX và ảnh thành text sạch ngay trên máy, không có hoá đơn cloud vision; chia theo heading, paragraph, cửa sổ kích thước cố định hoặc nhóm câu, thay vì một kiểu token window cho mọi thứ; admin chọn cách chia cho từng file lúc upload. Cột 2, Search inside Postgres: tìm câu trả lời theo nghĩa (embedding) và theo đúng từ (keyword search); một database duy nhất, không Pinecone, không thêm search engine; gộp hai danh sách đã xếp hạng bằng RRF viết bằng Python thuần. Cột 3, Observability & safety: theo dõi token và latency mỗi lượt chat (Langfuse); quét text được upload để tìm pattern prompt injection; chặn câu hỏi rủi ro trước khi chúng tới LLM. Dòng cuối: live demo, khoảng 60 phút, tái tạo mọi thứ trong GitHub Codespaces.

Tài liệu có thể tới ở nhiều định dạng khác nhau, hoặc ta có thể upload ở nhiều định dạng: PDF, file PowerPoint, file Word, thậm chí là ảnh. Talk sẽ nói về chuyện đó. Talk cũng sẽ nói về cách phải chunk những tài liệu này, vì đây là RAG: ta cần biết chia thông tin thế nào cho đúng, dựa trên chính tài liệu. Nên sẽ có phần về các kiểu chunking khác nhau, và anh sẽ cho xem trong trang admin cách chọn các strategy khác nhau để chunk tài liệu.

Sau đó là cách search hoạt động, cách embedding hoạt động, database trông ra sao, search dựa trên keyword search hay semantic search như thế nào. Rồi tới cách RRF chạy trong Python. Cuối cùng là observability: cách kiểm tra an toàn, cách quan sát toàn bộ quá trình bằng Langfuse, và cách ngăn pattern prompt injection hay bất kỳ câu hỏi rủi ro nào có thể đi tới LLM.

Anh dự định talk dài dưới một tiếng. Và kế hoạch là toàn bộ codebase có thể chạy dễ dàng trên GitHub Codespaces: chỉ cần pull từ repo về, chạy một lệnh, và mọi thứ sẵn sàng để thử trên Codespaces.

3. Demo: FAQ assistant cho employee handbook, và stack

Ứng dụng demo được hình dung là một FAQ assistant cho một employee handbook mẫu. Hãy tưởng tượng bạn có một công ty, và bộ phận HR đã upload cuốn handbook để nhân viên hỏi mọi câu hỏi họ có: về nghỉ phép, về các loại nghỉ phép khác nhau, nghỉ ốm, nghỉ phép cho cha mẹ (parental leave), hay bất kỳ câu hỏi nào mà HR có thể gặp như một câu hỏi thường ngày. Đó là bộ mẫu sẽ được upload trong demo.

Stack được dùng:

  • Python với FastAPI cho back-end.
  • React cho front-end.
  • PostgreSQL làm database.
  • Docker, đơn giản để có container, để có thể tái tạo và chạy lại ở bất kỳ đâu, ví dụ trong Codespaces hay bất kỳ môi trường nào khác.
  • Ollama cho các local language model và embedding model. Chúng chỉ chạy local, và cũng chạy được trên bất kỳ server nào. Không cần GPU: CPU là đủ. Nên một staging server bất kỳ, một codespace bất kỳ, đều chạy được.
  • Langfuse để theo dõi chuyện gì đang diễn ra, xem các cuộc chat và latency, nhờ đó cải thiện chất lượng chat.
Docker: chạy ở đâu cũng được (Codespaces, staging server), chỉ cần CPU Reactchat + admin FastAPIPython, Docling PostgreSQL + vector Ollama (chat, embed) Langfuse (trace)
Stack của demo: React cho giao diện chat và admin, FastAPI làm back-end, PostgreSQL làm database, Ollama chạy model chat và embedding ngay trên máy, Langfuse ghi trace. Docker gói tất cả để chạy lại ở bất kỳ đâu, không cần GPU.

4. Blueprint end-to-end

Tiếp theo là blueprint end-to-end của những gì talk sẽ bàn. Ta có một vài tài liệu làm source of truth cho chatbot, và ta upload chúng. Phía Python dùng Docling để chuyển các tài liệu thô đó thành file Markdown. Rồi ta chunk các file Markdown đó vào Postgres. Ở bước này, hệ thống cũng có thể chạy thêm một lượt scan nữa.

Phía bên kia là câu hỏi từ user: nhân viên hỏi những câu liên quan tới HR, và câu trả lời được retrieve từ database. Những kết quả liên quan, xếp hạng cao nhất, được đưa qua LLM, và LLM trả kết quả về chatbot dưới dạng một cuộc chat. Câu hỏi của ta được trả lời ngay trong khung chat, và ta có thể quan sát trong Langfuse chuyện gì đang diễn ra trong hệ thống.

Ingest PDF, DOCX, ảnh Doclingthành Markdown chunk + embed+ scan Postgres Query câu hỏi HR retrieve top-k LLM chat Langfusequan sát
Blueprint end-to-end theo lời Abed: tài liệu đi qua Docling thành Markdown, được chunk và embed vào Postgres; câu hỏi của nhân viên được retrieve từ cùng database đó, các kết quả xếp hạng cao nhất đi qua LLM rồi trả về khung chat, còn Langfuse quan sát toàn bộ.

5. Optimizing data ingest: structure first, chạy local

Phần tiếp theo là tối ưu hoá data ingest. Có hai cách. Cách thứ nhất là cứ thế ném tài liệu vào: ta có tài liệu và upload thẳng lên một cloud chatbot nào đó. Vấn đề thứ nhất của cách này, như đã nói, là ta tiêu token trước khi làm bất cứ việc gì. Vấn đề thứ hai là ta không biết tài liệu trông thế nào ở phía bên kia, nó được chunk ra sao, và LLM thực sự "nhìn" nó thế nào. Đó là một rủi ro về chất lượng: nếu có một bảng trong tài liệu, ta không biết nó được đọc ra sao ở đó, ta không nhìn thấy. Điều Abed muốn là rõ ràng hơn về cách tài liệu được lưu trong database của mình và cách nó được retrieve.

Cách thứ hai là structure first. Ta dùng CPU local. Nếu chạy ngay tại đây, ta upload tài liệu, Docling chuyển nó thành file Markdown, và ta biết rõ hơn chunking sẽ diễn ra thế nào. Anh sẽ cho xem vài ví dụ. Và tất nhiên prompt sẽ rẻ hơn nếu sau đó bạn muốn nói chuyện với một model cloud online. Còn nếu muốn chạy hết ở local, thì thực ra không tốn đồng nào.

Upload thẳng lên cloud Structure first (local) • tiêu token trước câu hỏi đầu tiên • không thấy tài liệu bị chunk ra sao • bảng được đọc thế nào? không biết • 200 trang, nhiều nhân viên: tốn kém • Docling ra Markdown trên CPU local • thấy và chọn được cách chunk • prompt rẻ hơn nếu sau đó gọi cloud • chạy local hết: không tốn phí
Hai cách đưa tài liệu vào chatbot mà Abed so sánh: kéo thả lên cloud chatbot, hay parse thành Markdown có cấu trúc ngay trên máy trước rồi mới chunk.

Pipeline Docling chạy local trên server của ta. Python nhận tài liệu, export ra Markdown và lưu file Markdown đó. Sau đó nó chunk theo strategy được chọn, dùng embedding model để embed, rồi insert vào vector database.

Vậy tại sao chạy local? Hãy hình dung: với một hai trang thì chi phí gần như bằng không. Nhưng nếu bạn có hai trăm trang, chi phí sẽ quá lớn. Và nếu bạn là một công ty lớn với rất nhiều nhân viên, họ cứ hỏi đi hỏi lại cùng những câu hỏi, hay họ muốn upload tài liệu, thì đó sẽ là một khoản chi phí lớn.

6. Trang admin và vì sao upload nguyên handbook là một ý tồi

Có bốn cách chunk một tài liệu. Nhưng trước khi nói về bốn cách đó, Abed mở trang admin để cho thấy anh đang nói về cái gì. Hình dung đây là trang admin của FAQ. Anh xoá bớt vài tài liệu đã chunk từ trước. Giả sử bạn muốn upload một file FAQ mới cho chính sách HR, để cập nhật dữ liệu cho chatbot thông qua hệ thống RAG.

Trang admin FAQs với khung Upload document, Upload screenshot or image và bảng Knowledge base
Trang admin của chatbot. Khung "Upload document" nhận PDF, PPTX, DOCX, XLSX, MD, TXT, parse bằng Docling rồi chunk và embed tự động, với ô chọn chunking strategy (mặc định là heading-based). Khung "Upload screenshot or image" dành cho email alert, tin Slack, thông báo bảo trì: trích text bằng một vision model local, index sau khi bạn duyệt lại text, khuyến nghị dùng sentence chunking. Bảng "Knowledge base" liệt kê từng file với strategy, số chunk, trạng thái và các nút xem chunk, archive, delete; cuốn "Sample Employee Handbook" chunk theo paragraph ra 129 chunk.

Trước khi upload, anh cho xem ý mình là gì. Một cách để nuôi chatbot bằng thông tin là thực sự tạo ra một file câu hỏi và câu trả lời. Dạng đó dễ tiêu hoá hơn cho hệ thống, dễ hiểu chuyện gì đang diễn ra hơn.

So sánh với việc dùng nguyên file handbook. Nếu muốn chunk cuốn handbook này và upload nguyên cả file PDF, thì sau khi chunk nó sẽ trông như thế này: bắt đầu bằng phần acknowledgement, rồi vài chunk chẳng mang nghĩa gì, rồi mới tới nội dung. Có những chunk không có nghĩa, ví dụ chỗ chữ ký và ngày tháng, về cơ bản là vô dụng. Những chunk như vậy làm chậm hệ thống, giảm độ chính xác của chat, và làm tăng hallucination.

File Word Sample Employee Handbook 28 trang mở trong trình duyệt
Cuốn "Sample Employee Handbook" dùng làm ví dụ: 28 trang, trang bìa gần như trống. Upload nguyên khối như vậy sinh ra những chunk như acknowledgement hay chỗ ký tên và ngày, không giúp gì cho việc trả lời.

Nên thay vì upload thẳng 28 trang, nếu ta chia tài liệu thành những file khác nhau, những file liên quan, dọn dữ liệu sạch nhất có thể rồi mới upload, ta sẽ có kết quả tốt hơn. Trong trường hợp FAQ, một cách upload là biến dữ liệu thành các cặp câu hỏi và câu trả lời: về giờ làm việc, về những tình huống khác nhau. Theo cách đó, khi tài liệu được dùng làm reference, ta có reference đúng và không gây nhầm lẫn.

File Markdown SampleCorp HR Handbook: Frequently Asked Questions với các câu hỏi làm heading
Bản FAQ đã dọn sạch: "SampleCorp HR Handbook: Frequently Asked Questions". Mỗi câu hỏi là một heading, ví dụ giờ làm việc cốt lõi tại SampleCorp, được làm việc ở nước ngoài bao nhiêu ngày, xin duyệt làm việc từ xa ở nước ngoài thế nào, nước nào không được làm từ xa, nhà cung cấp bảo hiểm là ai và khi nào bắt đầu có hiệu lực. Ngay dưới mỗi heading là câu trả lời.

7. Chiến lược 1: heading-based chunking và câu trả lời có reference

Có nhiều cách upload tài liệu. Cách đầu tiên: bạn có file câu hỏi và câu trả lời rồi upload nó. Đây là file FAQ PDF của Abed, cùng file PDF đó, và anh upload với strategy heading-based chunking. Trong lúc anh giải thích, Docling đang parse ở background. Heading-based nghĩa là mỗi chunk dựa trên một heading mà Docling tìm được, cộng với phần trả lời đi sau nó. Heading là câu hỏi, cộng câu trả lời, thành một chunk. Nhờ vậy ta biết chunking sạch sẽ, và dễ dẫn nguồn.

Trang admin đang upload với chunking strategy heading-based, dòng Parsing with Docling
Upload file FAQ với strategy "Heading-based (default)": nút chuyển sang "Uploading..." và dòng trạng thái "Parsing with Docling...". Bên dưới, bảng knowledge base còn giữ các tài liệu cũ đã archive.

Khi chunking xong, chỉ trong một giây, ta thấy đây là FAQ đầu tiên, và nó đã chunk thành công theo heading. Nó nhận ra heading là câu hỏi, rồi câu trả lời liên quan. Mỗi chunk có câu hỏi và câu trả lời của riêng nó.

Khung xem chunk của heading-faq-1.pdf, mỗi chunk là một câu hỏi kèm câu trả lời
Xem các chunk của "heading-faq-1.pdf" (13 chunk, strategy heading). Mỗi chunk mang một câu hỏi làm heading và câu trả lời ngay dưới, ví dụ chunk về mức phí bảo hiểm y tế công ty chi trả: SampleCorp trả 90% phí cho cá nhân nhân viên và 75% phí cho người phụ thuộc.

Giờ nếu ta hỏi chatbot một câu, chẳng hạn "do we have medical insurance premium?", ta nên kỳ vọng một câu trả lời có reference trỏ về đúng chunk đó. Ta đang nói về heading chunk: hệ thống hiểu tài liệu dựa trên các heading bạn có, và chunk từng tài liệu theo từng heading một.

Câu hỏi đó là câu đã chuẩn bị sẵn, và câu trả lời cũng là câu trả lời đã chuẩn bị: công ty mẫu chi trả chín mươi phần trăm phí bảo hiểm cho cá nhân nhân viên và bảy mươi lăm phần trăm phí cho người phụ thuộc. Phần reference cho thấy ta nhận được đúng câu trả lời đó. Không chỉ là câu trả lời đúng: bạn còn thấy câu trả lời này được sinh ra dựa trên tài liệu này, dựa trên chunk này. Nên dẫn nguồn rất dễ, và bạn biết vấn đề nằm ở đâu. Nếu có gì không chạy, bạn biết vì sao, và có thể lần theo xem tại sao chunk đúng lại không được retrieve. Theo cách này, bạn có một con đường rõ ràng hơn để debug.

Khung chat FAQ Assistant trả lời câu hỏi về bảo hiểm y tế, tooltip nguồn số 2 lấy từ cuốn handbook
Câu hỏi "do we have medical insurance premium?" được trả lời với hai nguồn. Tooltip đang mở là nguồn số 2, một chunk từ cuốn Sample Employee Handbook upload nguyên khối: nội dung lại nói về ngày trả lương (ngày 15 và ngày cuối tháng), không liên quan gì tới bảo hiểm. Đó chính là kiểu chunk khó lần theo mà Abed cảnh báo.

Hệ thống được cấu hình trả về top hai chunk (Abed sẽ nói thêm về các model ở phần sau), nên nó mang về hai kết quả. Nguồn thứ hai là cuốn sample employee handbook, file lớn kia. Như bạn thấy, đó là một chunk rất lớn. Và đây thực ra là một ví dụ hay, nếu so sánh hai nguồn với nhau, vì cuốn handbook được upload nguyên khối. Dù nó có trả về chút thông tin, thông tin đó không chính xác bằng, và bạn không biết nó chính xác tới đâu. Tất nhiên hệ thống đã đi qua file đó và tìm được gì đó, nhưng bạn không biết độ chính xác, và bạn không lần theo được, vì chunk đó không dễ truy vết.

8. Chiến lược 2: paragraph chunking

Vậy là cách chunk thứ nhất dựa trên heading. Cách thứ hai dựa trên paragraph. Về cơ bản, khi upload tài liệu, bạn nói với hệ thống: cứ mỗi đoạn văn bắt được thì làm thành một chunk riêng. Không quan tâm có heading hay không, hay bất cứ thứ gì khác.

Abed quay lại phần tài liệu và chọn paragraph chunking. Anh tìm file paragraph, "general policy", và upload nó. Như bạn thấy, toàn bộ file chỉ được chia theo đoạn văn, từng đoạn một thành một chunk. Không có heading, không có thông tin tách riêng nào.

Trang admin sau khi upload general-policy-paragraph.md với strategy Paragraph, 7 chunk
Chọn strategy "Paragraph" và upload "general-policy-paragraph.md": dòng xác nhận đã index với 7 chunk, và file mới xuất hiện đầu bảng knowledge base với strategy paragraph.

Và nếu nhìn vào chính thông tin đó, tìm nhanh file general policy paragraph, bạn thấy thông tin chỉ là từng đoạn văn nối tiếp nhau. Tức là bạn lại dọn dữ liệu, nhưng chia theo một hệ thống khác: thay vì heading, ở đây là đoạn văn. Đó là một cách khác để nuôi RAG bằng thông tin.

File Markdown general-policy-paragraph.md gồm các đoạn văn liền nhau không có heading
File nguồn "general-policy-paragraph.md": văn xuôi liền mạch về quy định văn phòng của SampleCorp (thẻ RFID, các vùng truy cập theo tầng, dress code, mạng nội bộ, khu vực dùng chung), không có heading. Mỗi đoạn trở thành một chunk.

9. Chiến lược 3 và 4: fixed 512 ký tự có overlap, và sentence groups

Cách thứ ba là fixed 512 ký tự. Bạn có thể nói: một trong những best practice của RAG là chỉ cần chia theo 512 ký tự, và có 64 ký tự overlap giữa các chunk, để không mất mạch. Nếu dữ liệu của bạn không được tổ chức, chỉ là dữ liệu lộn xộn mà bạn không dọn được, thì đó là một cách khác để upload tài liệu.

Lần này anh chọn một file khác: một file paragraph nữa, cho global travel. Khi nhìn vào chunking strategy này, bạn thấy nó bắt đầu chunk, rồi kết thúc ở 512. Rồi nó lại dùng, anh nghĩ là 64, đúng, 64 overlap. Nó dùng 64 overlap ở đây. Bạn thấy nó cắt từ chỗ này, từ đây tới đây là 512, từ đây tới đây là 32, rồi thêm 32 nữa từ đây tới đây. Tức là 32 cộng 32, tổng cộng 64. Đó là cách nó nối các chunk với nhau, và chuyện tương tự xảy ra ở các chỗ khác.

Các chunk fixed của global-travel-expence-guide-paragraph.md, cùng một cụm chữ được tô ở cuối chunk #2 và trong chunk #3
Các chunk của "global-travel-expence-guide-paragraph.md" với strategy fixed (7 chunk). Abed dùng ô tìm trong trang để tô cụm "global travel desk for assis" ở cả hai chỗ: chunk #2 kết thúc giữa chừng bằng "assis", còn chunk #3 mở đầu bằng chính đoạn cuối đó của chunk #2 (phần overlap) rồi mới đi tiếp với "assistance rather than booking independently".

Nhưng bạn thấy vấn đề của nó: ở chỗ này, chữ "assist" và "assistance" bị cắt, nó làm gãy chữ. Đây không phải cách tốt nhất, nhưng trong vài trường hợp nó vẫn có tác dụng.

Cách thứ tư là sentence-based. Nó có thể dựa trên năm câu: hệ thống đếm số câu và cắt ở đó. Mỗi cách có use case riêng.

Slide Four ways to split a document với bảng bốn strategy và Key insight
"Four ways to split a document". Heading: ranh giới là Markdown H1 tới H4, hợp với output của Docling vì đó là các phần có nghĩa tự nhiên. Paragraph: ranh giới là dòng trống, hợp với văn xuôi dày đặc. Fixed: 512 ký tự cộng 64 overlap, hợp với nguồn nhiễu hay không nhất quán. Sentence: 5 câu cộng 1 câu overlap, hợp với FAQ và email. Key insight: Docling vốn đã xuất ra cấu trúc nên heading là mặc định, nhưng cách chia đúng tuỳ vào nguồn. Design rule: strategy là một lựa chọn lúc runtime, admin chọn heading, paragraph, fixed hoặc sentence cho từng lần upload, không cần redeploy. Khung chat bên phải vẫn mở tooltip nguồn số 2 của câu hỏi về bảo hiểm y tế: chunk từ cuốn handbook nói về ngày trả lương.

10. Upload một screenshot: thông báo bảo trì cuối tuần

Một ví dụ thú vị khác: hãy tưởng tượng sắp có một đợt bảo trì, bạn nhận được một email, và bạn muốn upload nhanh tài liệu đó vào chat. Bạn không có gì khác ngoài một tấm screenshot. Giả sử bạn vừa nhận email nói rằng có kế hoạch bảo trì vào ngày này, từ giờ này, và bạn chỉ muốn upload nó vào chat cho user. Như vậy nếu tôi, một nhân viên, hỏi "cuối tuần này có kế hoạch bảo trì nào không?", tôi sẽ biết ngay.

Nên có một cách khác để upload screenshot dễ dàng. Abed đi tìm file, xin lỗi vì nó nằm trong thư mục screenshot của anh. Bạn upload screenshot, rồi hệ thống dùng một model khác biến ảnh thành text, và text thành file .md. Sau đó dùng strategy sentence cho các email để chunk text đó và đưa vào. Strategy được chọn là sentence group. Ảnh đã được biến thành text, và đã sẵn sàng cho chatbot: giờ ta có thể index nó vào knowledge base.

Trang admin đang trích text từ screenshot bằng vision model
Sau khi chọn screenshot, khung "Upload screenshot or image" hiện trạng thái "Extracting text with vision model (this may take a minute)...". Text trích ra được cho xem lại trước, rồi mới bấm index vào knowledge base.

File vừa upload xuất hiện kế tiếp trong danh sách: screenshot. Ta xem được nó được chunk ra sao. Vì đây chỉ là một email, nó không có heading, cũng không nhất thiết có các đoạn văn được chia gọn gàng. Và khi thiếu thời gian, khi đó chỉ là một tin nhắn ngắn, một thay đổi tạm thời, bạn không cần ngồi dọn dữ liệu. Bạn chỉ muốn kéo thả, cập nhật cho cuối tuần, và ai cũng biết cuối tuần này có kế hoạch bảo trì.

Trang admin xác nhận đã index screenshot với 6 chunk theo strategy sentence
Dòng xác nhận "Indexed screenshot: Screenshot 2026-06-19 094158.png (6 chunks)". Trong bảng knowledge base, file ảnh đứng đầu với strategy sentence và 6 chunk, cạnh file travel (fixed, 7 chunk) và file general policy (paragraph, 7 chunk).

Rồi khi quay lại khung chat và hỏi "is there any maintenance plan for this weekend?", về lý ta sẽ nhận được kết quả: đây là kế hoạch bảo trì sắp diễn ra, kèm tài liệu reference. Đây đúng là khung giờ bảo trì ta nhận được. Và thực ra reference là chính tấm screenshot vừa upload. Abed cười: giá mà anh đặt tên file screenshot cho tử tế hơn thì reference còn rõ nghĩa hơn nữa.

Khi rê chuột lên nguồn số 1, tooltip cho thấy nội dung đã trích từ tấm ảnh: một thông báo nhắc cả nhóm rằng bảo trì hệ thống được lên lịch vào thứ Bảy, 20 tháng 6 năm 2026, "maintenance window" bắt đầu 6:00 PM (18:00) và kết thúc 12:00 AM nửa đêm (00:00 Chủ nhật, 21 tháng 6).

Và một người sẽ nói: "theo thông tin tôi lấy được từ đây, ngày mai có kế hoạch bảo trì, từ 6 giờ chiều tới nửa đêm." Abed cũng cho thấy anh có thể archive hay xoá bất kỳ tài liệu nào trong số này một cách dễ dàng, và khi đó nó sẽ không còn hiển thị nữa.

Hộp thoại xác nhận xoá vĩnh viễn screenshot trong trang admin
Xoá một tài liệu khỏi knowledge base: trình duyệt hỏi lại có muốn xoá vĩnh viễn "Screenshot 2026-06-19 094158.png" không, và nhắc rằng thao tác này không hoàn tác được. Nút Archive bên cạnh chỉ ẩn tài liệu, có thể Restore sau.

Đó về cơ bản là những cách khác nhau để chunk. Và đây là một trong những phần quan trọng nhất của toàn bộ hệ thống, vì hầu hết các LLM đều sẽ chạy được. Cái quyết định là cách bạn nuôi dữ liệu và dọn dữ liệu: không kéo thả nguyên cuốn handbook vào để có một mớ dữ liệu lộn xộn. Điều đó giúp bạn rất nhiều để có một chatbot, trong trường hợp này là FAQ chatbot, thành công hơn. Các strategy khác nhau này sẽ giúp bạn. Rồi anh chuyển sang slide kế tiếp.

11. Agent chỉ là Python function

Hệ thống dùng Postgres, và cũng có vài agent, nhưng các agent đó chỉ là code Python. Chúng không thực sự là gì khác: hệ thống không gọi thêm một LLM nữa làm agent để làm việc gì đó cho ta. Một lý do là tốc độ, vì Abed chạy tất cả local, trên Ollama. Anh không cần ba, bốn agent lặp ba lần, bốn lần, vì khi đó anh sẽ phải chờ 20, 30 giây, và user sẽ mất hứng. Bạn chỉ muốn một lượt natural language chạy thôi. Phần còn lại, nếu một Python function làm được, thì để Python function chạy cho bạn.

Khi đó bạn cũng có toàn quyền kiểm soát function. Sẽ không có hallucination, vì bạn sẽ viết test suite để bao phủ phần lớn các tình huống. Điều đó giúp ích cho bạn.

Với các agent, anh cho xem phần settings. Anh có thể bật tắt agent. Ví dụ nếu ai đó muốn biết ngày hiện tại, function này được gọi và trả về dễ dàng. Bạn không cần LLM mang về ngày hiện tại. Bạn không cần LLM tính toán gì đó cho bạn. Vì một kế hoạch khác để mở rộng chatbot này là dùng nó cho customer service, nơi sẽ nói về sản phẩm, giá cả và tính toán. Đó là một hướng khác: thông tin sản phẩm, hay thậm chí một lượt search sâu hơn ở agent mode. Nhưng điểm cần lưu ý là vì đó lại là một lượt search khác, bạn sẽ mất phần reference ở đó. Và reference là điều quan trọng.

Tab Settings của trang admin: LLM configuration, Top-K chunks, Agent mode đang tắt
Tab Settings. "LLM configuration": chat model lấy từ instance Ollama đang chạy, đang chọn qwen2.5:0.5b-instruct, thay đổi có hiệu lực ở request chat kế tiếp mà không cần khởi động lại API; "Top-K chunks" là số đoạn tài liệu retrieve cho mỗi câu trả lời (1 tới 20), đang đặt 2. "Agent mode": khi bật, LLM tự quyết định gọi tool nào trước khi trả lời; khi tắt, chat quay về direct RAG (retrieve rồi trả lời, không có vòng lặp tool). Agent model cũng là qwen2.5:0.5b-instruct, và có 6 tool sẵn có như Search KB, List Products, Product Info.

Nhân tiện đang ở đây, anh cho xem mình thực sự dùng LLM nào. Anh dùng model nhỏ nhất của Qwen 2.5: bản instruct 0.5 tỷ tham số, và anh nghĩ nó chỉ khoảng 400 megabyte. Nhỏ tới mức đó. Anh cũng dùng model này cho agent mode nếu muốn chạy. Bật agent mode sẽ làm hệ thống chậm hơn một chút. Rồi anh quay lại slide.

Với cách làm việc này, bạn có toàn bộ visibility vào Python function: biết chuyện gì đang diễn ra. Bạn chỉ đưa cho LLM thông tin cần thiết, thông tin rõ ràng, và nhận lại thông tin rõ ràng. Điều đó giảm khả năng hallucination một cách đáng kể.

12. Hybrid search trong Postgres: semantic, keyword và số lượng retrieve

Tiếp theo, Abed nói về vector và text trong database. Khi chunk thông tin, mỗi chunk được lưu theo ID và section, cùng với loại vector embedding đang dùng. Rồi ta có chunk của mình, và thấy được cách hệ thống tìm nearest neighbor, đồng thời cũng tìm keyword match.

Vì sao cần cả hai? Nếu bạn có một chatbot customer service về sản phẩm, bạn không cần thông tin gần nhất. Bạn cần thông tin chính xác. Khi đụng tới con số, khi đụng tới sản phẩm, bạn cần đúng chính xác. Nếu là một chatbot y tế, bạn cần đúng loại thuốc đó, chứ không cần thứ gì tương tự hay gần giống. Nên ta cần cả keyword search lẫn semantic search cùng lúc. Ta có một hybrid search cho kết quả tốt hơn.

Rồi tới số lượng kết quả retrieve. Trong hệ thống, Abed giới hạn ở hai, vì trong trường hợp này không cần nhiều hơn. Nhưng nếu đó là sản phẩm, bạn không muốn chỉ mang về top hai sản phẩm cho user, vì có thể có hai mươi hay năm mươi sản phẩm cùng một brand. Nếu bạn không mở cửa sổ đủ rộng để kéo những thông tin đó về, những sản phẩm đó sẽ không bao giờ được bán và không bao giờ xuất hiện trong chatbot. Nên số lượng bạn fetch được là điều quan trọng.

Thêm về retrieval: ta dùng BM25 để lấy đúng keyword. Slide trước nói về việc lấy những gì gần nhất theo cosine distance. Khi upload thông tin vào vector database, những thông tin có nghĩa tương tự sẽ nằm cạnh nhau. Khi user tìm hay hỏi gì đó, hệ thống chuyển câu hỏi, câu query đó vào không gian của database, đi tìm những thông tin gần nhất với câu hỏi và trả về. Đó là lý do ta không thể chỉ trả về một kết quả: ta cần trả về nhiều hơn, để có nhiều câu trả lời và những câu trả lời sát hơn.

Còn ở sparse retrieval, ta cần câu trả lời chính xác, nên ta phải lọc, ví dụ theo ngôn ngữ, theo ID, theo đúng tên sản phẩm, SKU, hay tên brand. Đó là phần keyword retrieval.

câu hỏi của user Dense: embeddingnearest neighbor, cosine distance Sparse: keyword (BM25)tên sản phẩm, SKU, ID, brand RRF trong Pythongộp hai danh sách, lấy top-k cùng một Postgres,không search engine riêng
Hybrid search như Abed mô tả: cùng một câu hỏi đi hai đường trong cùng database, một đường tìm theo nghĩa bằng embedding và cosine distance, một đường tìm đúng từ khoá bằng BM25. Hai danh sách đã xếp hạng được gộp lại (slide mở đầu ghi là RRF bằng Python thuần) để giữ top-k kết quả liên quan nhất.

Rồi tới chuyện ranking, và đây là phần rất quan trọng. Khi có cả kết quả vector lẫn kết quả BM25, ta muốn trộn chúng lại mà vẫn mang về top hai hay top năm câu trả lời liên quan mà ta cần. Như vậy chính xác hơn. Nếu ta mang về quá nhiều, chẳng hạn hiển thị top hai mươi câu trả lời, user sẽ khá bối rối. Bạn chỉ muốn đảm bảo mang về đúng số lượng tuỳ theo use case. Nếu là sản phẩm, bạn mang về nhiều hơn, retrieve nhiều hơn. Nếu là thứ gì đó về y tế, bạn retrieve ít hơn. Bạn không muốn thông tin tràn lan khắp nơi. Bạn muốn thông tin chính xác hơn, vì hiển nhiên khi đó bạn chịu nhiều trách nhiệm pháp lý (liability) hơn.

Một điểm nữa là re-ranker. Khi có re-ranker, nó cũng mang thông tin về, rồi sau khi chấm điểm, ta quyết định mặc định hiển thị bao nhiêu kết quả. Ở đây Abed chỉ hiển thị hai.

13. Agent mode và direct RAG

Abed đã nói qua về agent mode so với direct RAG. Direct RAG chỉ đi theo một pipeline cố định. Nó không đi gọi thứ gì khác. Con đường khá rõ ràng: bạn embed query, có một hybrid retriever, và mang câu trả lời về. Nó hợp khi compliance là điều quan trọng với bạn.

Còn agent mode thì có thêm vài tool sẽ được gọi, ví dụ search hay compare product. Những tool này có thể mang về nhiều thông tin hơn, nhưng nằm ngoài tầm kiểm soát của bạn nhiều hơn. Điều đó khá quan trọng. Và nó mất thời gian hơn, vì có thêm một agent nữa làm việc. Về cơ bản, hệ thống lấy thông tin này, rồi agent mode dựa trên request của bạn sẽ gọi tool. Nên nó mất thời gian hơn một chút.

Slide Agent mode vs direct RAG với hai ô so sánh
"Agent mode vs direct RAG". Direct RAG (mặc định): pipeline cố định gồm embed query, hybrid retrieve, stream câu trả lời; nhanh, deterministic, thân thiện với compliance. Agent mode (bật tắt trong DB): LLM chọn tool như search_kb, compare_products, calculate, tối đa 5 vòng ReAct, mỗi bước tool hiện thành một thẻ trong UI. Dòng cuối: agent_mode là một setting trong DB lúc runtime, đổi hành vi mà không cần redeploy. Khung chat bên phải vẫn giữ câu trả lời cho câu hỏi về bảo trì cuối tuần đã demo ở mục 10.

14. Telemetry với Langfuse

Phần tiếp theo là telemetry và application guardrail. Có thể dùng Langfuse: nhóm đã tích hợp Langfuse vào function, nên có thể xem có những loại câu trả lời nào. Việc này khá dễ. Mọi thứ đều chạy local, không gọi ra ngoài gì cả, nên dễ theo dõi thông tin. Ví dụ, phiên Abed vừa chat qua widget là ẩn danh (anonymous), vì anh chưa đăng nhập. "FAQ chat", "FAQ agent" là tên của toàn bộ project anh đang chạy.

Mỗi cuộc hội thoại có một ID. Bạn có thể lần theo câu hỏi "có bảo trì nào cuối tuần này không" và câu trả lời cho nó. Bạn thấy được model nào đã dùng. Bạn thấy hệ thống đã trả về top hai kết quả, và vì là top hai nên nó reference tới tấm ảnh đó hai lần. Agent mode đang tắt. Đó là một lượt chat. Hai chunk thông tin được trả về, và lượt đó mất chừng ấy millisecond. Về cơ bản đó là những thông tin ta có.

Nếu dùng model bên ngoài, bạn còn có thể ước tính chi phí đang chi. Đó là một việc khác bạn có thể dùng nó để làm. Và nếu có nhiều session, nhiều user khác nhau, bạn có thể theo dõi lượt đăng nhập. Điều đó cho bạn cái nhìn tốt về cách user tương tác với chatbot.

Slide Telemetry and application guardrails, bảng Observability with Langfuse
"Telemetry and application guardrails: Observability with Langfuse". Bảng ba cột: năng lực, cách implement, có trong demo không. Token và latency trace: Langfuse trong chat.py, bọc mọi lời gọi Ollama (có, trong app FastAPI). Tool span: một Langfuse span cho mỗi lần agent gọi tool (có, ở agent mode). Gắn session và user: user_id, session_id trên trace (có). Quét injection lúc upload: _check_injection() (chỉ đi qua code). Guardrail lúc query: guardrails.py ba tầng (có, theo từng domain).

Như đã nói về user session và ID, tiếp theo ta có guardrail.

15. Guardrail viết trong code, không viết trong prompt

Vì dùng function cho guardrail, ta kiểm soát chúng tốt hơn. Abed hỏi chatbot: "how do I treat flu?" Anh đã tạo một phần riêng để trả lời mà không đi vào chat. Điểm mấu chốt của rất nhiều guardrail là: vấn đề phải bị chặn trước khi ta gửi đi. Một prompt injection, hay một câu hỏi mà ta không được phép trả lời, thì không nên gửi tới LLM để sinh ra bất cứ thứ gì. Nên ngay trong code, trước cả LLM, anh đã tạo một phần nói rằng: nếu có medical escalation, trả về thông điệp y tế này.

Khung chat trả về MEDICAL ESCALATION cho câu hỏi how do i treat flu
Hỏi "how do i treat flu", chatbot không đưa câu hỏi cho LLM mà trả về khối "MEDICAL ESCALATION": chatbot này chỉ cung cấp thông tin sản phẩm đã được duyệt; với mọi lo ngại về sức khoẻ, triệu chứng hay câu hỏi điều trị, hãy hỏi Healthcare Practitioner của bạn hoặc liên hệ trực tiếp với bác sĩ. Bên trái vẫn là slide telemetry và guardrail.

Bạn cũng thấy system prompt khá ngắn, vì về cơ bản Abed viết prompt cho mọi thứ trong code. System prompt trông rất nhỏ, chỉ vài câu, nhưng thực ra anh "prompt" nhiều hơn hẳn ở trong code thay vì trong prompt. Anh dùng code để nói điều gì nên làm và điều gì không. Medical escalation là một ví dụ, và bạn có thể thêm nhiều hơn tuỳ theo những mối lo của bạn với sản phẩm.

Đây là một ví dụ. Nó cần được mở rộng với nhiều keyword khác nhau, nhiều kiểu câu khác nhau, cần được thử để xem bao nhiêu lần ai đó có thể lách qua hay không. Nhưng về lý, đó là ví dụ về cách ta nên chặn, cách guardrail nên chặn mọi câu query không liên quan. Có thể làm tương tự với injection, v.v.

Còn gì nữa? Đã nói về Langfuse. Để dùng Langfuse, ta chỉ cần tạo một key và đặt vào hệ thống, thế là nó chạy. Đó là một chút về observability mà anh đã nói.

Rồi tới injection. Với các tool chống prompt injection, một lần nữa: nó không được đi tới LLM, nó phải bị chặn ở đó. Nó phải bị chặn trước khi được gửi đi. Hệ thống dùng intent regex, term dictionary và một LLM classifier để chặn những vấn đề này. Và tất nhiên có thể mở rộng thêm.

câu hỏi guardrail trong Python, có test intent regex term dictionary LLM classifier RAG + LLMchỉ câu đã qua bị chặn: trả thông điệp cố định (vd. medical escalation), không gửi tới LLM
Guardrail theo cách Abed làm: câu hỏi đi qua các lớp kiểm tra viết bằng code (intent regex, term dictionary, LLM classifier) trước khi tới LLM. Câu bị chặn nhận một thông điệp cố định; chỉ câu đã qua mới đi tiếp vào RAG và LLM.

Cái hay là vì nó chỉ là code, nó không cần một prompt dài, không cần gì cả. Kết quả là hiển nhiên: bạn biết mình đang chặn gì và không chặn gì. Nó không giống một LLM, lúc thì chịu nghe instruction của bạn, lúc thì lách ra và làm theo ý nó. Một phần lợi ích của việc tích hợp tất cả những thứ này vào Python trước khi gửi đi là bạn có thể có test chặt chẽ, để nói rằng: "đây là thứ tôi đang chặn, đây là lý do", và nó qua hết các test. Nhờ vậy bạn chắc chắn hơn.

16. Widget nhúng được, consent và hai model nhỏ

Tiếp theo là một slide nhỏ về widget. Abed đang chạy chatbot dưới dạng một widget, nhưng nó cũng có thể chạy trong một khung chat lớn hơn. Một điều nữa: trong khung chat, như một phần của luật chơi, bạn nên hỏi xem người dùng có đồng ý với các điều khoản họ đang dùng không. Nếu không, người đó sẽ không bắt đầu chat được.

Anh cũng cho widget khả năng reset consent nếu muốn: user có thể đặt lại sự đồng ý của mình. Việc này chỉ phục vụ mục đích demo. Khi đó widget sẽ hiện ra: nếu anh chọn không, nó sẽ không chạy. Nếu reset consent rồi chọn có, nó sẽ bắt đầu. Đó là phần về widget. Và vì anh dùng React, widget có thể dùng với bất kỳ framework nào khác. Anh dùng React chỉ cho đơn giản.

Slide Embeddable widget, same engine, any site, kèm đoạn script nhúng
"Embeddable widget, same engine, any site": một Web Component dùng Shadow DOM chung cho mọi trang. Một bundle IIFE duy nhất (khoảng 150 KB đã gzip). Không dùng iframe, CSS của trang chủ không lọt vào được. Có consent gate, streaming, citation, badge cho agent và thẻ cho từng bước tool. Đoạn nhúng mẫu được chép ở ngay dưới.
<script src="chatbot-widget.iife.js"
        data-api-url="https://api.example.com"></script>
<faq-chat></faq-chat>

Rồi tới các model đã dùng. Abed dùng hai model. Anh pull Qwen 2.5 về. Thực ra ban đầu anh dùng bản bảy tỷ tham số, nhưng nó chạy lâu hơn. Nên cho một server nhỏ hơn, anh đổi sang bản 0.5. Anh mở trang admin để cho xem: anh có nhiều lựa chọn khác, và đã thử nhiều model khác nhau. Nếu dùng model lớn hơn, nó chỉ chạy lâu hơn, và có thể dài dòng quá mức.

Phát hiện thú vị của anh là: bạn không cần model lớn nhất. Nếu bạn sàng lọc dữ liệu trước khi gửi tới LLM, một model nhỏ, model nhỏ nhất, vẫn xử lý được và cho bạn câu trả lời tốt. Và nó cho bạn ít hallucination hơn. Nó an toàn hơn, vì nó sẽ không bịa ra thông tin. Nếu không có thông tin, nó chỉ đơn giản nói: "Không, tôi không có thông tin đó." Đó là một cách guardrail khác, để không gặp chuyện LLM sinh ra thứ gì đó ngẫu nhiên.

Model thứ hai là embedding model BGE-M3. Khi upload bất cứ thứ gì, embedding model đó làm phần embedding. Nên về lý, bạn chỉ cần hai model: model chat nhỏ nhất, và một trong những model nhỏ nhất cho embedding.

Slide Reproducibility: one-click Codespaces với năm dòng lệnh
"Reproducibility: one-click Codespaces": năm lệnh để dựng lại toàn bộ hệ thống, chép ở ngay dưới. Lưu ý lệnh đầu trên slide pull qwen2.5:7b-instruct, còn trong demo Abed nói anh đã đổi sang bản 0.5B cho server nhỏ. Dòng cuối slide: mọi khẳng định về kiến trúc đều trỏ tới một file trong repo đã nộp.
ollama pull qwen2.5:7b-instruct && ollama pull bge-m3
docker compose up -d
cd api && pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8003 &
cd ../ui && npm install && npm run dev

Rồi nếu chạy trên Docker, một cú click là mọi thứ chạy. Bạn cần cài Python requirements, rồi có React chạy ở front-end. Thế là đủ. Abed nói còn một thứ đáng lẽ anh phải thêm vào slide mà chưa thêm: Langfuse cũng cần được thêm ở đây. Nó chạy ở một port khác, và đang thiếu trên slide này.

17. Key takeaway và lời kết

Key takeaway của talk:

  • Markdown có cấu trúc trước tiên. Dùng Docling làm phần việc nặng.
  • Postgres làm vector database và lưu mọi thứ ở đó.
  • Không dùng framework cụ thể nào. Chỉ dùng Ollama cho chat, không có framework trả phí nào khác ở đâu cả.
  • Langfuse cho telemetry, cũng dùng miễn phí.
  • Cách làm này có thể mở rộng sang những sản phẩm khác: tài liệu, catalog, hay dữ liệu từ bất kỳ database nào.
DoclingMarkdown trước Postgresvector + keyword Ollamamodel nhỏ, local Langfusetelemetry miễn phí không framework trả phí, không search engine riêng
Bốn mảnh ghép mà Abed tóm lại ở cuối talk, tất cả miễn phí và chạy được local: Docling cho ingest, Postgres cho lưu trữ và search, Ollama cho model, Langfuse cho telemetry.

Abed cảm ơn mọi người đã dành thời gian. Anh muốn được kết nối, và nếu ai có câu hỏi, có lo ngại gì, hay nghĩ ra điều gì có thể tích hợp tốt hơn, hãy chia sẻ ý tưởng với anh, trong phần comment hoặc nhắn cho anh trên LinkedIn. Anh cảm ơn AI Engineer đã cho anh cơ hội, và hẹn gặp lại lần sau. Cheers.

Nguồn và liên kết