Tôi sẽ không viết lại RAG là gì trong bài viết này, bởi vì chắc chắn là giới thiệu về RAG thì Llama Index sẽ ổn hơn tôi. Các bạn có thể đọc ở đây:
https://developers.llamaindex.ai/python/framework/understanding/rag/
Phải nói lại lịch sử một chút. Tôi bắt đầu làm RAG ở dạng early adopter, tức là gần như đồng thời với lúc mà các framework như LangChain hay LlamaIndex mới ra đời (và làm là vì khách ốp), cùng với các mô hình LLM thương mại đầu tiên. Việc này vừa có lợi vừa có hại. Lợi ở chỗ là được tiếp cận với mô hình này khá sớm, hại ở chỗ là đôi khi bị quá cẩn trọng khi bước vào một dự án, và có xu hướng over-engineer.
Trong series bài viết này, tôi sẽ giới thiệu cụ thể hơn các vấn đề thường gặp phải ở RAG, từ các khâu đầu tiên trong ingestion pipeline của RAG cho đến những thứ “khú đỉn” ở reranking, synthesis, evaluation, provenance, permission, vân vân và mây mây. Những thứ tôi gặp thật, đau thật, ăn đòn thật sau kha khá thời gian.
Cho nên đối với loạt bài về RAG này, tôi sẽ không giới thiệu hướng dẫn làm RAG cơ bản. Có thể tôi sẽ có một vài bài về việc các framework về AI hiện nay như PydanticAI hay DSPy xử lý RAG như thế nào, điểm mạnh yếu của từng framework. Nhưng đấy là tương lai. Còn giờ quay lại với Chunking. Tôi vẫn phải ca thán một lần nữa:
Chunking is fucking hard | Phân tách văn bản KHÓ VL

TẠI SAO?

