RAG là gì? Cho chatbot quán Mộc đọc đúng đoạn chính sách

RAG là gì và làm thế nào để chatbot chỉ đọc đúng đoạn chính sách liên quan tới câu hỏi: tách đoạn, tìm bằng từ khóa, ghép vào prompt, và chỗ nó còn hỏng.

RAG là gì? Viết tắt của retrieval-augmented generation: trước khi mô hình trả lời, code của bạn tìm vài đoạn tài liệu liên quan tới câu hỏi và dán chúng vào prompt. Mô hình chỉ đọc đúng phần cần. Bạn có muốn chatbot trả lời từ chính sách của quán mà không gửi cả tài liệu mỗi lần không? Cùng mình làm nhé!

Vì sao không gửi cả chính sách vào system prompt?

Ở bài trước, chatbot của quán Mộc (một quán cà phê nhỏ bán online, quán và dữ liệu trong series là minh họa) đã nhớ được cuộc trò chuyện. Chatbot này đã có vòng lặp agent để tự làm nhiều bước. Cuối bài trước, mình nhắc tới một chuyện chưa giải quyết. Quán có một tập chính sách về đổi trả, hoàn tiền, dị ứng và khuyến mãi, và system prompt của chatbot chỉ chứa mấy dòng về giờ mở cửa, phí ship và bảng giá.

Cách đơn giản nhất là dán cả tập chính sách vào system prompt. Với tài liệu ngắn thì cách này chạy tốt, và mình nghĩ bạn nên thử nó trước khi làm gì phức tạp hơn. Nhưng có hai cái giá. Mọi thứ trong prompt được gửi lại ở mỗi lần gọi, kể cả khi khách chỉ hỏi giờ mở cửa, nên chi phí và độ trễ tăng theo độ dài tài liệu. Và nếu tài liệu có 40 trang, câu trả lời cần một đoạn năm dòng nằm lẫn trong đó, cho mô hình đọc cả 40 trang chưa chắc tốt hơn cho nó đọc đúng năm dòng.

RAG là cách giải quyết cả hai. Quy trình gồm ba bước, mà bài này sẽ làm lần lượt:

  1. Tách tài liệu thành các đoạn nhỏ, làm một lần từ trước.
  2. Khi khách hỏi, tìm vài đoạn khớp nhất với câu hỏi. Đây là phần "retrieval", tìm kiếm.
  3. Ghép các đoạn đó vào prompt rồi mới hỏi mô hình. Đây là phần "augmented generation", trả lời có bổ sung tài liệu.

Mình sẽ làm với một chính sách mẫu 14 đoạn để bạn chạy được ngay trên máy, không cần key. Chính sách thật của quán sẽ dài hơn, nhưng cách làm không đổi.

Tài liệu chính sách trông thế nào?

Đây là toàn bộ chinh-sach.md, mình viết riêng cho bài này. Mỗi mục bắt đầu bằng một tiêu đề ##, và mình dựa vào chính các tiêu đề đó để tách đoạn:

chinh-sach.md
# Chính sách của quán cà phê Mộc

## Giờ mở cửa và ngày lễ
Mộc mở cửa từ 6:30 đến 22:00 mỗi ngày. Dịp Tết Nguyên đán quán nghỉ từ 28 tháng Chạp đến hết mùng 3 và thông báo trên trang chủ trước một tuần. Các ngày lễ khác quán vẫn mở, nhưng đơn giao tận nơi có thể chậm hơn.

## Đặt hàng và thanh toán
Khách đặt hàng trên website hoặc nhắn tin cho quán. Quán nhận thanh toán bằng chuyển khoản, ví điện tử và tiền mặt khi nhận hàng. Đơn chuyển khoản được pha sau khi quán thấy tiền về tài khoản, thường mất dưới hai phút.

## Phí giao hàng
Mộc giao trong bán kính 5 km, phí 15.000đ. Đơn từ 150.000đ được miễn phí giao hàng. Ngoài 5 km quán không nhận giao, nhưng khách có thể tự đặt xe công nghệ đến lấy.

## Hủy đơn
Khách hủy đơn miễn phí khi đơn còn ở trạng thái "đã nhận". Khi đơn chuyển sang "đang pha chế", quán không hủy được vì nguyên liệu đã dùng. Đơn chuyển khoản hủy hợp lệ sẽ được hoàn lại đủ số tiền, theo mục Hoàn tiền.

## Đổi món khi giao sai hoặc pha sai
Nếu quán giao sai món hoặc pha sai so với đơn, khách nhắn trong vòng 30 phút kể từ lúc nhận, kèm ảnh món đã giao. Quán sẽ pha lại món đúng và giao lại miễn phí, hoặc hoàn tiền nếu khách không muốn đợi. Món đã uống quá một nửa thì quán không đổi.

## Hoàn tiền
Quán hoàn tiền trong ba ngày làm việc, về đúng phương thức khách đã thanh toán. Đơn tiền mặt được hoàn bằng chuyển khoản vào tài khoản khách cung cấp. Số tiền hoàn là giá món, phí giao hàng chỉ được hoàn khi lỗi do quán.

## Dị ứng và thành phần
Cà phê muối và bạc xỉu có sữa. Bánh mì trứng có gluten và trứng. Khách dị ứng cần ghi chú trong đơn, quán sẽ báo lại có làm được hay không. Quán dùng chung dụng cụ pha chế nên không thể đảm bảo tuyệt đối không lẫn thành phần.

## Khuyến mãi và mã giảm giá
Mỗi đơn dùng một mã giảm giá. Mã không cộng dồn với chương trình tích điểm trong cùng một đơn. Mã hết hạn không được gia hạn, và đơn đã thanh toán không được áp mã thêm.

## Tích điểm
Mỗi 10.000đ tích một điểm. Một trăm điểm đổi một đồ uống bất kỳ có giá đến 35.000đ. Điểm có hiệu lực mười hai tháng kể từ ngày tích. Đơn bị hoàn tiền sẽ bị trừ lại số điểm đã tích.

## Đặt số lượng lớn
Đơn từ 20 ly trở lên cần đặt trước một ngày, và quán sẽ báo giá riêng. Đơn lớn không áp mã giảm giá. Quán xác nhận bằng tin nhắn trước 17:00 ngày hôm trước.

## Voucher quà tặng
Voucher có giá trị từ 100.000đ, hạn dùng sáu tháng. Voucher không đổi thành tiền mặt và không hoàn lại sau khi bán. Một đơn dùng được nhiều voucher.

## Ly tự mang
Khách mang ly riêng được giảm 3.000đ cho mỗi đồ uống. Ly cần sạch và dung tích từ 350 ml. Quán có quyền từ chối ly có mùi hoặc bị nứt.

## Bảo mật thông tin khách
Quán chỉ dùng số điện thoại và địa chỉ để giao hàng và chăm sóc đơn. Quán không bán hay chia sẻ thông tin của khách cho bên thứ ba. Khách muốn xóa thông tin có thể nhắn cho quán.

## Liên hệ
Khách nhắn tin cho quán trong giờ mở cửa. Trường hợp khẩn như dị ứng sau khi uống, khách gọi hotline của quán ngay.

Chú ý hai mục Hủy đơn và Hoàn tiền. Mục Hủy đơn chỉ nói "được hoàn lại đủ số tiền, theo mục Hoàn tiền", còn con số "ba ngày làm việc" nằm ở mục Hoàn tiền. Mình cố ý để như vậy, vì tài liệu thật cũng hay trỏ qua lại giữa các mục. Chi tiết này sẽ quan trọng ở gần cuối bài.

Tách tài liệu thành đoạn thế nào?

Mỗi đoạn là một đơn vị mà bộ tìm kiếm sẽ chấm điểm và gửi đi. Đoạn quá lớn thì kéo theo nhiều chữ thừa, đoạn quá nhỏ thì thiếu ngữ cảnh. Với tài liệu đã được tác giả chia mục sẵn, cách đơn giản và ít sai nhất là tách theo mục:

src/tach-doan.ts
import { readFileSync } from "node:fs";

export type Doan = { tieuDe: string; noiDung: string };

