Trong các hệ thống Retrieval-Augmented Generation, phần được nhắc tới nhiều nhất thường là Large Language Model. Tuy nhiên trước khi Model có thể tạo câu trả lời, hệ thống vẫn phải tìm được đúng tài liệu. Nếu Retriever đưa về sai Context thì một Model mạnh hơn cũng khó sửa được phần thông tin đầu vào đã sai.
Đây là lý do mình bắt đầu xây dựng VietRAG-Embed, một Embedding Model dành cho Retrieval tiếng Việt, được Fine-tune từ intfloat/multilingual-e5-base. Mục tiêu của mình không phải tạo ra một Model lớn hơn, mà là điều chỉnh Embedding Space để Query tiếng Việt nằm gần Passage thực sự trả lời nó và xa những Passage chỉ giống về chủ đề.
Nhìn lại 43 Commit của Project, quá trình này không diễn ra trong một lần Training. Mình bắt đầu từ Positive Pair tổng quát, thử thêm dữ liệu pháp luật, chuyển sang Triplet và Hard Negative, xây Dataset khoa học, bổ sung mMARCO tiếng Việt, sau đó mới gom lại thành Pipeline Training và Benchmark cuối.
Trong bài viết này mình sẽ giới thiệu VietRAG-Embed và kể lại những phần quan trọng nhất trong quá trình xây dựng Model.
I. VietRAG-Embed là gì?
VietRAG-Embed là một Dense Retrieval Model tiếng Việt. Model nhận Query hoặc Passage, biến chúng thành Vector 768 chiều đã được L2 Normalize, sau đó chúng ta có thể dùng Cosine Similarity hoặc Inner Product để tìm những Passage gần Query nhất.
Model được xây dựng trên multilingual-e5-base, sử dụng XLM-RoBERTa, Mean Pooling và có khoảng 278 triệu Parameters. Độ dài đầu vào tối đa là 512 Tokens. Đây là Embedding Model chứ không phải Generative Model: nó tìm tài liệu liên quan, nhưng không tự tạo câu trả lời.
Một điểm quan trọng của E5 là Prefix. Query phải có Prefix query:, còn Document hoặc Passage phải có Prefix passage:. Quy ước này được giữ nhất quán khi tạo dữ liệu, Fine-tune, Benchmark và Inference.
II. Vì sao mình chọn Fine-tune multilingual-e5-base?
Ở thời điểm bắt đầu Project, multilingual-e5-base đã là một Backbone khá thực tế cho tiếng Việt. Model hỗ trợ nhiều ngôn ngữ, kích thước vừa đủ để chạy trên GPU phổ thông và Output 768 chiều cũng thuận tiện khi đưa vào FAISS hoặc Vector Database.
Tuy nhiên một Embedding Model tổng quát chưa chắc đã hiểu đúng khái niệm “liên quan” trong Retrieval. Hai đoạn văn có thể cùng nói về Machine Learning, pháp luật hoặc thiên văn học nhưng chỉ một đoạn thực sự trả lời Query.
Vì vậy mục tiêu Fine-tune của mình tập trung vào ba loại dữ liệu:
- Anchor: câu hỏi hoặc Query tìm kiếm.
- Positive: Passage trả lời trực tiếp cho Query.
- Hard Negative: Passage có Semantic gần nhưng không phải kết quả đúng.
Phần khó nhất của Project vì thế không nằm ở vài chục dòng Training Script. Phần tốn nhiều thời gian hơn là tạo dữ liệu, kiểm tra Negative và xây một Benchmark đủ rõ để biết Model thực sự cải thiện ở đâu.
III. Quá trình xây dựng dữ liệu
1. Bắt đầu với Positive Pair tổng quát
Những Commit đầu tiên tập trung vào việc chuẩn hóa dữ liệu tiếng Việt từ Wikipedia, ViQuAD và một số nguồn Question Answering. Dữ liệu được đưa về PostgreSQL, những Passage dài được Chunk lại, sau đó Export sang SQLite để có thể Attach trực tiếp vào Kaggle.
Vòng Fine-tune đầu sử dụng Pair anchor và positive. Mình thử cả Notebook một GPU và Distributed Data Parallel trên hai GPU Kaggle, với Batch Size 32, hai Epoch và Learning Rate 2e-5.
Đây là một điểm khởi đầu hợp lý vì Multiple Negatives Ranking Loss có thể dùng Positive của Sample khác trong cùng Batch làm In-batch Negative. Nhưng sau thử nghiệm đầu, vấn đề trở nên khá rõ: Negative ngẫu nhiên thường quá dễ và không ép Model học những khác biệt nhỏ trong cùng chủ đề.
2. Thử nghiệm dữ liệu pháp luật
Sau dữ liệu tổng quát, mình xây thêm Pipeline cho dữ liệu pháp luật từ Zalo AI Legal và tạo một vòng Fine-tune riêng. Legal Retrieval là một bài toán phù hợp để kiểm tra Embedding Model vì nhiều văn bản dùng chung thuật ngữ nhưng khác điều, khoản hoặc phạm vi áp dụng.
Các Notebook ở giai đoạn này cũng lưu lại cả lỗi và những lần điều chỉnh đường dẫn Model, Dataset, Prefix cũng như Batch Sampler. Theo mình đây là phần khá bình thường của một Project Machine Learning: Training Run cuối thường ngắn gọn, nhưng để có được Run đó sẽ có nhiều vòng Data Processing và Debug trước đó.
3. Chuyển từ Pair sang Triplet và Hard Negative
Sang vòng tiếp theo, Dataset tổng quát được chuyển sang dạng Triplet gồm anchor, positive và hard_negative. Đây là thay đổi quan trọng nhất trong quá trình Fine-tune.
Để khai thác Hard Negative trên lượng dữ liệu lớn, mình xây một Pipeline dùng checkpoint trung gian để Encode các Positive Passage, sau đó tạo FAISS IVF-PQ Index. Index sử dụng 4.096 List, PQ với 64 Sub-vector, nprobe=32 và lấy Top 16 Candidate cho mỗi Query.
Candidate vẫn phải đi qua bước lọc để loại bản ghi trùng, Positive hiện tại và những trường hợp có nguy cơ trở thành False Negative. Pipeline có thể Resume, lưu Mapping giữa FAISS Position và Data ID, đồng thời yêu cầu Rebuild Index rõ ràng khi nguồn dữ liệu hoặc Model thay đổi.
Mình thấy Hard Negative Mining là phần cần thận trọng nhất. Một Passage có Similarity cao chưa chắc là Negative; đôi khi nó chỉ là một cách diễn đạt khác của đáp án đúng. Nếu gán nhầm, chúng ta đang dạy Model đẩy hai tài liệu đúng ra xa nhau.
4. Xây dựng VietEmbed-RAG Science
Để tăng độ đa dạng ngoài Web QA, mình tạo thêm dữ liệu khoa học và kỹ thuật bằng một Pipeline có LLM hỗ trợ. Dataset ban đầu có 174.812 Records, sau Validation và Deduplication chỉ còn 68.567 Records thuộc 7 Domain được giữ lại.
Pipeline kiểm tra JSON Schema, chuẩn hóa Unicode và Whitespace, giới hạn độ dài, loại Query/Positive/Hard Negative trùng nhau và thực hiện Semantic Deduplication trong từng Domain. Mình chọn lọc khá mạnh tay vì với Embedding Model, nhiều Sample gần như giống nhau không nhất thiết mang lại Training Signal tốt hơn.
Mình đã viết riêng một bài về VietEmbed-RAG Science, trong đó mô tả chi tiết hơn Dataset và những giới hạn của Synthetic Data.
5. Mở rộng với mMARCO tiếng Việt
Ở giai đoạn cuối, mình bổ sung mMARCO tiếng Việt để tăng quy mô Web Passage Retrieval. Source có gần 40 triệu Rows nên Script không tải toàn bộ vào Memory mà đọc theo Parquet Row Group, lọc từng Batch và ghi Output bằng Zstandard Compression.
Query được giới hạn từ 8 đến 384 ký tự; Positive và Negative từ 80 đến 2.000 ký tự. Những Record rỗng, trùng hoặc có cấu trúc không hợp lệ bị loại trước khi đưa vào PostgreSQL.
Với nguồn Hard Negative đã có Reranker Score, Pipeline chọn một Negative tốt nhất cho mỗi Query, loại mọi Positive Document ID và chỉ giữ Candidate có Score lớn hơn 0,5. Sau đó các ID được Map trở lại vị trí trong mMARCO Collection để lấy đúng nội dung Passage.
Trước khi Training, dữ liệu tiếp tục được Normalize NFC, loại ký tự điều khiển, kiểm tra lỗi Encoding và Deduplicate Triplet bằng SHA-256. Bản Export cuối cùng có 937.686 Records, gồm 869.119 mMARCO tiếng Việt và 68.567 Records khoa học.
IV. Recipe Fine-tune cuối cùng
Ở những vòng đầu mình sử dụng Learning Rate 2e-5 và Batch Size 32. Khi dữ liệu đã lớn và đa dạng hơn, Recipe cuối được điều chỉnh thận trọng hơn:
- Base Model:
intfloat/multilingual-e5-base. - Loss: Multiple Negatives Ranking Loss với Cosine Similarity và Scale 20.
- Batch Size: 24.
- Effective Passes: 2.
- Total Steps: 78.142.
- Learning Rate:
5e-6. - Warmup Ratio: 0,1.
- Precision: FP16.
- Seed: 42.
- Batch Sampler: No Duplicates Hashed.
- Multi-dataset Sampler: Proportional.
Pair và Triplet được load thành hai Dataset riêng. Cách làm này cho phép giữ những nguồn chỉ có Positive Pair thay vì bắt buộc tạo Hard Negative giả cho tất cả Record. Proportional Sampler tiếp tục trộn hai nguồn dựa trên kích thước, còn No Duplicates Hashed hạn chế Duplicate trong cùng Batch.
Run cuối sử dụng một NVIDIA Tesla T4 trên Kaggle và mất khoảng 11,2 giờ. Model được lưu theo định dạng Sentence Transformers, sau đó mình kiểm tra lại khả năng Load hoàn toàn Offline trước khi Public lên Hugging Face.
V. Benchmark VietRAG-Embed
Mình sử dụng hai nhóm Evaluation tách biệt: VN-MTEB để nhìn khả năng Retrieval trên các Task bên ngoài, và một mMARCO-VI Held-out Diagnostic để kiểm tra Model trên miền dữ liệu gần với Training.
VN-MTEB Retrieval
Trên 7 Task Retrieval đã chạy, Mean Score đạt 0,40254. Ba kết quả cao nhất gồm SciFact-VN 0,6130, TRECCOVID-VN 0,6068 và Quora-VN 0,5652.
Một Commit ở giữa quá trình từng ghi nhận Model v1 vượt E5-Base trên 6/7 Task. Tuy nhiên mình không xem một con số như vậy là kết luận cuối cùng. Dataset, checkpoint và cách chạy Benchmark đều tiếp tục thay đổi sau đó, vì vậy Model Card public chỉ giữ các kết quả có Artifact và Report đi kèm.
mMARCO-VI Shard Holdout
Bộ Diagnostic gồm 4.599 Query chưa xuất hiện trong Training Export và 7.949 Candidate Passage. Kết quả của Model:
- Recall@1: 0,8419.
- Recall@5: 0,9533.
- Recall@10: 0,9698.
- MRR@10: 0,8907.
- nDCG@10: 0,9102.
Median Rank của Positive Passage là 1. Tuy nhiên đây là In-domain Diagnostic chứ không phải Benchmark độc lập hoàn toàn. Có 215 Passage, tương đương 2,70% Corpus Evaluation, có Exact Normalized Match trong Training Corpus. Vì vậy kết quả này được báo cáo riêng và không cộng vào VN-MTEB Mean.
Đây cũng là cách mình muốn trình bày Benchmark: một con số cao chỉ có ý nghĩa khi chúng ta biết Split được tạo như thế nào, có Data Leakage hay không và nó gần Training Distribution đến mức nào.
VI. Sử dụng Model
Model đã được Public trên Hugging Face và có thể Load trực tiếp bằng Sentence Transformers.
pip install -U sentence-transformersVí dụ Encode một Query và hai Passage:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("nhminh107/VietRAG-Embed")
queries = [
"query: Thủ đô của Việt Nam là gì?",
]
passages = [
"passage: Hà Nội là thủ đô của Việt Nam.",
"passage: Nước đóng băng ở 0 độ C.",
]
query_embeddings = model.encode(
queries,
normalize_embeddings=True,
convert_to_tensor=True,
)
passage_embeddings = model.encode(
passages,
normalize_embeddings=True,
convert_to_tensor=True,
)
scores = model.similarity(query_embeddings, passage_embeddings)
print(scores)Vì Vector đã được Normalize, Cosine Similarity tương đương với Inner Product. Khi dùng FAISS, chúng ta có thể tạo IndexFlatIP cho Collection nhỏ hoặc chuyển sang IVF/HNSW với Collection lớn hơn.
import faiss
import numpy as np
documents = [
"Hà Nội là thủ đô của Việt Nam.",
"Nước đóng băng ở 0 độ C.",
"Sao Mộc là hành tinh lớn nhất Hệ Mặt Trời.",
]
document_embeddings = model.encode(
[f"passage: {text}" for text in documents],
normalize_embeddings=True,
convert_to_numpy=True,
).astype(np.float32)
index = faiss.IndexFlatIP(document_embeddings.shape[1])
index.add(document_embeddings)
query_embedding = model.encode(
["query: Thủ đô của Việt Nam là gì?"],
normalize_embeddings=True,
convert_to_numpy=True,
).astype(np.float32)
scores, indices = index.search(query_embedding, k=2)Trong hệ thống thực tế, nên Encode Passage theo Batch và lưu Mapping giữa Vector ID với Metadata của Document. Nếu Document dài hơn 512 Tokens, nên Chunk theo cấu trúc nội dung thay vì cắt tùy ý.
VII. Những giới hạn mình muốn nói rõ
VietRAG-Embed hiện tập trung chủ yếu vào Retrieval tiếng Việt. Mình chưa có đủ Evaluation để khẳng định chất lượng trên Query đa ngôn ngữ hoặc Code Retrieval.
Dữ liệu Training vẫn bị chi phối bởi Web Passage QA. Dataset khoa học giúp tăng độ đa dạng nhưng là Synthetic Data, vì vậy vẫn có thể chứa kiến thức chưa chính xác hoặc Hard Negative chưa thực sự tốt.
Model chỉ nhận tối đa 512 Tokens. Với tài liệu dài, chất lượng Retrieval phụ thuộc khá nhiều vào Chunking Strategy. Dense Retrieval cũng có thể đưa về một Passage rất giống về chủ đề nhưng sai về Fact, nên hệ thống RAG vẫn cần Citation, Reranker và Evaluation trên dữ liệu thực tế.
Cuối cùng, các kết quả Benchmark trong Model Card không đảm bảo Model sẽ tốt hơn trên Corpus riêng. Nếu dùng cho Production, mình nghĩ nên tạo một Test Set từ chính Query và Document của hệ thống rồi so sánh với Base Model trước khi quyết định thay thế.
VIII. Những gì mình học được
Sau Project này, điều mình thấy rõ nhất là Training Script chỉ là phần cuối của Pipeline. Phần lớn công việc nằm ở việc định nghĩa Positive, tìm Hard Negative, loại False Negative, giữ Provenance và ngăn Data Leakage giữa Train với Evaluation.
Mình cũng thay đổi cách tiếp cận qua từng vòng. Ban đầu mình tập trung vào số lượng Pair và cố tận dụng hai GPU. Về sau, mình giảm Learning Rate, tách Pair/Triplet, dùng Sampler chống Duplicate và dành nhiều thời gian hơn cho Data Quality cùng Benchmark Artifact.
Quá trình này chậm hơn một Training Run duy nhất, nhưng mỗi Commit đều giải quyết một vấn đề cụ thể: dữ liệu quá dài, Export không ổn định, Hard Negative quá dễ, Mapping sai, Benchmark bị lẫn với Training hoặc Model Final chưa được đóng gói đúng.
Theo mình đây mới là phần đáng giá nhất của VietRAG-Embed. Model public là kết quả cuối, còn Repo lưu lại cách mình đi từ một Notebook Fine-tune Pair đơn giản tới một Pipeline Retrieval có thể kiểm tra và tái hiện.
IX. Kết luận
VietRAG-Embed là thử nghiệm của mình trong việc Fine-tune một Embedding Model cho Retrieval tiếng Việt từ multilingual-e5-base. Bản public được Train trên 937.686 Records đã làm sạch, sử dụng Pair và Triplet, Multiple Negatives Ranking Loss cùng Hard Negative từ nhiều Pipeline khác nhau.
Model chưa phải câu trả lời cho mọi bài toán RAG tiếng Việt, nhưng nó là một Baseline thực tế cho Semantic Search, FAQ Retrieval, Knowledge Base và Candidate Generation trước Reranker.
Nếu bạn đang xây dựng một hệ thống RAG tiếng Việt, có thể thử VietRAG-Embed trên chính Corpus của mình. Điều mình quan tâm nhất không phải Model đạt thêm bao nhiêu điểm trên một bảng Benchmark, mà là nó có giúp tìm đúng Context hơn cho những Query người dùng thực sự đặt hay không.

Thảo luận bài viết
Câu hỏi, ghi chú và góc nhìn của bạn về nội dung này.