Đầu tiên, nếu bạn đọc các hướng dẫn cơ bản về RAG, phần về chunking thường… rất ngắn, hãy xem thử một hướng dẫn tôi chọn bừa trên mạng, ở 2 kết quả đầu tiên:
def perform_fixed_size_chunking(document, chunk_size=1000, chunk_overlap=200): """ Performs fixed-size chunking on a document with specified overlap. Args: document (str): The text document to process chunk_size (int): The target size of each chunk in characters chunk_overlap (int): The number of characters of overlap between chunks Returns: list: The chunked documents with metadata """ # Create the text splitter with optimal parameters text_splitter = CharacterTextSplitter( separator="\n\n", chunk_size=chunk_size, chunk_overlap=chunk_overlap, length_function=len ) # Split the text into chunks chunks = text_splitter.split_text(document) print(f"Document split into {len(chunks)} chunks") # Convert to Document objects with metadata documents = [] for i, chunk in enumerate(chunks): doc = Document( page_content=chunk, metadata={ "chunk_id": i, "total_chunks": len(chunks), "chunk_size": len(chunk), "chunk_type": "fixed-size" } ) documents.append(doc) return documents
Không có markdown, không có code block nên dùng tạm quote...
Nhìn quen chứ. Đoạn code này làm đúng một việc: Chia văn bản thành các đoạn bằng nhau trước khi vector hoá chúng trong vector DB. Giả sử đây là một văn bản PDF hoặc DOCX cơ bản, sạch sẽ, thuần chữ là chữ, cách tiếp cận này hoàn toàn hiệu quả.
Nó chia đều văn bản ra thành các đoạn nhỏ hơn cho dễ xử lý. Nó cho phép bảo vệ ngữ cảnh của từng đoạn trong văn bản bằng cách dùng overlap chunk (các đoạn cho phép trùng nhau để đảm bảo ngữ nghĩa khi tìm kiếm hoặc khi tổng hợp câu trả lời)
Vấn đề là: tài liệu thật, tài liệu sử dụng trong các cơ quan, tổ chức thật, thường không tử tế như vậy.
Tôi có khoảng hơn một năm làm việc với các văn bản liên quan đến các lĩnh vực sau, và chúng thực sự là những trường hợp vô cùng đau đầu:
- Các văn bản luật với đủ các loại điều khoản - Các văn bản hợp đồng với đủ các loại bảng, giá - Các văn bản liên quan đến tài chính (hồ sơ đấu thầu, báo cáo tài chính, văn bản kế toán)
Đau đầu vì đối với các văn bản này, bạn gần như không thể đối xử với chúng như một văn bản chữ thuần tuý được. Sẽ gặp những trường hợp như thế này, đặc biệt là với một workflow cơ bản:
- Biến đổi văn bản thành dạng markdown cho dễ xử lý (nhưng bạn không đọc văn bản và văn bản đó có một đống bảng) - Đưa văn bản markdown vào trong một cái hàm chunking kiểu ở trên kia để xử lý - Vector hoá, reranking, một đống thứ hầm bà lằng ở đây - Câu trả lời nghe có vẻ hay, số liệu đầy đủ, trích dẫn cả bảng cơ mà, bạn gật gù - Nhưng bạn, là một developer có tâm, sẽ đọc lại văn bản xem câu trả lời có thực sự đúng không - WTF? Tại sao nó lại lấy số liệu bảng này cộng với số liệu bảng kia????? - Bạn sẽ chửi tại sao model ngu thế, và chờ bản GPT7, 8, hoặc Opus 5, hay chờ hẳn Mythos đi. - Trong lúc chờ thì bạn bị đuổi việc
Nhưng bạn ơi, thực ra vấn đề ở đây chỉ nằm một phần ở model, đôi khi nằm một phần ở quá trình tìm kiếm nội dung phù hợp (retrieve, rerank), đôi khi nằm một phần ở tổng hợp, nhưng cái phần to nhất thì lại nằm ở việc chúng ta đối xử với các loại văn bản kia sai. Sai vì một lý do chính và vài lý do phụ. Lý do chính, là lý do cơ bản nhất trong việc làm sản phẩm mà hầu hết các dev đều nằm lòng:
“There is no-one-size-fits-all”
"Chẳng có cái gậy nào lại vừa tất cả mọi lỗ"
Và cái “gậy” ở đây, đôi khi lại nằm ở tầng đầu tiên của một ingestion pipeline (trong trường hợp này là chuỗi xử lý văn bản/dữ liệu trước khi đi vào RAG). Tôi chọn chunking không phải vì chunking là bước đầu tiên. Chunking không phải bước đầu tiên của ingestion pipeline, nhưng nó là một trong những bước đầu tiên làm lộ ra pipeline trước đó có đang phá cấu trúc tài liệu hay không, do một nguyên tắc rất cơ bản: GIGO (Garbage in garbage out - nếu bạn cho vào rác, thì bạn cũng sẽ nhận lại rác).
Đây là câu tụng điển hình của dân ML-NN (Machine Learning và Neural Networks), và trong thời gian tôi làm về LLM nói chung, câu này vẫn đúng, vì thực ra LLM về bản chất cũng vẫn là một mạng lưới NN phức tạp mà thôi. Chunking tại sao lại là bước kiểm định? Bởi vì:
- Đầu vào của nó là văn bản đã qua xử lý (xử lý ở đây tương đương với việc làm sạch dữ liệu. Tức là để chunking được tốt thì văn bản phải tương đối “sạch”, không bị các lỗi về format (chữ nhoè, lệch, chồng chéo) và không bị các lỗi về ngôn ngữ (lẫn lộn ngôn ngữ gây sai ngữ nghĩa). Tôi sẽ nói kỹ về việc xử lý văn bản sau, vì thực ra phần đấy trong nhiều trường hợp khá thủ công
- Đầu ra của nó là các đoạn/đoạn văn dùng cho bước tiếp theo (retrieval, sử dụng vector query để tìm các đoạn có ý nghĩa tương tự như câu hỏi). Và nếu chunking không tốt, thì việc bị lấy thừa, lấy thiếu, thậm chí lấy sai là không thể tránh khỏi.
Chính vì thế, đối với mỗi loại văn bản, sẽ cần có chiến thuật chunking khác nhau. Tuy nhiên, trước khi đi vào các loại chiến thuật đấy, chúng ta cũng sẽ luôn cần phải ghi nhớ nguyên tắc “đơn giản được thì cứ đơn giản”. Tức là cũng cần phải tránh việc làm những thứ thừa thãi, bởi vì bạn vẫn phải đảm bảo cân bằng các yếu tố khác. Ví dụ như nếu như bạn chỉ đang làm sản phẩm demo, việc quá quan tâm đến chunking đôi khi không cần thiết. Nếu như bạn chỉ đang làm sản phẩm MVP với các loại văn bản thông dụng, chỉ cần để ý đến ngữ cảnh của chunk mà thôi (sử dụng semantic chunking, lát nữa sẽ nói thêm).
Vì vậy, vấn đề không phải là “luôn luôn phải chunk thật phức tạp”. Vấn đề là phải biết mình đang chunk cái gì. Với tài liệu thường, chunk có thể là đoạn văn. Nhưng với luật, hợp đồng, hồ sơ tài chính, chunk đôi khi phải là điều, khoản, bảng, dòng bảng, ghi chú, mô tả rõ ràng. Nếu không phân biệt các loại đối tượng này, retrieval phía sau chỉ đang tìm kiếm trên một đơn vị biểu diễn văn bản (là chunk) đã sai từ đầu.
Trước khi đi vào phần tiếp theo, tôi có một DEMO REPO ở đây về RAG trước tôi làm cho một khách hàng cần tra cứu tài liệu về PDP8 (viết tắt của Power Development Plan 8 - Quy hoạch phát triển điện lực quốc gia VIII). Tôi sẽ lấy một số đoạn code trong này để giải thích về các khái niệm dùng trong bài. Đọc nốt ở đây, vì đoạn sau chủ yếu là code: