Ô tìm kiếm của hầu hết website vẫn là khớp từ khoá: khách gõ "bỏ đơn", hệ thống không thấy chữ "bỏ" trong bài "Cách huỷ đơn hàng" nên trả về trang trống. Semantic search (tìm kiếm theo nghĩa) giải quyết đúng chỗ này: văn bản được biến thành vector sao cho câu cùng nghĩa nằm gần nhau, rồi tìm theo khoảng cách. Bài này dựng một bộ 48 mục FAQ của cửa hàng online và 24 câu hỏi cố ý diễn đạt khác từ vựng, đo BM25 với hai mô hình embedding, đo tiền tố query:/passage:, thử cross-encoder để sắp xếp lại, rồi so các loại chỉ mục FAISS (Flat, IVF, HNSW, IVF-PQ) trên 200.000 vector.

Bài nối tiếp Chạy LLM local với Ollama và llama.cpp và Word embedding: Word2Vec, FastText và biểu diễn nghĩa của từ, dùng cách đo ở Đánh giá mô hình phân loại. Môi trường: Apple M4, 16 GB, Python 3.12, sentence-transformers 3.3.1, torch 2.5.1, faiss-cpu 1.9.0.post1, transformers 4.46.3. Mọi con số là output thật; mã nằm trong code/s7-35.

Vì sao tìm theo từ khoá chưa đủ

Trước khi có semantic search, tìm theo từ khoá (TF-IDF, BM25) so sánh các từ xuất hiện chung. Nó nhanh, giải thích được, và rất mạnh khi người dùng gõ đúng thuật ngữ như mã sản phẩm hay tên riêng. Nhưng tiếng Việt đời thường đầy từ đồng nghĩa và cách nói khác nhau: "ship" thay "vận chuyển", "pass" thay "mật khẩu", "hoá đơn đỏ" thay "hoá đơn VAT", "bỏ đơn" thay "huỷ đơn". Khớp từ khoá bỏ sót chính những câu này, và đó là khoảng trống mà semantic search lấp.

Semantic search đảo cách nhìn: một mô hình embedding học trên lượng văn bản lớn ánh xạ mỗi câu thành một vector, thường vài trăm chiều. Hai câu cùng nghĩa cho hai vector gần nhau theo độ đo cosine, dù không chung từ nào. Bài báo E5 (Wang và cộng sự, 12/2022) là một ví dụ: mô hình huấn luyện đối chiếu (contrastive) trên cặp văn bản yếu giám sát, và theo tác giả là mô hình đầu tiên vượt BM25 trên BEIR ở chế độ zero-shot. Nhưng chữ "vượt" ở đây là trung bình trên nhiều bộ dữ liệu tiếng Anh; trên dữ liệu tiếng Việt của bạn, kết quả phải tự đo, như phần dưới.

Semantic search hoạt động thế nào: từ văn bản tới vector

Quy trình semantic search có hai pha. Pha lập chỉ mục: nhúng từng tài liệu thành vector, chuẩn hoá về độ dài 1, lưu vào cấu trúc tìm kiếm. Pha truy vấn: nhúng câu hỏi bằng cùng mô hình, tìm k vector gần nhất, trả về tài liệu tương ứng. Với semantic search, tài liệu của Sentence Transformers phân biệt tìm kiếm đối xứng (truy vấn và tài liệu cùng độ dài, như tìm câu hỏi trùng lặp) với bất đối xứng (truy vấn ngắn, tài liệu là đoạn dài hơn, đúng ca FAQ này) và nhấn mạnh phải chọn mô hình đúng loại tác vụ.

Về toán của semantic search, nếu mọi vector đã chuẩn hoá L2 thì cosine bằng tích vô hướng (inner product), và thứ tự theo khoảng cách L2 cũng giống thứ tự theo cosine. Đoạn đầu của faiss_index.py kiểm tra điều này trên 1.000 vector ngẫu nhiên:

cosine == IP: True | cosine == L2 (chuẩn hoá): True

Hệ quả thực tế cho semantic search: luôn truyền normalize_embeddings=True khi nhúng, rồi dùng chỉ mục tích vô hướng (IndexFlatIP). Quên chuẩn hoá là lỗi phổ biến nhất của semantic search, vì khi đó "tích vô hướng" phụ thuộc cả độ dài vector chứ không chỉ hướng.

Dựng semantic search tối thiểu bằng sentence-transformers và FAISS

Mô hình dùng cho semantic search ở đây là intfloat/multilingual-e5-small, cùng họ với bài Multilingual E5 Text Embeddings (2/2024): ba cỡ small/base/large, huấn luyện hai giai đoạn (tiền huấn luyện đối chiếu trên khoảng 1 tỉ cặp văn bản đa ngôn ngữ, rồi tinh chỉnh trên dữ liệu có nhãn). Bản small cho vector 384 chiều và đủ nhẹ để chạy trên CPU. Dữ liệu mẫu trong faq_data.py là 48 mục FAQ viết tay (trung bình khoảng 20 từ) về đổi trả, vận chuyển, thanh toán, bảo hành, sản phẩm; không có dữ liệu khách hàng thật. Đoạn semantic search tối thiểu (demo_search.py):

from sentence_transformers import SentenceTransformer
import numpy as np, faiss
from faq_data import DOCS

model = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
D = model.encode(["passage: " + d for d in DOCS], normalize_embeddings=True)   # (48, 384)
index = faiss.IndexIDMap2(faiss.IndexFlatIP(D.shape[1]))                      # IP trên vector chuẩn hoá = cosine
index.add_with_ids(D, np.arange(len(DOCS)).astype("int64"))
faiss.write_index(index, "faq.faiss"); index = faiss.read_index("faq.faiss")

q = model.encode(["query: Máy rớt xuống đất bể màn hình có được bảo hành không?"], normalize_embeddings=True)
scores, ids = index.search(q, 3)

Output thật của semantic search với ba câu hỏi, mỗi câu dùng từ khác hẳn mục FAQ:

chỉ mục: 48 vector, d=384

Hỏi: Mình ngực 92 cm nên mặc cỡ nào?   (8.1 ms gồm cả nhúng câu hỏi)
  0.867  [18] Cách chọn size áo: đo vòng ngực và đối chiếu với bảng size, nếu ở giữa hai siz
  0.820  [27] Laptop văn phòng 14 inch nặng 1,3 kg, phù hợp mang đi học và đi làm hằng ngày.
  0.820  [21] Áo thun cotton có thể co nhẹ sau lần giặt đầu, nên chọn rộng hơn một chút nếu 

Hỏi: Máy rớt xuống đất bể màn hình có được bảo hành không?   (6.3 ms gồm cả nhúng câu hỏi)
  0.878  [16] Bảo hành không áp dụng cho máy rơi vỡ, vào nước hoặc đã bị can thiệp phần cứng
  0.842  [17] Pin laptop được bảo hành 6 tháng, các linh kiện còn lại bảo hành 24 tháng.
  0.839  [15] Bảo hành điện thoại chính hãng 12 tháng, lỗi do nhà sản xuất được đổi mới tron

Hỏi: Làm việc dựng phim 4K cần bao nhiêu bộ nhớ?   (6.1 ms gồm cả nhúng câu hỏi)
  0.892  [30] RAM 16 GB đủ cho lập trình và chỉnh sửa ảnh, nên chọn 32 GB nếu dựng video 4K.
  0.825  [28] Máy tính xách tay chơi game cần card đồ hoạ rời và quạt tản nhiệt lớn nên nặng
  0.824  [25] Điện thoại hỗ trợ hai SIM nano và một khe thẻ nhớ microSD tối đa 1 TB.

"Bể màn hình" khớp với "rơi vỡ", "bộ nhớ" khớp với "RAM", "ngực 92 cm" khớp với "vòng ngực": đúng thứ BM25 không làm được. Nhưng nhìn kỹ điểm số: kết quả đúng đạt 0,87–0,89, còn các kết quả sai vẫn 0,82–0,84. Điểm cosine của họ E5 dồn vào dải hẹp (câu hỏi thường gặp trong thẻ mô hình ghi điểm cosine phân bố quanh 0,7 đến 1,0 do dùng nhiệt độ thấp 0,01 khi huấn luyện, và khuyên chỉ so thứ tự tương đối của điểm), nên không thể đặt ngưỡng "trên 0,8 là liên quan" cho mô hình này. Câu hỏi thứ tư tôi thử là một câu không có đáp án trong FAQ:

