AI-native development⏱ 12 phút đọc · 7 thg 10, 2026

RAG là gì: cho AI trả lời đúng theo tài liệu của bạn, ví dụ TypeScript + pgvector

Chatbot trả lời tự tin nhưng sai chính sách công ty? RAG là gì theo góc nhìn dev web: pipeline chunk, embed, retrieve, generate, hybrid search, reranking, Contextual Retrieval, cách đo chất lượng, khi nào không cần RAG, kèm code TypeScript + pgvector.

HOLETEX · POST
RAG
retrieval

Sếp giao việc: làm một chatbot trả lời câu hỏi của khách dựa trên bộ tài liệu sản phẩm và chính sách của công ty. Bạn thử hỏi ChatGPT "Gói Pro được hoàn tiền trong bao lâu?". Nó trả lời trôi chảy, giọng rất chắc chắn, và sai. Không phải vì model kém, mà vì nó chưa từng đọc tài liệu của bạn.

Cách xử lý phổ biến nhất cho bài toán này là RAG, kèm theo đây là ví dụ TypeScript với PostgreSQL + pgvector.

RAG là gì

RAG (Retrieval-Augmented Generation) là kỹ thuật tìm các đoạn tài liệu liên quan tới câu hỏi, rồi đưa chúng vào prompt để model trả lời dựa trên đó. Tên gọi nói đúng ba bước:

  • Retrieval: tìm đoạn tài liệu liên quan trong kho dữ liệu của bạn.
  • Augmented: ghép các đoạn đó vào prompt.
  • Generation: model sinh câu trả lời dựa trên phần tài liệu vừa được đưa vào.

Mental model: RAG giống bài thi được mở tài liệu. Model không cần "học thuộc" dữ liệu của bạn (fine-tune), chỉ cần được đưa đúng trang tài liệu lúc làm bài. Vì vậy RAG hợp với dữ liệu thay đổi thường xuyên: cập nhật tài liệu là xong, không cần huấn luyện lại.

Nếu bạn đã đọc bài Context engineering là gì, RAG là một cách cụ thể để quyết định cái gì được vào context: trong hàng nghìn trang tài liệu, đoạn nào đáng để model đọc cho câu hỏi này?

Pipeline RAG: 2 giai đoạn, 5 bước

Giai đoạn indexing (chạy trước, chạy lại khi tài liệu đổi):

  1. Chunk: cắt tài liệu thành các đoạn nhỏ.
  2. Embed: dùng embedding model biến mỗi đoạn thành một vector (một dãy số thể hiện ý nghĩa của đoạn văn).
  3. Store: lưu đoạn văn và vector vào database có hỗ trợ tìm kiếm vector.

Giai đoạn query (chạy mỗi khi người dùng hỏi):

  1. Retrieve: embed câu hỏi bằng cùng embedding model, tìm các vector gần nhất, lấy ra top-k đoạn văn.
  2. Generate: ghép các đoạn đó vào prompt, gọi LLM để trả lời, kèm nguồn.

Ý tưởng cốt lõi: hai đoạn văn gần nghĩa sẽ có vector gần nhau, kể cả khi không dùng chung từ nào. Câu hỏi "trả lại tiền" vẫn có thể tìm ra đoạn viết về "chính sách hoàn phí".

Quy tắc không được quên: tài liệu và câu hỏi phải embed bằng cùng một model. Đổi embedding model đồng nghĩa với embed lại toàn bộ kho dữ liệu.

Chunking: bước quyết định nhiều hơn bạn nghĩ

Nhiều lỗi RAG không nằm ở model hay database, mà ở cách cắt tài liệu. Chunk quá to thì một đoạn chứa nhiều chủ đề, vector bị "loãng" và lấy về nhiều thứ thừa. Chunk quá nhỏ thì mất ngữ cảnh: đoạn "Thời hạn là 14 ngày" không nói 14 ngày cho việc gì.

Chiến lượcCách làmHợp với
Fixed-sizeCắt theo số ký tự/token cố định, có overlap giữa các đoạnVăn bản thô không có cấu trúc
Theo cấu trúcCắt theo heading Markdown, section HTML, hàm/class trong codeDocs, wiki, README, codebase
RecursiveThử cắt theo đoạn văn, không vừa thì theo câu, rồi theo từMặc định khá an toàn cho văn bản hỗn hợp
SemanticCắt tại chỗ embedding cho thấy đổi chủ đềTài liệu dài, ít heading

Lời khuyên thực tế: với tài liệu dev (Markdown, docs nội bộ), bắt đầu bằng cắt theo heading và giới hạn độ dài tối đa. Luôn lưu metadata (tên file, heading, URL) để trích nguồn và lọc. Kích thước chunk là tham số cần đo bằng dữ liệu của bạn.

Hybrid search: vector không phải lúc nào cũng thắng

Tìm kiếm bằng vector giỏi hiểu ý nghĩa, nhưng lại hay trượt với những thứ cần khớp chính xác: mã lỗi ERR_PAYMENT_402, tên hàm useFormStatus, mã đơn hàng, số phiên bản. Ngược lại, keyword search (BM25 hoặc full-text search) bắt chúng rất tốt nhưng không hiểu từ đồng nghĩa.

Hybrid search chạy cả hai rồi trộn kết quả. Cách trộn phổ biến là Reciprocal Rank Fusion (RRF): mỗi kết quả được cộng điểm 1 / (k + thứ hạng) từ từng danh sách, với k thường đặt là 60. RRF chỉ dùng thứ hạng nên không cần chuẩn hóa điểm của hai hệ thống khác thang đo. README của pgvector gợi ý đúng hướng này: kết hợp với full-text search của Postgres, rồi trộn bằng RRF hoặc cross-encoder.

Reranking: lọc lần hai cho chính xác

Vector search tối ưu cho tốc độ, nên thứ tự ở top đầu chưa phải chính xác nhất. Reranker (cross-encoder) đọc cùng lúc câu hỏi và từng đoạn ứng viên, chấm lại mức liên quan chính xác hơn, nhưng chậm hơn nhiều.

Mô hình hai tầng hay dùng: retrieve rộng (vài chục đoạn bằng hybrid search), rerank, rồi chỉ đưa vài đoạn tốt nhất vào prompt. Reranker dạng API phổ biến có Cohere Rerank và rerank-3 của Voyage AI (bản rerank-3-lite rẻ và nhanh hơn). Đổi lại là thêm một lần gọi API và độ trễ cho mỗi câu hỏi.

Contextual Retrieval: sửa lỗi chunk mất ngữ cảnh

Anthropic công bố kỹ thuật Contextual Retrieval để xử lý đúng vấn đề "chunk mất ngữ cảnh". Ý tưởng: trước khi embed, dùng một LLM đọc toàn bộ tài liệu và viết cho mỗi chunk một đoạn ngữ cảnh ngắn (khoảng 50 đến 100 token) giải thích chunk đó nằm ở đâu, nói về cái gì. Đoạn ngữ cảnh này được ghép vào đầu chunk rồi mới embed, và cũng đưa vào chỉ mục BM25.

Ví dụ chunk gốc "Thời hạn là 14 ngày kể từ ngày thanh toán" sẽ thành "Đoạn này thuộc mục Chính sách hoàn tiền gói Pro. Thời hạn là 14 ngày kể từ ngày thanh toán". Câu hỏi về hoàn tiền giờ có nhiều cơ hội tìm ra nó hơn.

Kết quả Anthropic báo cáo, đo bằng tỷ lệ truy xuất thất bại ở top 20 chunk:

Cấu hìnhTỷ lệ thất bạiGiảm so với ban đầu
Embedding thường5,7%
Contextual Embeddings3,7%35%
+ Contextual BM252,9%49%
+ Reranking (Cohere)1,9%67%

Lưu ý khi đọc bảng: đây là số liệu trên bộ thử nghiệm của Anthropic (tháng 9/2024, họ dùng Claude 3 Haiku để viết ngữ cảnh), dữ liệu của bạn sẽ cho kết quả khác; mỗi chunk tốn một lần gọi LLM lúc indexing (nhờ prompt caching, Anthropic ước tính khoảng 1,02 USD cho mỗi triệu token tài liệu); và hiệu quả đến từ việc cộng dồn cả ba kỹ thuật.

