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

Truy xuất thông tin (Information Retrieval)

Sau bài này, bạn sẽ:

  • Hiểu bài toán truy xuất thông tin (information retrieval): cho một truy vấn (query) và một kho văn bản, xếp hạng văn bản theo độ liên quan (relevance), và vì sao chỉ mục ngược (inverted index) làm việc này nhanh.
  • Đọc hiểu từng phần của công thức BM25: idf, bão hòa tần suất và chuẩn hóa độ dài, và tự tính được điểm của một từ.
  • Tính được tay và bằng code các độ đo precision@k, recall@k, MRR, AP và nDCG.
  • Nhận ra khi nào truy xuất thưa (BM25) thắng, khi nào truy xuất dày (bi-encoder, như DPR) thắng, và cách ghép chúng: cross-encoder xếp lại, truy xuất lai bằng Reciprocal Rank Fusion.

Một cửa hàng điện thoại có trang câu hỏi thường gặp (FAQ) gồm 10 câu trả lời ngắn: giờ mở cửa, đổi trả, phí giao hàng, cách thanh toán, bảo hành, khuyến mãi tai nghe BT200, đặt hàng trực tuyến, đổi hàng lỗi, chỗ gửi xe, giảm giá cho học sinh. Một khách gõ vào ô tìm kiếm: “bt200 có giảm giá không”.

Với 10 câu, bạn liếc qua là thấy câu về khuyến mãi BT200. Máy cũng làm được y như vậy: so truy vấn với từng câu. Nhưng một trang thương mại điện tử có hàng triệu mô tả sản phẩm và câu hỏi đáp. So với từng văn bản cho mỗi lần khách gõ thì quá chậm.

Còn một chuyện khó hơn: xếp thứ tự. Câu giảm giá cho học sinh (“học sinh sinh viên được giảm 5 phần trăm”) cũng có chữ “giảm”. Câu gửi xe (“cửa hàng có chỗ gửi xe”) có chữ “có”. Câu nào nên đứng trước? Chữ “có” xuất hiện ở khắp nơi, nên trùng chữ “có” chẳng nói lên gì. Chữ “bt200” thì hiếm, trùng nó gần như chắc chắn là đúng chỗ.

Rồi một khách khác gõ “hàng bị hỏng thì làm sao”. FAQ có câu “sản phẩm lỗi do nhà sản xuất được đổi mới trong 30 ngày đầu”, đúng là câu trả lời, nhưng không chung chữ nào với truy vấn. Tìm theo chữ sẽ trượt. Và khi bạn sửa cách tìm, làm sao biết bản mới tốt hơn bản cũ, thay vì chỉ thử vài câu rồi đoán?

Bài toán của bài này là: cho một kho văn bản và một truy vấn, tìm thật nhanh các văn bản liên quan, xếp chúng theo thứ tự hợp lý, và đo được thứ tự đó tốt tới đâu.

Bài toán trong bài gọi là truy xuất ad hoc (ad hoc retrieval): kho văn bản cố định, còn truy vấn thì mới với mỗi lần hỏi. Các ký hiệu:

  • Kho có NN văn bản (document), đánh số từ 0 như trong code. Một văn bản viết là DD; ∣D∣|D| là số từ của nó. Bài không dùng chữ dd cho văn bản, vì dd đã là số chiều của embedding.
  • Truy vấn QQ gồm các từ w1,…,wtw_1, \dots, w_t.
  • tf(w,D)\mathrm{tf}(w, D) (term frequency) là số lần từ ww xuất hiện trong DD. n(w)n(w) là số văn bản có chứa ww (document frequency).
  • Hệ thống trả về một danh sách xếp hạng: các văn bản sắp theo điểm score(Q,D)\mathrm{score}(Q, D) giảm dần, thường chỉ lấy kk văn bản đầu.

Độ liên quan là phán xét của người: văn bản có giúp người hỏi không. Nó không phải “có chung chữ hay không”. Câu đổi hàng lỗi liên quan tới “hàng bị hỏng thì làm sao” dù không chung chữ nào. Mọi hàm điểm chỉ là cách đoán độ liên quan; mục Đánh giá sẽ so lời đoán với phán xét của người.

Trong 10 câu FAQ của bài, các từ đã được tách theo âm tiết bằng khoảng trắng và viết thường. Cách tách từ tiếng Việt và vì sao nó quan trọng nằm ở bài Tách từ.

Chỉ mục ngược: tra từ, không quét văn bản

Phần tiêu đề “Chỉ mục ngược: tra từ, không quét văn bản”

Cuối nhiều cuốn sách giáo khoa có trang chỉ mục: mỗi thuật ngữ kèm số các trang nhắc tới nó. Muốn tìm “đạo hàm”, bạn không đọc lại cả cuốn, bạn tra chỉ mục. Chỉ mục ngược (inverted index) làm đúng như vậy cho kho văn bản:

  • Từ vựng (vocabulary): mọi từ xuất hiện trong kho. Kho FAQ của bài có 84 từ khác nhau.
  • Mỗi từ ww trỏ tới một posting list (danh sách văn bản chứa từ): các cặp (số thứ tự văn bản, tf\mathrm{tf}). Ví dụ “giảm” có posting list gồm văn bản 5 (1 lần) và văn bản 9 (1 lần).

Gọi là “ngược” vì bình thường ta lưu văn bản → các từ của nó; chỉ mục lưu chiều ngược lại, từ → các văn bản. Chỉ mục được dựng một lần khi lập chỉ mục, rồi dùng cho mọi truy vấn.

