Công ty bạn có con chatbot nội bộ chạy RAG trên toàn bộ tài liệu nhân sự. Hỏi "công ty cho nghỉ phép bao nhiêu ngày?", nó trả lời ngon lành. Hỏi tiếp "so với năm ngoái chính sách nghỉ phép năm nay đổi gì, và mình còn bao nhiêu ngày?" thì bắt đầu loạn: trích nhầm bản năm cũ, lờ đi phần so sánh, còn câu "mình còn bao nhiêu ngày" thì phán đại một con số.
Model không kém. Cái kém là cách tra cứu: lấy nguyên câu hỏi đi tìm đúng một lần, được gì dùng nấy. Trong khi câu hỏi kia cần ít nhất ba lần tìm, ở hai nguồn khác nhau, mà một trong hai nguồn còn chẳng phải tài liệu.
Agentic RAG sinh ra cho đúng loại câu hỏi này. Nghe thì như một khái niệm mới, nhưng thực ra nó là điểm đến của vài năm người ta vá dần từng lỗ của RAG cổ điển. Muốn hiểu nó cho đúng thì phải đi qua những cái lỗ đó trước.
# RAG cổ điển, nhắc lại nhanh
RAG (Retrieval-Augmented Generation) ra đời từ paper của nhóm Facebook AI năm 2020. Ý tưởng: model không biết tài liệu riêng của bạn, vậy trước khi trả lời thì đi tìm những đoạn liên quan rồi nhét vào prompt.
Một hệ thống RAG điển hình có hai pha. Pha chuẩn bị (indexing): tài liệu được cắt thành các đoạn (chunk) vài trăm token, mỗi đoạn được một embedding model đổi thành vector, lưu vào vector database. Pha trả lời: câu hỏi cũng thành vector, hệ thống lấy ra k đoạn có vector gần nhất (thường đo bằng cosine similarity), ghép vào prompt, model đọc và trả lời.
Gọn, nhanh, rẻ, một lần gọi model. Với câu hỏi kiểu tra một sự kiện nằm gọn trong một đoạn tài liệu thì làm rất tốt. Vấn đề nằm ở chữ "một lần": tìm một lần, không kiểm tra, không tìm lại.
# những chỗ RAG cổ điển hỏng
Mình gom lại thành mấy loại, vì mỗi loại cần một cách chữa khác.
Hỏng ở tầng tìm kiếm. Vector search giỏi bắt ý nghĩa, nhưng dở với những thứ phải khớp chính xác: mã lỗi ERR_4021, tên riêng, số hiệu điều khoản, tên một hàm trong code. Người hỏi "lỗi ERR_4021 là gì" có thể nhận về những đoạn nói chung chung về lỗi, còn đúng đoạn có ERR_4021 thì không lọt top. Ngược lại, người hỏi "nghỉ đẻ được mấy tháng" trong khi tài liệu viết "chế độ thai sản" thì vector search lại làm tốt hơn tìm theo từ khoá.
Hỏng ở cách cắt tài liệu. Cắt theo số token cứng nhắc thì một bảng lương bị chặt đôi, nửa đầu ở chunk này, tiêu đề cột ở chunk kia. Đoạn "điều kiện áp dụng" nằm cách đoạn "mức hưởng" ba chunk, tìm được đoạn này thì mất đoạn kia.
Hỏng ở câu hỏi nhiều bước. "Người phụ trách dự án X đang báo cáo cho ai?" phải tìm người phụ trách trước, có tên người đó rồi mới tìm được quản lý của họ. Tìm bằng nguyên câu hỏi thì hiếm khi ra cả hai.
Hỏng vì thiếu nguồn. Phần "mình còn bao nhiêu ngày phép" nằm trong database của hệ thống HR, không nằm trong tài liệu nào cả. Vector search không truy vấn database được.
Hỏng vì không biết mình không biết. Kết quả tìm được chẳng liên quan gì, RAG cổ điển vẫn nhét vào prompt, model vẫn cố trả lời dựa trên đó. Thế là bịa một cách rất tự tin, kèm cả trích dẫn trông như thật.
# vá tầng tìm kiếm trước đã
Trước khi nói tới agent, có hai kỹ thuật mà theo mình hệ thống RAG nào cũng nên có, vì chúng rẻ mà hiệu quả rõ.
hybrid search và RRF
Vì vector search và tìm theo từ khoá (BM25) mạnh ở những chỗ khác nhau, cách phổ biến là chạy cả hai rồi gộp kết quả. Cái khó là điểm số của hai bên không cùng thang đo: cosine similarity nằm trong khoảng −1 đến 1, điểm BM25 thì có thể lên vài chục. Cộng thẳng thì vô nghĩa.
Reciprocal Rank Fusion (RRF) giải quyết bằng cách bỏ qua điểm số, chỉ nhìn thứ hạng:
RRF(d) = Σ 1 / (k + rank_i(d))
i
rank_i(d): thứ hạng của tài liệu d trong danh sách kết quả thứ i (bắt đầu từ 1)
k: hằng số làm mượt, thường chọn 60Một tài liệu đứng đầu ở cả hai danh sách được 1/61 + 1/61. Một tài liệu đứng đầu bên này nhưng không xuất hiện bên kia chỉ được 1/61. Tài liệu được cả hai cách tìm cùng "đồng ý" sẽ nổi lên trên. Cài đặt chỉ vài dòng:
from collections import defaultdict
def rrf(result_lists: list[list[str]], k: int = 60, top_n: int = 10) -> list[str]:
scores = defaultdict(float)
for results in result_lists:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] += 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)[:top_n]
dense = ["d3", "d1", "d7", "d2"] # kết quả vector search
bm25 = ["d9", "d3", "d2", "d5"] # kết quả tìm theo từ khoá
print(rrf([dense, bm25])) # d3 lên đầu vì cả hai cách đều xếp nó caorerank
Vector search so câu hỏi và tài liệu một cách gián tiếp: mỗi bên được biến thành vector riêng rẽ, rồi so hai vector. Nhanh, nhưng thô. Một cross-encoder (reranker) thì đọc câu hỏi và tài liệu cùng lúc, chấm điểm độ liên quan chính xác hơn hẳn, đổi lại chậm hơn nhiều.
Nên người ta dùng theo kiểu phễu: hybrid search lấy rộng ra 50 đoạn, reranker chấm kỹ 50 đoạn đó, giữ lại 5 đoạn tốt nhất đưa vào prompt. Bước này thường cải thiện chất lượng rõ rệt, đổi lại thêm cỡ vài chục tới vài trăm mili giây tuỳ model rerank và phần cứng.
Làm tốt hai việc này rồi hãy tính tới agent. Nhảy thẳng vào agentic RAG để chữa một vấn đề mà thực ra chỉ cần thêm BM25 với reranker là xong thì vừa tốn tiền vừa khó debug, mà kết quả có khi còn tệ hơn.
# từng bước tiến về phía agent
Phần tìm kiếm đã tốt hơn, nhưng các lỗi "nhiều bước", "thiếu nguồn", "không biết mình không biết" vẫn còn. Mấy năm qua, cộng đồng nghiên cứu đi từng bước một.
Bước đầu là viết lại câu hỏi trước khi tìm. Cho LLM diễn đạt lại câu hỏi cho rõ, tách câu phức thành nhiều câu đơn. Một biến thể thú vị là HyDE (cuối 2022): thay vì tìm bằng câu hỏi, cho LLM viết thử một câu trả lời giả định rồi dùng chính câu trả lời đó đi tìm. Nghe ngược đời, nhưng hợp lý: câu trả lời (dù chưa đúng) thường "giống" tài liệu hơn là câu hỏi, nên vector của nó nằm gần tài liệu thật hơn.
Bước thứ hai là để model tự quyết khi nào cần tìm và tự chấm kết quả. Self-RAG (2023) train model sinh ra những token đặc biệt để tự hỏi: câu này có cần tra cứu không, đoạn vừa tìm có liên quan không, câu mình vừa viết có được tài liệu chống lưng không. Nhờ vậy model không còn nhắm mắt dùng mọi thứ được đưa vào.
Bước thứ ba là kiểm tra rồi sửa. Corrective RAG (2024) thêm một bộ đánh giá nhẹ (bản gốc dùng model T5-large đã fine-tune) chấm độ liên quan của từng đoạn tìm được, rồi xếp cả lượt tìm vào một trong ba mức. Có ít nhất một đoạn điểm cao thì xem là đúng: giữ tài liệu nhưng cắt nhỏ ra, lọc bỏ những câu không liên quan. Mọi đoạn đều điểm thấp thì xem là sai: bỏ hết, quay sang tìm trên web. Lưng chừng thì là mơ hồ: dùng cả tài liệu đã lọc lẫn kết quả web.
Song song đó, cách làm agent kiểu ReAct (2022), trong đó model xen kẽ giữa suy nghĩ và hành động, ngày càng chín. Đến khi model đủ giỏi gọi tool, người ta nhận ra không cần dựng sẵn từng bước nữa. Cứ đưa cho agent một bộ công cụ tra cứu kèm vài nguyên tắc, nó tự lên kế hoạch. Đó là agentic RAG.
# agentic RAG trông thế nào
Lõi là một vòng lặp. Agent nhận câu hỏi, tự hỏi cần biết thêm gì, chọn công cụ, đọc kết quả, chấm xem đủ chưa. Chưa thì tìm tiếp với truy vấn khác hoặc công cụ khác. Đủ rồi thì viết câu trả lời kèm nguồn.
Có hai chi tiết trong sơ đồ hay bị bỏ qua. Một là ngân sách: vòng lặp phải có giới hạn số bước, số token, thời gian. Hai là nhánh "hết ngân sách" vẫn phải trả lời, nhưng nói rõ phần nào chưa tìm được, thay vì im lặng hoặc bịa.
Quay lại câu hỏi nghỉ phép. Một agent tử tế sẽ: tìm chính sách năm nay, tìm chính sách năm ngoái, so hai bản, rồi gọi tool hệ thống HR lấy số ngày còn lại. Bốn lần tra, hai nguồn, một câu trả lời đầy đủ. Giống người nội trợ đi chợ nấu canh chua: nghĩ xem cần cá, me, thơm, rau om; ghé đúng sạp; thấy me hết thì đổi sang giấm; về tới nhà còn kiểm lại xem thiếu gì. RAG cổ điển thì giống đưa đứa em tờ giấy ghi "đồ nấu canh chua", nó bốc đại ở sạp đầu tiên rồi về.
# không phải lúc nào cũng cần agent tự do
Trong thực tế, "agentic" là một dải nhiều mức chứ không phải bật hay tắt.
Mức thấp nhất là router: LLM chỉ đọc câu hỏi và quyết định gửi đi đâu (tài liệu, database, web), sau đó vẫn là một đường ống cố định. Một lần gọi LLM thêm, dễ kiểm soát, đủ cho rất nhiều sản phẩm.
Cao hơn một chút là tách câu hỏi: LLM chia câu hỏi phức tạp thành các câu con, tìm song song cho từng câu, rồi gộp. Hợp với câu hỏi so sánh, tổng hợp. Vì các câu con chạy song song nên độ trễ không tăng nhiều.
Tiếp theo là tìm lặp có kiểm tra: sau mỗi lần tìm có một bước chấm, chưa đủ thì viết lại truy vấn và tìm tiếp, có giới hạn số vòng. Đây là chỗ CRAG và Self-RAG thuộc về.
Cao nhất là agent tự do, hoặc nhiều agent phối hợp: model tự quyết toàn bộ kế hoạch với bộ công cụ trong tay. Mạnh nhất, cũng khó lường và khó debug nhất. Nếu đi hướng nhiều agent, bài LangGraph và multi-agent có nói về cách quản lý trạng thái.
Lời khuyên của mình: bắt đầu từ router, đo xem loại câu hỏi nào đang trả lời tệ, rồi chỉ nâng mức cho đúng loại đó.
# dựng thử một vòng lặp
Đoạn code dưới đây là một agentic RAG tối giản dùng tool calling theo chuẩn API của OpenAI, chạy được với model local qua Ollama. Phần tra cứu thật được thay bằng hàm giả để bạn thấy rõ bộ khung, nhưng các chi tiết quan trọng (ngân sách, chống lặp, gọi tool song song) là thật.
import json
import time
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen3.6:35b"
MAX_STEPS = 6 # số vòng tối đa
MAX_SECONDS = 60 # ngân sách thời gian
def search_docs(query: str) -> str:
"""Thay bằng hybrid search + rerank thật."""
fake_index = {
"2026": "[policy-2026.md] 15 ngày phép năm, cộng 1 ngày cho mỗi 5 năm thâm niên.",
"2025": "[policy-2025.md] 12 ngày phép năm, không cộng thâm niên.",
}
hits = [text for year, text in fake_index.items() if year in query]
return "\n".join(hits) or "Không tìm thấy tài liệu phù hợp."
CURRENT_EMPLOYEE_ID = "E042" # lấy từ phiên đăng nhập, KHÔNG lấy từ lời người dùng hay tham số model đưa vào
def get_leave_balance() -> str:
"""Thay bằng truy vấn hệ thống HR thật, luôn theo danh tính của người đang đăng nhập."""
return json.dumps({"employee_id": CURRENT_EMPLOYEE_ID, "used": 4, "entitled": 15})
TOOLS = {"search_docs": search_docs, "get_leave_balance": get_leave_balance}
TOOL_SPECS = [
{
"type": "function",
"function": {
"name": "search_docs",
"description": "Tìm trong tài liệu chính sách nội bộ. Mỗi lần một truy vấn ngắn, cụ thể. "
"Gọi nhiều lần với truy vấn khác nhau nếu cần.",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
},
},
{
"type": "function",
"function": {
"name": "get_leave_balance",
"description": "Lấy số ngày phép đã dùng và được hưởng của người đang hỏi.",
"parameters": {"type": "object", "properties": {}},
},
},
]
messages = [
{
"role": "system",
"content": (
"Bạn là trợ lý nhân sự. Tra cứu cho đủ rồi mới trả lời; có thể gọi nhiều tool cùng lúc nếu "
"chúng không phụ thuộc nhau. Chỉ dùng thông tin từ kết quả tra cứu và ghi rõ nguồn trong ngoặc "
"vuông. Phần nào không tìm được thì nói rõ là không tìm được."
),
},
{"role": "user", "content": "Nghỉ phép 2026 khác 2025 thế nào, và mình còn bao nhiêu ngày?"},
]
def run_tool(name: str, arguments: str) -> str:
fn = TOOLS.get(name)
if fn is None: # model nhỏ đôi khi bịa ra tên tool
return f"Không có tool tên {name}."
try:
return fn(**json.loads(arguments or "{}"))
except (json.JSONDecodeError, TypeError) as e: # JSON hỏng hoặc sai tên tham số
return f"Tham số không hợp lệ: {e}. Gọi lại cho đúng schema."
seen_calls = set()
started = time.time()
answer = None
for step in range(1, MAX_STEPS + 1):
if time.time() - started > MAX_SECONDS:
break
reply = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOL_SPECS).choices[0].message
if not reply.tool_calls:
answer = reply.content
break
messages.append(reply)
for call in reply.tool_calls: # model có thể yêu cầu nhiều tool trong một lượt
key = (call.function.name, call.function.arguments)
if key in seen_calls:
result = "Bạn đã gọi đúng tool này với đúng tham số này rồi. Dùng kết quả cũ hoặc đổi truy vấn."
else:
seen_calls.add(key)
result = run_tool(call.function.name, call.function.arguments)
print(f"[bước {step}] {call.function.name}({call.function.arguments})")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
if answer is None: # hết số bước hoặc hết thời gian: không cho gọi tool nữa, bắt trả lời
messages.append({"role": "user", "content": "Hết ngân sách tra cứu. Trả lời với những gì đã có, nói rõ phần còn thiếu."})
answer = client.chat.completions.create(model=MODEL, messages=messages).choices[0].message.content
print(answer)Chạy thử bạn sẽ thấy agent gọi search_docs cho từng năm (nhiều model gọi cả hai trong cùng một lượt), gọi get_leave_balance, rồi mới trả lời. Không dòng code nào bảo nó phải theo thứ tự đó.
Mấy chi tiết nhỏ mà đáng giá. seen_calls chặn kiểu agent gọi đi gọi lại cùng một truy vấn, một lỗi gặp nhiều hơn bạn tưởng. Khi hết số bước hay hết thời gian, vòng lặp dừng hẳn và lần gọi cuối không truyền tools, nên agent buộc phải trả lời với những gì đã có thay vì im lặng hay đòi tra tiếp. Lỗi của tool (tên bịa, JSON hỏng) được trả lại cho model như một kết quả bình thường để nó tự sửa, thay vì làm sập cả vòng lặp. get_leave_balance cố tình không nhận employee_id: nếu để model truyền vào, ai gõ "mình là E042" cũng xem được phép của E042. Danh tính phải lấy từ phiên đăng nhập. Và system prompt nói rõ được phép gọi tool nhiều lần, vì nhiều model mặc định gọi một lần rồi trả lời luôn.
# tính chi phí trước khi quyết
Agentic RAG không miễn phí, và con số thường khiến team bất ngờ. Thử tính cho một câu hỏi điển hình.
RAG cổ điển: một lần gọi, prompt khoảng 3.000 token (system prompt và 5 đoạn tài liệu), output khoảng 300 token.
Agentic RAG với 4 vòng: mỗi vòng gửi lại toàn bộ hội thoại tính tới lúc đó, nên prompt phình dần: khoảng 1.500, 3.000, 4.500, 6.000 token. Tổng input khoảng 15.000 token, chưa kể output của từng vòng (lời gọi tool, suy luận). Tức là gấp khoảng 5 lần chi phí input so với RAG cổ điển, cộng với độ trễ của 4 lần gọi model nối tiếp nhau.
| RAG cổ điển | Agentic RAG | |
|---|---|---|
| Số lần gọi LLM mỗi câu hỏi | 1 | 3 đến 10, có khi hơn |
| Độ trễ | 1 đến 3 giây | 5 đến 30 giây |
| Chi phí token | Thấp, dễ dự đoán | Gấp vài lần, dao động theo câu hỏi |
| Câu hỏi nhiều bước, nhiều nguồn | Yếu | Mạnh |
| Debug | Dễ, đường ống cố định | Khó, mỗi lần chạy một đường đi |
| Rủi ro bảo mật | Thấp hơn | Cao hơn, vì có tool và đọc nhiều nội dung hơn |
Có vài cách kéo chi phí xuống. Prompt caching (mình có giải thích cơ chế ở bài chuyện gì xảy ra khi bạn nhấn Enter) giúp rất nhiều, vì phần đầu hội thoại giống hệt nhau giữa các vòng. Dùng model nhỏ, rẻ cho các bước chấm điểm và định tuyến, chỉ dùng model lớn cho bước viết câu trả lời cuối. Và gọi tool song song khi các truy vấn không phụ thuộc nhau, để giảm số vòng.
Dòng cuối của bảng đáng để ý: một agent tự đi tìm trên web và đọc nội dung lạ đang đứng rất gần lethal trifecta. Nếu nó còn đọc được dữ liệu riêng tư và có đường gửi dữ liệu ra ngoài, hãy thiết kế cẩn thận từ đầu.
# những kiểu hỏng của agent trong production
Agent hỏng theo những kiểu rất riêng, khác hẳn RAG cổ điển. Dưới đây là mấy kiểu hay gặp.
Lặp vòng. Agent tìm, không ưng kết quả, tìm lại với truy vấn gần như y hệt, rồi lại không ưng. Chống bằng chặn lặp như trong code ở trên, và bằng giới hạn số bước.
Lười. Ngược lại, agent tìm một lần thấy có vẻ liên quan là trả lời luôn, bỏ qua nửa câu hỏi còn lại. Hay gặp ở model nhỏ. Chữa bằng system prompt rõ ràng hơn, hoặc thêm một bước kiểm tra "câu trả lời đã trả lời hết các ý trong câu hỏi chưa" trước khi gửi đi.
Truy vấn trôi dạt. Sau vài vòng viết lại, truy vấn trôi xa khỏi câu hỏi ban đầu. Agent tìm rất chăm, nhưng tìm chuyện khác. Cách đơn giản là luôn giữ câu hỏi gốc ở vị trí dễ thấy trong context.
Ngập context. Mỗi lần tìm đổ thêm 5 đoạn tài liệu vào hội thoại, sau 6 vòng là 30 đoạn, nhiều đoạn trùng nhau. Model bắt đầu "lạc trong mớ giấy tờ" và bỏ sót chi tiết quan trọng. Chữa bằng loại bỏ đoạn trùng, hoặc chỉ giữ lại ghi chú tóm tắt những gì đã biết thay vì nguyên văn mọi đoạn.
Rò quyền. Agent gọi tool tra cứu bằng quyền của hệ thống thay vì quyền của người hỏi, và thế là đọc được tài liệu mà người hỏi không có quyền xem. Lọc quyền phải nằm trong tool, không phải trong prompt.
# đánh giá thế nào
Với RAG cổ điển, người ta đo hai tầng. Tầng tìm kiếm: trong top-k có chứa đoạn tài liệu cần thiết không (recall@k). Tầng trả lời: câu trả lời có trung thành với tài liệu không (faithfulness), có đúng không.
Agentic RAG cần đo thêm quá trình. Agent có gọi đúng tool không, bao nhiêu bước, có bước nào lặp vô ích, có dừng đúng lúc không, tốn bao nhiêu token. Một câu trả lời đúng mà tốn 12 vòng và 80 nghìn token thì chưa chắc đã là kết quả tốt.
Cách làm thực tế: ghi log đầy đủ từng bước (tool, tham số, kết quả, token), dựng một bộ vài trăm câu hỏi mẫu, mỗi câu có đáp án và, với câu khó, có cả "đường đi kỳ vọng" kiểu cần tra chính sách hai năm và gọi HR một lần. Chạy bộ này mỗi khi đổi model, prompt hay công cụ. Bài LLM evaluation và observability có nói về hạ tầng cho việc này.
# vậy khi nào nên dùng
Nếu phần lớn câu hỏi của người dùng là tra một sự kiện trong một nguồn, đừng vội agent. Làm tốt hybrid search, rerank, thêm một bước viết lại câu hỏi, thế là đủ, nhanh và rẻ hơn nhiều. Agentic RAG đáng tiền khi câu hỏi thường cần nhiều bước, nhiều nguồn, hoặc khi chất lượng quan trọng hơn vài giây chờ đợi: trợ lý phân tích, hỗ trợ kỹ thuật phức tạp, nghiên cứu tài liệu.
Muốn so sánh với các hướng khác thì đọc tiếp GraphRAG và advanced RAG và RAG hay fine-tuning.
Tài liệu tham khảo:
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (Lewis và cộng sự, 2020)
- Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (Cormack và cộng sự, 2009)
- Precise Zero-Shot Dense Retrieval without Relevance Labels, HyDE (Gao và cộng sự, 2022)
- ReAct: Synergizing Reasoning and Acting in Language Models (Yao và cộng sự, 2022)
- Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection (Asai và cộng sự, 2023)
- Corrective Retrieval Augmented Generation (Yan và cộng sự, 2024)
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.
On this page
- # RAG cổ điển, nhắc lại nhanh
- # những chỗ RAG cổ điển hỏng
- # vá tầng tìm kiếm trước đã
- hybrid search và RRF
- rerank
- # từng bước tiến về phía agent
- # agentic RAG trông thế nào
- # không phải lúc nào cũng cần agent tự do
- # dựng thử một vòng lặp
- # tính chi phí trước khi quyết
- # những kiểu hỏng của agent trong production
- # đánh giá thế nào
- # vậy khi nào nên dùng