Nếu không muốn tự chạy LLM cho từng chunk, Voyage AI hiện có model voyage-context-4 tạo "contextualized chunk embeddings", tức vector của chunk đã mang ngữ cảnh của cả tài liệu. Một phiên bản rẻ hơn nhiều mà bạn làm được ngay: ghép tên file và heading vào đầu chunk trước khi embed. Ví dụ bên dưới làm đúng như vậy.

Ví dụ: RAG tối giản với TypeScript + PostgreSQL + pgvector

Ví dụ dùng PostgreSQL 18 + pgvector 0.8.x (Docker), thư viện pg + pgvector, OpenAI SDK cho embedding (text-embedding-3-small, 1536 chiều) và sinh câu trả lời (gpt-6-luna, model rẻ cho tác vụ khối lượng lớn). Có sẵn Postgres thì không cần thêm hạ tầng mới chỉ để thử RAG.

1. Chuẩn bị

bash
docker run -d --name rag-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 pgvector/pgvector:pg18-bookworm

mkdir rag-demo && cd rag-demo
npm init -y && npm pkg set type=module
npm i openai pg pgvector
npm i -D tsx

export OPENAI_API_KEY="sk-..."
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"

2. Schema: vector + full-text trong cùng một bảng

sql
-- schema.sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS chunks (
  id        bigserial PRIMARY KEY,
  source    text NOT NULL,
  heading   text NOT NULL DEFAULT '',
  content   text NOT NULL,
  embedding vector(1536) NOT NULL,
  -- 'simple' vì Postgres không có từ điển tiếng Việt sẵn
  tsv       tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED
);

CREATE INDEX IF NOT EXISTS chunks_embedding_idx
  ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS chunks_tsv_idx
  ON chunks USING gin (tsv);

Chạy: docker exec -i rag-db psql -U postgres < schema.sql. Index HNSW tạo được ngay trên bảng rỗng (khác IVFFlat cần dữ liệu để "train"). vector_cosine_ops đi với toán tử <=> (cosine distance). Lưu ý index HNSW của kiểu vector hỗ trợ tối đa 2.000 chiều, nên nếu dùng model 3072 chiều bạn phải giảm chiều hoặc chuyển sang halfvec.

3. Indexing: chunk, embed, store

ts
// ingest.ts: npx tsx ingest.ts ./docs
import fs from "node:fs/promises";
import path from "node:path";
import OpenAI from "openai";
import pg from "pg";
import pgvector from "pgvector/pg";

const openai = new OpenAI(); // đọc OPENAI_API_KEY từ env
const db = new pg.Client({ connectionString: process.env.DATABASE_URL });

type Chunk = { source: string; heading: string; content: string };

