Hỏi đáp (Question Answering)
Mục tiêu
Phần tiêu đề “Mục tiêu”Sau bài này, bạn sẽ:
- Phân biệt được các loại hỏi đáp (question answering, QA): trích xuất từ đoạn văn, miền mở, sinh câu trả lời, và dựa trên cơ sở tri thức.
- Hiểu cách một mô hình kiểu BERT làm QA trích xuất (extractive QA): chấm điểm đầu và cuối cho từng token, rồi chọn span tốt nhất có ràng buộc, kể cả khi câu hỏi không có đáp án.
- Cài đặt được việc chọn span và hai độ đo chuẩn của SQuAD: khớp hoàn toàn (exact match, EM) và F1 mức token.
- Nhận ra vì sao trong hệ thống retriever–reader, lỗi của retriever đặt trần cho cả hệ thống.
Cần biết trước
Phần tiêu đề “Cần biết trước”- BERT, thuộc phần Xử lý ngôn ngữ tự nhiên (Đã học)
- Truy xuất thông tin (Information Retrieval), thuộc phần Xử lý ngôn ngữ tự nhiên (Đã học)
Trực giác
Phần tiêu đề “Trực giác”Bạn hỏi: “Vịnh Hạ Long được UNESCO công nhận là di sản thiên nhiên thế giới lần đầu năm nào?”. Trên tay bạn có một đoạn văn:
Vịnh Hạ Long được UNESCO công nhận là di sản thiên nhiên thế giới lần đầu năm 1994 và lần thứ hai năm 2000.
(Năm 2023, di sản được mở rộng thành “Vịnh Hạ Long – Quần đảo Cát Bà”. Đoạn văn trên chỉ nói hai lần đầu, và bài chỉ cần chừng đó.)
Bạn không cần “nghĩ ra” câu trả lời. Nó nằm sẵn trong đoạn văn. Việc của bạn là chỉ tay vào đúng chỗ: từ chữ nào tới chữ nào. Ở đây là đúng một chữ, “1994”.
Nghe dễ, nhưng có bẫy. Đoạn văn có hai năm. Câu hỏi hỏi “lần đầu”, nên “2000” sai dù trông rất giống một câu trả lời. Người đọc phải hiểu quan hệ giữa “lần đầu” và “1994”, không chỉ tìm một con số.
Bẫy thứ hai: hỏi “Vịnh Hạ Long rộng bao nhiêu ki-lô-mét vuông?”. Đoạn văn không nói. Một người đọc cẩn thận sẽ trả lời “đoạn này không có thông tin đó”, chứ không chỉ bừa vào “1994” chỉ vì đó là một con số trông có vẻ hợp.
Bẫy thứ ba: ngoài đời bạn hiếm khi được đưa sẵn đúng đoạn văn. Bạn có cả một kho, như toàn bộ Wikipedia tiếng Việt, và phải tìm đoạn trước khi tìm câu trả lời trong đoạn.
Bài toán của bài này là: cho một câu hỏi và một đoạn văn, chỉ ra đoạn trích (từ token nào tới token nào) trả lời câu hỏi, hoặc nói rằng không có; rồi mở rộng ra khi đoạn văn phải tự tìm trong một kho lớn.
Lý thuyết
Phần tiêu đề “Lý thuyết”Các loại hỏi đáp
Phần tiêu đề “Các loại hỏi đáp”“Hỏi đáp” là tên chung của nhiều bài toán khác nhau. Chúng khác nhau ở chỗ câu trả lời đến từ đâu.
| Loại | Đầu vào | Câu trả lời | Ví dụ |
|---|---|---|---|
| Trích xuất (extractive), còn gọi là đọc hiểu máy (machine reading comprehension) | Câu hỏi và một đoạn văn | Một đoạn trích nguyên văn (span) của đoạn văn | SQuAD |
| Miền mở (open-domain) | Câu hỏi và một kho văn bản | Tìm đoạn liên quan, rồi trích hoặc sinh câu trả lời | DrQA, DPR |
| Sinh câu trả lời (generative, abstractive) | Câu hỏi, có thể kèm đoạn văn | Câu do mô hình tự viết, không nhất thiết có nguyên văn trong đoạn | T5, RAG, chatbot |
| Dựa trên cơ sở tri thức (knowledge-base QA) | Câu hỏi và một cơ sở tri thức có cấu trúc | Đổi câu hỏi thành truy vấn, chạy truy vấn | “Thủ đô của Việt Nam?” thành tra (Việt Nam, thủ đô, ?) |
Bài này đi sâu vào loại thứ nhất, vì nó là viên gạch của loại thứ hai. Loại thứ ba bài chỉ nói ý chính và trỏ sang bài RAG. Loại thứ tư gần với bài Graph RAG, nơi tri thức lưu thành đồ thị.
SQuAD: hỏi đáp trích xuất thành một bộ dữ liệu
Phần tiêu đề “SQuAD: hỏi đáp trích xuất thành một bộ dữ liệu”Rajpurkar và cộng sự (2016) dựng SQuAD (Stanford Question Answering Dataset): hơn 100 000 câu hỏi do người viết trên các đoạn văn Wikipedia tiếng Anh. Luật chơi rất rõ: câu trả lời luôn là một đoạn trích liền mạch của đoạn văn. Nhờ vậy, đáp án của mỗi câu chỉ là hai con số: vị trí bắt đầu và vị trí kết thúc.
Đây là điểm mấu chốt. Thay vì bắt mô hình viết ra câu trả lời, ta chỉ bắt nó chỉ vào hai vị trí. Bài toán sinh văn bản khó nhằn biến thành hai bài toán phân loại: “token nào là điểm đầu?” và “token nào là điểm cuối?”.
Với tiếng Việt, có bộ UIT-ViQuAD (Nguyen và cộng sự, 2020), cùng kiểu với SQuAD: khoảng 23 000 cặp câu hỏi và câu trả lời trên 5 109 đoạn văn của 174 bài Wikipedia tiếng Việt.
Mô hình: hai vectơ cho điểm đầu và điểm cuối
Phần tiêu đề “Mô hình: hai vectơ cho điểm đầu và điểm cuối”Cách làm dưới đây theo paper BERT (Devlin và cộng sự, 2019, mục 4.2). Bạn đã gặp BERT, [CLS], [SEP] và cách fine-tune ở bài BERT.
Bước 1: ghép câu hỏi và đoạn văn thành một chuỗi.
[CLS] câu hỏi [SEP] đoạn văn [SEP]BERT đọc cả chuỗi bằng self-attention hai chiều, nên mỗi token của đoạn văn “nhìn thấy” câu hỏi và ngược lại. Ra khỏi lớp cuối, token ở vị trí có một vectơ , với là số chiều ẩn ( ở BERT-Base).
Bước 2: thêm hai vectơ học được. Gọi chúng là và , mỗi vectơ chiều. Đây là toàn bộ tham số mới khi fine-tune; mọi thứ khác lấy từ BERT đã pre-train. Mỗi token nhận hai điểm, là hai logit:
lớn nghĩa là “token trông giống điểm đầu của câu trả lời”, lớn nghĩa là “trông giống điểm cuối”. Trong thư viện, hai vectơ này là một lớp tuyến tính từ chiều ra 2 số (có thêm hệ số tự do), áp lên mọi token.
Bước 3: softmax trên các vị trí. Đổi logit thành xác suất, nhưng lần này softmax chạy trên các vị trí trong chuỗi, không phải trên từ vựng:
Ở SQuAD 1.1, paper BERT lấy tổng qua các token của đoạn văn. Cài đặt của Hugging Face lấy softmax trên cả chuỗi, gồm cả [CLS], để học được nhãn “không có đáp án” trỏ vào [CLS] (mục SQuAD 2.0 bên dưới). Bài viết thay cho để khỏi lẫn với logit cuối . Như vậy mô hình là hai bộ phân loại, mỗi bộ có số “lớp” bằng số vị trí được xét.
Bước 4: hàm mất mát. Gọi , là vị trí đầu và cuối đúng. Mỗi bên là một bài phân loại, nên dùng cross-entropy cho từng bên rồi cộng lại:
Paper BERT viết “tổng log-likelihood của vị trí đầu và cuối đúng”, chính là . Hugging Face lấy trung bình hai khoản (chia 2), chỉ khác một hằng số nhân.
Chọn span: vì sao không lấy argmax từng bên
Phần tiêu đề “Chọn span: vì sao không lấy argmax từng bên”Lúc dùng, cách nghĩ đầu tiên là lấy và . Cách này hỏng, vì hai bên được chọn độc lập. Không có gì bảo đảm , cũng không có gì ngăn span dài cả trăm token.
Bảng dưới là logit giả định (tự đặt, không phải của mô hình thật) cho câu hỏi “lần đầu năm nào?”. Mọi vị trí khác có logit . Token đánh số từ 0, với [CLS] ở vị trí 0, như ô code đầu tiên của bài; chuỗi bỏ phần câu hỏi cho gọn, nên đoạn văn bắt đầu ngay ở vị trí 1.
| Vị trí | 17 | 18 | 24 |
|---|---|---|---|
| Token | năm | 1994 | 2000 |
| (logit đầu) | 2.0 | 3.9 | 4.2 |
| (logit cuối) | 4.5 | 3.0 |
Argmax riêng chọn đầu ở 24 (“2000”) và cuối ở 18 (“1994”): span ngược, vô nghĩa. Mô hình hơi phân vân ở điểm đầu giữa hai năm, và argmax riêng phóng đại sự phân vân đó thành một lỗi.
Cách đúng: chấm điểm từng cặp. Điểm của span từ tới là . Vì sao là tổng logit? Lấy log xác suất của cặp, coi hai bên độc lập:
Dòng thứ nhất: log của tích bằng tổng các log. Dòng thứ hai: thay công thức softmax vào, log của một phân số là log tử trừ log mẫu, và . Hai khoản không phụ thuộc hay , nên cặp nào có lớn nhất cũng là cặp có xác suất lớn nhất. Khỏi cần tính softmax.
Rồi chỉ xét các cặp hợp lệ, với là độ dài tối đa của câu trả lời (tính bằng token):
Ràng buộc bỏ span ngược. Ràng buộc bỏ span quá dài: câu trả lời kiểu SQuAD thường chỉ vài từ. Ngoài ra, và chỉ chạy trên token của đoạn văn, không trên câu hỏi hay token đặc biệt. Với token và giới hạn , chỉ có khoảng cặp, rất rẻ.
Với bảng trên và : cặp được , hơn “2000” một mình được , và hơn “năm 1994” được . Câu trả lời là “1994”. Paper BERT viết đúng quy tắc này: chọn span có lớn nhất với .
Khi câu hỏi không có đáp án: SQuAD 2.0
Phần tiêu đề “Khi câu hỏi không có đáp án: SQuAD 2.0”Mô hình trên luôn trả về một span, kể cả khi đoạn văn không có câu trả lời. Rajpurkar, Jia và Liang (2018) làm SQuAD 2.0: thêm hơn 50 000 câu hỏi không có đáp án, do người viết cố tình cho giống câu có đáp án. Một mô hình mạnh đạt 86% F1 trên SQuAD 1.1 chỉ còn 66% F1 trên SQuAD 2.0 (số trong paper, ở thời điểm 2018).
Cách xử lý của BERT (mục 4.3 của paper) dùng lại token [CLS] ở vị trí 0:
- Khi huấn luyện, câu không có đáp án được gán nhãn : “span” bắt đầu và kết thúc ở
[CLS]. - Khi dùng, tính điểm “không có đáp án” , rồi so với span tốt nhất . Chỉ trả lời khi
Ngưỡng chọn trên tập dev sao cho F1 lớn nhất. lớn thì mô hình hay từ chối; nhỏ (âm) thì mô hình hay trả lời bừa. Ở ô code đầu tiên, với câu hỏi về diện tích, span tốt nhất được 5.0 còn : với mô hình từ chối, với nó trả lời “1994”, sai.
Đoạn văn dài hơn mô hình đọc được
Phần tiêu đề “Đoạn văn dài hơn mô hình đọc được”BERT nhận tối đa 512 token. Một bài Wikipedia dài hơn nhiều. Cách thường dùng là cửa sổ trượt (sliding window): cắt đoạn văn thành nhiều cửa sổ, mỗi cửa sổ ghép với câu hỏi thành một đầu vào riêng. Hai cửa sổ liền nhau gối lên nhau một phần. Không gối thì câu trả lời nằm vắt qua chỗ cắt bị chia đôi, và không cửa sổ nào chứa trọn nó.
Ví dụ: script mẫu của Hugging Face dùng tối đa 384 token mỗi đầu vào, hai cửa sổ chung nhau 128 token (tham số stride của bộ tách từ, với truncation="only_second" để chỉ cắt đoạn văn, không cắt câu hỏi). Cẩn thận khi đọc code cũ: script run_squad.py gốc của BERT cũng có doc_stride=128, nhưng ở đó 128 là bước nhảy giữa đầu hai cửa sổ, không phải số token chung. Chạy mô hình trên từng cửa sổ, lấy span tốt nhất của mỗi cửa sổ, rồi chọn span có điểm cao nhất trong tất cả.
Đánh giá: EM và F1 mức token
Phần tiêu đề “Đánh giá: EM và F1 mức token”Mô hình trả lời “năm 1994”, đáp án mẫu ghi “1994”. Đúng hay sai? SQuAD dùng hai độ đo, sau một bước chuẩn hóa cả hai chuỗi.
Chuẩn hóa theo script đánh giá chính thức của SQuAD: đổi sang chữ thường, bỏ dấu câu, bỏ mạo từ tiếng Anh “a”, “an”, “the”, rồi gộp khoảng trắng. Bỏ mạo từ là quy ước riêng của script tiếng Anh này: “the Eiffel Tower” và “Eiffel Tower” được tính là một.
Đừng áp nguyên bước bỏ mạo từ cho tiếng Việt. Tiếng Việt không có mạo từ, nhưng bước này vẫn xóa mọi chữ “a”, “an” đứng riêng: “Long An” thành “long”, “An Giang” thành “giang”, “an toàn giao thông” thành “toàn giao thông”, “A Lưới” thành “lưới”. Dự đoán “Long” so với đáp án “Long An” khi đó được EM = 1, dù thiếu nửa tên tỉnh. Chấm tiếng Việt thì giữ chữ thường, bỏ dấu câu, gộp khoảng trắng, và bỏ bước mạo từ. Với bản đó, “năm 1994” và “1994” vẫn khác nhau.
Khớp hoàn toàn (EM): bằng 1 nếu hai chuỗi đã chuẩn hóa giống hệt nhau, ngược lại bằng 0.
F1 mức token: tách hai chuỗi thành token theo khoảng trắng, đếm số token chung (tính cả lặp lại), rồi
(precision) hỏi “dự đoán có bao nhiêu phần đúng”, (recall) hỏi “đáp án được phủ bao nhiêu”. “năm 1994” so với “1994”: chung 1 token, , , nên . F1 cho điểm một phần, còn EM thì không.
Thêm hai quy ước của script:
- Một câu hỏi có thể có nhiều đáp án mẫu (mỗi người gán nhãn chọn hơi khác). Lấy điểm cao nhất trên các đáp án mẫu, rồi lấy trung bình trên mọi câu hỏi.
- Ở SQuAD 2.0, câu không có đáp án có đáp án mẫu là chuỗi rỗng. Nếu dự đoán hoặc đáp án rỗng thì F1 bằng 1 khi cả hai cùng rỗng, bằng 0 khi chỉ một bên rỗng.
Hỏi đáp miền mở: retriever và reader
Phần tiêu đề “Hỏi đáp miền mở: retriever và reader”Khi không ai đưa sẵn đoạn văn, Chen và cộng sự (2017) đề xuất DrQA, chia việc cho hai phần:
- Retriever (bộ truy xuất): từ câu hỏi, tìm vài bài liên quan trong toàn bộ Wikipedia tiếng Anh. DrQA dùng TF-IDF trên từ và cặp từ (bigram), không học gì.
- Reader (bộ đọc): chạy QA trích xuất trên từng đoạn tìm được, rồi chọn span có điểm cao nhất. Reader của DrQA là một mạng RNN; ngày nay thường là một mô hình kiểu BERT như ở trên.
Cách chấm điểm và xếp hạng văn bản (TF-IDF, BM25, truy xuất dày) nằm ở bài Truy xuất thông tin. Ở đây chỉ cần một hệ quả: reader chỉ đọc được những gì retriever đưa. Nếu đoạn chứa câu trả lời không nằm trong top-, reader giỏi mấy cũng không trả lời đúng. Tỉ lệ câu hỏi có đáp án trong top- là trần của cả hệ thống.
Số của DrQA cho thấy rõ điều này. Retriever của nó tìm được bài chứa câu trả lời trong top-5 cho 77.8% câu hỏi SQuAD. Reader, khi được đưa đúng đoạn, đạt EM 69.5 trên tập dev SQuAD. Cả hệ thống, phải tự tìm trong Wikipedia, chỉ còn EM 27.1 (bản chỉ huấn luyện trên SQuAD). Lỗi của hai phần chồng lên nhau, và đoạn văn thật dài hơn, nhiễu hơn đoạn được đưa sẵn.
Karpukhin và cộng sự (2020) thay retriever TF-IDF/BM25 bằng DPR (Dense Passage Retrieval): hai bộ mã hóa BERT biến câu hỏi và đoạn văn thành vectơ, xếp hạng bằng tích vô hướng. Retriever tốt hơn nâng trần lên, và độ chính xác của cả hệ thống tăng theo.
Sinh câu trả lời có truy xuất
Phần tiêu đề “Sinh câu trả lời có truy xuất”Reader trích xuất có hai giới hạn: câu trả lời phải nằm nguyên văn trong một đoạn, và nó không ghép được ý từ nhiều đoạn. Lewis và cộng sự (2020) thay reader bằng một mô hình sinh (BART, một encoder–decoder như ở bài Mô hình chuỗi sang chuỗi): mô hình đọc câu hỏi cùng các đoạn tìm được rồi viết câu trả lời. Họ gọi cách này là RAG (Retrieval-Augmented Generation). Chatbot trả lời theo tài liệu ngày nay làm theo đúng khuôn này với một LLM, như bài RAG và chatbot hiện đại.
Đổi lại, câu trả lời sinh ra không còn được bảo đảm nằm trong đoạn văn: mô hình có thể thêm ý không có trong đó. QA trích xuất vẫn có chỗ đứng khi bạn cần câu trả lời chỉ ra được vị trí trong văn bản gốc (để tô sáng, để kiểm), chạy rẻ trên CPU, và không bịa thêm chữ.
Giới hạn
Phần tiêu đề “Giới hạn”- Câu trả lời phải là một span liền mạch. Câu hỏi “có hay không”, câu cần tính toán (“cách nhau bao nhiêu năm?”) hay cần ghép nhiều chỗ không có span nào đúng. Cần mô hình sinh, như ở mục trên.
- Mô hình học mẹo của bộ dữ liệu. Một mô hình SQuAD 1.1 quen với việc luôn có đáp án, và có xu hướng chọn span có cùng kiểu với câu hỏi (hỏi “năm nào” thì chọn một con số). Ô DistilBERT của bài cho thấy nó trả lời bừa khi đoạn văn không có thông tin.
- Điểm của span không phải độ tin cậy đã hiệu chỉnh. Xem hộp thứ hai ở mục Lỗi hay gặp.
Minh họa
Phần tiêu đề “Minh họa”Hình dưới là hệ thống hỏi đáp miền mở. Hãy nhìn chỗ kho văn bản thu hẹp thành vài đoạn: mọi thứ reader biết đều đi qua chỗ hẹp đó.
Ví dụ code
Phần tiêu đề “Ví dụ code”Code trong bài chạy thử lần cuối tháng 11/2026: hai ô đầu với Python 3.14 và NumPy 2.4 (bản có trong Pyodide), ô DistilBERT với Python 3.14, transformers 5.22 và PyTorch 2.14 trên CPU. Hai ô đầu chạy ngay trên trang và chạy độc lập với nhau. Ô thứ ba cần PyTorch nên chạy trên máy bạn.
Chọn span và quyết định “không có đáp án”
Phần tiêu đề “Chọn span và quyết định “không có đáp án””Ô này làm lại mục Lý thuyết trên logit giả định: đoạn văn về vịnh Hạ Long đã tách theo âm tiết, [CLS] ở vị trí 0. Để gọn, ô bỏ phần câu hỏi khỏi chuỗi; mô hình thật có câu hỏi ở giữa [CLS] và đoạn văn, và các vị trí đó bị loại khỏi việc tìm span. Hàm best_span duyệt mọi cặp với .
Chọn span tốt nhất từ logit đầu/cuối
# Chọn span tốt nhất từ logit đầu/cuối (số giả định), và ca không có đáp ánimport numpy as np
# Đoạn văn đã tách token theo âm tiết; vị trí 0 là [CLS]toks = ("[CLS] vịnh hạ long được unesco công nhận là di sản thiên nhiên " "thế giới lần đầu năm 1994 và lần thứ hai năm 2000 .").split()L = len(toks)
def logits(peaks, base=-2.0): # mọi vị trí -2, trừ vài vị trí tự đặt z = np.full(L, base) for i, v in peaks.items(): z[i] = v return z
def best_span(s, e, max_len=5): best, arg = -np.inf, None for i in range(1, L): # bỏ [CLS] ở vị trí 0 for j in range(i, min(i + max_len, L)): # i <= j < i + max_len if s[i] + e[j] > best: best, arg = s[i] + e[j], (i, j) return best, arg
a, b = toks.index("1994"), toks.index("2000") # 18 và 24# Câu hỏi 1: "... lần đầu năm nào?"s = logits({a - 1: 2.0, a: 3.9, b: 4.2})e = logits({a: 4.5, b: 3.0})i, j = int(np.argmax(s)), int(np.argmax(e))print(f"argmax separately: start={i} {toks[i]}, end={j} {toks[j]}")score, (i, j) = best_span(s, e)span = " ".join(toks[i:j + 1])print(f"best span: ({i}, {j}) '{span}' {score:.1f}")
# Câu hỏi 2: "... rộng bao nhiêu km2?" (đoạn văn không nói)s = logits({0: 3.0, a: 2.4, b: 2.1})e = logits({0: 3.2, a: 2.6, b: 1.0})score, (i, j) = best_span(s, e)null = s[0] + e[0] # điểm của "không có đáp án" = span [CLS]span = " ".join(toks[i:j + 1])print(f"best span: '{span}' {score:.1f}, null {null:.1f}")for tau in [0.0, -1.5]: # trả lời khi score > null + tau print(f"tau={tau}:", span if score > null + tau else "(no answer)")Lần chạy đầu cần tải Python, khoảng 10 MB.
- Argmax riêng cho
start=24 2000, end=18 1994: điểm đầu nằm sau điểm cuối, đúng cái bẫy ở mục Lý thuyết. - Span tốt nhất là
(18, 18) '1994'với điểm 8.4, tức . - Câu hỏi về diện tích: span tốt nhất vẫn là “1994” với 5.0, nhưng điểm
nullcủa[CLS]là 6.2. Vớitau=0.0, nên mô hình trả lời(no answer). Hạ ngưỡng xuốngtau=-1.5thì , và mô hình trả lời “1994”, sai. Chọn là đánh đổi giữa từ chối nhầm và trả lời bừa.
EM và F1 mức token
Phần tiêu đề “EM và F1 mức token”Ô này cài hai độ đo của SQuAD, với hàm normalize theo đúng bốn bước của script đánh giá. Tham số en bật hay tắt bước bỏ mạo từ: en=True là script SQuAD nguyên bản, en=False là bản cho tiếng Việt. Ô chấm chín cặp dự đoán và đáp án mẫu; cột en cho biết cặp đó dùng bản nào.
EM và F1 mức token
# EM và F1 mức token: chuẩn hóa của SQuAD, và bản cho tiếng Việtimport reimport stringfrom collections import Counter
def normalize(s, en=True): s = s.lower() # chữ thường s = "".join(ch for ch in s if ch not in string.punctuation) # bỏ dấu câu if en: # bỏ mạo từ tiếng Anh; chấm tiếng Việt thì KHÔNG làm bước này s = re.sub(r"\b(a|an|the)\b", " ", s) return " ".join(s.split()) # gộp khoảng trắng
def em(pred, gold, en=True): return float(normalize(pred, en) == normalize(gold, en))
def f1(pred, gold, en=True): p, g = normalize(pred, en).split(), normalize(gold, en).split() if not p or not g: # ca không có đáp án: chỉ đúng khi cả hai rỗng return float(p == g) common = sum((Counter(p) & Counter(g)).values()) # số token chung if common == 0: return 0.0 prec, rec = common / len(p), common / len(g) return 2 * prec * rec / (prec + rec)
cases = [ # (dự đoán, các đáp án mẫu, có bỏ mạo từ tiếng Anh không) ("1994.", ["1994"], False), ("năm 1994", ["1994"], False), ("năm 1994", ["1994", "năm 1994"], False), ("2000", ["1994"], False), ("The Eiffel Tower", ["Eiffel Tower"], True), ("vịnh Hạ Long ở Quảng Ninh", ["Quảng Ninh"], False), ("", [""], False), ("Long", ["Long An"], True), # script SQuAD nguyên bản: "an" bị bỏ ("Long", ["Long An"], False), # bản tiếng Việt]for pred, golds, en in cases: # lấy điểm cao nhất trên các đáp án mẫu e = max(em(pred, g, en) for g in golds) f = max(f1(pred, g, en) for g in golds) print(f"{pred!r:28} en={en!s:5} EM={e:.0f} F1={f:.3f}")Lần chạy đầu cần tải Python, khoảng 10 MB.
'1994.'được EM 1: dấu chấm bị bỏ khi chuẩn hóa.'năm 1994'so với"1994"được EM 0, F1 0.667, như phép tính ở mục Lý thuyết. Thêm"năm 1994"vào danh sách đáp án mẫu (dòng thứ ba) thì điểm thành 1, vì lấy điểm cao nhất trên các đáp án mẫu.'2000'được 0 ở cả hai: không chung token nào.'The Eiffel Tower'được EM 1 nhờ bỏ mạo từ “the”, quy ước chỉ có nghĩa với tiếng Anh.'vịnh Hạ Long ở Quảng Ninh'so với “Quảng Ninh”: 6 token, chung 2, nên , , F1 0.500. Câu trả lời dài dòng bị F1 phạt qua precision.- Cặp rỗng được 1 ở cả hai: mô hình từ chối đúng một câu không có đáp án.
- Hai dòng cuối chấm cùng một cặp, dự đoán
'Long'so với đáp án “Long An”. Vớien=True, chữ “An” bị xóa như một mạo từ, nên EM 1 và F1 1.000: một câu trả lời thiếu nửa tên tỉnh được chấm là đúng hoàn toàn. Vớien=False, EM 0 và F1 0.667, đúng như mong đợi.
DistilBERT fine-tune trên SQuAD
Phần tiêu đề “DistilBERT fine-tune trên SQuAD”Ô này dùng distilbert/distilbert-base-cased-distilled-squad: DistilBERT (Sanh và cộng sự, 2019), bản BERT thu nhỏ bằng chắt lọc tri thức (knowledge distillation), đã fine-tune trên SQuAD 1.1. Theo trang mô hình, nó đạt EM 79.6 và F1 87.0 trên tập dev SQuAD 1.1. Đoạn văn tiếng Anh do bài tự viết, vì mô hình chỉ biết tiếng Anh. Ba câu hỏi: hai câu có đáp án, một câu không (đoạn văn không nói gì về độ sâu).
Từ transformers 5, pipeline question-answering đã bị bỏ, nên ô này gọi thẳng AutoModelForQuestionAnswering và tự chọn span. Nhờ vậy bạn thấy đúng các bước của mục Lý thuyết. Đây là phần chọn span, tính cho mọi cặp cùng lúc bằng một ma trận:
score = s[:, None] + e[None, :] # score[i, j] = s_i + e_jn = len(s)ok = torch.ones(n, n).triu().tril(max_len - 1).bool()score = score.masked_fill(~ok, -1e4) # bỏ j < i và span quá dàitriu() giữ các ô , tril(max_len - 1) giữ các ô , với là max_len = 15. Trước đó, logit của mọi token không thuộc đoạn văn đã bị đặt về , nhờ sequence_ids cho biết token nào thuộc câu hỏi (0), đoạn văn (1) hay là token đặc biệt.
Chạy trên máy bạn
QA trích xuất với DistilBERT: logit đầu/cuối và chọn span
- Cần cài
pip install transformers torch- Phần cứng
- CPU là đủ. Lần đầu tải khoảng 261 MB (mô hình và bộ tách từ). Trên máy phát triển, mỗi lần chạy mất khoảng một tới hai phút, phần lớn là thời gian nạp PyTorch và transformers
# QA trích xuất bằng DistilBERT fine-tune trên SQuAD 1.1: logit đầu/cuốiimport torchfrom transformers import AutoModelForQuestionAnswering, AutoTokenizer
NAME = "distilbert/distilbert-base-cased-distilled-squad"tok = AutoTokenizer.from_pretrained(NAME)model = AutoModelForQuestionAnswering.from_pretrained(NAME).eval()
context = ( "Ha Long Bay is in Quang Ninh province, in northeast Vietnam. " "It has nearly 2,000 limestone islands and islets. UNESCO first " "inscribed the bay as a World Heritage Site in 1994, and again " "in 2000 for its geology. Tourists usually explore it by boat.")questions = [ "When was Ha Long Bay first inscribed as a World Heritage Site?", "Which province is Ha Long Bay in?", "How deep is the water in Ha Long Bay?", # đoạn văn không nói]
def answer(q, max_len=15): enc = tok(q, context, return_tensors="pt", return_offsets_mapping=True) offsets = enc.pop("offset_mapping")[0] in_ctx = torch.tensor([k == 1 for k in enc.sequence_ids(0)]) with torch.no_grad(): out = model(**enc) s, e = out.start_logits[0], out.end_logits[0] null = (s[0] + e[0]).item() # điểm của [CLS] s = s.masked_fill(~in_ctx, -1e4) # chỉ chọn token của đoạn văn e = e.masked_fill(~in_ctx, -1e4) score = s[:, None] + e[None, :] # score[i, j] = s_i + e_j n = len(s) ok = torch.ones(n, n).triu().tril(max_len - 1).bool() score = score.masked_fill(~ok, -1e4) # bỏ j < i và span quá dài i, j = divmod(int(score.argmax()), n) p = s.softmax(0)[i] * e.softmax(0)[j] # xác suất của span a, b = int(offsets[i][0]), int(offsets[j][1]) return context[a:b], p.item(), a, b, score[i, j].item(), null
for q in questions: text, p, a, b, best, null = answer(q) print(q) print(f" answer={text!r} p={p:.3f} start={a} end={b}") print(f" best span score={best:.2f} [CLS] score={null:.2f}")Kết quả mẫu
Lấy từ một lần chạy thật.
When was Ha Long Bay first inscribed as a World Heritage Site? answer='1994' p=0.992 start=170 end=174 best span score=19.55 [CLS] score=-4.73Which province is Ha Long Bay in? answer='Quang Ninh' p=0.812 start=18 end=28 best span score=25.48 [CLS] score=-1.71How deep is the water in Ha Long Bay? answer='2,000 limestone islands and islets' p=0.228 start=75 end=109 best span score=1.38 [CLS] score=-14.37Đọc kết quả:
- Câu 1 trả lời đúng
'1994', không nhầm sang “2000”, vớip=0.992.plà , softmax tính trên các token của đoạn văn.start=170 end=174là vị trí ký tự trong đoạn văn, có được nhờoffset_mappingcủa bộ tách từ:context[170:174]đúng là “1994”. Câu trả lời trích xuất luôn chỉ ra được chỗ của nó trong văn bản gốc. - Câu 2 trả lời
'Quang Ninh', vớip=0.812. Điểm span 25.48 lại cao hơn câu 1, dùpthấp hơn:pchỉ so span này với các span khác trong cùng một lần chạy, còn logit thô không so được giữa hai câu hỏi. - Câu 3 không có đáp án, nhưng mô hình vẫn trả lời
'2,000 limestone islands and islets'. Mô hình SQuAD 1.1 luôn chỉ vào một span. Dấu hiệu duy nhất là điểm thấp: span 1.38 vàp=0.228. - Điểm
[CLS]không cứu được. Ở câu 3, điểm[CLS]là , thấp hơn span bừa rất xa, nên quy tắc SQuAD 2.0 với vẫn trả lời. Mô hình này chưa bao giờ được dạy trỏ vào[CLS], nên điểm đó vô nghĩa. Muốn từ chối được, cần một mô hình fine-tune trên dữ liệu có câu không có đáp án, như SQuAD 2.0.
Mô hình không lấy mẫu, nên mỗi lần chạy trên cùng máy cho cùng kết quả; trên bản thư viện khác, số có thể lệch ở chữ số cuối.
Lỗi hay gặp
Phần tiêu đề “Lỗi hay gặp”Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Tóm tắt và đọc thêm
Phần tiêu đề “Tóm tắt và đọc thêm”Ý chính:
- QA trích xuất trả lời bằng một đoạn trích của đoạn văn. Mô hình kiểu BERT đọc
[CLS] câu hỏi [SEP] đoạn văn [SEP], cho mỗi token hai logit và , huấn luyện bằng tổng cross-entropy của vị trí đầu và cuối. - Chọn span bằng , không lấy argmax riêng từng bên. Câu không có đáp án (SQuAD 2.0): trả lời khi span tốt nhất hơn điểm
[CLS]cộng ngưỡng chọn trên tập dev. Đoạn văn dài: cửa sổ trượt có gối đầu. - EM và F1 mức token chấm sau khi chuẩn hóa (chữ thường, bỏ dấu câu, bỏ mạo từ tiếng Anh); lấy điểm cao nhất trên các đáp án mẫu. F1 cho điểm một phần, EM thì không.
- Hỏi đáp miền mở ghép retriever với reader; tỉ lệ có đáp án trong top- là trần của cả hệ thống. Thay reader bằng mô hình sinh là RAG: linh hoạt hơn, nhưng mất bảo đảm câu trả lời nằm nguyên văn trong tài liệu.
Đọc thêm:
- Nguyen, K. V., Nguyen, D.-V., Nguyen, A. G.-T., Nguyen, N. L.-T. (2020). A Vietnamese Dataset for Evaluating Machine Reading Comprehension. COLING 2020. Bộ UIT-ViQuAD: hỏi đáp trích xuất kiểu SQuAD trên Wikipedia tiếng Việt, kèm phân tích các loại câu hỏi khó.
- Rajpurkar, P., Zhang, J., Lopyrev, K., Liang, P. (2016). SQuAD: 100,000+ Questions for Machine Comprehension of Text. EMNLP 2016. Cách dựng bộ dữ liệu và lý do chọn câu trả lời là đoạn trích. Bản 2.0: Rajpurkar, P., Jia, R., Liang, P. (2018). Know What You Don’t Know: Unanswerable Questions for SQuAD. ACL 2018.
- Devlin, J., Chang, M.-W., Lee, K., Toutanova, K. (2019). BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL 2019, mục 4.2 và 4.3. Nguồn của vectơ đầu/cuối, quy tắc chọn span và ngưỡng cho câu không có đáp án.
- Chen, D., Fisch, A., Weston, J., Bordes, A. (2017). Reading Wikipedia to Answer Open-Domain Questions. ACL 2017. Hệ thống retriever–reader DrQA; bảng 3 và bảng 6 cho thấy retriever đặt trần cho cả hệ thống.
- Jurafsky, D., Martin, J. H. Speech and Language Processing, bản thảo lần 3 (8/2026), chương 11 “Information Retrieval and RAG”, mục 11.5 và 11.6. Các bộ dữ liệu hỏi đáp và cách đánh giá, đặt cạnh truy xuất và RAG.
- Hugging Face. Question answering, chương 7 của khóa học LLM. Cài đặt đầy đủ: cửa sổ trượt với
stride, gán nhãn cho cửa sổ không chứa câu trả lời, gộp span từ nhiều cửa sổ.
Trình duyệt đang chặn lưu dữ liệu. Tiến độ sẽ mất khi bạn tải lại trang.
Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu