Một tài liệu trở thành câu trả lời có trích dẫn như thế nào
Ragenta là SaaS RAG + agents đa tenant tôi đã xây ở NYB AI. Một workspace upload tài liệu của mình, đặt câu hỏi, và nhận câu trả lời chỉ đúng đoạn văn gốc mà nó lấy ra. Mọi lần gọi model đều trừ từ sổ credit, và mọi job ingestion phải sống sót nếu deploy rơi đúng lúc nó đang chạy.
Dưới đây là đường đi của một tài liệu, từ lúc upload đến khi thành trích dẫn [[n]] trong câu trả lời chat. Sáu stage id đúng là những id backend đang dùng; sơ đồ bên cạnh chạy theo thẻ bạn đang đọc.
Pipeline của Ragenta: từ upload đến câu trả lời có trích dẫn
- 01
Ingest
Upload đẩy một job BullMQ mà payload chỉ có
{documentId, workspaceId}; mọi thứ còn lại đọc từ DB lúc job chạy. Job id làingest:{documentId}:{attempt}và khoản trừ credit được key theo id đó — retry không bao giờ tính tiền hai lần. Ba lần thử, không dùng mặc định của BullMQ: tài liệu đã hỏng hai lần thì lần ba thường cũng hỏng, mà mỗi lần thử là một cuộc gọi provider mất tiền. - 02
Parse
Bộ extractor theo từng định dạng (
unpdf,mammoth,exceljs, email, hierarchy) cho ra các section kèm khoảng trang. PDF scan không có lớp text nên fail với lý do mà người đọc xử lý được, thay vì âm thầm index ra rỗng. Không có OCR trong đường ingestion — lý do fail nói thẳng như vậy. - 03
Chunk
Port lại
naive_mergecủa RAGFlow: cắt tại dấu kết câu (kể cả CJK。;!?, để knowledge base tiếng Việt hay tiếng Trung không biến thành một chunk khổng lồ), gộp lại tới 512 token, rồi gắn phần đuôi của chunk trước vào đầu chunk sau. Tóm tắt RAPTOR (tuỳ chọn) được index như chunkkind = 'summary'và trích dẫn như mọi đoạn văn khác. - 04
Embed
100 input mỗi lần gọi (giới hạn của provider), input dài quá thì cắt ở 8 000 token thay vì fail cả tài liệu. Model embedding được cố định cho từng knowledge base ngay lúc tạo — đổi model không thể embed lại những gì đã có. Text ở lại Postgres cùng
tsvector; vector sang Qdrant, key theo id dòng chunk. Các khoảng đã embed và đã trả tiền được dùng lại khi retry. - 05
Retrieve
Hybrid: cosine của Qdrant (trong [0,1]) và
ts_rank_cdcủa Postgres (không chặn trên, chuẩn hoá theo kết quả tốt nhất) trộn vớivectorWeight, cắt ở ngưỡng similarity. Ba modehybrid · vector · keyword; nửa nào bị tắt thì bỏ luôn cuộc gọi chứ không gán trọng số 0. Reranker (tuỳ chọn) chỉ chạy trên tập ứng viên đã trộn, không bao giờ trên cả corpus. - 06
Cite
Model trích dẫn bằng
[[n]]; server đóng băng các đoạn văn tương ứng ngay trên dòng message, nên render chỉ là tra theo chỉ số — không có bước khớp similarity hậu kỳ có thể trôi sang sai đoạn. Trong agent run,CitationCollectorđánh số đoạn văn một lần cho cả run, nên[[1]]là cùng một đoạn dù tool call nào tìm ra nó.
§1Bài toán
Các team muốn hỏi trên chính tài liệu của mình và nhận câu trả lời kiểm chứng được. Phần lớn demo "chat với PDF" đổ vỡ khi lên production ở bốn điểm: tenancy (tài liệu của ai?), tiền (ai trả token, và retry có trừ tiền hai lần không?), niềm tin (trích dẫn có thật sự chỉ đúng đoạn văn không?) và vận hành (PDF 400 trang fail giữa chừng lúc embedding thì sao?).
Ragenta là SaaS đa tenant tôi xây để trả lời cả bốn. Những ràng buộc tôi làm việc bên trong: một codebase backend đóng thành một image, tenancy dựa trên organization của Better Auth, mọi lần gọi model trừ từ sổ credit, và một đường ingestion phải sống sót khi deploy rơi giữa job.
§2Kiến trúc
Một codebase backend, một image, hai process: start:api phục vụ Hono API còn start:worker xử lý các queue BullMQ cho ingestion, agent run, billing và webhook. PostgreSQL là nguồn sự thật và giữ chỉ mục lexical tsvector; Qdrant giữ dense vector, mỗi model embedding một collection; MinIO giữ file upload; Better Auth quản danh tính, và một workspace chính là một organization.
Trình duyệt nói chuyện với Next.js 16 qua ky, Next.js proxy /api/* sang Hono API, và API là thứ duy nhất chạm vào các kho dữ liệu. Chat streaming và upload đi thẳng bằng fetch thay vì ky, vì ky buffer toàn bộ response.
- Phân lớp chặt: routes → controller → service → repository → db. Service không bao giờ import hono, nhờ vậy một job BullMQ gọi được đúng service mà HTTP route đang gọi.
- Frontend: Next.js 16 App Router, React 19, TanStack Query v5, ky + zod. Có Hono proxy nên URL backend không bao giờ lọt vào bundle.
- Không có lớp JWT. Backend xác thực trực tiếp session cookie của Better Auth; proxy chỉ chuyển tiếp, không thêm gì.
- Cookie workspace chỉ là lựa chọn của người dùng, không phải chứng thực. Nó được đối chiếu với danh sách membership thật, và backend trả 404 cho workspace bạn không thuộc về.
- Role ở client chỉ phục vụ UX (canContribute, canAdminister). Backend kiểm tra lại membership và role ở ranh giới tài nguyên trên mọi request.
- Text của chunk nằm ở Postgres; vector của nó nằm ở Qdrant, key theo id dòng chunk.
§3Pipeline, từng bước một
- 01
pendingIngest
Payload cố tình mỏng. Job xếp hàng trước khi deploy sẽ chạy trên dữ liệu đúng ở thời điểm sau deploy, không phải trên một snapshot cũ.
Hai lớp chặn dùng chung một id: enqueue trùng cùng attempt là no-op phía BullMQ, còn khoản trừ trong ledger cho attempt đó rơi vào đúng key ấy nên không thể ghi hai lần.
Giới hạn ba lần là quyết định về chi phí: mỗi retry là một cuộc gọi provider mất tiền, và file hỏng không tự lành ở lần thứ tư.
- 02
parsingParse
Extractor tách theo hình dạng tài liệu chứ không chỉ theo đuôi file: general, structured, tabular, email và hierarchy, dựng trên unpdf, mammoth và exceljs.
Mỗi section mang theo khoảng trang của nó, và chunk thừa hưởng thành fromPage và toPage.
File không có lớp text dừng ở đây với lý do đọc được, thay vì thành một tài liệu rỗng được đánh dấu ready.
- 03
chunkingChunk
Gộp chạy hai lượt: đoạn nào vượt ngân sách thì cắt tại dấu kết câu, rồi ghép các mảnh lại tới ngân sách token (mặc định 512), kèm phần trăm overlap lấy từ đuôi chunk trước.
RAPTOR gom đoạn văn theo cosine similarity bằng một lượt greedy nearest-neighbour, cố ý không dùng UMAP+GMM. Dễ suy luận hơn, và việc gom nhóm trước khi model tóm tắt từng cụm không cần gì hơn thế.
Chunk summary tồn tại để câu hỏi về cả tài liệu có thứ để khớp. Chúng được đánh dấu là do model viết, nên người đọc phân biệt được tóm tắt sinh ra với đoạn văn trích nguyên.
- 04
embedding → readyEmbed
Cỡ batch là giới hạn của provider, không phải nút tinh chỉnh, nên không ai đổi được nó qua cài đặt.
Cắt input quá dài ở 8 000 token chỉ mất phần đuôi của một chunk; fail cả tài liệu thì mất tất cả.
Cố định model theo knowledge base là lý do Qdrant có một collection cho mỗi model embedding, và là lý do hai base embed bằng model khác nhau không thể search chung về sau.
Khả năng resume quan trọng nhất với PDF lớn: fail ở cuối chỉ embed lại những khoảng chưa trả tiền. Job bỏ cuộc thì dòng kết thúc ở failed, không phải ready.
- 05
Retrieve
Hai điểm số nằm trên hai thang khác nhau, nên điểm lexical được chuẩn hoá theo kết quả tốt nhất trong tập trả về trước khi trộn. Cả hai phép search chạy trên cùng một corpus.
Các base embed bằng model khác nhau bị từ chối kèm thông báo thay vì search chung, vì vector của chúng không so được với nhau.
Màn hình tài liệu hiển thị chính các chunk, vì retrieval chỉ có thể trả về một trong những đoạn đó. Danh sách tài liệu chỉ poll khi còn dòng đang chạy và dừng ngay khi mọi dòng đã xong.
- 06
Cite
Đoạn văn được lưu cùng message, không tra lại lúc render, nên thứ người đọc mở ra chính xác là thứ model đã thấy.
Một agent run có thể search nhiều lần qua nhiều tool call. Đánh số một lần cho cả run thay vì mỗi lần search giữ
[[1]]ổn định suốt câu trả lời.
§4Trình soạn agent flow
- Một graph DSL thuộc về sản phẩm, không thuộc về thư viện canvas. @xyflow/react chỉ là một view của nó; backend lưu và chạy nó, nên nâng cấp thư viện canvas không bao giờ là đổi định dạng dữ liệu.
- 16 loại node: begin, llm, knowledge_search, agent, categorize, switch, user_input, message, http, ocr, vision, stt, tts, excel, browser, loop.
- Node rẽ nhánh (categorize, switch) định tuyến qua params riêng của chúng. switch.otherwise tối đa 4 đích, loop tối đa 25 vòng, label tối đa 80 ký tự — tất cả mirror từ schema backend.
- validateFlow phía client mirror bộ quy tắc lúc publish của backend và bổ sung những lỗi backend chỉ phát hiện giữa run: prompt rỗng, nhánh nối vào khoảng không. Mỗi vấn đề mang blocksPublish tách khỏi level, nên bản đang dở vẫn lưu được trong khi một số lỗi nhất định vẫn hiện là error.
- Params tới client dưới dạng Record<string, unknown>. categoriesOf và casesOf kiểm tra có phải mảng không thay vì ép kiểu, vì đã có lần một chuỗi nằm ở chỗ đáng lẽ là danh sách làm sập cả màn hình agent.
- Run được checkpoint trên agent_run.state tại ranh giới node, chỉ lưu giá trị. Run chờ người dùng nhiều ngày vẫn sống qua deploy, và run bị crash được process khác tiếp tục.
- usage_ledger.reference = agent-run:{runId}:step:{seq} có unique index. Giữ nguyên seq qua các attempt chính là toàn bộ cơ chế chống trừ tiền hai lần.
- user_input tạm dừng run bao lâu cũng được. http từ chối các dải địa chỉ private.
§5Những quyết định khó
- 01
Trích dẫn được đóng băng phía server.
Render là tra theo chỉ số, không bao giờ là khớp similarity có thể trôi sang sai đoạn. Vì sao không khớp lại lúc render: ít code hơn, nhưng similarity có thể rơi vào đoạn kế bên, và trích dẫn sai còn tệ hơn không có.
- 02
State streaming nằm ở local, không nằm trong query cache.
Mỗi token một lần
setQueryDatathì mọi subscriber đều render lại. Stream ghi vào local state; cache được invalidate một lần lúc kết thúc và dữ liệu của server — kèm trích dẫn, model và chi phí — được lấy làm chuẩn. - 03
Chỉ poll khi còn thứ đang chạy.
Danh sách tài liệu dừng ngay khi mọi dòng đã
readyhoặcfailed. Vì sao không poll theo chu kỳ cố định: một trang đang đứng yên sẽ gọi API liên tục mà chẳng để làm gì. - 04
Từ chối xảy ra trước khi stream mở.
Hết credit, model ngoài gói, thiếu knowledge base: một 4xx bình thường kèm thông báo, không bao giờ là error frame sau khi UI đã chuyển sang trạng thái "đang trả lời".
- 05
Credit được cấp bởi webhook của Stripe, không bao giờ bởi lệnh checkout.
Khoản cấp key theo id object của Stripe, nên event gửi lại rơi vào unique (kind, reference) của ledger và không làm gì cả. Vì sao không cấp lúc checkout: trình duyệt có thể đóng trước khi thanh toán hoàn tất, còn webhook thì có thể đến hai lần.
- 06
Khoá single-flight của auto-reload là một UPDATE có điều kiện.
Không phải đọc-rồi-ghi, nên hai replica worker không thể cùng trừ tiền. Thẻ bị từ chối thì tắt auto-reload luôn thay vì retry mỗi năm phút.
- 07
Một credit = một input token của model baseline.
Đơn giá của mỗi model suy ra từ chi phí provider và được đóng băng trên dòng usage cùng pricingVersion. Đổi giá không bao giờ viết lại lịch sử; dòng cũ vẫn nói đúng số tiền của ngày nó chạy.
§6Kết quả
Những gì đã ship: workspace dựa trên organization của Better Auth với membership được kiểm tra lại ở mọi ranh giới tài nguyên; worker ingestion có job resume được và chỉ tính tiền một lần; hybrid retrieval với trích dẫn đóng băng trên dòng message; sổ credit nạp từ webhook Stripe với pricingVersion trên mọi dòng usage; và trình soạn agent flow 16 loại node có run checkpoint tại ranh giới node và sống qua deploy.
§7Điều tôi sẽ làm khác
- Đưa OCR vào đường ingestion. Hiện PDF scan fail với lý do rõ ràng, thế là trung thực, nhưng câu trả lời đúng là phải index được nó.
- Giữ README backend đi cùng code. Nó vẫn ghi knowledge base, chat và agents là chưa làm, trong khi các module đã nằm trong src/modules.
- Một HTTP client thay vì hai. Chat streaming và upload đi thẳng bằng fetch vì ky buffer response, để lại hai đường code phải thống nhất với nhau về auth và xử lý lỗi.
Software Engineer tại NYB AI, 06/2026 – 09/2026. Tôi đã xây RAG pipeline và agent runner, cùng với workspace và RBAC, sổ credit và token, Stripe, và deployment.
- loại node
- 16
- lần thử ingestion
- 3
- input mỗi lần gọi embedding
- 100
- chunk mặc định
- 512 token
- trần input embed
- 8k token
- vòng loop tối đa
- 25