AITrợ lý AI cho BanHang, phần 1/2

Embedding và tìm kiếm ngữ nghĩa: từ câu hỏi tiếng Việt đến đúng đoạn tài liệu

Đo trên 21 đoạn chính sách của BanHang: từ khóa trượt câu diễn đạt lại, embedding trượt câu gõ không dấu, và cách ghép hai cách tìm.

Mục lục
  1. 1. Dữ liệu thử: 21 đoạn tài liệu, 8 câu hỏi
  2. 2. Tìm theo từ khóa trượt ở đâu
  3. 3. Embedding và cosine similarity
  4. 4. Chạy thật với multilingual-e5-small
  5. 5. Vì sao câu không dấu trượt, và cách ghép với từ khóa
  6. 6. Quét toàn bộ bằng numpy, và khi nào cần ANN
  7. 7. Ghi chú khi đưa vào BanHang
  8. Những chỗ hay hiểu sai
  9. Đọc tiếp
  10. Nguồn

Trợ lý AI của BanHang trả lời khách về đổi trả, giao hàng, bảo hành và sản phẩm dựa trên tài liệu nội bộ. Trước khi mô hình ngôn ngữ viết câu trả lời, hệ thống phải tìm đúng đoạn tài liệu để đưa vào prompt. Khách gõ doi tra hang trong bao lau, tài liệu viết "Khách được đổi hoặc trả sản phẩm trong vòng 7 ngày". Bài này đo hai cách tìm trên 21 đoạn tài liệu và 8 câu hỏi: so khớp từ khóa, và embedding với mô hình mở intfloat/multilingual-e5-small. Sau đó đo chi phí quét vector bằng numpy đến 100.000 đoạn.

Đọc nhanh

  • Từ khóa trượt khi khách diễn đạt khác tài liệu: đoạn đúng đứng đầu ở 2/8 câu khi giữ dấu, 3/8 khi bỏ dấu. Bỏ dấu còn sinh trùng giả: mấy và máy cùng thành may.
  • Embedding tìm đúng câu diễn đạt lại (cỡ và size, freeship và miễn phí giao hàng), 4/8 câu đứng đầu. Nhưng hai câu không dấu rơi xuống hạng 16 và 17, trong khi cùng câu có dấu đứng hạng 1.
  • Vector chuẩn hóa về độ dài 1 thì cosine là tích vô hướng. Quét hết 100.000 vector 384 chiều float32 (146,5 MiB) bằng numpy mất khoảng 10 ms trên laptop. Quy mô của BanHang chưa cần chỉ mục ANN.
  • Đưa văn bản về Unicode NFC trước khi embed. Lưu tên mô hình và commit cạnh vector. Đổi mô hình là embed lại toàn bộ.

1. Dữ liệu thử: 21 đoạn tài liệu, 8 câu hỏi

Bộ thử là tài liệu hư cấu của BanHang: 17 đoạn chính sách, dịch vụ và 4 đoạn về sản phẩm, mỗi đoạn một ý. Mỗi câu hỏi có đúng một đoạn đáp án. Lưu thành du_lieu.py, các script sau đều import file này.

# du_lieu.py: 21 đoạn tài liệu nội bộ (hư cấu) và 8 câu hỏi thử của BanHang
CORPUS = [
    ("doi-tra-01", "Khách được đổi hoặc trả sản phẩm trong vòng 7 ngày kể từ ngày nhận hàng, nếu sản phẩm còn nguyên tem, nhãn và hộp."),
    ("doi-tra-02", "Sản phẩm đã qua sử dụng, trầy xước hoặc thiếu phụ kiện không được nhận đổi trả, trừ khi lỗi do nhà sản xuất."),
    ("doi-tra-03", "Đồ lót, đồ bơi, mỹ phẩm đã mở niêm phong và thẻ cào điện thoại không áp dụng chính sách đổi trả."),
    ("doi-tra-04", "Sản phẩm lỗi do nhà sản xuất: BanHang chịu phí vận chuyển hai chiều. Đổi vì không còn nhu cầu: khách trả phí gửi hàng về kho."),
    ("doi-tra-05", "Để gửi yêu cầu đổi trả, vào mục Đơn hàng của tôi, chọn đơn, bấm Yêu cầu đổi trả và đính kèm ảnh sản phẩm."),
    ("hoan-tien-01", "Tiền được hoàn về phương thức thanh toán ban đầu trong 5 đến 7 ngày làm việc sau khi kho xác nhận đã nhận lại hàng."),
    ("hoan-tien-02", "Đơn thanh toán khi nhận hàng (COD) được hoàn tiền bằng chuyển khoản vào tài khoản ngân hàng do khách cung cấp."),
    ("bao-hanh-01", "Điện thoại và máy tính bảng mua tại BanHang được bảo hành 12 tháng tại trung tâm bảo hành chính hãng."),
    ("bao-hanh-02", "Bảo hành không áp dụng cho hư hỏng do rơi vỡ, vào nước hoặc tự ý tháo máy."),
    ("giao-hang-01", "Đơn từ 300.000 đồng được miễn phí giao hàng trong nội thành Hà Nội và TP.HCM. Đơn dưới mức này tính phí 25.000 đồng."),
    ("giao-hang-02", "Thời gian giao hàng nội thành là 1 đến 2 ngày, các tỉnh khác 3 đến 5 ngày làm việc."),
    ("giao-hang-03", "Khách hủy đơn miễn phí khi đơn chưa chuyển sang trạng thái Đang giao."),
    ("thanh-toan-01", "BanHang nhận thanh toán bằng thẻ ATM nội địa, thẻ Visa, Mastercard, ví điện tử và tiền mặt khi nhận hàng."),
    ("thanh-toan-02", "Trả góp 0% cho đơn từ 3.000.000 đồng qua thẻ tín dụng của ngân hàng liên kết, kỳ hạn 6 hoặc 12 tháng."),
    ("hoa-don-01", "Khách cần hóa đơn VAT nhập mã số thuế và tên công ty ở bước thanh toán. Hóa đơn điện tử gửi qua email trong 24 giờ."),
    ("diem-01", "Mỗi 10.000 đồng chi tiêu được tích 1 điểm. 100 điểm đổi được phiếu giảm giá 50.000 đồng."),
    ("sp-00042", "Tai nghe không dây BH Air 2 (SP-00042): chống ồn chủ động, pin 30 giờ kèm hộp sạc, sạc 10 phút dùng được 3 giờ."),
    ("sp-01207", "Nồi chiên không dầu 5,5 lít (SP-01207): công suất 1.700 W, lòng nồi chống dính, hẹn giờ tối đa 60 phút."),
    ("sp-02315", "Áo khoác gió nam (SP-02315): vải chống thấm nhẹ, mũ tháo rời, size S đến XXL. Giặt máy chế độ nhẹ, không dùng chất tẩy."),
    ("size-ao-nam", "Bảng size áo nam: S cho người 50 đến 58 kg, M từ 58 đến 65 kg, L từ 65 đến 72 kg, XL từ 72 đến 80 kg."),
    ("cskh-01", "Tổng đài chăm sóc khách hàng làm việc từ 8:00 đến 21:00 mỗi ngày, kể cả chủ nhật."),
]

CAU_HOI = [  # (câu hỏi của khách, id đoạn đúng)
    ("doi tra hang trong bao lau", "doi-tra-01"),
    ("Mặc thử thấy không vừa thì gửi lại được không?", "doi-tra-01"),
    ("Bao lâu thì tôi nhận lại tiền?", "hoan-tien-01"),
    ("ship ve tinh mat may ngay", "giao-hang-02"),
    ("Điện thoại rơi vỡ màn hình có được bảo hành không?", "bao-hanh-02"),
    ("Tôi nặng 68 kg thì mặc áo cỡ nào?", "size-ao-nam"),
    ("Son môi đã bóc seal có trả được không?", "doi-tra-03"),
    ("Mua bao nhiêu thì được freeship?", "giao-hang-01"),
]

Câu 1 và 4 gõ không dấu. Phần lớn câu còn lại diễn đạt khác tài liệu: cỡ thay size, freeship thay miễn phí giao hàng, bóc seal thay mở niêm phong. hit@1 đếm số câu có đoạn đúng đứng đầu, hit@3 đếm số câu có đoạn đúng trong 3 đoạn đầu (giả định trợ lý đưa 3 đoạn vào prompt). Hòa điểm tính bất lợi cho đoạn đúng. 8 câu đủ để thấy kiểu lỗi, không đủ để chọn mô hình.

2. Tìm theo từ khóa trượt ở đâu

Cách đơn giản nhất: tách câu hỏi và từng đoạn thành tập âm tiết viết thường, điểm là số âm tiết chung. Biến thể thứ hai bỏ dấu cả hai phía trước khi so.

# tu_khoa.py: điểm của một đoạn = số âm tiết chung với câu hỏi
import re
import unicodedata
from du_lieu import CORPUS, CAU_HOI

def bo_dau(s: str) -> str:
    s = unicodedata.normalize("NFD", s).replace("đ", "d").replace("Đ", "D")
    return "".join(c for c in s if unicodedata.category(c) != "Mn")

def tach(s: str, bo: bool) -> set[str]:
    s = bo_dau(s.lower()) if bo else s.lower()
    return set(re.findall(r"\w+", s))

def diem_tu_khoa(cau: str, bo: bool) -> list[int]:
    q = tach(cau, bo)
    return [len(q & tach(text, bo)) for _, text in CORPUS]

def hang_cua(diem, i: int) -> int:
    # Hòa điểm tính bất lợi: đoạn i đứng sau mọi đoạn có điểm >= nó
    return sum(1 for d in diem if d >= diem[i]) if diem[i] > 0 else len(diem)

if __name__ == "__main__":
    ids = [i for i, _ in CORPUS]
    for bo in (False, True):
        hang = [hang_cua(diem_tu_khoa(cau, bo), ids.index(dung)) for cau, dung in CAU_HOI]
        print(f"{'bỏ dấu ' if bo else 'giữ dấu'}: hạng {hang}, "
              f"hit@1 = {sum(h == 1 for h in hang)}/8, hit@3 = {sum(h <= 3 for h in hang)}/8")
giữ dấu: hạng [4, 15, 1, 21, 2, 1, 2, 8], hit@1 = 2/8, hit@3 = 4/8
bỏ dấu : hạng [1, 15, 1, 3, 2, 1, 2, 9], hit@1 = 3/8, hit@3 = 6/8
  • Giữ dấu, câu 4 không chung âm tiết nào với đoạn nào, hạng 21 trên 21. Câu 1 chỉ khớp chữ trong, hòa với ba đoạn khác.
  • Bỏ dấu đưa câu 1 lên hạng 1. Câu 4 lên hạng 3 nhưng hòa điểm với hai đoạn khác, trong đó có bao-hanh-01: máy tính bỏ dấu thành may tinh, trùng với mấy và tỉnh của khách. Cả hai điểm đều là trùng giả.
  • Bỏ dấu, câu freeship xếp bao-hanh-01 đầu bảng vì bảo thành bao. Đoạn đúng đứng hạng 9.
  • Câu 2 đứng hạng 15 ở cả hai biến thể: ý "trả lại hàng" được nói bằng những chữ tài liệu không dùng.

Tìm theo từ khóa coi mỗi âm tiết là một chiều riêng. freeship và miễn phí giao hàng nằm trên các chiều khác nhau nên không bao giờ cộng điểm cho nhau. Bỏ dấu gộp các âm tiết chỉ khác dấu về một chiều: cứu câu không dấu, đồng thời gộp nhầm bảo với bao, máy với mấy.

3. Embedding và cosine similarity

Embedding là vector số thực độ dài cố định mà mô hình gán cho một đoạn văn bản; multilingual-e5-small cho 384 chiều. Theo model card, mô hình được huấn luyện contrastive trên các cặp văn bản đi cùng nhau (tiêu đề và nội dung, câu hỏi và câu trả lời, câu và bản dịch): vector trong một cặp bị kéo lại gần, văn bản không liên quan bị đẩy ra xa. Nhờ vậy câu hỏi và đoạn trả lời nó có vector gần cùng hướng, kể cả khi không chung chữ nào.

