Bỏ qua để đến nội dung

Hỏi đáp (Question Answering)

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.

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.

“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àoCâu trả lờiVí 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ănMột đoạn trích nguyên văn (span) của đoạn vănSQuAD
Miền mở (open-domain)Câu hỏi và một kho văn bảnTìm đoạn liên quan, rồi trích hoặc sinh câu trả lờiDrQA, DPR
Sinh câu trả lời (generative, abstractive)Câu hỏi, có thể kèm đoạn vănCâu do mô hình tự viết, không nhất thiết có nguyên văn trong đoạnT5, 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í ii có một vectơ hi∈RD\mathbf{h}_i \in \mathbb{R}^D, với DD là số chiều ẩn (D=768D = 768 ở BERT-Base).

Bước 2: thêm hai vectơ học được. Gọi chúng là wstart\mathbf{w}_{\text{start}} và wend\mathbf{w}_{\text{end}}, mỗi vectơ DD 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:

si=wstart⊤hi,ei=wend⊤his_i = \mathbf{w}_{\text{start}}^\top \mathbf{h}_i, \qquad e_i = \mathbf{w}_{\text{end}}^\top \mathbf{h}_i

sis_i lớn nghĩa là “token ii trông giống điểm đầu của câu trả lời”, eie_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ừ DD 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:

pstart(i)=exp⁡(si)∑kexp⁡(sk),pend(j)=exp⁡(ej)∑kexp⁡(ek)p_{\text{start}}(i) = \frac{\exp(s_i)}{\sum_{k} \exp(s_k)}, \qquad p_{\text{end}}(j) = \frac{\exp(e_j)}{\sum_{k} \exp(e_k)}

Ở 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 exp⁡(⋅)\exp(\cdot) thay cho e(⋅)e^{(\cdot)} để khỏi lẫn với logit cuối eje_j. 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 i⋆i^\star, j⋆j^\star 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:

L=−ln⁡pstart(i⋆)−ln⁡pend(j⋆)\mathcal{L} = -\ln p_{\text{start}}(i^\star) - \ln p_{\text{end}}(j^\star)