Hỏi: Nhà tôi có nuôi mèo, có nên mua máy hút bụi?   (6.2 ms gồm cả nhúng câu hỏi)
  0.833  [20] Sản phẩm giặt máy ở chế độ nhẹ, không dùng chất tẩy mạnh và không sấy ở nhiệt 
  0.827  [39] Để đánh giá sản phẩm, bạn cần đăng nhập và đã mua sản phẩm đó trong vòng 6 thá
  0.821  [44] Dịch vụ lắp đặt tại nhà áp dụng cho máy lạnh và tivi trên 55 inch, phí từ 300.

Kết quả hạng 1 đạt 0,833, gần bằng điểm của kết quả sai hạng 2 ở câu "bể màn hình" (0,842), dù nội dung hoàn toàn không liên quan. Một hệ thống semantic search thật cần cách xử lý "không có đáp án": ngưỡng hiệu chỉnh theo dữ liệu riêng, hoặc bước kiểm tra thứ hai như cross-encoder ở mục sau.

Đo semantic search: BM25 so với embedding trên 24 câu hỏi

Muốn biết semantic search có đáng không, phải đo. search_eval.py chấm 24 câu hỏi, mỗi câu có đúng một mục FAQ là đáp án, bằng ba chỉ số: Recall@1 (đáp án ở hạng 1), Recall@3, và MRR (trung bình nghịch đảo hạng của đáp án đúng). BM25 tự cài đặt, tách theo âm tiết. Nhãn đáp án tôi gán tay; lần chạy đầu cho kết quả thấp bất thường vì nhãn của năm câu bị lệch một vị trí, tôi sửa nhãn rồi chạy lại toàn bộ. Lỗi dữ liệu đánh giá dễ xảy ra hơn lỗi mô hình.

phương pháp                             R@1    R@3    MRR
BM25 (từ khoá)                         0.58   0.71  0.677
e5-small, có tiền tố                   0.62   0.83  0.759
e5-small, thiếu tiền tố                0.67   0.92  0.798
nomic-embed-text, có tiền tố           0.38   0.50  0.490
nomic-embed-text, thiếu tiền tố        0.33   0.46  0.444

Cần đọc từng dòng một cách thận trọng, vì chỉ có 24 câu hỏi (mỗi câu đổi 4,2 điểm phần trăm):

  • e5-small hơn BM25 ở mọi chỉ số (R@3 0,83 so với 0,71, tức 20 câu so với 17 câu trong top 3), nhưng khoảng cách ở R@1 chỉ là 15 câu so với 14 câu. BM25 không tệ, vì câu hỏi tuy diễn đạt khác vẫn chia sẻ nhiều từ. Semantic search thắng rõ nhất ở top 3, tức khi bạn đưa vài ứng viên cho bước sau.
  • Tiền tố query:/passage:: thẻ mô hình E5 viết rõ "this is how the model is trained, otherwise you will see a performance degradation" và khuyên dùng cặp tiền tố này cho truy hồi bất đối xứng. Trên bộ này, chạy không tiền tố lại nhỉnh hơn (R@3 0,92 so với 0,83), nhưng chênh lệch là 2 câu trong 24; không đủ để kết luận bỏ tiền tố. Tôi vẫn dùng tiền tố cho cấu hình chính và chỉ nêu đây là điều cần đo lại trên dữ liệu của bạn.
  • nomic-embed-text yếu hẳn (R@1 0,38, thua cả BM25). Lý do dễ thấy: bài báo Nomic Embed mô tả đây là mô hình embedding tiếng Anh (ngữ cảnh 8192 token), huấn luyện cho tiếng Anh. Mô hình này chạy được qua Ollama nên tiện, nhưng embedding tiếng Anh không phải lựa chọn cho tiếng Việt. Đây là thí nghiệm để tránh sai lầm đó.

Danh sách câu sai của e5-small cho thấy mô hình sai ở đâu: "Ship có mất tiền không nếu đặt đơn lớn?" (đáp án là mục phí vận chuyển) bị kéo sang mục đơn đặt trước; "Tôi không nhớ pass tài khoản nữa" (đáp án là quên mật khẩu) bị kéo sang mục bảo mật dữ liệu; "Có quẹt thẻ trả dần từng tháng" bị kéo sang mục quà tặng. Đó là tiếng lóng và cách nói tắt ("ship", "pass", "quẹt thẻ") mà dữ liệu huấn luyện tiếng Việt có ít. Giải pháp thực tế cho semantic search tiếng Việt: bổ sung từ đồng nghĩa vào chính mục FAQ, hoặc thêm câu hỏi mẫu cho từng mục rồi nhúng cả chúng.