"Gần cùng hướng" đo bằng cosine similarity, từ −1 đến 1: cos(a, b) = (a · b) / (‖a‖ × ‖b‖), với a · b = a1×b1 + a2×b2 + ... và ‖a‖ = √(a · a). Ví dụ minh họa 3 chiều, trục đặt tên "đổi trả", "giao hàng", "bảo hành" cho dễ hình dung. 384 chiều của mô hình thật không có tên.

q  (câu hỏi "trả hàng mất mấy ngày") = (3, 1, 0)
d1 (đoạn chính sách đổi trả)         = (4, 0, 1)
d2 (đoạn thời gian giao hàng)        = (1, 3, 0)

cos(q, d1) = (3×4 + 1×0 + 0×1) / (√10 × √17) = 12 / (3,162 × 4,123) = 12 / 13,038 ≈ 0,920
cos(q, d2) = (3×1 + 1×3 + 0×0) / (√10 × √10) = 6 / 10 = 0,600

Chuẩn hóa trước:  q̂ = q / √10 = (0,9487; 0,3162; 0)    d̂1 = d1 / √17 = (0,9701; 0; 0,2425)
q̂ · d̂1 = 0,9487 × 0,9701 + 0,3162 × 0 + 0 × 0,2425 ≈ 0,920

Vector đã chuẩn hóa có độ dài 1, mẫu số bằng 1, cosine chỉ còn là tích vô hướng. Vì vậy nên lưu vector đã chuẩn hóa: so một câu hỏi với N đoạn thành một phép nhân ma trận N × d với vector d chiều (E @ q ở mục 4 và 6).

4. Chạy thật với multilingual-e5-small

Mô hình intfloat/multilingual-e5-small, commit 614241f622f53c4eeff9890bdc4f31cfecc418b3, tải từ Hugging Face ngày 2026-10-01, khoảng 470 MiB trên đĩa. Model card ghi: 12 layer, 384 chiều, giấy phép MIT, 100 ngôn ngữ của XLM-RoBERTa, có vi. Câu hỏi phải bắt đầu bằng query: , đoạn tài liệu bằng passage: ; văn bản dài hơn 512 token bị cắt. Cài trong một virtualenv riêng bằng pip install sentence-transformers==6.1.0 torch==2.14.1 numpy==2.4.4, kéo theo transformers 5.18.0; torch là bản CPU. Trên máy thử, lệnh cài mất khoảng 11 phút. ngu_nghia.py xếp hạng bằng tích vô hướng, tính sẵn cách ghép với từ khóa (dòng RRF, mục 5) và chạy lại hai câu không dấu ở dạng có dấu.

# ngu_nghia.py: tìm bằng embedding, rồi trộn với từ khóa bỏ dấu bằng RRF
import sys
import time
import numpy as np
from sentence_transformers import SentenceTransformer
from du_lieu import CORPUS, CAU_HOI
from tu_khoa import diem_tu_khoa, hang_cua

TEN = sys.argv[1] if len(sys.argv) > 1 else "intfloat/multilingual-e5-small"
model = SentenceTransformer(TEN, device="cpu")
ids = [i for i, _ in CORPUS]

def hang(diem):  # hạng 1..21 của mọi đoạn; hòa điểm thì cùng hạng
    diem = np.asarray(diem, dtype=float)
    return (diem[None, :] > diem[:, None]).sum(axis=1) + 1

model.encode("query: khởi động")  # lần gọi đầu chậm hơn, không tính giờ
t0 = time.perf_counter()
E = model.encode(["passage: " + t for _, t in CORPUS], normalize_embeddings=True)
print(TEN, E.shape, E.dtype, f"mã hóa 21 đoạn: {(time.perf_counter() - t0) * 1000:.0f} ms")

kq, ms = {"embedding": [], "RRF": []}, []
for cau, dung in CAU_HOI:
    t0 = time.perf_counter()
    q = model.encode("query: " + cau, normalize_embeddings=True)
    ms.append((time.perf_counter() - t0) * 1000)
    s = E @ q  # hai vector dài 1 nên tích vô hướng chính là cosine
    rrf = 1 / (60 + hang(s)) + 1 / (60 + hang(diem_tu_khoa(cau, bo=True)))
    i = ids.index(dung)
    kq["embedding"].append(hang_cua(s, i))
    kq["RRF"].append(hang_cua(rrf, i))
    top3 = ", ".join(f"{ids[j]} {s[j]:.3f}" for j in np.argsort(-s)[:3])
    print(f"{cau[:26]:26} | hạng {kq['embedding'][-1]:2} | {top3}")