Vì sao nhanh. Với truy vấn “bt200 có giảm giá không”, máy chỉ mở bốn posting list (“không” không có trong kho): 1 + 3 + 2 + 1 = 7 mục. Văn bản nào không nằm trong posting list nào thì điểm bằng 0, không cần đụng tới. Trong kho thật, phần lớn các từ là hiếm, nên posting list của chúng ngắn, và mỗi truy vấn chỉ chạm vào một phần rất nhỏ của kho. Những từ có posting list rất dài, như “có”, “và”, lại đóng góp ít điểm nhất, như mục BM25 sẽ cho thấy.

Cách dùng chỉ mục đơn giản nhất là truy xuất Boolean (Boolean retrieval): truy vấn là một biểu thức logic, kết quả là một tập văn bản.

  • giảm AND giá: giao hai posting list. Văn bản 5 và 9 có “giảm”, chỉ văn bản 5 có “giá”, nên kết quả là văn bản 5. Nếu mỗi posting list được sắp theo số thứ tự văn bản, máy giao chúng bằng hai con trỏ chạy song song, mỗi danh sách chỉ đi qua một lần.
  • giảm OR thẻ: hợp hai danh sách, được văn bản 3, 5, 9.
  • giảm AND NOT thẻ: bỏ những văn bản có “thẻ”, còn văn bản 5.

Boolean vẫn được dùng trong tra cứu pháp lý hay tìm bằng sáng chế, nơi người dùng là chuyên gia viết truy vấn cẩn thận. Với người dùng bình thường nó có hai nhược điểm. Một, không xếp hạng: văn bản trúng thì trúng, không có cái nào hơn cái nào. Hai, quá chặt hoặc quá lỏng: bt200 AND có AND giảm AND giá AND không trả về rỗng, vì không văn bản nào có “không”; còn dùng OR thì văn bản nào có chữ “có” cũng lọt vào, ngang hàng với câu về BT200. Ta cần một điểm cho mỗi văn bản.

Bài Vector Semantics và Embeddings đã dựng cách chấm điểm đầu tiên: biểu diễn truy vấn và văn bản bằng vectơ TF-IDF, mỗi từ có trọng số lớn khi nó xuất hiện nhiều trong văn bản nhưng hiếm trong kho, rồi xếp theo độ tương tự cosine giữa hai vectơ. Ô code thứ nhất của bài vẫn tính TF-IDF cosine để so.

TF-IDF với cosine chạy khá tốt, nhưng có hai chỗ chưa khéo khi dùng để tìm kiếm:

  • Tần suất tăng tuyến tính. Một văn bản nhắc “giảm” 10 lần có phần tf gấp 10 văn bản nhắc 1 lần. Nhưng nhắc lần thứ mười chẳng cho thêm bao nhiêu bằng chứng so với lần thứ hai.
  • Độ dài xử lý cứng nhắc. Cosine chia cho độ dài vectơ, nên một văn bản dài bị phạt dù nó dài vì bàn nhiều chuyện có ích.

BM25 sửa đúng hai chỗ này.

BM25 (Best Matching 25, còn gọi là Okapi BM25) ra đời từ hệ thống Okapi ở đại học City, London, vào thập niên 1990, và được Robertson và Zaragoza (2009) tổng kết trong khung xác suất về độ liên quan. Đến nay nó vẫn là mốc so sánh quen thuộc của hầu hết nghiên cứu về tìm kiếm. Điểm của văn bản DD với truy vấn QQ là tổng điểm của từng từ trong truy vấn:

score(Q,D)=∑i=1tidf(wi)⋅tf(wi,D) (k1+1)tf(wi,D)+k1⋅B(D)\mathrm{score}(Q, D) = \sum_{i=1}^{t} \mathrm{idf}(w_i) \cdot \frac{\mathrm{tf}(w_i, D)\,(k_1 + 1)}{\mathrm{tf}(w_i, D) + k_1 \cdot B(D)}

với hệ số độ dài

B(D)=1−b+b⋅∣D∣avgdlB(D) = 1 - b + b \cdot \frac{|D|}{\mathrm{avgdl}}

trong đó avgdl\mathrm{avgdl} là độ dài trung bình của văn bản trong kho, còn k1≥0k_1 \ge 0 và 0≤b≤10 \le b \le 1 là hai tham số chọn tay. Từ không có trong DD có tf=0\mathrm{tf} = 0, nên phân số bằng 0 và từ đó không cộng gì. Vì vậy chỉ cần duyệt posting list. Ta đọc từng phần.

Phần 1: idf, độ hiếm của từ.

idf(w)=ln⁡(1+N−n(w)+0.5n(w)+0.5)\mathrm{idf}(w) = \ln\left(1 + \frac{N - n(w) + 0.5}{n(w) + 0.5}\right)

Phân số so số văn bản không chứa ww với số văn bản chứa ww. Từ hiếm thì phân số lớn, từ phổ biến thì phân số nhỏ. Hai số 0.50.5 là phần hiệu chỉnh khi ước lượng, thừa hưởng từ trọng số Robertson–Spärck Jones: chúng giữ cho phân số hữu hạn và khác 0 ở cả hai đầu, kể cả khi từ có ở mọi văn bản (n(w)=Nn(w) = N). Trong kho FAQ, N=10N = 10:

  • “bt200” chỉ có ở 1 văn bản: idf=ln⁡(1+9.5/1.5)=ln⁡7.33≈1.992\mathrm{idf} = \ln(1 + 9.5 / 1.5) = \ln 7.33 \approx 1.992.
  • “giảm” có ở 2 văn bản: idf=ln⁡(1+8.5/2.5)=ln⁡4.4≈1.482\mathrm{idf} = \ln(1 + 8.5 / 2.5) = \ln 4.4 \approx 1.482.
  • “hàng” có ở 6 văn bản: idf=ln⁡(1+4.5/6.5)≈ln⁡1.692≈0.526\mathrm{idf} = \ln(1 + 4.5 / 6.5) \approx \ln 1.692 \approx 0.526.