// Cắt Markdown theo heading "## ", section nào dài quá thì cắt tiếp theo đoạn văn
function chunkMarkdown(source: string, md: string, maxChars = 2000): Chunk[] {
  const chunks: Chunk[] = [];
  for (const section of md.split(/\n(?=## )/)) {
    const heading = section.match(/^## (.+)/)?.[1]?.trim() ?? "";
    let buf = "";
    for (const para of section.split(/\n{2,}/)) {
      if (buf && buf.length + para.length > maxChars) {
        chunks.push({ source, heading, content: buf.trim() });
        buf = "";
      }
      buf += para + "\n\n";
    }
    if (buf.trim()) chunks.push({ source, heading, content: buf.trim() });
  }
  return chunks;
}

async function embed(texts: string[]): Promise<number[][]> {
  const res = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: texts,
  });
  return res.data.map((d) => d.embedding);
}

await db.connect();
await pgvector.registerTypes(db);

const dir = process.argv[2] ?? "./docs";
for (const file of await fs.readdir(dir)) {
  if (!file.endsWith(".md")) continue;
  const md = await fs.readFile(path.join(dir, file), "utf8");
  const chunks = chunkMarkdown(file, md);

  await db.query("BEGIN"); // fail giữa chừng thì file cũ vẫn còn nguyên
  await db.query("DELETE FROM chunks WHERE source = $1", [file]); // index lại cả file
  for (let i = 0; i < chunks.length; i += 100) {
    const batch = chunks.slice(i, i + 100);
    // Contextual retrieval phiên bản rẻ: ghép tên file + heading vào trước khi embed
    const vectors = await embed(
      batch.map((c) => `${c.source} > ${c.heading}\n\n${c.content}`),
    );
    for (const [j, c] of batch.entries()) {
      await db.query(
        "INSERT INTO chunks (source, heading, content, embedding) VALUES ($1, $2, $3, $4)",
        [c.source, c.heading, c.content, pgvector.toSql(vectors[j])],
      );
    }
  }
  await db.query("COMMIT");
  console.log(`${file}: ${chunks.length} chunks`);
}
await db.end();

4. Query: hybrid search + generate

ts
// ask.ts: npx tsx ask.ts "Gói Pro được hoàn tiền trong bao lâu?"
import OpenAI from "openai";
import pg from "pg";
import pgvector from "pgvector/pg";

const openai = new OpenAI();
const db = new pg.Client({ connectionString: process.env.DATABASE_URL });

// Vector search và full-text search, mỗi bên lấy top 20, trộn bằng RRF (k = 60)
const HYBRID_SQL = `
WITH semantic AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
  FROM chunks
  ORDER BY embedding <=> $1
  LIMIT 20
),
keyword AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS rank
  FROM chunks,
       -- đổi AND thành OR: câu hỏi tự nhiên hiếm khi chứa đủ mọi từ
       to_tsquery('simple', replace(plainto_tsquery('simple', $2)::text, '&', '|')) AS q
  WHERE tsv @@ q
  ORDER BY ts_rank_cd(tsv, q) DESC
  LIMIT 20
)
SELECT c.source, c.heading, c.content,
       COALESCE(1.0 / (60 + s.rank), 0) + COALESCE(1.0 / (60 + k.rank), 0) AS score
FROM semantic s
FULL OUTER JOIN keyword k ON s.id = k.id
JOIN chunks c ON c.id = COALESCE(s.id, k.id)
ORDER BY score DESC
LIMIT 5`;

const question = process.argv.slice(2).join(" ");

await db.connect();
await pgvector.registerTypes(db);

const { data } = await openai.embeddings.create({
  model: "text-embedding-3-small", // phải trùng model lúc ingest
  input: question,
});
const { rows } = await db.query(HYBRID_SQL, [pgvector.toSql(data[0].embedding), question]);

const docs = rows
  .map((r, i) => `<doc id="${i + 1}" source="${r.source} > ${r.heading}">\n${r.content}\n</doc>`)
  .join("\n");

const response = await openai.responses.create({
  model: "gpt-6-luna",
  instructions:
    "Bạn là trợ lý hỗ trợ khách hàng. Chỉ trả lời dựa trên các tài liệu trong <docs>. " +
    "Nếu tài liệu không có thông tin, nói rõ là không tìm thấy, không tự suy đoán. " +
    "Ghi [id] của tài liệu sau mỗi ý.",
  input: `<docs>\n${docs}\n</docs>\n\nCâu hỏi: ${question}`,
});

console.log(response.output_text);
console.log("\nNguồn:\n" + rows.map((r, i) => `[${i + 1}] ${r.source} > ${r.heading}`).join("\n"));
await db.end();

Ba điểm đáng chú ý:

  • Prompt ép model bám tài liệu và cho phép nói "không tìm thấy". Thiếu câu này, model sẽ lấp chỗ trống bằng kiến thức chung, quay lại đúng vấn đề ban đầu.
  • Trả về nguồn để người dùng tự kiểm tra. Đây là thứ làm người dùng tin hệ thống.
  • Full-text cấu hình simple chỉ đưa từ về chữ thường, không bỏ stop word hay biến đổi từ gốc (parser vẫn tách theo dấu câu, nên ERR_PAYMENT_402 thành err, payment, 402). Vậy là đủ để bắt mã lỗi, tên hàm, tên sản phẩm. Vì đổi sang OR nên các từ phổ biến như "là", "gì" cũng khớp, ts_rank_cd sẽ đẩy đoạn khớp nhiều từ hơn lên trên.

Muốn dùng Claude thay OpenAI?

Anthropic không có embedding model riêng, docs của họ giới thiệu Voyage AI (nay thuộc MongoDB, API key tạo trong MongoDB Atlas, mục AI Model APIs). Đổi hàm embed sang Voyage (model voyage-4, mặc định 1024 chiều, nhớ sửa cột thành vector(1024)):

ts
async function embed(texts: string[], inputType: "document" | "query"): Promise<number[][]> {
  const res = await fetch("https://ai.mongodb.com/v1/embeddings", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
    },
    body: JSON.stringify({ input: texts, model: "voyage-4", input_type: inputType }),
  });
  const json = await res.json();
  return json.data.map((d: { embedding: number[] }) => d.embedding);
}

Voyage khuyên luôn truyền input_type: "document" khi ingest, "query" khi embed câu hỏi. Nhớ sửa cả hai chỗ gọi: trong ingest.ts gọi embed(texts, "document"), trong ask.ts thay openai.embeddings.create(...) bằng const [qVec] = await embed([question], "query") rồi truyền pgvector.toSql(qVec). Bước generate thì gọi Claude qua @anthropic-ai/sdk như bình thường.

Đo chất lượng RAG: đừng đánh giá bằng cảm giác

RAG có hai chỗ có thể hỏng, nên đo riêng từng chỗ.

1. Retrieval có tìm đúng không? Chuẩn bị một bộ câu hỏi thật (lấy từ ticket hỗ trợ, câu hỏi trong Slack), mỗi câu ghi lại tài liệu nào chứa câu trả lời. Đo recall@k: bao nhiêu phần trăm câu hỏi có đoạn đúng nằm trong top k kết quả. Đây cũng là thước đo Anthropic dùng trong bảng Contextual Retrieval ở trên.

ts
// eval.ts (giả định bạn tách phần query trong ask.ts thành hàm retrieve())
const evalSet = [
  { q: "Gói Pro được hoàn tiền trong bao lâu?", expected: "refund-policy.md" },
  { q: "Lỗi ERR_PAYMENT_402 nghĩa là gì?", expected: "payment-errors.md" },
  // ...càng nhiều câu hỏi thật càng tốt
];

let hits = 0;
for (const { q, expected } of evalSet) {
  const rows = await retrieve(q); // top 5
  if (rows.some((r) => r.source === expected)) hits++;
}
console.log(`recall@5 = ${((hits / evalSet.length) * 100).toFixed(1)}%`);

2. Câu trả lời có trung thành với tài liệu không? Retrieval đúng mà model vẫn thêm thắt thì vẫn sai. Kiểm tra mỗi ý trong câu trả lời có nằm trong các đoạn đã đưa vào không. Chấm tay một mẫu nhỏ, hoặc dùng một LLM khác làm giám khảo nhưng đối chiếu với chấm tay vài lần.

Có bộ đo rồi, mọi thay đổi (kích thước chunk, thêm reranker, đổi embedding model) được quyết định bằng con số thay vì cảm giác.

Khi nào bạn KHÔNG cần RAG

RAG thêm một hệ thống cần vận hành: pipeline indexing, đồng bộ khi tài liệu đổi. Trước khi dựng, hỏi xem có cách đơn giản hơn không.

Tài liệu đủ nhỏ để nhét hết vào prompt. Anthropic khuyên: knowledge base dưới 200.000 token (khoảng 500 trang) thì cứ đưa toàn bộ vào prompt, kết hợp prompt caching. Năm 2026, Claude Opus 5.5, Sonnet 5.5 và dòng GPT-6 đều có context window khoảng 1M token trên API, nên ngưỡng này còn rộng hơn. Nhưng context càng dài thì độ chính xác có xu hướng giảm (context rot), tốn tiền và chậm hơn, nên hãy đo.