print(f"mã hóa 1 câu hỏi: trung vị {np.median(ms):.0f} ms")
for ten, h in kq.items():
    print(f"{ten}: hạng {h}, hit@1 = {sum(x == 1 for x in h)}/8, hit@3 = {sum(x <= 3 for x in h)}/8")

for cau, dung in [("doi tra hang trong bao lau", "doi-tra-01"), ("đổi trả hàng trong bao lâu", "doi-tra-01"),
                  ("ship ve tinh mat may ngay", "giao-hang-02"), ("ship về tỉnh mất mấy ngày", "giao-hang-02")]:
    s = E @ model.encode("query: " + cau, normalize_embeddings=True)
    print(f"{cau:26} | hạng {hang_cua(s, ids.index(dung)):2} | điểm {s[ids.index(dung)]:.3f}")

Kết quả với Python 3.14.3, numpy 2.4.4:

intfloat/multilingual-e5-small (21, 384) float32 mã hóa 21 đoạn: 704 ms
doi tra hang trong bao lau | hạng 16 | doi-tra-04 0.835, bao-hanh-02 0.820, doi-tra-02 0.820
Mặc thử thấy không vừa thì | hạng  4 | doi-tra-02 0.852, doi-tra-04 0.849, doi-tra-05 0.844
Bao lâu thì tôi nhận lại t | hạng  1 | hoan-tien-01 0.891, doi-tra-01 0.859, hoan-tien-02 0.853
ship ve tinh mat may ngay  | hạng 17 | giao-hang-03 0.839, bao-hanh-02 0.836, doi-tra-01 0.830
Điện thoại rơi vỡ màn hình | hạng  1 | bao-hanh-02 0.893, bao-hanh-01 0.860, doi-tra-02 0.851
Tôi nặng 68 kg thì mặc áo  | hạng  1 | size-ao-nam 0.870, sp-02315 0.809, doi-tra-05 0.793
Son môi đã bóc seal có trả | hạng  2 | doi-tra-02 0.834, doi-tra-03 0.821, doi-tra-05 0.817
Mua bao nhiêu thì được fre | hạng  1 | giao-hang-01 0.830, diem-01 0.830, thanh-toan-02 0.827
mã hóa 1 câu hỏi: trung vị 50 ms
embedding: hạng [16, 4, 1, 17, 1, 1, 2, 1], hit@1 = 4/8, hit@3 = 5/8
RRF: hạng [8, 5, 1, 11, 1, 1, 2, 1], hit@1 = 4/8, hit@3 = 5/8
doi tra hang trong bao lau | hạng 16 | điểm 0.803
đổi trả hàng trong bao lâu | hạng  1 | điểm 0.916
ship ve tinh mat may ngay  | hạng 17 | điểm 0.813
ship về tỉnh mất mấy ngày  | hạng  1 | điểm 0.876

Câu diễn đạt lại là chỗ embedding hơn từ khóa: cỡ tìm ra bảng size, rơi vỡ màn hình chọn đúng bao-hanh-02, freeship tìm ra giao-hang-01 mà từ khóa xếp hạng 9 (dù chỉ hơn diem-01 ở chữ số thập phân thứ tư). Hai câu không dấu thì ngược lại: hạng 16 và 17, tệ hơn từ khóa bỏ dấu. Bốn dòng cuối là phép thử có kiểm soát: cùng câu đó, có dấu, đứng hạng 1.

Điểm in ra nằm giữa 0,79 và 0,92. Model card e5 giải thích: cosine của mô hình này phân bố quanh 0,7 đến 1,0 do temperature 0,01 trong hàm mất mát InfoNCE, chỉ thứ tự điểm có ý nghĩa. Đoạn sai đứng đầu câu 1 được 0,835, cao hơn đoạn đúng của câu freeship (0,830), nên ngưỡng cố định kiểu "trên 0,8 là liên quan" không tách được đúng với sai.

Về số đo thời gian

Máy thử: laptop Intel Core Ultra 5 125U (12 core, 14 luồng), 32 GB RAM, Windows 11, có lúc chạy song song tác vụ khác. Qua 6 lần chạy, mã hóa 21 đoạn mất 0,7 đến 4,3 giây (trung vị 1,29 s), trung vị một câu hỏi từ 50 đến 383 ms, thứ hạng giống hệt nhau. Thời gian chỉ đúng về bậc độ lớn: mã hóa một câu hỏi tốn hàng chục đến hàng trăm mili giây.

5. Vì sao câu không dấu trượt, và cách ghép với từ khóa

Mô hình không đọc chữ, nó đọc token. Script dưới in chuỗi token tokenizer của e5 tạo ra:

# xem_token.py: mô hình nhận chuỗi token nào từ câu có dấu, không dấu và dạng NFD
import unicodedata
from sentence_transformers import SentenceTransformer

tok = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu").tokenizer
cau = "Khách được đổi hoặc trả sản phẩm"
for s in ["đổi trả hàng trong bao lâu", "doi tra hang trong bao lau", cau, unicodedata.normalize("NFD", cau)]:
    print(" ".join(tok.tokenize(s)))
▁đổi ▁trả ▁hàng ▁trong ▁bao ▁lâu
▁do i ▁tra ▁hang ▁trong ▁bao ▁la u
▁Khách ▁được ▁đổi ▁hoặc ▁trả ▁sản ▁phẩm
▁Khách ▁đươ c ▁đôi ▁ho ạc ▁trả ▁sản ▁ph âm

Có dấu, mỗi âm tiết là một token. Không dấu, cùng câu thành 8 token khác: doi bị cắt thành do và i. Với mô hình, tra và trả là hai token riêng. Model card không nói gì về văn bản không dấu, và phép đo ở mục 4 cho thấy e5-small không tự nối hai dạng. Dòng 3 và 4 là lỗi khác, xem mục 7.

Cách thứ nhất: ghép hai bảng xếp hạng bằng Reciprocal Rank Fusion (RRF), điểm = 1/(60 + hạng embedding) + 1/(60 + hạng từ khóa bỏ dấu), hằng số 60 theo Cormack và cộng sự (2009). Đoạn hạng 4 bên này, hạng 1 bên kia được 1/64 + 1/61 ≈ 0,03202. Đoạn hạng 1 và hạng 21 chỉ được 1/61 + 1/81 ≈ 0,02874. Đoạn được cả hai cách xếp cao thắng.

Cách thứ hai: mô hình lớn hơn, intfloat/multilingual-e5-base (768 chiều, commit d128750597153bb5987e10b1c3493a34e5a4502a, khoảng 1.082 MiB), chạy bằng python ngu_nghia.py intfloat/multilingual-e5-base. Tổng hợp trên cùng 8 câu:

Cách tìm hit@1 hit@3 Hạng câu 1 và câu 4 (không dấu)
Từ khóa, giữ dấu 2/8 4/8 4 và 21
Từ khóa, bỏ dấu 3/8 6/8 1 và 3
e5-small 4/8 5/8 16 và 17
e5-small + từ khóa bỏ dấu (RRF) 4/8 5/8 8 và 11
e5-base 5/8 5/8 4 và 4
e5-base + từ khóa bỏ dấu (RRF) 5/8 7/8 2 và 1

RRF kéo câu không dấu lên nhưng với e5-small chưa vào top 3. e5-base cộng RRF đưa đoạn đúng vào top 3 ở 7/8 câu; đổi lại vector dài gấp đôi và mô hình nặng gấp hơn hai lần trên đĩa. Câu 2 trượt ở mọi cách vì ba đoạn đổi trả khác cũng nói về trả hàng; câu này có lẽ có nhiều hơn một đoạn đúng. Cách thứ ba, bài này không đo: phát hiện câu không dấu (cau == bo_dau(cau)) rồi khôi phục dấu trước khi embed, chẳng hạn nhờ mô hình ngôn ngữ viết lại câu hỏi.