Cải thiện semantic search: thêm mô hình và sắp xếp lại bằng cross-encoder

Hai điều đáng thử trước khi tăng cỡ mô hình trong semantic search. Thứ nhất, một mô hình embedding đa ngôn ngữ khác cùng 384 chiều. Thứ hai, sắp xếp lại (rerank) top-5 bằng cross-encoder: thay vì nhúng câu hỏi và tài liệu riêng rồi so vector (bi-encoder), cross-encoder đọc cặp (câu hỏi, tài liệu) cùng lúc và cho điểm liên quan, chính xác hơn nhưng chậm hơn nhiều vì phải chạy cho từng cặp. Mô hình dùng là cross-encoder/mmarco-mMiniLMv2-L12-H384-v1, huấn luyện trên MS MARCO bản dịch đa ngôn ngữ. Kết quả từ models_rerank.py:

mô hình                                      chiều   R@1   R@3   R@5    MRR  ms/câu
multilingual-e5-small                          384  0.62  0.83  0.92  0.759     4.5
paraphrase-multilingual-MiniLM-L12-v2          384  0.62  0.75  0.75  0.713     4.6

Rerank top-5 của e5-small bằng cross-encoder mmarco-mMiniLMv2-L12-H384-v1:
  trước rerank  R@1=0.62 R@3=0.83 MRR=0.759
  sau rerank    R@1=0.75 R@3=0.92 MRR=0.838   (+46 ms/câu cho 5 cặp)
  câu sửa đúng nhờ rerank: 5, câu bị làm sai: 2

Hai điều rút ra cho semantic search. Mô hình paraphrase-multilingual-MiniLM-L12-v2 (huấn luyện cho tìm câu tương đương, tức bài toán đối xứng) kém e5-small ở R@3 và R@5 (0,75 so với 0,83 và 0,92) trong ca bất đối xứng này, đúng như tài liệu Sentence Transformers cảnh báo về việc chọn mô hình theo tác vụ. Còn rerank nâng R@1 từ 0,62 lên 0,75: sửa đúng 5 câu nhưng làm sai 2 câu, tức lợi ròng 3 câu trên 24. Con số này đủ để cân nhắc nhưng chưa đủ để chắc chắn; chi phí là thêm khoảng 46 ms mỗi câu (năm cặp, đo trên CPU), gấp 10 lần thời gian nhúng. Rerank luôn là đánh đổi độ trễ lấy độ chính xác, và chỉ áp dụng cho top-k nhỏ.

FAISS cho semantic search: chọn chỉ mục Flat, IVF, HNSW hay PQ

Với semantic search trên 48 mục, vét cạn (Flat) là đủ và là lựa chọn duy nhất cho kết quả chính xác tuyệt đối. Câu hỏi thật là khi nào cần chỉ mục xấp xỉ (ANN). Hướng dẫn chọn chỉ mục của FAISS tóm tắt: Flat cho kết quả chính xác và không cần huấn luyện; HNSW rất nhanh và chính xác nhưng tốn RAM, bộ nhớ mỗi vector là d*4 + M*2*4 byte, không xoá được vector; IVF chia dữ liệu thành các nhóm, cần dữ liệu huấn luyện cỡ 30 đến 256 lần số nhóm; PQ nén vector xuống còn M byte khi bộ nhớ là ưu tiên. HNSW dựa trên bài báo Malkov và Yashunin: đồ thị nhiều tầng, tác giả nêu độ phức tạp tìm kiếm tăng theo hàm logarit.

Để thấy khác biệt bằng số, faiss_index.py sinh 200.000 vector tổng hợp 384 chiều có 400 cụm (dữ liệu tổng hợp, không phải văn bản thật) và so bốn loại chỉ mục với Flat làm chuẩn. Recall@10 là tỉ lệ 10 kết quả của chỉ mục xấp xỉ trùng với 10 kết quả đúng của Flat:

dữ liệu: 200,000 vector x 384 chiều = 293 MiB float32; 500 truy vấn, k=10
chỉ mục                       build s     MiB  ms/truy vấn  recall@10
Flat (vét cạn)                    0.0     293         0.17      1.000
IVF nlist=512 nprobe=1            0.9     295         0.03      0.940
IVF nlist=512 nprobe=8            0.9     295         0.08      1.000
IVF nlist=512 nprobe=32           0.9     295         0.31      1.000
HNSW M=32 efSearch=16            27.4     345         0.06      0.889
HNSW M=32 efSearch=64            27.4     345         0.13      0.997
HNSW M=32 efSearch=128           27.4     345         0.14      1.000
IVF-PQ m=48 nprobe=32            15.6      12         0.14      0.196

nạp lại IVF từ đĩa cho cùng kết quả: True

Phân tích có kiểm chứng:

  • Ở 200.000 vector, Flat đã chỉ mất 0,17 ms mỗi truy vấn. Con số này đo theo lô 500 truy vấn một luồng (FAISS dùng phép nhân ma trận cho lô), nên truy vấn đơn lẻ sẽ chậm hơn; tôi chưa đo trường hợp đó. Kết luận chắc chắn: với vài trăm nghìn vector, ANN chưa cần thiết, và Flat đơn giản, đúng tuyệt đối.
  • IVF gần như miễn phí về bộ nhớ (295 MiB so với 293) và build dưới 1 giây; tăng nprobe từ 1 lên 8 đưa recall từ 0,94 lên 1,00. Lưu ý nprobe=32 chậm hơn Flat trên dữ liệu này (0,31 ms so với 0,17 ms): quét nhiều nhóm mà vẫn tính trên một luồng thì mất lợi thế.
  • HNSW nhanh nhưng build mất 27,4 giây (gấp 30 lần IVF) và tốn thêm 18% bộ nhớ (345 MiB). Số đo khớp công thức của FAISS: 384*4 + 32*2*4 = 1.792 byte mỗi vector, nhân 200.000 là khoảng 342 MiB. efSearch là núm vặn recall: 16 cho 0,889, 64 cho 0,997.
  • IVF-PQ nén 293 MiB xuống 12 MiB (24 lần) nhưng recall chỉ 0,196. Cần diễn giải đúng: dữ liệu tổng hợp trộn nhiễu Gauss khó nén, và tôi chưa bật bước sắp xếp lại bằng vector gốc mà hướng dẫn FAISS khuyên dùng cùng PQ. Tôi không khẳng định PQ cho recall này trên embedding văn bản thật, chỉ nêu rằng PQ trả giá bằng độ chính xác và cần đo trên đúng dữ liệu của bạn.

Dòng cuối kiểm tra việc lưu rồi nạp lại bằng write_index/read_index cho đúng cùng kết quả, điều kiện cần để chỉ mục sống qua lần khởi động lại dịch vụ. Với semantic search quy mô lớn, hướng dẫn FAISS cũng khuyến nghị số nhóm IVF trong khoảng 4 đến 16 lần căn bậc hai số vector khi dưới một triệu; với 200.000 vector là khoảng 1.800 đến 7.200, còn tôi dùng 512 để build nhanh, nên số liệu IVF ở đây không phải cấu hình tối ưu. Vận hành ở quy mô lớn còn có lọc metadata, cập nhật và nhân bản, sẽ nằm ở bài sau.

Chi phí và giới hạn tài nguyên của semantic search

Chi phí semantic search ở quy mô này rất nhỏ: nhúng 48 mục mất 0,64 giây (tải mô hình đã có cache khoảng 0,5 giây), nhúng một câu hỏi vài mili giây, tiến trình đánh giá dùng khoảng 0,8 GB RAM và thí nghiệm FAISS lớn nhất dùng khoảng 1,8 GB theo /usr/bin/time -l. Mô hình e5-small có khoảng 117,7 triệu tham số (đếm bằng sum(p.numel())), phần lớn là bảng embedding từ vựng đa ngôn ngữ, nên chạy tốt trên CPU. Thẻ mô hình cũng ghi văn bản dài hơn 512 token bị cắt.

