Embedding và tìm kiếm ngữ nghĩa bằng Python

Khách gõ “trả hàng lấy lại tiền”, nhưng trang trợ giúp của bạn ghi “chính sách hoàn tiền”. Tìm theo từ khóa sẽ trượt vì hai câu gần như không chung chữ nào. Embedding giải quyết chuyện này bằng cách biến mỗi đoạn văn thành một vector số. Hai câu cùng nghĩa sẽ cho ra hai vector nằm gần nhau, kể cả khi dùng chữ khác hẳn nhau.

Cài đặt

pip install sentence-transformers

Thư viện sentence-transformers chạy mô hình ngay trên máy bạn, không tốn phí API. Mô hình paraphrase-multilingual-MiniLM-L12-v2 nhỏ, hiểu tiếng Việt, rất hợp để bắt đầu. Lần chạy đầu thư viện sẽ tải mô hình về, mất khoảng vài trăm MB.

Biến câu thành vector

import numpy as np
from sentence_transformers import SentenceTransformer

mo_hinh = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

cau = [
    "Làm sao để được hoàn tiền?",
    "Tôi muốn trả hàng và lấy lại tiền",
    "Cửa hàng mở cửa mấy giờ?",
]
vec = mo_hinh.encode(cau, normalize_embeddings=True)
print(vec.shape)               # (3, 384): 3 câu, mỗi câu 384 số
print(np.round(vec @ vec.T, 2))

Khi đã chuẩn hóa (normalize_embeddings=True), tích vô hướng của hai vector chính là độ tương đồng cosine. Giá trị càng gần 1 thì hai câu càng giống nghĩa. Trong ma trận in ra, ô của cặp câu thứ nhất và thứ hai sẽ cao hơn hẳn các cặp có câu hỏi giờ mở cửa.

Tìm kiếm ngữ nghĩa trong vài dòng

tai_lieu = [
    "Chính sách hoàn tiền: đổi trả trong 7 ngày nếu sản phẩm lỗi.",
    "Giờ làm việc: 8h đến 21h tất cả các ngày trong tuần.",
    "Phí giao hàng nội thành 20.000 đồng, miễn phí cho đơn từ 500.000 đồng.",
    "Thanh toán bằng chuyển khoản, ví điện tử hoặc tiền mặt khi nhận hàng.",
]
vec_tai_lieu = mo_hinh.encode(tai_lieu, normalize_embeddings=True)

def tim_kiem(cau_hoi, k=2):
    v = mo_hinh.encode([cau_hoi], normalize_embeddings=True)[0]
    diem = vec_tai_lieu @ v
    thu_tu = np.argsort(-diem)[:k]
    return [(tai_lieu[i], round(float(diem[i]), 2)) for i in thu_tu]

print(tim_kiem("ship về Hà Nội mất bao nhiêu tiền?"))

Câu hỏi không có chữ “phí” hay “giao hàng” nào, vậy mà đoạn về phí giao hàng vẫn lên đầu. Với mô hình nhỏ này, điểm thường chỉ ở khoảng 0,3 và các đoạn có khi chỉ hơn nhau vài phần trăm. Hãy nhìn vào thứ tự xếp hạng thay vì giá trị tuyệt đối, và đừng đặt ngưỡng cứng kiểu “trên 0,8 mới tính” khi chưa đo trên dữ liệu thật của mình.

Lưu ý khi đưa vào dự án thật

  • Tính một lần, dùng nhiều lần. Encode tài liệu tốn thời gian, nên hãy lưu vector lại bằng np.save("vec.npy", vec_tai_lieu) thay vì tính lại mỗi lần có câu hỏi.
  • Dùng cùng một mô hình cho tài liệu và câu hỏi. Đổi mô hình thì phải encode lại toàn bộ, vì vector của hai mô hình khác nhau không so sánh được với nhau.
  • Đoạn dài sẽ bị cắt. print(mo_hinh.max_seq_length) cho biết mô hình đọc tối đa bao nhiêu token. Với mô hình này con số là 128 token, tức chừng một đoạn văn ngắn. Tài liệu dài cần chia thành đoạn nhỏ trước khi encode.
  • Vài chục nghìn đoạn thì NumPy vẫn chạy tốt. Lớn hơn nữa thì nên chuyển sang cơ sở dữ liệu vector như pgvector, Qdrant hoặc FAISS.

Bài tiếp theo sẽ ghép phần tìm kiếm này với một mô hình ngôn ngữ để làm hệ thống hỏi đáp trên tài liệu riêng (RAG).

Lên đầu trang