Robertson và Zaragoza viết idf không có số 1+1 + bên trong: ln⁡N−n(w)+0.5n(w)+0.5\ln\frac{N - n(w) + 0.5}{n(w) + 0.5}. Dạng đó âm khi từ có mặt ở hơn nửa kho: với “hàng” nó cho ln⁡(4.5/6.5)≈−0.368\ln(4.5 / 6.5) \approx -0.368, tức có chữ “hàng” lại bị trừ điểm. Nhiều thư viện, như Lucene (lõi của Elasticsearch), thêm 1+1 + để idf luôn dương. Code của bài dùng dạng có 1+1 +.

Phần 2: bão hòa tần suất (term frequency saturation). Tạm cho văn bản có độ dài đúng bằng trung bình, tức B(D)=1B(D) = 1. Phần tần suất còn là tf (k1+1)tf+k1\frac{\mathrm{tf}\,(k_1 + 1)}{\mathrm{tf} + k_1}. Với k1=1.2k_1 = 1.2:

tf\mathrm{tf}123510100
Phần tần suất1.0001.3751.5711.7741.9642.174

Lần xuất hiện đầu tiên cho 1, lần thứ hai thêm 0.375, lần thứ ba thêm gần 0.2, rồi càng về sau càng thêm ít. Khi tf\mathrm{tf} rất lớn, tử và mẫu đều gần bằng tf\mathrm{tf} nhân một hằng số, nên phân số tiến tới k1+1=2.2k_1 + 1 = 2.2 mà không bao giờ vượt qua. Đây là ý “bão hòa”: một văn bản không thể leo hạng chỉ bằng cách lặp một từ khóa nhiều lần. k1k_1 chỉnh độ cong: k1=0k_1 = 0 thì phần này luôn bằng 1 (chỉ xét từ có mặt hay không), k1k_1 rất lớn thì gần tuyến tính như TF-IDF.

Thừa số (k1+1)(k_1 + 1) ở tử chỉ làm cho một lần xuất hiện có phần tần suất bằng đúng 1. Nó nhân mọi điểm với cùng một hằng số, nên không đổi thứ hạng; Robertson và Zaragoza ghi rằng có bản BM25 bỏ thừa số này; Lucene là một ví dụ, nó tính phần tần suất không nhân (k1+1)(k_1 + 1) ở tử.

Phần 3: chuẩn hóa độ dài (length normalization). B(D)B(D) bằng 1 khi văn bản dài đúng trung bình, lớn hơn 1 khi dài hơn, nhỏ hơn 1 khi ngắn hơn. Nó nằm ở mẫu số, cạnh k1k_1: văn bản dài cần nhiều lần xuất hiện hơn mới đạt cùng phần tần suất. Lý do: một văn bản dài có nhiều chỗ để nhắc một từ một cách tình cờ. bb chỉnh mức phạt: b=0b = 0 thì bỏ hẳn chuẩn hóa, b=1b = 1 thì chia hẳn theo độ dài. Robertson và Zaragoza ghi rằng các giá trị khoảng 0.5<b<0.80.5 < b < 0.8 và 1.2<k1<21.2 < k_1 < 2 thường chạy tốt; bài dùng k1=1.2k_1 = 1.2, b=0.75b = 0.75, một cặp mặc định hay gặp.

Tính tay một từ. Truy vấn “hàng bị hỏng thì làm sao”, văn bản 6 (“bạn có thể đặt hàng trực tuyến và nhận hàng tại cửa hàng”): tf=3\mathrm{tf} = 3, ∣D∣=13|D| = 13, và kho có avgdl=13.1\mathrm{avgdl} = 13.1.

B=1−0.75+0.75⋅1313.1≈0.994phaˆˋn taˆˋn suaˆˊt=3⋅2.23+1.2⋅0.994=6.64.193≈1.574điểm=0.526⋅1.574≈0.828\begin{aligned} B &= 1 - 0.75 + 0.75 \cdot \frac{13}{13.1} \approx 0.994 \\ \text{phần tần suất} &= \frac{3 \cdot 2.2}{3 + 1.2 \cdot 0.994} = \frac{6.6}{4.193} \approx 1.574 \\ \text{điểm} &= 0.526 \cdot 1.574 \approx 0.828 \end{aligned}

Dòng một: văn bản ngắn hơn trung bình một chút nên BB hơi nhỏ hơn 1. Dòng hai: thay tf=3\mathrm{tf} = 3, k1=1.2k_1 = 1.2 và BB vào phần tần suất. Dòng ba: nhân với idf của “hàng”. Văn bản 3 và văn bản 2 đều có “hàng” 2 lần, nhưng văn bản 3 dài 12 từ còn văn bản 2 dài 16 từ, nên văn bản 3 được 0.741 còn văn bản 2 chỉ được 0.681: cùng tần suất, văn bản ngắn hơn được điểm cao hơn. Ô code thứ nhất in đúng ba số 0.83, 0.74, 0.68 này.

Đánh giá: so thứ hạng với phán xét của người

Phần tiêu đề “Đánh giá: so thứ hạng với phán xét của người”

Muốn đo, bạn cần một bộ đánh giá: một tập truy vấn, và với mỗi truy vấn, danh sách văn bản liên quan do người chấm (relevance judgments). Có thể chấm hai mức (liên quan hay không) hoặc nhiều mức (0, 1, 2…). Bài RAG và chatbot hiện đại dùng hit rate, độ đo đơn giản nhất: văn bản đúng có nằm trong top-kk không. Các độ đo dưới đây nói nhiều hơn thế.

Lấy truy vấn “hàng bị hỏng thì làm sao”. Người chấm cho: văn bản 7 (đổi hàng lỗi) mức 2, văn bản 1 (đổi trả) mức 1, văn bản 4 (bảo hành) mức 1, còn lại mức 0. Vậy có R=3R = 3 văn bản liên quan. Một bộ truy xuất trả về top-5 là văn bản 7, 4, 6, 1, 3. Ta tính từng độ đo trên danh sách này.

Precision@k và recall@k. Precision@k là tỉ lệ văn bản liên quan trong kk văn bản đầu; recall@k là tỉ lệ văn bản liên quan của cả kho đã lọt vào kk văn bản đầu:

P@k=soˆˊ va˘n bản lieˆn quan trong top-kk,R@k=soˆˊ va˘n bản lieˆn quan trong top-kR\mathrm{P@}k = \frac{\text{số văn bản liên quan trong top-}k}{k}, \qquad \mathrm{R@}k = \frac{\text{số văn bản liên quan trong top-}k}{R}

Top-3 là 7, 4, 6, có hai văn bản liên quan, nên P@3=2/3≈0.667\mathrm{P@}3 = 2/3 \approx 0.667 và R@3=2/3\mathrm{R@}3 = 2/3. Top-5 chứa đủ ba văn bản liên quan, nên R@5=1\mathrm{R@}5 = 1. Hai độ đo kéo về hai phía: tăng kk thì recall không giảm, còn precision thường giảm. Cả hai đều không quan tâm thứ tự bên trong top-kk.

Reciprocal rank và MRR. Nhiều khi người dùng chỉ cần một câu trả lời đúng, càng sớm càng tốt. Reciprocal rank (RR) là 1/vị trıˊ của va˘n bản lieˆn quan đaˆˋu tieˆn1 / \text{vị trí của văn bản liên quan đầu tiên}, và bằng 0 nếu không có văn bản liên quan nào trong danh sách. Ở đây văn bản 7 đứng đầu nên RR=1\mathrm{RR} = 1. Nếu nó đứng thứ tư thì RR=0.25\mathrm{RR} = 0.25. MRR (mean reciprocal rank) là trung bình RR trên mọi truy vấn của bộ đánh giá.

Average precision và MAP. AP thưởng cho việc đưa mọi văn bản liên quan lên sớm. Mỗi lần gặp một văn bản liên quan ở vị trí ii, ghi lại precision tại đúng vị trí đó, rồi cộng lại và chia cho RR. Văn bản liên quan không tìm thấy góp 0:

AP=1R∑i : va˘n bản ở vị trıˊ i lieˆn quanP@i\mathrm{AP} = \frac{1}{R} \sum_{i \,:\, \text{văn bản ở vị trí } i \text{ liên quan}} \mathrm{P@}i

Văn bản liên quan nằm ở vị trí 1, 2 và 4, với precision tại các vị trí đó là 1/11/1, 2/22/2 và 3/43/4. Vậy AP=(1+1+0.75)/3≈0.917\mathrm{AP} = (1 + 1 + 0.75)/3 \approx 0.917. Vì sao chia cho RR chứ không chia cho số văn bản tìm được? Để hệ thống bỏ sót văn bản liên quan bị phạt. MAP (mean average precision) là trung bình AP trên mọi truy vấn.

nDCG@k (normalized discounted cumulative gain, Järvelin và Kekäläinen, 2002) dùng được mức liên quan nhiều bậc. Gọi gig_i là mức liên quan của văn bản ở vị trí ii. Mỗi văn bản đóng góp gig_i, nhưng bị chiết khấu (discount) theo vị trí, vì người dùng ít khi đọc xuống dưới:

DCG@k=∑i=1kgilog⁡2(i+1)\mathrm{DCG@}k = \sum_{i=1}^{k} \frac{g_i}{\log_2(i + 1)}

Vị trí 1 chia cho log⁡22=1\log_2 2 = 1, vị trí 2 chia cho log⁡23≈1.585\log_2 3 \approx 1.585, vị trí 5 chia cho log⁡26≈2.585\log_2 6 \approx 2.585. DCG của các truy vấn khác nhau không so được với nhau, vì truy vấn có nhiều văn bản liên quan thì DCG lớn sẵn. Nên ta chia cho IDCG, DCG của thứ tự lý tưởng (xếp mức liên quan giảm dần), để được một số trong [0,1][0, 1]:

DCG@5=2log⁡22+1log⁡23+0log⁡24+1log⁡25+0log⁡26≈2+0.631+0.431=3.062IDCG@5=2log⁡22+1log⁡23+1log⁡24≈2+0.631+0.5=3.131nDCG@5=3.062/3.131≈0.978\begin{aligned} \mathrm{DCG@}5 &= \frac{2}{\log_2 2} + \frac{1}{\log_2 3} + \frac{0}{\log_2 4} + \frac{1}{\log_2 5} + \frac{0}{\log_2 6} \approx 2 + 0.631 + 0.431 = 3.062 \\ \mathrm{IDCG@}5 &= \frac{2}{\log_2 2} + \frac{1}{\log_2 3} + \frac{1}{\log_2 4} \approx 2 + 0.631 + 0.5 = 3.131 \\ \mathrm{nDCG@}5 &= 3.062 / 3.131 \approx 0.978 \end{aligned}