Một lỗi môi trường đáng ghi lại, vì sẽ gặp trên macOS: nạp sentence-transformers (PyTorch) và faiss trong cùng một tiến trình làm chương trình thoát với mã 139 (segmentation fault) ở lần chạy đầu của tôi, nhiều khả năng do hai thư viện cùng nạp OpenMP runtime. Đặt OMP_NUM_THREADS=1 khi chạy khắc phục được, và đó cũng là cách tôi chạy mọi script ở đây. Biến KMP_DUPLICATE_LIB_OK=TRUE mà thông báo lỗi gợi ý được chính thông báo đó gọi là không an toàn, nên tôi không dùng.

Khi nào không nên dùng semantic search

  • Truy vấn là mã, tên riêng, số hiệu (mã đơn HD-2024-000417, SKU): semantic search làm mờ chính xác ký tự vì embedding gom các chuỗi gần giống; BM25 hoặc tìm chính xác tốt hơn. Thực tế thường kết hợp cả hai (tìm lai) rồi gộp xếp hạng.
  • Cần giải thích vì sao ra kết quả: semantic search không chỉ ra được từ trùng, còn khớp từ khoá chỉ ra được từ nào trùng; khoảng cách vector thì không.
  • Kho dữ liệu nhỏ và từ vựng đồng nhất: BM25 đạt R@1 0,58 so với 0,62 ở trên, chênh nhau một câu; không đáng đánh đổi độ phức tạp nếu đo trên dữ liệu của bạn cũng cho kết quả như vậy.
  • Dữ liệu nhạy cảm: coi kho vector như dữ liệu gốc về phân quyền và lưu trữ, vì vector vẫn mang thông tin về nội dung.

Lỗi thường gặp

  1. Quên chuẩn hoá vector trong semantic search rồi dùng tích vô hướng, làm xếp hạng phụ thuộc độ dài vector.
  2. Dùng mô hình tiếng Anh cho semantic search tiếng Việt: nomic-embed-text trong bài thua cả BM25.
  3. Đặt ngưỡng điểm cố định mà không hiệu chỉnh: điểm cosine của E5 dồn trong dải hẹp, câu không liên quan vẫn đạt trên 0,8.
  4. Dùng ANN quá sớm cho semantic search: ở 200.000 vector Flat vẫn nhanh; HNSW chỉ thêm 27 giây build và 18% bộ nhớ.
  5. Đánh giá semantic search bằng vài ví dụ nhìn thấy đẹp thay vì bộ câu hỏi có đáp án; và quên soát lại nhãn đáp án.
  6. Kết luận từ chênh lệch một hai câu khi bộ thử chỉ có 24 câu.

Checklist đưa semantic search vào sản phẩm

  • Gom vài chục đến vài trăm câu hỏi thật (đã ẩn danh) kèm đáp án đúng; đo Recall@k và MRR, so với BM25 làm mốc.
  • Chọn mô hình cho semantic search theo ngôn ngữ và loại tác vụ; kiểm tra tiền tố bắt buộc của mô hình.
  • Chuẩn hoá vector, dùng IndexFlatIP cho đến khi dữ liệu và độ trễ buộc phải đổi.
  • Cân nhắc tìm lai (BM25 cộng embedding) và rerank top-k nhỏ; đo độ trễ cộng thêm.
  • Có đường xử lý "không có đáp án" cho semantic search và theo dõi các câu hỏi thất bại để bổ sung dữ liệu.
  • Lưu chỉ mục semantic search ra đĩa, ghi phiên bản mô hình cùng chỉ mục; đổi mô hình là phải nhúng lại toàn bộ.

Mã semantic search nằm trong code/s7-35: demo_search.py, search_eval.py, models_rerank.py, faiss_index.py cùng các tệp *_output.txt và requirements.txt. Mô hình tự tải từ Hugging Face lần đầu; chạy các script với OMP_NUM_THREADS=1.

Bài tiếp theo

Chọn vector database: FAISS, pgvector, Qdrant và chỉ mục HNSW. FAISS là thư viện, không phải cơ sở dữ liệu: bài sau so sánh nó với pgvector và Qdrant về lọc theo metadata, cập nhật, nhân bản, và chỉ mục HNSW chạy ra sao khi dữ liệu thay đổi liên tục. Nếu muốn ôn cách trình bày yêu cầu cho mô hình trước khi ghép với kết quả tìm kiếm, xem Prompt engineering có hệ thống.