6. Quét toàn bộ bằng numpy, và khi nào cần ANN

BanHang có 5.000 sản phẩm cộng các bộ chính sách, cắt nhỏ có thể đến vài chục nghìn đoạn (ước lượng). Script dưới đo thời gian lấy top-3 bằng cách quét hết N vector ngẫu nhiên 384 chiều đã chuẩn hóa; khối lượng tính không phụ thuộc giá trị vector.

# brute_force.py: quét toàn bộ N vector float32 đã chuẩn hóa, lấy top-3
import time
import numpy as np

D = 384
rng = np.random.default_rng(42)

for N in (10_000, 100_000):
    X = rng.standard_normal((N, D), dtype=np.float32)
    X /= np.linalg.norm(X, axis=1, keepdims=True)
    q = rng.standard_normal(D, dtype=np.float32)
    q /= np.linalg.norm(q)

    ms = []
    for _ in range(200):
        t0 = time.perf_counter()
        s = X @ q                                  # cosine = tích vô hướng
        top = np.argpartition(-s, 3)[:3]           # 3 điểm cao nhất, chưa xếp
        top = top[np.argsort(-s[top])]
        ms.append((time.perf_counter() - t0) * 1000)

    print(f"N = {N:>7,} | {X.nbytes / 2**20:6.1f} MiB | "
          f"trung vị {np.median(ms):5.2f} ms | p95 {np.percentile(ms, 95):5.2f} ms | top-3 {top}")

Python 3.14.3, numpy 2.4.4 (OpenBLAS 0.3.31), cùng laptop. Qua bốn lần chạy, trung vị là 0,94 đến 1,43 ms với 10.000 đoạn, 9,6 đến 11,6 ms với 100.000 đoạn, p95 của 100.000 đoạn từ 12,3 đến 15,3 ms. Một lần chạy:

N =  10,000 |   14.6 MiB | trung vị  0.94 ms | p95  1.37 ms | top-3 [9117 5671 5624]
N = 100,000 |  146.5 MiB | trung vị  9.62 ms | p95 12.26 ms | top-3 [99553 31092 95355]

Mỗi câu hỏi đọc toàn bộ ma trận một lần: 100.000 × 384 × 4 byte = 153,6 MB, tức 146,5 MiB. Nhân ma trận với vector chỉ làm 2 phép tính trên mỗi 4 byte đọc vào, nên tốc độ đọc bộ nhớ quyết định thời gian, ở đây khoảng 16 GB/s (153,6 MB trong 9,62 ms). Thời gian tăng tuyến tính theo N × d. Với BanHang, 20.000 đoạn × 384 chiều là 29,3 MiB, quét mất khoảng 2 ms (ước lượng). Mã hóa câu hỏi trên cùng máy tốn 50 đến 383 ms, nên phép quét chưa phải chỗ cần tối ưu.

Chỉ mục ANN (approximate nearest neighbor) đổi độ chính xác lấy tốc độ. HNSW (Malkov và Yashunin) dựng đồ thị nhiều tầng nối các vector gần nhau; lúc tìm nó đi theo cạnh đồ thị thay vì đọc mọi vector, nên có thể bỏ sót láng giềng gần nhất thật. README của pgvector ghi: không có chỉ mục thì tìm chính xác, recall hoàn hảo; thêm chỉ mục thì đổi một phần recall lấy tốc độ; HNSW cân bằng tốc độ và recall tốt hơn IVFFlat nhưng build chậm hơn, tốn bộ nhớ hơn. Chuyển sang ANN khi p95 của phép quét vượt ngân sách độ trễ ở N thực tế, hoặc ma trận không còn vừa RAM. Trước khi bật, đo recall của chỉ mục trên bộ câu hỏi của mình, lấy kết quả quét toàn bộ làm đáp án chuẩn.

7. Ghi chú khi đưa vào BanHang