// Tách tài liệu Markdown thành các đoạn: mỗi tiêu đề "## ..." mở một đoạn mới.
export function tachDoan(markdown: string): Doan[] {
  return markdown
    .split(/^## /m)
    .slice(1) // phần trước "## " đầu tiên chỉ là tiêu đề chung của tài liệu
    .map((khoi) => {
      const [tieuDe, ...dong] = khoi.split("\n");
      return { tieuDe: tieuDe.trim(), noiDung: dong.join("\n").trim() };
    });
}

export function docChinhSach(): Doan[] {
  return tachDoan(readFileSync(new URL("../chinh-sach.md", import.meta.url), "utf8"));
}

Regex ^## với cờ m khớp chữ ## ở đầu mỗi dòng. Hàm split cắt tài liệu tại đó, và slice(1) bỏ phần trước tiêu đề đầu tiên. Chạy lên cho ra 14 đoạn, đúng bằng số mục.

Một cách tách khác là cắt theo số ký tự cố định, ví dụ cứ 500 ký tự một đoạn. Nó dùng được cho tài liệu không có cấu trúc, nhưng dễ cắt đứt giữa câu, và đó là một trong những chỗ làm RAG trả lời lệch. Mình sẽ để nó cho các bài viết sau, và ở bài này giữ cách cắt theo mục.

Tìm đoạn khớp với câu hỏi bằng cách nào?

Đây là phần "retrieval". Cách ngây thơ nhất mà vẫn làm được việc là đếm từ: câu hỏi có những từ nào, và đoạn nào chứa nhiều từ đó nhất. Nhưng đếm đều thì mấy từ như "tiền" hay "đơn" xuất hiện ở nhiều đoạn sẽ lấn át những từ hiếm như "miễn". Vì vậy mình cho mỗi từ một trọng số, từ nào có trong ít đoạn thì nặng điểm hơn. Công thức này có tên là IDF, viết tắt của inverse document frequency. Nó là một nửa của thuật toán TF-IDF kinh điển trong tìm kiếm văn bản, và mình chỉ dùng nửa này cho gọn:

src/tim-doan.ts
import type { Doan } from "./tach-doan.ts";

// Từ quá phổ biến, không giúp phân biệt đoạn này với đoạn khác.
const TU_BO_QUA = new Set(["và", "là", "của", "có", "cho", "được", "không", "thì", "mà", "em", "mình", "bạn", "ạ", "nhé", "này", "một", "khi", "nếu", "bị", "để", "với", "các", "những", "thế", "nào", "sao", "gì", "quán", "mộc"]);

export function tachTu(chu: string): string[] {
  return (chu.toLowerCase().match(/\p{L}+|\d+/gu) ?? []).filter((tu) => !TU_BO_QUA.has(tu));
}

export type KetQuaTim = { doan: Doan; diem: number; tuKhop: string[] };

// Chấm điểm bằng từ khóa: mỗi từ của câu hỏi có trong đoạn được cộng điểm,
// từ càng hiếm (ít đoạn chứa) càng nhiều điểm. Tiêu đề đoạn được tính cùng nội dung.
export function timDoan(cauHoi: string, cacDoan: Doan[], soDoan = 2): KetQuaTim[] {
  const tuCuaDoan = cacDoan.map((d) => new Set(tachTu(`${d.tieuDe} ${d.noiDung}`)));
  const tuCauHoi = [...new Set(tachTu(cauHoi))];
  const idf = (tu: string) => Math.log(1 + cacDoan.length / (1 + tuCuaDoan.filter((s) => s.has(tu)).length));

  return cacDoan
    .map((doan, i) => {
      const tuKhop = tuCauHoi.filter((tu) => tuCuaDoan[i].has(tu));
      return { doan, tuKhop, diem: Number(tuKhop.reduce((tong, tu) => tong + idf(tu), 0).toFixed(2)) };
    })
    .filter((kq) => kq.diem > 0)
    .sort((a, b) => b.diem - a.diem)
    .slice(0, soDoan);
}

Có vài điều trong code này bạn nên biết. Biểu thức \p{L}+ khớp một dãy chữ cái của mọi ngôn ngữ, kể cả chữ có dấu tiếng Việt, nên "hoàn" không bị cắt ở chữ "à". Danh sách TU_BO_QUA là những từ như "và", "của" mà câu hỏi nào cũng có, nên chúng không giúp tìm gì. Danh sách này mình tự soạn, và bạn có thể thấy nó khá tùy tiện, vì mình thêm dần cho tới khi kết quả bớt nhiễu. Cuối cùng, hàm chỉ trả về soDoan đoạn đầu, mặc định là 2. Con số này là một lựa chọn. Lấy nhiều đoạn hơn thì ít bỏ sót hơn nhưng prompt dài hơn.

Trước khi gọi mô hình, mình muốn xem bộ tìm kiếm chọn gì. File thu-tim.ts in ra các đoạn được chọn kèm điểm và những từ đã khớp cho năm câu hỏi:

src/thu-tim.ts
// In ra các đoạn mà bộ tìm kiếm chọn cho từng câu hỏi, để xem nó đúng hay sai trước khi gọi mô hình.
import { docChinhSach } from "./tach-doan.ts";
import { timDoan } from "./tim-doan.ts";

const CAC_DOAN = docChinhSach();
console.log(`Tài liệu có ${CAC_DOAN.length} đoạn: ${CAC_DOAN.map((d) => d.tieuDe).join(" | ")}\n`);

const CAU_HOI = [
  "Đơn từ bao nhiêu tiền thì được miễn phí giao hàng?",
  "Quán giao sai món thì được hoàn tiền bao lâu?",
  "Em hủy đơn chuyển khoản rồi, bao lâu thì tiền về?",
  "Ly cà phê pha sai, em muốn quán đền tiền",
  "Mình bị dị ứng sữa thì uống gì được?",
];

for (const cau of CAU_HOI) {
  console.log(`Khách: ${cau}`);
  const kq = timDoan(cau, CAC_DOAN);
  if (kq.length === 0) console.log("  (không có đoạn nào khớp)");
  for (const { doan, diem, tuKhop } of kq) console.log(`  ${diem.toFixed(2)}  ${doan.tieuDe}   [khớp: ${tuKhop.join(", ")}]`);
  console.log();
}
node src/thu-tim.ts
Tài liệu có 14 đoạn: Giờ mở cửa và ngày lễ | Đặt hàng và thanh toán | Phí giao hàng | Hủy đơn | Đổi món khi giao sai hoặc pha sai | Hoàn tiền | Dị ứng và thành phần | Khuyến mãi và mã giảm giá | Tích điểm | Đặt số lượng lớn | Voucher quà tặng | Ly tự mang | Bảo mật thông tin khách | Liên hệ

Khách: Đơn từ bao nhiêu tiền thì được miễn phí giao hàng?
  7.12  Phí giao hàng   [khớp: đơn, từ, miễn, phí, giao, hàng]
  6.88  Đổi món khi giao sai hoặc pha sai   [khớp: đơn, từ, tiền, miễn, phí, giao]

Khách: Quán giao sai món thì được hoàn tiền bao lâu?
  7.32  Đổi món khi giao sai hoặc pha sai   [khớp: giao, sai, món, hoàn, tiền]
  5.24  Hoàn tiền   [khớp: giao, món, hoàn, tiền]

Khách: Em hủy đơn chuyển khoản rồi, bao lâu thì tiền về?
  6.92  Hủy đơn   [khớp: hủy, đơn, chuyển, khoản, tiền]
  6.57  Đặt hàng và thanh toán   [khớp: đơn, chuyển, khoản, tiền, về]

Khách: Ly cà phê pha sai, em muốn quán đền tiền
  6.25  Đổi món khi giao sai hoặc pha sai   [khớp: pha, sai, muốn, tiền]
  5.49  Dị ứng và thành phần   [khớp: cà, phê, pha]

Khách: Mình bị dị ứng sữa thì uống gì được?
  5.55  Dị ứng và thành phần   [khớp: dị, ứng, sữa]
  4.80  Liên hệ   [khớp: dị, ứng, uống]

Câu đầu và câu hai chọn đúng đoạn ở vị trí số một. Câu thứ năm cũng đúng, dù đoạn thứ hai, "Liên hệ", chỉ khớp được vì có chữ "dị ứng". Mình tạm hài lòng với ba câu này. Hai câu còn lại thì chưa, và mình sẽ quay lại sau khi nối nó với mô hình.

Ghép đoạn vào prompt như thế nào?

Có đoạn rồi, giờ ghép chúng vào tin nhắn gửi cho mô hình. Tài liệu của Anthropic về prompt cho ngữ cảnh dài có ba lời khuyên mà mình làm theo. Đặt tài liệu dài ở đầu prompt và câu hỏi ở cuối. Bọc mỗi tài liệu trong thẻ để mô hình phân biệt được đâu là tài liệu, đâu là câu hỏi. Và với tác vụ cần bám tài liệu, cho mô hình biết phải dựa vào đâu.

Mình thêm một yêu cầu nữa: mỗi đoạn có một tên, và mô hình phải ghi tên đoạn mình dùng ở cuối câu trả lời. Nhờ vậy, khi câu trả lời sai, bạn nhìn vào là biết nó dựa vào đoạn nào.

src/tra-loi.ts
import Anthropic from "@anthropic-ai/sdk";

import type { Doan } from "./tach-doan.ts";
import { tachTu } from "./tim-doan.ts";

export const MODEL = "claude-opus-5-5";

export const SYSTEM = `Bạn là nhân viên chăm sóc khách hàng của quán cà phê Mộc. Trả lời ngắn gọn, thân thiện, bằng tiếng Việt.
Chỉ trả lời dựa trên các đoạn trong thẻ <tai_lieu>. Cuối câu trả lời, ghi tên đoạn bạn đã dùng trong ngoặc vuông.
Nếu các đoạn không có thông tin để trả lời, hãy nói quán chưa có thông tin đó, đừng đoán.`;

// Ghép các đoạn tìm được vào tin nhắn của khách. Mỗi đoạn có tên để mô hình trích nguồn.
export function ghepCauHoi(cauHoi: string, cacDoan: Doan[]): string {
  const taiLieu = cacDoan.map((d) => `<doan ten="${d.tieuDe}">\n${d.noiDung}\n</doan>`).join("\n");
  return `<tai_lieu>\n${taiLieu}\n</tai_lieu>\n\nCâu hỏi của khách: ${cauHoi}`;
}

export function coKey(): boolean {
  return Boolean(process.env.ANTHROPIC_API_KEY) && process.env.MOC_OFFLINE !== "1";
}

export async function traLoi(cauHoi: string, cacDoan: Doan[]): Promise<string> {
  if (!coKey()) return moHinhOffline(cauHoi, cacDoan);
  const client = new Anthropic();
  const response = await client.messages.create({
    model: MODEL,
    max_tokens: 16000,
    output_config: { effort: "low" },
    system: SYSTEM,
    messages: [{ role: "user", content: ghepCauHoi(cauHoi, cacDoan) }],
  });
  return response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
}

// Mô hình giả lập, không phải mô hình thật: chọn câu trong các đoạn có nhiều từ trùng nhất với câu hỏi
// và đọc lại nguyên văn. Nó chỉ dùng được chữ có trong đoạn, nên cho thấy rõ mô hình "nhìn thấy" gì.
function moHinhOffline(cauHoi: string, cacDoan: Doan[]): string {
  const tuHoi = new Set(tachTu(cauHoi));
  const cacCau = cacDoan.flatMap((d) =>
    d.noiDung.split(/(?<=\.)\s+/).map((cau) => ({ cau, ten: d.tieuDe, khop: tachTu(cau).filter((tu) => tuHoi.has(tu)).length })),
  );
  const tot = cacCau.filter((c) => c.khop >= 2).sort((a, b) => b.khop - a.khop).slice(0, 2);
  if (tot.length === 0) return "Dạ Mộc chưa có thông tin này ạ.";
  return `Dạ, ${tot.map((c) => c.cau).join(" ")} [${[...new Set(tot.map((c) => c.ten))].join("; ")}]`;
}

Về mô hình giả lập, mình nói thẳng cho bạn biết. Nó không hiểu gì cả, nó chọn hai câu có nhiều từ trùng nhất với câu hỏi trong các đoạn được gửi rồi đọc lại. Mình dùng nó vì mục đích của bài là cho thấy mô hình *nhìn thấy* gì, và một mô hình thật gần như chắc chắn viết trơn tru hơn nhiều. Nhưng nó có một tính chất hữu ích: nó không thể biết điều gì không có trong các đoạn được gửi, giống như mô hình thật đang được dặn "đừng đoán". Mình chưa chạy chế độ gọi Claude thật khi viết bài này, vì vậy mọi câu trả lời bên dưới là của mô hình giả lập. Bạn có key thì chạy lại và so sánh.

File cuối cùng nối ba bước lại và in ra số ký tự gửi đi, để so với việc gửi cả tài liệu. Mình đo bằng ký tự, không phải token, như các bài trước:

src/chay.ts
// RAG đầy đủ: tìm đoạn, ghép vào prompt, hỏi mô hình. So với việc gửi cả tài liệu ở mỗi lần gọi.
import { docChinhSach } from "./tach-doan.ts";
import { ghepCauHoi, traLoi } from "./tra-loi.ts";
import { timDoan } from "./tim-doan.ts";

const CAC_DOAN = docChinhSach();
const CAU_HOI = process.argv.slice(2).length ? [process.argv.slice(2).join(" ")] : [
  "Đơn từ bao nhiêu tiền thì được miễn phí giao hàng?",
  "Em hủy đơn chuyển khoản rồi, bao lâu thì tiền về?",
];

const kyTuCaTaiLieu = ghepCauHoi("", CAC_DOAN).length;
for (const cau of CAU_HOI) {
  const duocChon = timDoan(cau, CAC_DOAN).map((kq) => kq.doan);
  console.log(`Khách: ${cau}`);
  console.log(`  gửi kèm: ${duocChon.map((d) => d.tieuDe).join(" + ")}`);
  console.log(`  ${ghepCauHoi(cau, duocChon).length} ký tự (gửi cả tài liệu: ${kyTuCaTaiLieu} ký tự)`);
  console.log(`Mộc: ${await traLoi(cau, duocChon)}\n`);
}

Để chạy, bạn tạo một thư mục, đặt vào đó chinh-sach.md và các file trong thư mục src/ ở trên, rồi cài SDK bằng npm install @anthropic-ai/sdk@1.5.0. File tra-loi.ts import SDK ngay cả khi bạn chạy offline. Cần Node.js 22.18 trở lên để chạy thẳng file .ts.

node src/chay.ts
Khách: Đơn từ bao nhiêu tiền thì được miễn phí giao hàng?
  gửi kèm: Phí giao hàng + Đổi món khi giao sai hoặc pha sai
  594 ký tự (gửi cả tài liệu: 3176 ký tự)
Mộc: Dạ, Đơn từ 150.000đ được miễn phí giao hàng. Nếu quán giao sai món hoặc pha sai so với đơn, khách nhắn trong vòng 30 phút kể từ lúc nhận, kèm ảnh món đã giao. [Phí giao hàng; Đổi món khi giao sai hoặc pha sai]

Khách: Em hủy đơn chuyển khoản rồi, bao lâu thì tiền về?
  gửi kèm: Hủy đơn + Đặt hàng và thanh toán
  600 ký tự (gửi cả tài liệu: 3176 ký tự)
Mộc: Dạ, Đơn chuyển khoản hủy hợp lệ sẽ được hoàn lại đủ số tiền, theo mục Hoàn tiền. Đơn chuyển khoản được pha sau khi quán thấy tiền về tài khoản, thường mất dưới hai phút. [Hủy đơn; Đặt hàng và thanh toán]

Câu đầu tiên trả lời đúng, và chỉ gửi 594 ký tự thay cho 3.176. Phần thừa trong câu trả lời, câu về 30 phút, là do mô hình giả lập quá thô, không phải lỗi của RAG. Với tài liệu mẫu chỉ 14 đoạn, mức tiết kiệm này nhìn nhỏ, nhưng tỷ lệ mới quan trọng. Số ký tự gửi đi của RAG gần như không đổi dù tài liệu dài thêm, còn của cách gửi cả tài liệu thì tăng theo độ dài tài liệu. Nếu chính sách thật dài gấp mười lần bản mẫu, cách gửi cả tài liệu tốn gấp mười lần ở mỗi tin nhắn.

Câu trả lời thứ hai thiếu gì?

Câu thứ hai là chỗ RAG bắt đầu lộ giới hạn. Khách hỏi bao lâu thì tiền về, và câu trả lời là "được hoàn lại đủ số tiền, theo mục Hoàn tiền". Thiếu mất con số quan trọng nhất, ba ngày làm việc. Câu thứ hai trong trả lời, về dưới hai phút, là chuyện đơn được pha sau khi tiền về, hoàn toàn không liên quan tới hoàn tiền.

Nhìn lại kết quả tìm kiếm ở trên thì lý do khá rõ. Câu hỏi có "hủy", "đơn", "chuyển khoản", "tiền" và "về", nhưng không có chữ "hoàn". Đoạn Hoàn tiền có con số cần tìm, lại không chứa chữ "hủy" hay "chuyển khoản", nên điểm không đủ vào top 2. Hai đoạn được chọn đều có từ trùng với câu hỏi, nhưng không đoạn nào có câu trả lời. Bộ tìm kiếm làm đúng việc nó được giao, đếm từ giống nhau, còn việc khách *muốn biết* gì thì nó không biết.

Đây là điều mình nghĩ nhiều người mới làm RAG hay bỏ qua. Khi câu trả lời sai, bạn dễ đổ cho mô hình, và dễ sửa prompt hoài mà không đỡ. Nhưng mô hình trong ví dụ này làm đúng. Nó được dặn chỉ dựa vào các đoạn được đưa, và hai đoạn đó không có con số. Một mô hình thật có thể làm tệ hơn, nó có thể đoán "khoảng 1 đến 2 ngày" từ kiến thức chung. Cả hai kết quả đều bắt nguồn từ chỗ bước tìm kiếm đã không đưa đoạn cần thiết.

Câu thứ ba cũng đáng xem. Mình chạy riêng nó:

node src/chay.ts 'Ly cà phê pha sai, em muốn quán đền tiền'
Khách: Ly cà phê pha sai, em muốn quán đền tiền
  gửi kèm: Đổi món khi giao sai hoặc pha sai + Dị ứng và thành phần
  652 ký tự (gửi cả tài liệu: 3176 ký tự)
Mộc: Dạ, Nếu quán giao sai món hoặc pha sai so với đơn, khách nhắn trong vòng 30 phút kể từ lúc nhận, kèm ảnh món đã giao. Quán sẽ pha lại món đúng và giao lại miễn phí, hoặc hoàn tiền nếu khách không muốn đợi. [Đổi món khi giao sai hoặc pha sai]

Câu trả lời đúng, nhưng chỉ vì đoạn đầu tiên trúng. Khách viết "đền tiền" còn tài liệu viết "hoàn tiền", nên hai chữ đó không giúp gì. Đoạn thứ hai, Dị ứng và thành phần, được chọn vì có chữ "cà", "phê" và "pha", và nó chẳng liên quan gì tới chuyện pha sai. Nếu tài liệu không có sẵn chữ "pha sai", bộ tìm kiếm này sẽ bó tay.

Khi nào RAG không phải cách đúng?

Sau khi làm một vòng, mình thấy RAG đáng dùng khi tài liệu đủ dài để gửi hết là tốn, hoặc khi nó thay đổi thường xuyên đến mức không muốn viết lại system prompt mỗi lần. Nó cũng giúp bạn kiểm soát được câu trả lời dựa vào đâu, nhờ tên đoạn ở cuối câu.

Nhưng nếu tài liệu chỉ vài trang, mình sẽ gửi cả tài liệu và bỏ qua phần tìm kiếm. Mô hình hiện nay đọc được ngữ cảnh rất dài, và bạn không phải gánh thêm một bộ phận có thể trả sai đoạn. Bước tìm kiếm là một nơi nữa để lỗi xảy ra, và lỗi ở đó khó thấy hơn lỗi ở mô hình, vì câu trả lời vẫn nghe rất trôi chảy. Mình nghĩ cách chọn hợp lý là bắt đầu bằng cách đơn giản nhất chạy được, và chỉ làm RAG khi chi phí hay độ trễ thật sự thành vấn đề.

Nếu bạn làm RAG, có ba thói quen mình thấy đáng giữ. In ra đoạn nào được chọn cho mỗi câu hỏi, như thu-tim.ts làm, trước khi nhìn câu trả lời. Dặn mô hình trích tên đoạn và nói "chưa có thông tin" khi đoạn không đủ, vì một câu trả lời "không biết" tốt hơn một câu trả lời bịa. Và giữ một danh sách câu hỏi thật của khách để chạy lại mỗi khi đổi cách tách hay cách tìm.

Khách không dùng từ giống tài liệu

Chatbot của Mộc giờ đọc được chính sách mà không phải gửi nó cả tập ở mỗi lần gọi, và với những câu hỏi dùng đúng từ của tài liệu thì nó tìm đúng đoạn. Nhưng khách hàng thật không đọc tài liệu trước khi hỏi. Họ viết "đền tiền", "tiền về", "bị lộn món", còn tài liệu viết "hoàn tiền", "giao sai". Hai cách nói cùng nghĩa mà không có chữ nào giống nhau, và bộ tìm kiếm đếm từ không nhìn ra được. Chuyện con số "ba ngày" nằm ở đoạn khác với câu hỏi cũng là một dạng của vấn đề đó.

Làm sao để tìm theo nghĩa chứ không theo chữ, và tách đoạn thế nào để mỗi đoạn tự đủ ý, là việc của các bài viết sau.

Bạn theo dõi cả series ở Xây AI agent đầu tiên. Nếu đang ôn phỏng vấn, các câu hỏi về AI agent có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.