Agent tự tìm được. Với codebase, xu hướng hiện nay là just-in-time retrieval: agent giữ đường dẫn file, tên hàm, rồi dùng tool như grep, glob để đọc đúng thứ cần. Claude Code làm theo hướng này (CLAUDE.md nạp từ đầu, file code tìm khi cần), tránh được index bị cũ nhưng chậm hơn dữ liệu index sẵn. Cursor thì kết hợp cả hai (semantic search cạnh grep, chính xác hơn trung bình 12,5% theo nghiên cứu của họ). Bài học: với agent, retrieval có thể là một tool mà agent chủ động gọi (xem bài AI agent là gì), không nhất thiết là bước cố định trước mỗi câu hỏi.

Dữ liệu có cấu trúc. "Doanh thu tháng 8 là bao nhiêu" không cần vector search, nó cần một câu SQL. Cho model gọi tool truy vấn database hoặc API sẽ chính xác hơn tìm đoạn văn gần nghĩa.

Bạn cần đổi hành vi, không phải thêm kiến thức. Muốn model viết theo giọng thương hiệu hay một định dạng cố định thì system prompt, ví dụ mẫu hoặc fine-tune hợp hơn RAG.

Sai lầm thường gặp

  • Đổi embedding model mà không embed lại. Kết quả tìm kiếm thành vô nghĩa mà không báo lỗi gì.
  • Chỉ dùng vector search. Người dùng hỏi bằng mã lỗi, tên API, và vector search trượt. Thêm full-text search là việc rẻ nếu bạn đã dùng Postgres.
  • Nhồi quá nhiều chunk vào prompt. Lấy top 30 "cho chắc" làm câu trả lời tệ hơn và đắt hơn. Retrieve rộng, rerank, đưa vào ít.
  • Không xử lý tài liệu bị xóa. Chunk của trang đã gỡ vẫn nằm trong index và được trích dẫn.
  • Quên phân quyền. Nhân viên phòng A hỏi và nhận được đoạn trích từ tài liệu mật của phòng B. Lọc theo quyền ngay trong câu SQL retrieve, đừng để model tự "giữ bí mật". Với index HNSW, thêm WHERE có thể làm kết quả ít đi; pgvector 0.8 có SET hnsw.iterative_scan = relaxed_order để quét tiếp tới khi đủ.
  • Tin tuyệt đối nội dung được truy xuất. Tài liệu đưa vào prompt có thể chứa câu lệnh độc hại do người khác cài vào. Đây là dạng indirect prompt injection, đặc biệt nguy hiểm khi model còn có quyền gọi tool.

Tóm lại

RAG là cách cho AI đọc đúng tài liệu vào đúng lúc. Bản tối giản chỉ cần vài chục dòng TypeScript và Postgres bạn đã có. Phần khó nằm ở chất lượng: chunking, hybrid search, reranking, ngữ cảnh cho từng chunk, và một bộ đo để biết thay đổi nào thực sự giúp ích. Trước khi dựng, luôn hỏi: tài liệu có đủ nhỏ để đưa thẳng vào context không, hay agent có tự tìm được không?

Một hệ thống RAG thật còn cần giao diện chat tử tế: streaming, hiển thị nguồn, xử lý lỗi. Muốn tự tin dựng phần đó thì khóa React PRO của HoleTex dạy React tới mức làm chủ được một ứng dụng production, kể cả khi phần lớn code do AI viết.

Bài liên quan

Nguồn tham khảo: Introducing Contextual Retrieval (anthropic.com), Effective context engineering for AI agents (anthropic.com), Embeddings (platform.claude.com), Models overview (platform.claude.com), pgvector (github.com), pgvector-node (github.com), Embeddings guide (developers.openai.com), Text generation (developers.openai.com), OpenAI models (developers.openai.com), Voyage AI embeddings (docs.voyageai.com), Voyage reranker (docs.voyageai.com), Voyage AI trên MongoDB Atlas (mongodb.com), Text search parsers (postgresql.org), Improving agent with semantic search (cursor.com), PostgreSQL release notes (postgresql.org). Cập nhật 2026-10-08.

Thấy hay? Chia sẻ