Dòng một cộng đóng góp của văn bản 7, 4, 6, 1, 3 với mức 2, 1, 0, 1, 0. Dòng hai là thứ tự lý tưởng: mức 2 trước, rồi hai văn bản mức 1. nDCG gần 1 vì danh sách gần lý tưởng; chỉ văn bản 1 bị đẩy xuống vị trí 4 thay vì 3. Có các biến thể khác: bài báo gốc của Järvelin và Kekäläinen chia cho log⁡bi\log_b i từ vị trí bb trở đi, với bb là tham số (thường b=2b = 2, nên hai vị trí đầu không bị chiết khấu); dạng log⁡2(i+1)\log_2(i + 1) ở trên là dạng thông dụng về sau, còn nhiều thư viện dùng tử số 2gi−12^{g_i} - 1 để nhấn mạnh văn bản rất liên quan. Khi so hai hệ thống, hãy dùng cùng một công thức.

BM25 và TF-IDF là truy xuất thưa (sparse retrieval): vectơ của văn bản có một chiều cho mỗi từ trong từ vựng, gần như mọi chiều bằng 0. Chúng chỉ khớp được khi truy vấn và văn bản dùng chung chữ. “Hàng bị hỏng” và “sản phẩm lỗi” không chung chữ nào, nên BM25 bó tay.

Truy xuất dày (dense retrieval) biểu diễn mỗi đoạn văn bằng một vectơ dày, vài trăm chiều, do một mạng học được, sao cho hai đoạn cùng nghĩa có vectơ gần nhau dù khác chữ. Đại diện tiêu biểu là DPR (Dense Passage Retriever, Karpukhin và cộng sự, 2020), một bi-encoder (bộ mã hóa đôi):

score(Q,D)=EQ(Q)⊤ED(D)\mathrm{score}(Q, D) = E_Q(Q)^\top E_D(D)

EQE_Q và EDE_D là hai mạng BERT, mỗi mạng biến một đoạn văn thành vectơ D=768D = 768 chiều (vectơ của token [CLS]), và điểm là tích vô hướng của hai vectơ. Chữ “bi” nằm ở chỗ truy vấn và văn bản được mã hóa riêng rẽ. Nhờ vậy vectơ của mọi văn bản được tính trước một lần, lúc lập chỉ mục; lúc hỏi chỉ còn mã hóa truy vấn và tìm các vectơ có tích vô hướng lớn nhất.

DPR được huấn luyện trên các cặp (câu hỏi, đoạn chứa câu trả lời). Với mỗi câu hỏi, đoạn đúng phải có điểm cao hơn các đoạn âm (negative): đoạn đúng của các câu hỏi khác trong cùng lô (in-batch negatives), cộng một đoạn BM25 xếp cao nhưng không chứa câu trả lời (hard negative). Trên các bộ hỏi đáp miền mở tiếng Anh, DPR hơn BM25 khoảng 9 tới 19 điểm phần trăm về độ chính xác top-20. Paper cũng ghi một ngoại lệ đáng nhớ: trên SQuAD, nơi người ra đề viết câu hỏi trong lúc nhìn đoạn văn nên mượn nhiều chữ của nó, BM25 vẫn hơn.

Tìm láng giềng gần đúng. Với hàng triệu vectơ, tính tích vô hướng với từng vectơ vẫn chậm. Các thư viện tìm láng giềng gần đúng (approximate nearest neighbor, ANN) như FAISS (Johnson, Douze và Jégou; arXiv 2017, bản tạp chí IEEE Transactions on Big Data 2019) chia không gian thành cụm hay nén vectơ, chỉ so với một phần nhỏ của kho, đổi một chút recall lấy tốc độ. Phần kho vectơ và độ tương tự cosine được dùng thực tế ở bài RAG và chatbot hiện đại.

Bi-encoder nén cả văn bản thành một vectơ trước khi biết truy vấn, nên mất chi tiết. Cross-encoder làm ngược lại: ghép truy vấn và văn bản thành một chuỗi [CLS] truy vấn [SEP] văn bản [SEP], cho qua BERT một lần, và đọc ra một điểm liên quan. Mọi lớp attention đều nhìn được cả hai phía cùng lúc, nên mô hình thấy chính xác từ nào của truy vấn ứng với chỗ nào của văn bản. Nogueira và Cho (2019) dùng cách này để xếp lại top-1000 đoạn của BM25 trên MS MARCO, và tăng MRR@10 khoảng 27% tương đối so với kết quả tốt nhất trước đó.

Cái giá: điểm chỉ có khi đã có truy vấn, nên không tính trước được. Kho một triệu văn bản là một triệu lần chạy BERT cho mỗi câu hỏi. Vì vậy hệ thống thật chia hai tầng: một bộ truy xuất rẻ (BM25 hoặc bi-encoder) lấy vài chục tới vài trăm ứng viên, rồi cross-encoder xếp lại (rerank) các ứng viên đó. Tầng một lo recall, tầng hai lo thứ tự ở đầu danh sách.

Thưa và dày sai ở những chỗ khác nhau, nên ghép chúng thường tốt hơn từng cái. Khó khăn là điểm của hai bên không cùng thang: BM25 cho 5.48, TF-IDF cosine cho 0.44, còn cosine của embedding luôn nằm trong [−1,1][-1, 1]. Reciprocal Rank Fusion (RRF, Cormack, Clarke và Büttcher, 2009) né chuyện này bằng cách chỉ dùng thứ hạng:

RRF(D)=∑r1c+rankr(D)\mathrm{RRF}(D) = \sum_{r} \frac{1}{c + \mathrm{rank}_r(D)}

