Bài viết này dành cho những tâm hồn đồng điệu đang muốn build một con LLM lên production.
Ôn bài cũ - Một mô hình RAG cơ bản gồm những gì?
RAG không hề phức tạp, nó chỉ đơn giản là xây dựng một hệ thống xung quanh một con LLM và dùng đúng dữ liệu để con LLM trả lời dùng chính dữ liệu của bạn. Các kĩ thuật mà RAG sử dụng đến hầu hết đều đã xuất hiện rất lâu, như là xử lý dữ liệu chữ, semantic search, knowledge graphs, các mô hình ML cơ bản, vector database. Nhờ RAG mà câu trả lời của LLM có thể truy xuất thông tin từ nguồn dữ liệu bất kì với giới hạn kích thước duy nhất là chiếc ví của bạn.
Hình bên trên là một mô hình RAG cơ bản. Khi user đặt câu hỏi, hệ thống sẽ truy xuất ở trong database, xem dữ liệu nào liên quan đến câu hỏi nhất thì lấy nó ra. Sau đó tất cả thông tin liên quan được tổng hợp để đưa cho LLM trả lời user.
Cách thức để đo lường độ tương quan giữa hai thông tin khá đa dạng, mà phổ biến nhất là xét cosine similarity của embedding hai thông tin đó.
Sau khi có thông tin liên quan, chúng ta xây dựng một prompt bao gồm system prompt, câu hỏi của người dùng, và ngữ cảnh - thông tin đã được tìm thấy bên trên.
Tuy nhiên, trong khi làm thì một số vấn đề có thể xuất hiện như là:
- Vấn đề khi đo lường độ tương quan
- Chunk optimization: Thông tin lưu vào vector database thì phải bẻ nhỏ nó ra thành các chunk rồi mới dùng nó để đem ra so sánh (hoặc để nguyên cũng được nhưng nó nặng lắm không ai làm thế cả). Vậy bẻ nhỏ thông tin như thế nào là vừa đủ nhỏ để vừa đi vào thông tin chi tiết được cho từng mẩu thông tin, vừa lưu giữ được thông tin ngữ cảnh xung quanh nó?
- Theo dõi performance của model như thế nào? (LLMOps)
- Người dùng hỏi khó quá, phức tạp quá thì xử lý như thế nào?
Vậy thì để cải thiện RAG, chúng ta có thể ngó qua 17 cách sau, chia theo 5 giai đoạn chính của RAG:
- Pre-Retrieval: Giai đoạn biến dữ liệu thành các embedding để đưa vào vector store
- Retrieval: Tìm thông tin liên quan
- Post-Retrieval: Tìm được thông tin rồi thì xử lý một chút trước rồi đưa qua LLM chứ nhỉ, thử xem sao
- Generation: Để LLM tự tung tự tác với thông tin mình đưa nó
- Routing: Thiết kế luồng đi - Ví dụ nếu câu hỏi khó quá thì routing sẽ là bước chúng ta cài đặt hệ thống để bẻ nhỏ câu hỏi ra thành các câu hỏi dễ hơn rồi kết hợp với nhau
0. Thu thập/Xử lý dữ liệu thô
(1) Chuẩn bị dữ liệu tốt
Dữ liệu tốt cần thỏa mãn ít nhất các yếu tố sau:
- Nội dung đa dạng, đầy đủ, có ngữ cảnh
- Không chứa thông tin không liên quan, lỗi, nhiễu
- Cân bằng - Số lượng dữ liệu mỗi loại cân bằng
- Không chứa ngôn từ kích động, gây phản cảm, chứa thông tin bảo mật
Dữ liệu có nhãn thì hiệu quả của mô hình sẽ dễ kiểm soát và cải thiện hơn. Còn nếu không có nhãn thì vẫn có cách để mô hình học, nên đừng lo quá.
1. Indexing / Chunking — Chunk Optimization
(2) Tối ưu chunk
Fixed-size (in characters) Overlapping Sliding Window
Phương pháp này liên quan đến việc chia văn bản thành các đoạn cố định dựa trên số ký tự. Sự đơn giản trong triển khai và việc bao gồm các đoạn chồng chéo nhằm ngăn chặn việc cắt câu hoặc ý nghĩ. Tuy nhiên, các hạn chế bao gồm việc kiểm soát kích thước ngữ cảnh không chính xác, nguy cơ cắt từ hoặc câu, và thiếu sự xem xét về ngữ nghĩa. Phù hợp cho phân tích khám phá nhưng không được khuyến nghị cho các tác vụ yêu cầu hiểu sâu về ngữ nghĩa.
Ví dụ sử dụng LangChain:
text = "..." # your text
from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter( chunk_size = 256, chunk_overlap = 20
)
docs = text_splitter.create_documents([text])
Recursive Structure Aware Splitting Phương pháp lai kết hợp giữa cửa sổ trượt kích thước cố định và phân chia nhận thức cấu trúc. Nó cố gắng cân bằng kích thước đoạn cố định với ranh giới ngôn ngữ, cung cấp kiểm soát ngữ cảnh chính xác. Độ phức tạp trong triển khai cao hơn, với nguy cơ kích thước đoạn biến đổi. Hiệu quả cho các tác vụ yêu cầu độ chi tiết và tính toàn vẹn ngữ nghĩa nhưng không được khuyến nghị cho các tác vụ nhanh hoặc các chia nhỏ cấu trúc không rõ ràng.
Ví dụ sử dụng LangChain:
text = "..." # your text
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter( chunk_size = 256, chunk_overlap = 20, separators = ["\n
Structure Aware Splitting (by Sentence, Paragraph)
Phương pháp này xem xét cấu trúc tự nhiên của văn bản, phân chia nó dựa trên câu, đoạn văn, phần hoặc chương. Tôn trọng ranh giới ngôn ngữ bảo vệ tính toàn vẹn ngữ nghĩa, nhưng gặp khó khăn khi cấu trúc phức tạp khác nhau. Hiệu quả cho các tác vụ yêu cầu ngữ cảnh và ngữ nghĩa, nhưng không phù hợp với các văn bản thiếu các chia nhỏ cấu trúc rõ ràng.
Ví dụ:
text = "..." # your text
docs = text.split(".")
Content-Aware Splitting (Markdown, LaTeX, HTML)
Phương pháp này tập trung vào loại nội dung và cấu trúc, đặc biệt trong các tài liệu có cấu trúc như Markdown, LaTeX hoặc HTML. Nó đảm bảo các loại nội dung không bị trộn lẫn trong các đoạn, giữ nguyên tính toàn vẹn. Thách thức bao gồm việc hiểu cú pháp cụ thể và không phù hợp với các tài liệu không có cấu trúc. Hữu ích cho các tài liệu có cấu trúc nhưng không được khuyến nghị cho nội dung không có cấu trúc.
Ví dụ cho văn bản Markdown sử dụng LangChain:
from langchain.text_splitter import MarkdownTextSplitter
markdown_text = "..." markdown_splitter = MarkdownTextSplitter(chunk_size=100, chunk_overlap=0)
docs = markdown_splitter.create_documents([markdown_text])
Ví dụ cho văn bản LaTeX sử dụng LangChain:
from langchain.text_splitter import LatexTextSplitter
latex_text = "..."
latex_splitter = LatexTextSplitter(chunk_size=100, chunk_overlap=0)
docs = latex_splitter.create_documents([latex_text])
NLP Chunking: Tracking Topic Changes
Một phương pháp tinh vi dựa trên hiểu biết ngữ nghĩa, phân chia văn bản thành các đoạn bằng cách phát hiện các thay đổi quan trọng về chủ đề. Đảm bảo tính nhất quán ngữ nghĩa nhưng yêu cầu các kỹ thuật NLP nâng cao. Hiệu quả cho các tác vụ yêu cầu ngữ cảnh ngữ nghĩa và tính liên tục của chủ đề nhưng không phù hợp cho các nhiệm vụ có sự trùng lặp chủ đề cao hoặc chia nhỏ đơn giản.
Ví dụ sử dụng bộ công cụ NLTK từ LangChain:
text = "..." # your text
from langchain.text_splitter import NLTKTextSplitter
text_splitter = NLTKTextSplitter()
docs = text_splitter.split_text(text)
(3) Cải thiện chất lượng dữ liệu - Từ viết tắt, thuật ngữ chuyên môn, link
Để mô hình nhận diện được các từ viết tắt tốt hơn, chúng ta có thể tạo một bảng thuật ngữ để xử lý dữ liệu trước khi đưa vào model, hoặc nếu máy đủ khỏe có thể đưa bảng thuật ngữ vào LLM cho nó tự xử lý
(4) Thêm metadata
Bạn có thể thêm metadata vào cho các vector trong database để thực hiện lọc dễ dàng hơn. Giả sử như khách hàng của bạn ở châu Á, thì thay vì tìm trong toàn bộ database bạn có thể tìm trong database châu Á trước cho dễ.
(5) Lựa chọn cơ chế indexing phù hợp
Mình thì tin rằng nếu model tạo embedding đủ tốt rồi thì cơ chế tìm kiếm thế nào cũng được, chỉ cần đủ nhanh và phương án đủ tốt là được. Cơ bản nhất thì chúng ta dùng KNN, nếu như có đủ phần cứng và dữ liệu ít. Còn nếu như dữ liệu vài triệu dòng thì tiền đâu cho vừa. Nên thường thế giới họ dùng các phương pháp tìm lân cận xấp xỉ (ANN) đến từ các framework như FAISS của Mark râu, NMSLIB, ANNOY của Spotify hay qrant mà OpenAI đang dùng. Về cơ bản, các framework này sẽ phân cụm trước/phân nhánh trước bằng các cơ chế khác nhau, rồi sau đó chỉ tìm các điểm dữ liệu trong cùng một (vài) cụm/nhánh gần nhất. Nếu có thời gian thì các bạn có thể thử nghiệm với các cơ chế indexing khác nhau xem có gì khác biệt không.
(6) Sử dụng mô hình tạo embedding phù hợp
Thật ra mình nghĩ dùng BM25 là một baseline không tệ để tìm kiếm các tài liệu liên quan đến query của người dùng. Trong trường hợp dữ liệu ít và các đặc trưng giữa các câu từ mình đánh giá là đủ khác nhau thì chỉ cần một mô hình đơn giản, và thế là có thể skip luôn phần này.
Sau khi sử dụng BM25 xong mà thấy kết quả chưa đủ tốt hay tốc độ infer chậm quá thì các bạn có thể dùng đến phương pháp tạo vector store từ các dữ liệu của mình và thực hiện retrieve thông tin trong đó. Tuy nhiên, phương pháp này đòi hỏi mô hình tạo embedding phải phù hợp, tức là có khả năng đưa các nội dung khác nhau ra xa nhau còn các nội dung giống nhau thì có khoảng cách gần hơn.
Benchmark đầu tiên mà mình nghĩ tới cho các mô hình tạo embedding là MTEB. Trong này các bạn sẽ thấy được bảng xếp hạng real-time của các mô hình tạo embedding, được phân loại theo các tác vụ, loại mô hình, kích thước mô hình và ngôn ngữ. Trong đó, bảng xếp hạng theo tác vụ retrieval, retrieval w/ instruction và reranking sẽ phù hợp hơn với RAG.
Tuy nhiên các mô hình bên trên chưa được tối ưu cho tiếng việt cho lắm. Các bạn có thể thử với Vistral của VILM (7B tham số, mình chạy thử trên con GPU 11GB thấy nó tải được khoảng 4000 token một lúc). Hoặc mọi người cũng có thể xem danh sách các model trên huggingface.
Hoặc nếu mọi người đánh giá lượng dữ liệu của mình hay là tác vụ đang sử dụng quá đặc thù và cần được huấn luyện lại thì có thể dùng một mô hình pretrain bất kỳ rồi huấn luyện nó theo một downstream task phù hợp với bộ dữ liệu bạn có. Nên chọn mô hình cùng ngôn ngữ với bộ dữ liệu của bạn. Về hướng training có thể tham khảo thêm SimCSE để có thể train mô hình (cả supervised và unsupervised đều được) hoặc các mô hình sentence embedding khác như InferSent, Universal Sentence Encoder, SBERT,...
Lưu ý nho nhỏ, vừa có một chiếc paper mới ra hơn 1 tháng trước của Google cho thấy việc fine-tuning LLM với dữ liệu mới không những không hiệu quả mà còn làm tăng hallucination với dữ liệu cũ. Paper vẫn còn hơi mới nên chúng ta sẽ theo dõi, từ giờ đến khi có động thái mới thì chạy song song baseline với LLM được fine-tune
2. Tối ưu retrieval - Query Translation / Query Rewriting / Query Extension
Đôi khi query của người dùng không tối ưu được khả năng của LLM. Prompt đưa vào cho LLM cần được viết sao cho LLM có thể hiểu được bối cảnh, ngữ nghĩa, và kì vọng của người dùng như ví dụ sau
Hoặc thậm chí query của chúng ta có thể quá phức tạp và