Paper BERT viết “tổng log-likelihood của vị trí đầu và cuối đúng”, chính là −L-\mathcal{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 i=arg⁡max⁡isii = \arg\max_i s_i và j=arg⁡max⁡jejj = \arg\max_j e_j. Cách này hỏng, vì hai bên được chọn độc lập. Không có gì bảo đảm i≤ji \le j, 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 −2-2. 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í171824
Tokennăm19942000
sis_i (logit đầu)2.03.94.2
eie_i (logit cuối)−2.0-2.04.53.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ừ ii tới jj là si+ejs_i + e_j. Vì sao là tổng logit? Lấy log xác suất của cặp, coi hai bên độc lập:

ln⁡(pstart(i) pend(j))=ln⁡pstart(i)+ln⁡pend(j)=(si−ln⁡∑kexp⁡(sk))+(ej−ln⁡∑kexp⁡(ek))\begin{aligned} \ln\big(p_{\text{start}}(i) \, p_{\text{end}}(j)\big) &= \ln p_{\text{start}}(i) + \ln p_{\text{end}}(j) \\ &= \Big(s_i - \ln \textstyle\sum_k \exp(s_k)\Big) + \Big(e_j - \ln \textstyle\sum_k \exp(e_k)\Big) \end{aligned}

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à ln⁡exp⁡(si)=si\ln \exp(s_i) = s_i. Hai khoản ln⁡∑\ln \sum không phụ thuộc ii hay jj, nên cặp nào có si+ejs_i + e_j 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 KK là độ dài tối đa của câu trả lời (tính bằng token):

(i^,j^)=arg⁡max⁡i≤j<i+K  (si+ej)(\hat{i}, \hat{j}) = \arg\max_{i \le j < i + K} \; (s_i + e_j)

Ràng buộc i≤ji \le j bỏ span ngược. Ràng buộc j<i+Kj < i + K bỏ span quá dài: câu trả lời kiểu SQuAD thường chỉ vài từ. Ngoài ra, ii và jj 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 LL token và giới hạn KK, chỉ có khoảng L⋅KL \cdot K cặp, rất rẻ.

Với bảng trên và K=5K = 5: cặp (18,18)(18, 18) được 3.9+4.5=8.43.9 + 4.5 = 8.4, hơn “2000” một mình (24,24)(24, 24) được 4.2+3.0=7.24.2 + 3.0 = 7.2, và hơn “năm 1994” (17,18)(17, 18) được 2.0+4.5=6.52.0 + 4.5 = 6.5. Câu trả lời là “1994”. Paper BERT viết đúng quy tắc này: chọn span có si+ejs_i + e_j lớn nhất với j≥ij \ge i.

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 i⋆=j⋆=0i^\star = j^\star = 0: “span” bắt đầu và kết thúc ở [CLS].
  • Khi dùng, tính điểm “không có đáp án” snull=s0+e0s_{\text{null}} = s_0 + e_0, rồi so với span tốt nhất s^=si^+ej^\hat{s} = s_{\hat{i}} + e_{\hat{j}}. Chỉ trả lời khi
s^>snull+τ\hat{s} > s_{\text{null}} + \tau

Ngưỡng τ\tau chọn trên tập dev sao cho F1 lớn nhất. τ\tau lớn thì mô hình hay từ chối; τ\tau 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 snull=6.2s_{\text{null}} = 6.2: với τ=0\tau = 0 mô hình từ chối, với τ=−1.5\tau = -1.5 nó trả lời “1994”, sai.

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ả.

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

P=soˆˊ token chungsoˆˊ token của dự đoaˊn,R=soˆˊ token chungsoˆˊ token của đaˊp aˊn,F1=2PRP+RP = \frac{\text{số token chung}}{\text{số token của dự đoán}}, \quad R = \frac{\text{số token chung}}{\text{số token của đáp án}}, \quad F_1 = \frac{2PR}{P + R}

PP (precision) hỏi “dự đoán có bao nhiêu phần đúng”, RR (recall) hỏi “đáp án được phủ bao nhiêu”. “năm 1994” so với “1994”: chung 1 token, P=1/2P = 1/2, R=1R = 1, nên F1=2⋅12⋅1/(12+1)=0.667F_1 = 2 \cdot \frac{1}{2} \cdot 1 / (\frac{1}{2} + 1) = 0.667. 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.

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:

  1. 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ì.
  2. 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-kk, reader giỏi mấy cũng không trả lời đúng. Tỉ lệ câu hỏi có đáp án trong top-kk 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.

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ữ.

  • 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.

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 đó.

Câu hỏiKho đoạn vănhàng triệu đoạnRetrieverBM25 hoặcbi-encoder (DPR)Top-k đoạnĐoạn 1Đoạn 2Đoạn 3ReaderBERT chấm điểmđầu và cuối trên từng đoạnCâu trả lờiđoạn trích + điểm
Retriever chọn kk đoạn; reader tìm span có si+ejs_i + e_j lớn nhất trên từng đoạn rồi lấy span tốt nhất trong tất cả.

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 i≤j<i+Ki \le j < i + K với K=5K = 5.

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 án
import 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 3.9+4.53.9 + 4.5.
  • 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 null của [CLS] là 6.2. Với tau=0.0, 5.0<6.25.0 < 6.2 nên mô hình trả lời (no answer). Hạ ngưỡng xuống tau=-1.5 thì 5.0>4.75.0 > 4.7, và mô hình trả lời “1994”, sai. Chọn τ\tau là đánh đổi giữa từ chối nhầm và trả lời bừa.

Ô 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ệt
import re
import string
from 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 P=1/3P = 1/3, R=1R = 1, 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ới en=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ới en=False, EM 0 và F1 0.667, đúng như mong đợi.

Ô 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_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

triu() giữ các ô j≥ij \ge i, tril(max_len - 1) giữ các ô j−i≤K−1j - i \le K - 1, với KK là max_len = 15. Trước đó, logit của mọi token không thuộc đoạn văn đã bị đặt về −104-10^4, 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ối
import torch
from 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.73
Which province is Ha Long Bay in?
answer='Quang Ninh' p=0.812 start=18 end=28
best span score=25.48 [CLS] score=-1.71
How 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ới p=0.992. p là pstart(i^)⋅pend(j^)p_{\text{start}}(\hat{i}) \cdot p_{\text{end}}(\hat{j}), softmax tính trên các token của đoạn văn. start=170 end=174 là vị trí ký tự trong đoạn văn, có được nhờ offset_mapping củ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ới p=0.812. Điểm span 25.48 lại cao hơn câu 1, dù p thấp hơn: p chỉ 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à −14.37-14.37, thấp hơn span bừa rất xa, nên quy tắc SQuAD 2.0 với τ=0\tau = 0 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.

Câu 1/5 Một đoạn văn có 5 token (vị trí 1 tới 5). Logit đầu là s=(0.5,3.0,1.0,0.2,3.5)s = (0.5, 3.0, 1.0, 0.2, 3.5), logit cuối là e=(0.1,2.0,4.0,0.5,0.3)e = (0.1, 2.0, 4.0, 0.5, 0.3), độ dài span tối đa K=3K = 3 token. Mô hình nên trả lời span nào?
Câu 2/5 Bạn có một mô hình chỉ fine-tune trên SQuAD 1.1 (mọi câu hỏi đều có đáp án) và muốn nó biết nói "không có trong đoạn văn". Cách nào hợp lý nhất?
Câu 3/5 Đáp án mẫu là "Quảng Ninh", mô hình trả lời "tỉnh Quảng Ninh". Chuẩn hóa kiểu SQuAD cho tiếng Việt (chữ thường, bỏ dấu câu, gộp khoảng trắng). EM và F1 bằng bao nhiêu?
Câu 4/5 Hệ thống hỏi đáp miền mở của bạn: retriever lấy top-5 đoạn, và chỉ 60% câu hỏi có đoạn chứa đáp án trong top-5. Khi được đưa đúng đoạn, reader trả lời đúng 85%. Nên cải thiện phần nào trước?
Câu 5/5 Đoạn văn dài 1 000 token, mô hình nhận tối đa 384 token. Vì sao các cửa sổ cắt ra nên gối lên nhau (stride) thay vì cắt liền nhau?

Ý 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 si=wstart⊤his_i = \mathbf{w}_{\text{start}}^\top \mathbf{h}_i và ei=wend⊤hie_i = \mathbf{w}_{\text{end}}^\top \mathbf{h}_i, huấn luyện bằng tổng cross-entropy của vị trí đầu và cuối.
  • Chọn span bằng arg⁡max⁡i≤j<i+K(si+ej)\arg\max_{i \le j < i + K} (s_i + e_j), 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 τ\tau 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-kk 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:

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