Tổng chạy qua các danh sách xếp hạng rr; văn bản không có mặt trong một danh sách thì danh sách đó góp 0. Hằng số cc làm dịu khoảng cách giữa hạng 1 và hạng 2, để một hệ thống “lệch” không áp đảo; bài báo chọn c=60c = 60 qua thử nghiệm và ghi rằng giá trị chính xác không quá quan trọng.

Ghép hai danh sách của ví dụ đánh giá ở trên: BM25 cho 6, 3, 2, 4, 8, còn bộ truy xuất dày cho 7, 4, 6, 1, 3. Văn bản 6 được 1/61+1/63≈0.03231/61 + 1/63 \approx 0.0323, văn bản 4 được 1/64+1/62≈0.03181/64 + 1/62 \approx 0.0318, văn bản 3 được 1/62+1/65≈0.03151/62 + 1/65 \approx 0.0315, còn văn bản 7 chỉ được 1/61≈0.01641/61 \approx 0.0164 và rơi xuống hạng 4. RRF đưa lên đầu những văn bản cả hai bên cùng thấy. Ở đây BM25 sai gần hết, nên ghép vào làm kết quả tệ đi so với chỉ dùng bộ dày. Truy xuất lai giúp khi cả hai thành phần đều khá; luôn đo trên bộ đánh giá của bạn trước khi tin.

Tình huốngThường thắngVì sao
Mã sản phẩm, mã đơn, số hiệu, tên riêng hiếm (“bt200”)Thưa (BM25)Khớp đúng chữ, từ hiếm có idf cao; embedding hay làm nhòe các mã na ná nhau
Người hỏi dùng chữ khác người viết (“hàng hỏng” và “sản phẩm lỗi”)DàyHọc được từ đồng nghĩa và cách diễn đạt khác
Miền mới, không có dữ liệu huấn luyện, cần chạy ngayThưaKhông cần huấn luyện; bộ dày học ở miền khác có thể kém đi
Cần giải thích vì sao văn bản được chọnThưaĐiểm là tổng theo từng từ, chỉ ra được
Câu hỏi dài, tự nhiên, nhiều cách nóiDàySo nghĩa cả câu, không chỉ từng chữ

Khi không chắc, hệ thống thật thường chạy cả hai, ghép bằng RRF, rồi xếp lại bằng cross-encoder.

  • Chất lượng tách từ quyết định chỉ mục. Truy vấn và văn bản phải đi qua đúng cùng một bước tách từ và chuẩn hóa; xem hộp đầu tiên ở mục Lỗi hay gặp.
  • Bộ đánh giá tốn công. Không có phán xét của người thì không tính được độ đo nào. Bài RAG và chatbot hiện đại bàn cách sinh câu hỏi tổng hợp và cái bẫy của nó.
  • Truy xuất chỉ trả về văn bản, không trả lời. Trích đúng câu trả lời từ đoạn văn là việc của bài Hỏi đáp (Question Answering), nơi bộ truy xuất của bài này trở thành tầng đầu.

Bảng dưới là một phần chỉ mục ngược mà ô code thứ nhất dựng từ 10 câu FAQ. Hãy nhìn cột thứ hai: từ nằm ở càng nhiều văn bản thì idf càng nhỏ, và chú ý “sản” có 2 lần ở văn bản 7 vì văn bản đó có cả “sản phẩm” lẫn “sản xuất”.

TừSố văn bản chứa từ n(w)n(w)Posting list (văn bản: số lần)idf BM25
hàng60: 1, 2: 2, 3: 2, 4: 1, 6: 3, 8: 10.526
cửa50: 2, 3: 1, 4: 1, 6: 1, 8: 10.693
có31: 1, 6: 1, 8: 11.145
đổi21: 1, 7: 11.482
sản21: 1, 7: 21.482
giảm25: 1, 9: 11.482
lỗi17: 11.992
bt20015: 11.992

Văn bản được đánh số từ 0 như trong code: văn bản 0 là câu giờ mở cửa, văn bản 5 là câu về tai nghe BT200, văn bản 9 là câu giảm giá cho học sinh. Truy vấn “bt200 có giảm giá không” chỉ cần đọc các hàng “bt200”, “có”, “giảm” và “giá” (posting list của “giá” chỉ có văn bản 5); mọi văn bản khác không bao giờ bị đụng tới.

Code trong bài chạy thử lần cuối tháng 11/2026 trên Pyodide 314.0.7 (Python 3.14). Cả hai ô chỉ dùng thư viện có sẵn của Python, chạy ngay trên trang và chạy độc lập với nhau.

Ô này dựng chỉ mục ngược cho 10 câu FAQ, in posting list của vài từ, rồi xếp hạng ba truy vấn bằng BM25 (k1=1.2k_1 = 1.2, b=0.75b = 0.75, idf dạng có 1+1 +). Để so, nó tính thêm TF-IDF cosine theo công thức idf và chuẩn hóa mặc định của TfidfVectorizer trong scikit-learn (idf làm trơn ln⁡1+N1+n(w)+1\ln\frac{1 + N}{1 + n(w)} + 1, rồi chuẩn hóa vectơ về độ dài 1). Chỉ khác ở bước tách từ: ô này tách theo khoảng trắng nên giữ cả token một ký tự như “8”, còn token_pattern mặc định của scikit-learn bỏ chúng, viết lại bằng Python thuần để ô chạy tức thì. Mỗi kết quả là một cặp (số thứ tự văn bản, điểm); chỉ in văn bản có điểm dương.

Chỉ mục ngược và BM25 trên 10 câu FAQ