Kích thước đoạn. e5-small đọc tối đa 512 token, phần sau bị bỏ mà kết quả vẫn trả về bình thường: ghép 21 đoạn được 584 token, nhân đôi chuỗi rồi thêm một câu vào cuối, cosine với chuỗi chưa thêm vẫn là 1,0000. paraphrase-multilingual-MiniLM-L12-v2 chỉ đọc 128 token theo model card. e5 lấy trung bình vector mọi token (pooling_mode_mean_tokens), nên đoạn trộn đổi trả với bảo hành cho vector nằm giữa hai chủ đề. Mỗi đoạn một ý. Cắt tài liệu dài thì cho hai đoạn liền nhau chồng lên nhau một hai câu, để quy định vắt qua ranh giới còn nguyên trong ít nhất một đoạn. Kích thước và độ chồng lấn chọn bằng cách đo trên bộ câu hỏi.

Chuẩn hóa vector. Truyền normalize_embeddings=True ở cả hai phía; mặc định là False. e5-small có sẵn bước Normalize trong modules.json, mô hình khác có thể không. Quên chuẩn hóa thì tích vô hướng ưu tiên vector dài, không còn là cosine.

Đưa văn bản về NFC trước khi embed

Dòng 3 và 4 ở mục 5 là cùng một câu, khác dạng Unicode. Chữ tiếng Việt có thể ở dạng dựng sẵn (NFC, ổ là một code point) hoặc tổ hợp (NFD, o cộng dấu mũ cộng dấu hỏi). Với NFD, tokenizer của e5 làm rơi dấu: đổi thành đôi, phẩm thành ph và âm. Câu đổi trả hàng trong bao lâu ở dạng NFC đứng hạng 1 (0,916), dạng NFD hạng 2 (0,884). Gọi unicodedata.normalize("NFC", s) cho cả tài liệu lẫn câu hỏi.

Lưu mô hình cạnh vector. Vector của hai mô hình không so được với nhau, kể cả khi cùng số chiều: e5-small và MiniLM đều 384 chiều, nên kiểm tra số chiều không bắt được lỗi trộn. Mỗi bộ vector lưu kèm tên mô hình, commit, phiên bản sentence-transformers và torch, cờ chuẩn hóa, tiền tố, dạng Unicode, ngày embed. Nạp mô hình với revision= bằng commit đầy đủ; model card e5 ghi khác phiên bản transformers và pytorch có thể làm kết quả lệch rất nhỏ. Đổi mô hình hoặc commit thì embed lại toàn bộ vào bảng mới rồi chuyển sang một lần. Lấy trung vị 1,29 s cho 21 đoạn trên laptop thử, 20.000 đoạn mất 20.000 × 1,29 / 21 ≈ 1.229 s, khoảng 20 phút (ước lượng).

Tách từ. e5 dùng XLMRobertaTokenizer (SentencePiece) trên văn bản thô, như dòng 1 ở mục 5, nên không cần tách từ trước. Không phải mô hình nào cũng vậy: model card của bkai-foundation-models/vietnamese-bi-encoder, xây trên PhoBERT, yêu cầu đầu vào tách từ sẵn (vui_tính) bằng pyvi, underthesea hoặc RDRSegmenter.

Những chỗ hay hiểu sai

  • "Mô hình đa ngôn ngữ hiểu tiếng Việt không dấu như có dấu." Với e5-small, cùng một câu đứng hạng 1 khi có dấu và hạng 16 khi không dấu.
  • "Cosine trên 0,8 là liên quan." Điểm của e5 dồn trong 0,7 đến 1,0; ở đây một đoạn sai được 0,835, một đoạn đúng 0,830. Chỉ thứ tự có nghĩa.
  • "Bỏ dấu cả hai phía là xử lý xong câu không dấu." Nó gộp mấy với máy, bảo với bao, và đẩy đoạn sai lên đầu.
  • "Tìm kiếm vector cần chỉ mục ANN ngay từ đầu." Quét hết 100.000 vector 384 chiều bằng numpy mất khoảng 10 ms.

Đọc tiếp

Nguồn

Đọc tiếp

Trong SQL Server

Kiểu dữ liệu, collation và khóa chính

Chọn kiểu cho tiền, ngày giờ, chữ tiếng Việt và khóa chính của BanHang dựa trên số byte trên page, cách engine so sánh giá trị, và lỗi mà mỗi lựa chọn sai gây ra.

42 phút đọc