# Chỉ mục ngược, BM25 và TF-IDF cosine trên 10 câu hỏi thường gặp
import math
from collections import Counter, defaultdict
docs = [ # đã tách âm tiết bằng khoảng trắng, chữ thường
"cửa hàng mở cửa từ 8 giờ sáng đến 9 giờ tối mỗi ngày",
"bạn có thể đổi trả sản phẩm trong 7 ngày nếu còn hóa đơn",
"phí giao hàng nội thành 20 nghìn đơn trên 500 nghìn "
"được miễn phí giao hàng",
"cửa hàng nhận tiền mặt thẻ ngân hàng và ví điện tử",
"điện thoại mua tại cửa hàng được bảo hành 12 tháng",
"tai nghe bt200 đang giảm giá 15 phần trăm đến hết tháng này",
"bạn có thể đặt hàng trực tuyến và nhận hàng tại cửa hàng",
"sản phẩm lỗi do nhà sản xuất được đổi mới trong 30 ngày đầu",
"cửa hàng có chỗ gửi xe máy miễn phí cho khách",
"học sinh sinh viên được giảm 5 phần trăm khi xuất trình thẻ",
]
toks = [doc.split() for doc in docs]
N = len(docs)
avgdl = sum(len(t) for t in toks) / N # độ dài trung bình (số từ)
print(f"N = {N}, avgdl = {avgdl:.1f}")
# chỉ mục ngược: từ -> {số thứ tự văn bản: số lần từ xuất hiện}
index = defaultdict(dict)
for j, t in enumerate(toks):
for w, c in Counter(t).items():
index[w][j] = c
for w in ["đổi", "giảm", "bt200", "hàng"]:
print(w, "->", index[w])
def bm25(query, k1=1.2, b=0.75):
score = defaultdict(float)
for w in query.split():
post = index.get(w, {}) # từ không có trong kho: bỏ qua
n = len(post)
idf = math.log(1 + (N - n + 0.5) / (n + 0.5))
for j, tf in post.items(): # chỉ duyệt văn bản chứa w
norm = 1 - b + b * len(toks[j]) / avgdl
score[j] += idf * tf * (k1 + 1) / (tf + k1 * norm)
return sorted(score.items(), key=lambda x: -x[1])
def tfidf_cos(query): # TF-IDF như scikit-learn mặc định, rồi cosine
idf = {w: math.log((1 + N) / (1 + len(p))) + 1 for w, p in index.items()}
def vec(t):
v = {w: c * idf[w] for w, c in Counter(t).items() if w in idf}
s = math.sqrt(sum(x * x for x in v.values())) or 1.0
return {w: x / s for w, x in v.items()}
q = vec(query.split())
score = {j: sum(q[w] * doc.get(w, 0) for w in q)
for j, doc in enumerate(map(vec, toks))}
return sorted(score.items(), key=lambda x: -x[1])
queries = ["đổi trả sản phẩm lỗi", "bt200 có giảm giá không",
"hàng bị hỏng thì làm sao"]
for q in queries:
print("query:", q)
for name, f in [("bm25 ", bm25), ("tfidf", tfidf_cos)]:
top = [(j, round(s, 2)) for j, s in f(q)[:3] if s > 0]
print(" ", name, top)

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • N = 10, avgdl = 13.1: 10 văn bản, trung bình 13.1 từ mỗi văn bản. Posting list in ra khớp bảng ở mục Minh họa.
  • “đổi trả sản phẩm lỗi”: cả hai cách đều xếp văn bản 7 (đổi hàng lỗi, 6.82) trên văn bản 1 (đổi trả, 6.26). Hai văn bản cùng có “đổi”, “sản”, “phẩm”; văn bản 1 có “trả”, văn bản 7 có “lỗi”, hai từ cùng hiếm như nhau. Chênh lệch đến từ “sản”: văn bản 7 có nó 2 lần, nhưng lần thứ hai là “sản” của “sản xuất”, không phải “sản phẩm”. Tách theo âm tiết làm chỉ mục không phân biệt được hai từ này.
  • “bt200 có giảm giá không”: văn bản 5 đứng đầu rất xa (5.48), vì nó khớp ba từ, trong đó “bt200” và “giá” chỉ có ở văn bản này. Văn bản 9 (1.49) chỉ khớp “giảm”. Văn bản 8 (1.23) lọt top-3 chỉ nhờ chữ “có”, một từ gần như vô nghĩa ở đây.
  • “hàng bị hỏng thì làm sao”: cả hai cách đều trượt. Chỉ “hàng” có trong kho, nên văn bản có nhiều chữ “hàng” nhất (văn bản 6, đặt hàng trực tuyến) đứng đầu, rồi tới văn bản 3 và 2, đúng như phép tính tay ở mục BM25. Văn bản 7 trả lời đúng câu hỏi lại không có điểm. Đây là giới hạn của truy xuất thưa mà truy xuất dày giải quyết.

Trên kho nhỏ này BM25 và TF-IDF cho cùng thứ tự top-3 ở cả ba truy vấn. Khác biệt của BM25 (bão hòa, chuẩn hóa độ dài có tham số) hiện rõ hơn trên kho thật, nơi văn bản dài ngắn chênh nhau nhiều.

Ô này tính các độ đo của mục Đánh giá cho truy vấn “hàng bị hỏng thì làm sao”, với hai danh sách top-5. Danh sách A là đúng kết quả BM25 của ô trên (ô trên chỉ in top-3; vị trí 4 và 5 là văn bản 4 và 8, cùng 0.56, nên thứ tự của chúng là thứ tự trong chỉ mục). Danh sách B là kết quả giả định của một bộ truy xuất dày, đặt tay để minh họa, không phải đầu ra của một mô hình thật.

Precision@k, recall@k, RR, AP và nDCG@k

# Các độ đo xếp hạng: P@k, R@k, RR, AP, nDCG@k cho hai hệ thống
import math
# mức liên quan do người chấm cho truy vấn "hàng bị hỏng thì làm sao"
# (2: rất liên quan, 1: liên quan; văn bản không có tên ở đây là 0)
rel = {7: 2, 1: 1, 4: 1}
runs = {
"A (bm25)": [6, 3, 2, 4, 8], # top-5 của BM25 ở ô trước
"B (dense)": [7, 4, 6, 1, 3], # một bộ truy xuất dày, giả định
}
def precision_at(ranked, k):
return sum(doc in rel for doc in ranked[:k]) / k
def recall_at(ranked, k):
return sum(doc in rel for doc in ranked[:k]) / len(rel)
def reciprocal_rank(ranked):
for i, doc in enumerate(ranked, start=1):
if doc in rel:
return 1 / i
return 0.0 # không tìm thấy văn bản liên quan nào
def average_precision(ranked):
hits, total = 0, 0.0
for i, doc in enumerate(ranked, start=1):
if doc in rel:
hits += 1
total += hits / i # precision tại đúng vị trí trúng
return total / len(rel) # chia cho mọi văn bản liên quan
def ndcg_at(ranked, k):
dcg = sum(rel.get(doc, 0) / math.log2(i + 1)
for i, doc in enumerate(ranked[:k], start=1))
ideal = sorted(rel.values(), reverse=True)[:k]
idcg = sum(g / math.log2(i + 1) for i, g in enumerate(ideal, start=1))
return dcg / idcg
for name, ranked in runs.items():
print(name)
print(f" P@3 = {precision_at(ranked, 3):.3f}"
f" R@3 = {recall_at(ranked, 3):.3f}"
f" R@5 = {recall_at(ranked, 5):.3f}")
print(f" RR = {reciprocal_rank(ranked):.3f}"
f" AP = {average_precision(ranked):.3f}"
f" nDCG@5 = {ndcg_at(ranked, 5):.3f}")

Lần chạy đầu cần tải Python, khoảng 10 MB.

Dòng của B khớp phép tính tay ở mục Đánh giá: P@3 = 0.667, R@5 = 1.000, RR = 1.000, AP = 0.917, nDCG@5 = 0.978. Dòng của A cho thấy BM25 tệ tới đâu trên truy vấn này: không văn bản liên quan nào trong top-3 (P@3 = R@3 = 0), văn bản liên quan đầu tiên là văn bản 4 ở vị trí 4 (RR = 0.250), chỉ tìm được một trong ba văn bản liên quan (R@5 = 0.333), nên AP = 0.083 và nDCG@5 = 0.138. Ở đây mọi độ đo cùng nói một điều; trên bộ đánh giá thật, chúng có thể bất đồng, và chọn độ đo nào là chọn điều người dùng của bạn quan tâm.

Câu 1/5 Với BM25, k1=1.2k_1 = 1.2 và b=0b = 0, một từ trong truy vấn xuất hiện 1 lần trong văn bản. Nếu từ đó xuất hiện 10 lần thì phần điểm của từ này (cùng idf) tăng khoảng bao nhiêu lần?
Câu 2/5 Kho có 8 văn bản liên quan tới một truy vấn. Hệ thống trả về 10 văn bản, trong đó có 4 văn bản liên quan. Precision@10 và recall@10 là bao nhiêu?
Câu 3/5 Đánh giá ba truy vấn. Văn bản liên quan đầu tiên nằm ở vị trí 1 với truy vấn thứ nhất, vị trí 3 với truy vấn thứ hai, và không có trong danh sách với truy vấn thứ ba. MRR là bao nhiêu?
Câu 4/5 Khách nhắn: "đơn DH48213 giao tới đâu rồi". Kho có hàng triệu tin nhắn chăm sóc khách hàng, mỗi tin ghi một mã đơn, và hệ thống phải trả lời trong dưới một giây. Bộ truy xuất nào nhiều khả năng đưa đúng tin nhắn của đơn DH48213 lên đầu?
Câu 5/5 Vì sao người ta không dùng cross-encoder để chấm thẳng mọi văn bản trong kho một triệu văn bản?

Ý chính:

  • Truy xuất ad hoc: cho truy vấn và kho văn bản, trả về danh sách xếp hạng theo độ liên quan do người phán xét. Chỉ mục ngược lưu từ → posting list, nên mỗi truy vấn chỉ chạm vào các văn bản có chung từ với nó.
  • BM25 cộng, trên từng từ của truy vấn, idf nhân phần tần suất tf (k1+1)tf+k1(1−b+b ∣D∣/avgdl)\frac{\mathrm{tf}\,(k_1 + 1)}{\mathrm{tf} + k_1 (1 - b + b\,|D|/\mathrm{avgdl})}: từ hiếm nặng hơn, lặp từ thì bão hòa, văn bản dài bị chia nhẹ.
  • Precision@k và recall@k đếm trong top-kk; MRR nhìn văn bản liên quan đầu tiên; MAP thưởng cho đưa mọi văn bản liên quan lên sớm; nDCG dùng mức liên quan nhiều bậc với chiết khấu 1/log⁡2(i+1)1/\log_2(i + 1).
  • Bi-encoder (DPR) tính trước vectơ văn bản và khớp theo nghĩa; cross-encoder chính xác hơn nhưng chỉ đủ nhanh để xếp lại ứng viên; RRF ghép nhiều danh sách bằng thứ hạng. Thưa thắng ở từ khóa hiếm, dày thắng khi người hỏi diễn đạt khác.

Đọ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