GĐ23 — Embeddings + Vector Databases + RAG

Ghi chú học Backend + AI cho Frontend engineer (mạnh JS/TS). Mục tiêu: hiểu sâu cách máy "hiểu nghĩa" của text và cách xây một hệ thống hỏi-đáp trên tài liệu riêng (RAG) mà không cần fine-tune model. Mỗi khái niệm: định nghĩa → tại sao quan trọng → cơ chế → code ngắn → pitfall thực tế.

Bối cảnh nhanh cho dân FE: bạn đã quen gọi API, xử lý JSON, và (ở GĐ trước) đã học Postgres. GĐ này gắn hai thứ đó lại: gọi một embedding API để biến text thành số, lưu số đó vào Postgres (qua pgvector), rồi truy vấn "theo nghĩa" thay vì "theo từ khoá". Đó là nền của mọi tính năng "chat với tài liệu", "semantic search", "AI trả lời dựa trên docs nội bộ".

Kiểm chứng ngày 2026-10-05: text-embedding-004 đã tắt từ 2026-01-14; thay bằng gemini-embedding-2 (GA, 128–3072 chiều, không có task_type, dùng tiền tố prompt) hoặc gemini-embedding-001 (có task_type, còn dùng đến 2028-05-14); OpenAI text-embedding-3-small 1536 chiều và -large 3072 chiều, cả hai có tham số dimensions; pgvector 0.8.x: index HNSW/IVFFlat cho vector tối đa 2000 chiều, halfvec 4000, bit 64000; lọc WHERE áp dụng sau khi quét index (post-filter), iterative scan có từ 0.8.0. Tên model và giới hạn đổi theo thời gian: kiểm trang models/docs trước khi dùng.

Đối chiếu thêm cùng ngày: Gemini embeddings (tiền tố task: search result | query: ... và title: ... | text: ..., "over 100 languages", 128–3072 chiều, khuyến nghị 768/1536/3072, bản cắt được normalize tự động, tối đa 8192 token), OpenAI embeddings (vector normalize về độ dài 1, tham số dimensions, tối đa 8192 token), Cohere Rerank (rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5 đa ngôn ngữ), PostgreSQL unaccent và Text Search Controls (ts_rank không dùng thông tin toàn cục), OWASP Top 10 for LLM Applications 2025. unaccent(text) là STABLE: đọc từ file unaccent--1.1.sql của PostgreSQL 17.9. Chưa xác minh: tiếng Việt có được liệt kê rõ cho embedding của Gemini hay OpenAI hay không. Đã chạy thật: rag-fake.ts + test (node --test, Node 24.21). Chưa chạy (không dựng Postgres/pgvector): mọi SQL pgvector, full-text unaccent, EXPLAIN, đo recall theo ef_search; code pg/@google/genai mới thêm đã tsc --strict.


1. Embeddings là gì#

Định nghĩa. Embedding là phép biến một đoạn text (từ, câu, đoạn văn, thậm chí ảnh) thành một vector số thực có độ dài cố định, ví dụ [0.021, -0.44, ...] với 1536 chiều. Điểm mấu chốt: hai đoạn text gần nghĩa nhau sẽ cho hai vector gần nhau trong không gian đó. "con chó" và "chú cún" ở gần; "con chó" và "lãi suất ngân hàng" ở xa.

Tại sao quan trọng. Máy tính không so sánh nghĩa của chữ trực tiếp được. Với FE, bạn quen str1 === str2 hoặc str.includes(sub) — đó là so khớp ký tự, không hiểu nghĩa. Embedding cho phép so khớp ngữ nghĩa (semantic): người dùng gõ "làm sao đổi mật khẩu" vẫn tìm ra tài liệu tiêu đề "Reset credentials", dù không trùng một từ nào. Đây là thứ khiến search "thông minh" và là input bắt buộc cho RAG.

Cơ chế. Một model embedding (thường là một transformer neural network đã được huấn luyện) đọc text, và ở lớp cuối cho ra một vector. Trong quá trình train, model được ép sao cho text đồng nghĩa → vector gần nhau (khoảng cách nhỏ), text khác nghĩa → xa nhau. Vector đó là một điểm trong không gian nhiều chiều; "nghĩa" được mã hoá thành hướng của vector.

  • Dimension (số chiều): text-embedding-3-small = 1536 chiều, -3-large = 3072 chiều; Gemini gemini-embedding-2 = 128–3072 chiều. Nhiều chiều hơn = biểu diễn tinh vi hơn nhưng tốn RAM/băng thông, chậm hơn khi so sánh, và (với pgvector) có thể vượt giới hạn index (mục 5). OpenAI -3 hỗ trợ cắt chiều (Matryoshka) qua tham số dimensions, Gemini qua outputDimensionality, để đánh đổi độ chính xác lấy kích thước nhỏ hơn. Nếu tự cắt vector đã sinh sẵn thì phải normalize lại.
  • Model Gemini và loại tác vụ. text-embedding-004 đã tắt (2026-01-14), đừng dùng. gemini-embedding-2 không có tham số task_type: bạn nói rõ đây là câu hỏi hay tài liệu bằng tiền tố trong text đầu vào (mẫu chính thức nằm ở trang embeddings của Google, hãy chép từ đó). gemini-embedding-001 vẫn dùng được đến 2028-05-14 và có task_type (SDK JS @google/genai: config.taskType, ví dụ RETRIEVAL_QUERY / RETRIEVAL_DOCUMENT; REST: task_type). Chọn một model, ghi tên + số chiều vào metadata, và dùng đúng cùng cách định dạng cho query và document.
  • Vector thường được normalize (chuẩn hoá về độ dài 1) — quan trọng cho phần similarity ở mục 2.

Code ngắn (Node/TS).

typescriptReady
import OpenAI from "openai";const openai = new OpenAI();async function embed(text: string): Promise<number[]> {  const res = await openai.embeddings.create({    model: "text-embedding-3-small",    input: text,  });  return res.data[0].embedding; // number[] dài 1536}const v = await embed("làm sao đổi mật khẩu");console.log(v.length); // 1536

Pitfall / case thực tế.

  • Trộn model. Vector từ model A không so sánh được với vector từ model B. Nếu bạn đổi model embedding, phải re-embed toàn bộ dữ liệu cũ, nếu không kết quả tìm kiếm sẽ nhiễu loạn. Ghi rõ model + dimension vào metadata mỗi record.
  • Giới hạn token đầu vào. Model có max input token (~8191 với -3). Nhồi cả file 50 trang vào một lần → lỗi hoặc bị cắt cụt → embedding "trung bình hoá" mất hết chi tiết. Đây chính là lý do phải chunking (mục 3).
  • Chi phí + rate limit. Embedding tính tiền theo token và có giới hạn request. Ingest 100k đoạn → phải batch (gửi nhiều input một lần) và có retry.

Tiếng Việt và đa ngôn ngữ: đo, đừng tin quảng cáo#

  • Trang embeddings của Google nói gemini-embedding-2 hỗ trợ "over 100 languages"; tiếng Việt có nằm trong danh sách ngôn ngữ cho embedding hay không: chưa xác minh (trang chỉ liệt kê rõ ở phần ngôn ngữ giao diện). Trang embeddings của OpenAI không nêu hiệu năng tiếng Việt của text-embedding-3-*: chưa xác minh. Cohere nêu rerank-v4.0-pro, rerank-v4.0-fast và rerank-v3.5 là đa ngôn ngữ (xem mục 9).
  • Việc chắc chắn làm được: dựng golden set 20-50 câu hỏi tiếng Việt thật của người dùng bạn, gồm cả câu không dấu ("dang nhap") và câu viết tắt, chạy recall@k (mục 12) cho từng model ứng viên rồi chọn theo số đo. Ghi model và số chiều vào metadata (đã nêu ở trên).
  • Chuẩn hoá Unicode về NFC trước khi embed và trước khi băm content_hash: cùng một chữ có thể là một ký tự tổ hợp hoặc chữ cái cộng dấu rời, hai dạng cho hai chuỗi byte (và có thể hai vector) khác nhau.
  • Với gemini-embedding-2, tiền tố nằm trong text đầu vào. Mẫu theo trang docs cho tác vụ tìm kiếm:
typescriptReady
import { GoogleGenAI } from "@google/genai";const ai = new GoogleGenAI({}); // GEMINI_API_KEY từ envconst asQuery = (q: string) => `task: search result | query: ${q}`;const asDoc = (title: string, text: string) => `title: ${title} | text: ${text}`;async function embedGemini(contents: string): Promise<number[]> {  const res = await ai.models.embedContent({    model: "gemini-embedding-2",    contents,    config: { outputDimensionality: 768 }, // 128-3072; bản cắt đã được normalize sẵn  });  return res.embeddings?.[0]?.values ?? [];}// ingest: embedGemini(asDoc(title, chunk)); query: embedGemini(asQuery(question))

Docs Google khuyến nghị 768, 1536 hoặc 3072 chiều và cho biết bản cắt được normalize tự động; giới hạn đầu vào 8192 token (gộp mọi loại dữ liệu). Đã tsc --strict với @google/genai 2.27.0; chưa gọi API thật (không có key), nên chất lượng thật trên tiếng Việt chưa đo.


2. Similarity metrics (đo độ giống nhau)#

Định nghĩa. Sau khi có hai vector, ta cần một con số nói "chúng giống nhau bao nhiêu". Ba thước đo phổ biến:

  • Cosine similarity: cos của góc giữa hai vector. Nằm trong [-1, 1]; càng gần 1 càng cùng hướng (giống nghĩa), 0 là vuông góc (không liên quan).
  • Dot product (tích vô hướng): a·b = Σ aᵢbᵢ. Vừa xét hướng vừa xét độ dài.
  • Euclidean distance (L2): khoảng cách thẳng giữa hai điểm; càng nhỏ càng giống. pgvector ký hiệu toán tử <-> cho L2, <=> cho cosine distance, <#> cho negative dot product.

Tại sao quan trọng. Metric quyết định "top-k" nào được trả về khi retrieve. Chọn sai metric (hoặc sai so với cách index được build) → xếp hạng sai → RAG lấy nhầm ngữ cảnh → câu trả lời sai. Đây là một quyết định thiết kế, không phải chi tiết vặt.

Cơ chế — vì sao cosine phổ biến. Cosine chỉ quan tâm hướng, bỏ qua độ lớn của vector. Với text, "nghĩa" nằm ở hướng, còn độ lớn thường phản ánh độ dài văn bản / tần suất — thứ ta không muốn nó ảnh hưởng. Một chi tiết quan trọng: nếu vector đã được normalize về độ dài 1, thì cosine similarity và dot product cho cùng thứ tự xếp hạng, và L2 cũng tương đương về ranking. Nhiều model (gồm OpenAI -3) trả vector đã normalize → dùng dot product (<#>) sẽ nhanh hơn mà kết quả giống cosine.

Code ngắn.

typescriptReady
function cosine(a: number[], b: number[]): number {  let dot = 0, na = 0, nb = 0;  for (let i = 0; i < a.length; i++) {    dot += a[i] * b[i];    na += a[i] * a[i];    nb += b[i] * b[i];  }  return dot / (Math.sqrt(na) * Math.sqrt(nb));}// Thực tế: để DB tính, đừng kéo hàng triệu vector về Node rồi loop.

Kết quả mong đợi (tính tay): cosine([1, 0], [1, 1]) = 1 / (1 x căn 2) ≈ 0,7071; cosine([1, 2], [2, 4]) = 1 (cùng hướng, độ dài khác nhau vẫn là 1), còn dot product của cặp này là 10 chứ không phải 1, đó là lý do dot product chỉ tương đương cosine khi đã normalize.

Pitfall / case thực tế.

  • Lệch metric giữa index và query. Nếu tạo index HNSW theo vector_cosine_ops nhưng truy vấn bằng toán tử L2 <->, Postgres không dùng index → full scan chậm. Metric của index và của query phải khớp.
  • Quên normalize khi tự tính dot product. Vector chưa normalize + dot product → tài liệu dài (vector "to") bị thiên vị điểm cao một cách giả tạo.
  • Số cosine cao ≠ đúng. 0.83 nghe "rất giống" nhưng ngưỡng phụ thuộc model/ domain. Đừng hardcode > 0.8 một cách mù quáng; phải đo (mục 12).

3. Chunking (chia nhỏ tài liệu)#

Định nghĩa. Chunking là cắt tài liệu lớn thành các mảnh nhỏ ("chunk") trước khi embed, mỗi chunk là một đơn vị được embed và lưu độc lập.

Tại sao quan trọng. Ba lý do:

  1. Giới hạn input của model embedding (mục 1) — không thể embed cả cuốn.
  2. Độ chính xác retrieve. Embed cả một trang trộn 5 chủ đề → vector "trung bình", không giống rõ bất kỳ câu hỏi cụ thể nào. Chunk nhỏ, một ý → vector sắc nét, retrieve trúng hơn.
  3. Context window của LLM (mục 10) — bạn chỉ nhồi được vài chunk vào prompt, nên mỗi chunk phải "đáng giá" và đủ ngắn.

Cơ chế.

  • Chunk size: thường 200–800 token (hoặc ~500–2000 ký tự). Nhỏ quá → mất ngữ cảnh xung quanh; lớn quá → loãng nghĩa và tốn context.
  • Overlap: cho các chunk chồng lấn nhau ~10–20% (vd overlap 50–100 token). Mục đích: một câu bị cắt ngang ranh giới chunk vẫn xuất hiện trọn vẹn ở ít nhất một chunk → không mất thông tin ở mép.
  • Fixed-size vs semantic splitting:
    • Cố định: cắt theo số ký tự/token, đơn giản, nhanh, nhưng có thể cắt giữa câu.
    • Semantic / structural: cắt theo ranh giới tự nhiên — đoạn văn, heading Markdown, dấu câu, hoặc theo độ "nhảy" ngữ nghĩa giữa các câu. Chất lượng cao hơn, giữ trọn ý.

Code ngắn (fixed-size + overlap, đơn giản theo ký tự).

typescriptReady
function chunk(text: string, size = 1000, overlap = 150): string[] {  const out: string[] = [];  for (let i = 0; i < text.length; i += size - overlap) {    out.push(text.slice(i, i + size));  }  return out;}// Nâng cấp thực tế: tách theo "\n\n" (đoạn) trước, gộp tới ~size,// rồi mới fallback cắt cứng cho đoạn quá dài.

Kết quả mong đợi (tính tay): văn bản dài 2500 ký tự, size = 1000, overlap = 150 cho 3 chunk bắt đầu ở vị trí 0, 850 và 1700 (bước nhảy 850; vị trí 2550 vượt độ dài nên dừng); chunk cuối dài 800 ký tự. Hai chunk liền nhau chia sẻ 150 ký tự. Lưu ý: văn bản đúng 1000 ký tự vẫn sinh thêm một chunk thứ hai dài 150 ký tự, trùng hoàn toàn phần đuôi chunk đầu; bản dùng thật nên dừng khi chunk trước đã chạm cuối văn bản.

Pitfall / case thực tế.

  • Cắt giữa bảng/đoạn code làm hỏng nghĩa → mất khả năng trả lời câu hỏi về bảng đó. Ưu tiên tách theo cấu trúc (heading, block).
  • Overlap = 0 với câu hỏi rơi đúng mép chunk → thông tin bị chia đôi, không chunk nào chứa đủ → retrieve trượt.
  • Chunk quá to → top-k chunk đã ngốn hết context window, LLM không còn chỗ cho câu hỏi + câu trả lời, hoặc tốn tiền vô ích.
  • Không lưu metadata (nguồn, số trang, doc_id) trên mỗi chunk → sau này không trích nguồn (citation) được (mục 10).

4. Vector database#

Định nghĩa. Database chuyên lưu vector và trả về "k vector gần nhất" với một vector truy vấn thật nhanh (nearest-neighbor search), thường kèm lọc metadata.

Tại sao quan trọng. Tính cosine với hàng triệu vector bằng vòng lặp trong Node là bất khả thi về hiệu năng. Vector DB dùng index chuyên biệt (mục 5) để tìm gần đúng trong mili-giây. Đây là "kho trí nhớ" của hệ RAG.

Các lựa chọn & khi nào dùng cái nào.

Lựa chọnBản chấtDùng khi
pgvectorExtension của PostgresĐã dùng Postgres; muốn giữ vector cạnh dữ liệu quan hệ, join/lọc bằng SQL, một hệ để vận hành. Quy mô vừa (đến hàng triệu vector).
PineconeManaged SaaS thuần vectorMuốn không lo vận hành, scale lớn, serverless; chấp nhận trả tiền + phụ thuộc vendor.
QdrantVector DB (Rust), self-host hoặc cloudCần filter metadata mạnh, hiệu năng cao, muốn open-source tự host.
WeaviateVector DB open-source, có hybrid search/module hoáCần hybrid built-in, schema hoá, GraphQL.

Managed vs self-host. Managed (Pinecone, Qdrant Cloud): nhanh khởi động, không lo backup/scaling, nhưng tốn tiền và dữ liệu ra ngoài. Self-host (pgvector trên Postgres của bạn, Qdrant/Weaviate docker): kiểm soát và rẻ hơn ở quy mô, đổi lại bạn tự lo ops.

Khuyến nghị cho bạn: đã học Postgres → bắt đầu bằng pgvector. Một DB, một transaction, join thẳng vector với bảng documents/users. Chỉ chuyển sang vector DB chuyên dụng khi thực sự chạm giới hạn (hàng chục triệu vector, throughput cao).

Code ngắn (bật extension).

textReady
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector, chạy 1 lần trên DB

Pitfall / case thực tế.

  • Chọn vector DB riêng quá sớm khi Postgres thừa sức → thêm một hệ phải sync, backup, và giữ nhất quán. YAGNI.
  • Quên đồng bộ metadata. Vector ở Pinecone, dữ liệu gốc ở Postgres → dễ lệch (xoá doc ở Postgres nhưng vector còn ở Pinecone → trả về "ma"). pgvector tránh được vì chung một DB, một transaction.
  • Dimension cố định theo cột. Cột vector(1536) khoá chặt số chiều; đổi model khác chiều = phải migrate cột.

5. Vector index (HNSW vs IVFFlat)#

Định nghĩa. Index tăng tốc tìm nearest-neighbor. Tìm chính xác tuyệt đối (so với mọi vector) gọi là exact NN, rất chậm. Thay vào đó ta dùng ANN (Approximate Nearest Neighbor): chấp nhận thỉnh thoảng bỏ sót một kết quả để đổi lấy tốc độ gấp nhiều lần. pgvector có hai loại index:

  • HNSW (Hierarchical Navigable Small World): đồ thị nhiều tầng; tìm bằng cách "đi bộ" trên đồ thị từ tầng thô đến tầng mịn. Rất nhanh, recall cao, không cần dữ liệu có sẵn để build. Đổi lại: tốn RAM nhiều hơn và build chậm hơn.
  • IVFFlat (Inverted File): chia không gian thành lists cụm (clusters); truy vấn chỉ quét vài cụm gần nhất (probes). Ít RAM hơn, build nhanh, nhưng cần có sẵn dữ liệu để phân cụm tốt và recall thường thấp hơn HNSW.

Tại sao quan trọng. Đây là chỗ đánh đổi tốc độ ↔ độ chính xác (recall) ↔ bộ nhớ. Chọn/tune sai → hoặc chậm, hoặc bỏ sót kết quả đúng (recall thấp) mà không hề báo lỗi — im lặng trả kết quả kém.

Cơ chế / tham số.

  • HNSW: m (số cạnh mỗi node) và ef_construction (chất lượng build); khi query chỉnh hnsw.ef_search — cao hơn = chính xác hơn nhưng chậm hơn.
  • IVFFlat: lists (số cụm, gợi ý ~rows/1000); query chỉnh ivfflat.probes — nhiều probe = recall cao hơn nhưng chậm hơn.

Code ngắn.

textReady
-- HNSW cho cosine (khuyên dùng cho hầu hết trường hợp)CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);SET hnsw.ef_search = 100; -- tăng recall khi query-- IVFFlat: build SAU khi đã nạp đủ dữ liệuCREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)  WITH (lists = 200);SET ivfflat.probes = 10;
textReady
-- Chiều > 2000: cột vector(3072) KHÔNG index được. Lưu half-precision (tối đa 4000 chiều):CREATE TABLE documents_3072 (id bigserial PRIMARY KEY, embedding halfvec(3072));CREATE INDEX ON documents_3072 USING hnsw (embedding halfvec_cosine_ops);-- Hoặc giữ cột vector(3072) và index trên biểu thức ép kiểu; query phải dùng đúng biểu thức đó:--   CREATE INDEX ON documents USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);--   ... ORDER BY embedding::halfvec(3072) <=> $1 LIMIT 5;

Mức đã kiểm: SQL này chỉ đối chiếu README pgvector (cú pháp halfvec, ép kiểu trong index, giới hạn 2000/4000 chiều, halfvec_*_ops theo mẫu halfvec_l2_ops); chưa chạy trên Postgres có cài pgvector.

Pitfall / case thực tế.

  • Tạo IVFFlat khi bảng trống → các cụm vô nghĩa → recall tệ. Phải nạp dữ liệu trước rồi mới index (hoặc reindex sau khi nạp).
  • ops của index ≠ toán tử query (đã nêu ở mục 2) → không dùng được index.
  • Kỳ vọng exact. ANN là gần đúng; recall < 100% là bình thường. Nếu cần chính xác tuyệt đối cho tập nhỏ, có thể bỏ index (seq scan) hoặc tăng ef_search/ probes.
  • Quá giới hạn chiều của index. pgvector chỉ index được cột vector tới 2000 chiều (halfvec tới 4000, bit tới 64000). Cột vector(3072) (ví dụ text-embedding-3-large hoặc gemini-embedding-2 ở số chiều mặc định) vẫn lưu và truy vấn được, nhưng không tạo được index HNSW/IVFFlat, nên mọi truy vấn là quét toàn bảng. Hai cách thoát: (a) giảm chiều ngay lúc embed (dimensions / outputDimensionality, ≤2000); (b) lưu bằng halfvec(3072) hoặc index trên biểu thức ép kiểu, xem khối SQL halfvec ngay phía trên.
  • RAM nổ với HNSW khi vài triệu vector nhiều chiều — cân nhắc giảm dimension (Matryoshka), dùng halfvec (một nửa dung lượng) hoặc chuyển hạ tầng.
  • Lọc WHERE không làm index nhanh hơn. Với index xấp xỉ, pgvector lọc sau khi quét index: hnsw.ef_search mặc định 40 ứng viên, điều kiện khớp 10% hàng thì trung bình chỉ còn khoảng 4 hàng, có thể ít hơn LIMIT. Từ 0.8.0 bật được iterative scan (SET hnsw.iterative_scan = relaxed_order) để quét thêm khi chưa đủ kết quả; điều kiện chọn lọc ít hàng thì cân nhắc partial index hoặc partition. Đo bằng EXPLAIN ANALYZE thay vì đoán.

Lọc theo tenant: cách kiểm tra index có đang làm rơi kết quả#

Với bảng nhiều tenant (cột tenant_id), truy vấn là WHERE tenant_id = $2 ORDER BY embedding <=> $1 LIMIT 5. Cách xử lý (iterative scan strict_order/relaxed_order, partial index, partition) và Row-Level Security sẽ học đầy đủ ở GĐ25 (giai đoạn sau); phần này chỉ thêm cách kiểm tra trên dữ liệu của bạn, vì lỗi này không báo gì cả:

textReady
EXPLAIN (ANALYZE, BUFFERS)SELECT id FROM documentsWHERE tenant_id = 'tenant-nhỏ-nhất'ORDER BY embedding <=> $1::vectorLIMIT 5;
  • Đọc dòng Index Scan using ... hnsw rồi so rows= thực tế của node với LIMIT: nhận về ít hơn 5 dòng trong khi tenant đó có hơn 5 chunk nghĩa là index đã quét xong ứng viên mà lọc loại hết.
  • Dòng Rows Removed by Filter cho thấy bao nhiêu ứng viên bị lọc sau khi index trả về.
  • Chạy lại với SET hnsw.iterative_scan = relaxed_order; rồi so số dòng trả về và thời gian.
  • tenant_id phải lấy từ phiên đã xác thực ở server, không bao giờ từ body request; một index xấp xỉ dùng chung cho mọi tenant cũng khiến tenant lớn chen chỗ ứng viên của tenant nhỏ, nên recall của tenant nhỏ thấp hơn mức đo trung bình (OWASP LLM08).

Chưa chạy trên pgvector thật nên không có plan mẫu để trích; các tham số đối chiếu README pgvector.


6. RAG pipeline đầy đủ#

Định nghĩa. RAG = Retrieval-Augmented Generation: thay vì hỏi thẳng LLM (nó chỉ biết dữ liệu lúc train, dễ bịa), ta tìm ngữ cảnh liên quan từ kho của mình rồi nhét vào prompt để LLM trả lời dựa trên ngữ cảnh đó.

Tại sao quan trọng. Cho phép LLM trả lời về dữ liệu riêng/mới (docs nội bộ, sản phẩm của bạn) mà không cần fine-tune, giảm hallucination, và trích được nguồn. Đây là kiến trúc mặc định cho "chatbot trên tài liệu".

Cơ chế — chia làm 2 pha.

Pha INGEST (offline, làm trước):

textReady
Tài liệu ─▶ [1] Chunk ─▶ [2] Embed từng chunk        ─▶ [3] Store (vector + text + metadata)

Pha QUERY (online, mỗi câu hỏi):

textReady
Câu hỏi ─▶ [4] Embed câu hỏi ─▶ [5] Retrieve top-k chunk gần nhất        ─▶ [6] (tuỳ chọn) Rerank        ─▶ [7] Augment: ghép chunk + câu hỏi thành prompt        ─▶ [8] LLM Generate câu trả lời (kèm citation)

Từng bước:

  1. Chunk tài liệu (mục 3).
  2. Embed mỗi chunk (mục 1).
  3. Store vector + text gốc + metadata (doc_id, nguồn, trang) vào vector DB.
  4. Embed câu hỏi bằng cùng model đã dùng ở bước 2.
  5. Retrieve top-k (vd k=5) chunk gần vector câu hỏi nhất (mục 5, 7).
  6. Rerank (mục 9) để lọc lại thứ tự cho chính xác — tuỳ chọn nhưng đáng giá.
  7. Augment: dựng prompt = hướng dẫn hệ thống + các chunk (context) + câu hỏi.
  8. Generate: LLM đọc context và trả lời, kèm trích nguồn.

Code ngắn (khung pha query).

typescriptReady
async function ragAnswer(question: string) {  const qVec = await embed(question);              // [4]  const chunks = await retrieveTopK(qVec, 5);      // [5]  const context = chunks    .map((c, i) => `[${i + 1}] (${c.source}) ${c.content}`)    .join("\n\n");  const prompt =    `Chỉ trả lời dựa trên ngữ cảnh. Nếu thiếu, nói "không đủ dữ liệu". ` +    `Trích nguồn theo [số].\n\nNgữ cảnh:\n${context}\n\nCâu hỏi: ${question}`;  return chat(prompt);                              // [8]}

Pitfall / case thực tế.

  • Model embedding ở bước 2 và 4 khác nhau → vector không cùng không gian → retrieve rác. Đây là lỗi kinh điển.
  • Không có câu "nếu thiếu thì nói không biết" → LLM vẫn bịa dù context không chứa đáp án.
  • k quá lớn → nhồi cả context nhiễu, LLM lạc; k quá nhỏ → thiếu thông tin.
  • Retrieve tốt nhưng prompt tệ (không tách rõ context vs câu hỏi) → LLM lẫn lộn.
  • Coi chunk là đáng tin. Chunk đến từ tài liệu mà người khác có thể sửa hoặc tải lên, nên có thể chứa chỉ thị ("bỏ qua hướng dẫn trên..."): prompt injection gián tiếp. Bọc phần ngữ cảnh như dữ liệu untrusted (thẻ ngẫu nhiên và escape, xem GĐ22 mục 9) thay vì nối thẳng vào prompt như khung ngắn ở trên.

Khi nào không cần RAG#

RAG thêm một pipeline phải vận hành (ingest, index, đo recall). Hỏi trước khi dựng:

  • Kho nhỏ vừa context window (vài chục trang): nhồi thẳng vào prompt, kèm prompt caching (GĐ22 mục 10), thường rẻ hơn và không có lỗi retrieve trượt.
  • Câu hỏi cần tổng hợp toàn kho hoặc tính toán ("tháng này có bao nhiêu đơn hoàn tiền"): đó là truy vấn SQL/aggregate, retrieve top-k chỉ thấy vài mẩu.
  • Dữ liệu có cấu trúc, tra theo khoá (trạng thái đơn, giá): gọi thẳng API hoặc DB qua tool (GĐ24), không cần embedding.
  • Cần khớp chính xác theo từ khoá (mã sản phẩm, số hợp đồng): full-text (mục 8) đủ, không cần vector.

RAG hợp khi kho lớn, chủ yếu là văn bản tự do, và câu hỏi nhắm vào vài đoạn cụ thể.


7. Ví dụ pgvector (bảng, insert, truy vấn)#

Định nghĩa. Áp dụng pgvector cụ thể: cột kiểu vector, chèn embedding, và truy vấn "gần nhất" bằng toán tử khoảng cách + ORDER BY ... LIMIT k.

Tại sao quan trọng. Đây là "hình hài SQL" của bước Store + Retrieve. Với FE đã biết Postgres, nắm cú pháp này là bạn tự dựng được retrieval layer.

Cơ chế. vector(1536) là cột giữ mảng 1536 số. Toán tử <=> = cosine distance (0 = giống hệt). ORDER BY embedding <=> $1 LIMIT 5 = "5 hàng gần vector $1 nhất". Có index HNSW/IVFFlat thì Postgres dùng ANN thay vì quét toàn bảng.

Code ngắn (schema + insert + query, dùng pg driver).

textReady
-- SchemaCREATE TABLE documents (  id        bigserial PRIMARY KEY,  doc_id    text,            -- tài liệu gốc (dùng ở mục 11 để xoá/re-embed theo doc)  source    text,            -- nguồn để trích dẫn  content   text,            -- chunk text gốc  embedding vector(1536)     -- khớp dimension của model);CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
typescriptReady
import { Pool } from "pg";const pool = new Pool();// INSERT: pgvector nhận literal dạng '[0.1,0.2,...]'async function insertChunk(docId: string, source: string, content: string, v: number[]) {  await pool.query(    `INSERT INTO documents (doc_id, source, content, embedding) VALUES ($1, $2, $3, $4)`,    [docId, source, content, `[${v.join(",")}]`]  );}// RETRIEVE top-k gần nhất theo cosine distanceasync function retrieveTopK(qVec: number[], k = 5) {  const { rows } = await pool.query(    `SELECT id, doc_id, source, content, 1 - (embedding <=> $1) AS score       FROM documents      ORDER BY embedding <=> $1      LIMIT $2`,    [`[${qVec.join(",")}]`, k]  );  return rows; // [{ id, doc_id, source, content, score }]}

Pitfall / case thực tế.

  • Truyền vector sai định dạng. pgvector cần literal '[...]' (dấu ngoặc vuông), không phải mảng JS thô hay ARRAY[...]. Sai → lỗi cast.
  • SELECT * kéo cả cột embedding về Node mỗi query → tốn băng thông vô ích; chỉ select cột cần.
  • Quên LIMIT → trả cả bảng. Và nếu ORDER BY không dùng đúng toán tử của index → seq scan chậm.
  • 1 - distance để ra "similarity" cho dễ đọc/threshold — nhớ đây là cosine.

8. Hybrid search (keyword + vector)#

Định nghĩa. Kết hợp tìm theo từ khoá (full-text / BM25) với tìm theo vector (semantic), rồi trộn điểm hai bên thành một bảng xếp hạng.

Tại sao quan trọng. Vector thuần đôi khi trượt ở những chỗ mà từ khoá lại mạnh: mã sản phẩm ("SKU-8842"), tên riêng, số phiên bản, thuật ngữ hiếm, viết tắt. Embedding "làm mượt" ngữ nghĩa nên có thể coi "SKU-8842" và "SKU-8843" gần như nhau — sai chí mạng. Keyword search khớp chính xác token thì bắt đúng. Ngược lại keyword không hiểu đồng nghĩa. Hybrid lấy điểm mạnh cả hai.

Cơ chế.

  • Keyword / full-text: BM25 là công thức xếp hạng dùng tần suất từ và độ hiếm của từ trên cả kho (IDF); Elasticsearch/OpenSearch dùng nó. Postgres tsvector + ts_rank/ts_rank_cd không phải BM25: hai hàm đó chỉ nhìn tần suất, độ gần và vị trí của từ khớp trong từng tài liệu, không dùng thống kê toàn kho (tài liệu Postgres nói rõ: không dùng thông tin toàn cục). Đủ tốt để làm một nhánh của hybrid, nhưng đừng kỳ vọng xếp hạng như BM25; cần BM25 thật thì dùng công cụ search riêng (GĐ11). Giỏi khớp chính xác.
  • Vector: giỏi ngữ nghĩa.
  • Trộn điểm: phổ biến là RRF (Reciprocal Rank Fusion) — cộng 1/(k+rank) từ mỗi danh sách; hoặc chuẩn hoá điểm rồi weighted sum. RRF không cần scale điểm hai bên đồng nhất nên rất tiện.

Code ngắn (ý tưởng RRF trong app).

typescriptReady
function rrfMerge(vecIds: string[], kwIds: string[], k = 60) {  const score = new Map<string, number>();  const add = (ids: string[]) =>    ids.forEach((id, rank) =>      score.set(id, (score.get(id) ?? 0) + 1 / (k + rank)));  add(vecIds); add(kwIds);  return [...score.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);}// vecIds: id xếp theo cosine; kwIds: id xếp theo ts_rank (full-text)

Ví dụ tính tay với k = 60, rank tính từ 0: vecIds = [A, B, C], kwIds = [B, D, A]. A = 1/60 + 1/62 ≈ 0,03280; B = 1/61 + 1/60 ≈ 0,03306; C = 1/62 ≈ 0,01613; D = 1/61 ≈ 0,01639. Kết quả mong đợi của rrfMerge: [B, A, D, C]. B thắng dù không đứng đầu danh sách vector, vì xuất hiện cao ở cả hai danh sách; D chỉ có ở danh sách từ khoá vẫn xếp trên C chỉ có ở danh sách vector.

textReady
-- Full-text side (Postgres)SELECT id FROM documentsWHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', $1)ORDER BY ts_rank(to_tsvector('simple', content),                 plainto_tsquery('simple', $1)) DESCLIMIT 20;

Pitfall / case thực tế.

  • Chỉ dùng vector cho catalog/mã kỹ thuật → user tìm "SKU-8842" ra nhầm sản phẩm gần giống. Hybrid cứu.
  • Cộng thẳng hai điểm khác thang đo (cosine ∈ [0,1] vs ts_rank vô hạn) → bên nào thang lớn thắng áp đảo. Dùng RRF hoặc normalize.
  • Bỏ index full-text (GIN trên tsvector) → keyword side chậm.

Full-text cho tiếng Việt có dấu và không dấu: unaccent + GIN#

Người dùng hay gõ không dấu ("dang nhap") trong khi tài liệu có dấu ("đăng nhập"). Cấu hình simple ở trên coi hai chuỗi là hai từ khác nhau. Cách xử lý: một text search configuration riêng chạy từ điển unaccent trước simple, kèm index GIN trên đúng biểu thức đó.

textReady
CREATE EXTENSION IF NOT EXISTS unaccent;CREATE TEXT SEARCH CONFIGURATION vi_unaccent (COPY = simple);ALTER TEXT SEARCH CONFIGURATION vi_unaccent  ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;-- Index trên biểu thức; truy vấn phải dùng ĐÚNG biểu thức này mới dùng được indexCREATE INDEX documents_fts_idx ON documents  USING gin (to_tsvector('vi_unaccent', content));SELECT id FROM documentsWHERE to_tsvector('vi_unaccent', content) @@ plainto_tsquery('vi_unaccent', $1)ORDER BY ts_rank(to_tsvector('vi_unaccent', content),                 plainto_tsquery('vi_unaccent', $1)) DESCLIMIT 20;
  • Vì sao dùng cấu hình thay vì viết unaccent(content) trong index: hàm unaccent(text) được khai báo STABLE (đã đọc file SQL contrib của PostgreSQL 17.9), không phải IMMUTABLE, nên Postgres từ chối dùng nó trong biểu thức index; còn to_tsvector('cấu_hình_hằng', ...) thì được.
  • simple không stemming và không tách từ tiếng Việt: token là các cụm ký tự cách nhau bởi khoảng trắng, tức từng âm tiết ("đăng", "nhập"), không phải từ ghép. Chấp nhận được cho nhánh keyword của hybrid; cần tách từ chuẩn thì phải xử lý ngoài Postgres (chưa xác minh công cụ cụ thể).
  • Quy tắc bỏ dấu mặc định (unaccent.rules) có đủ nguyên âm có dấu tiếng Việt và đ: đã kiểm bằng script đọc file rules của PostgreSQL 17 cài sẵn (chỉ đọc file): 134 chữ cái Việt (thường và hoa) đều ánh xạ ra chữ ASCII (đ→d, ơ→o, ư→u, ế→e). Chuẩn hoá chuỗi về NFC trước khi ghi và truy vấn để dạng tổ hợp khớp với bảng quy tắc (lưu ý phòng ngừa, chưa tạo ca lỗi để chứng minh).
  • Đổi vi_unaccent (thêm từ điển, sửa mapping) sau khi đã build index thì phải REINDEX.
  • Mức đã kiểm: SQL chỉ đối chiếu trang unaccent và Text Search Controls của PostgreSQL docs, chưa chạy trên Postgres; không có output mẫu để trích.

9. Reranking#

Định nghĩa. Sau khi retrieve top-k (vd k=20) bằng vector/hybrid, dùng một rerank model (cross-encoder) đọc cặp (câu hỏi, chunk) cùng lúc và chấm lại điểm liên quan, rồi giữ lại top-n (vd 3–5) tốt nhất để đưa vào prompt.

Tại sao quan trọng. Embedding retrieval là bi-encoder: câu hỏi và chunk được embed riêng lẻ rồi so vector → nhanh nhưng thô, dễ đưa vài chunk "gần gần" mà không thực sự trả lời. Cross-encoder đọc cả hai cùng lúc nên đánh giá liên quan chính xác hơn nhiều → tăng độ đúng của câu trả lời cuối, đặc biệt quan trọng khi context window hẹp (chỉ nhồi được 3–5 chunk).

Cơ chế. Retrieve rộng (k lớn, recall cao) → rerank hẹp (precision cao). Cross- encoder chậm nên chỉ chạy trên ~20 ứng viên, không phải toàn kho. Dịch vụ: Cohere Rerank, Voyage rerank, Jina reranker, hoặc model local (bge-reranker).

Code ngắn (Cohere Rerank).

typescriptReady
import { CohereClient } from "cohere-ai";const co = new CohereClient();async function rerank(question: string, candidates: string[], topN = 4) {  const res = await co.rerank({    model: "rerank-v3.5", // đa ngôn ngữ; kiểm trang models của Cohere trước khi dùng    query: question,    documents: candidates,    topN,  });  // res.results: [{ index, relevanceScore }] đã sắp xếp  return res.results.map((r) => candidates[r.index]);}

Pitfall / case thực tế.

  • Rerank tất cả kho → chậm và đắt. Chỉ rerank top-k đã lọc bằng vector.
  • Retrieve k quá nhỏ trước rerank (vd k=3) → rerank không cứu được vì chunk đúng chưa từng lọt vào danh sách. Nguyên tắc: retrieve rộng, rerank hẹp.
  • Coi rerank là bắt buộc luôn → thêm latency + chi phí; với dữ liệu nhỏ/đơn giản có thể chưa cần. Đo lợi ích thật (mục 12).

10. Quản lý context window#

Định nghĩa. Context window là giới hạn token LLM đọc được trong một lần gọi (system + context + câu hỏi + câu trả lời đều tính). Quản lý context = quyết định nhồi bao nhiêu chunk, cắt bớt ra sao, và trích nguồn để vừa đủ và rẻ.

Tại sao quan trọng. Nhồi càng nhiều chunk không phải càng tốt: (a) tốn tiền (tính theo input token), (b) "lost in the middle" — LLM hay bỏ sót thông tin ở giữa prompt dài, (c) chunk nhiễu làm loãng và tăng hallucination. Trích nguồn (citation) là hàng rào chống bịa: bắt LLM chỉ ra chunk nào chống lưng cho câu.

Cơ chế.

  • Chọn top-n nhỏ sau rerank (thường 3–6 chunk) thay vì đổ hết top-k.
  • Cắt/nén chunk quá dài; ưu tiên đặt chunk quan trọng ở đầu và cuối prompt (tránh vùng giữa).
  • Đánh số chunk và yêu cầu LLM trích [n] → mỗi câu trả lời truy ngược được nguồn; nếu context không đủ, yêu cầu trả lời "không đủ dữ liệu".
  • Budget token: ước lượng tokens ≈ ký tự / 4 (tiếng Anh) để canh không vượt.

Code ngắn (canh ngân sách + ép citation).

typescriptReady
function buildContext(chunks: {text: string; source: string}[], maxChars = 6000) {  let used = 0;  const parts: string[] = [];  for (let i = 0; i < chunks.length; i++) {    const block = `[${i + 1}] (${chunks[i].source}) ${chunks[i].text}`;    if (used + block.length > maxChars) break; // cắt khi hết ngân sách    parts.push(block); used += block.length;  }  return parts.join("\n\n");}const system =  "Chỉ dùng ngữ cảnh. Mỗi khẳng định phải kèm [số nguồn]. " +  "Nếu ngữ cảnh không chứa câu trả lời, nói rõ 'không đủ dữ liệu'.";

Pitfall / case thực tế.

  • Nhồi 50 chunk cho chắc → đắt gấp bội, chậm, và "lost in the middle" khiến chunk đúng ở giữa bị lơ.
  • Không ép citation → không cách nào kiểm chứng, user tin lời bịa.
  • Không có fallback "không đủ dữ liệu" → LLM luôn cố trả lời kể cả khi retrieve trượt hoàn toàn.
  • Quên system + user cũng ăn token → prompt vượt giới hạn, bị cắt câu trả lời.

11. Cập nhật & xoá dữ liệu#

Định nghĩa. Vòng đời dữ liệu trong RAG: khi tài liệu gốc đổi/xoá, các embedding tương ứng phải được re-embed / xoá / version để kho không chứa dữ liệu cũ (stale).

Tại sao quan trọng. Embedding là ảnh chụp của text tại thời điểm ingest. Sửa tài liệu mà không re-embed → RAG trả lời theo bản cũ (stale data) — nguy hiểm với chính sách, giá, pháp lý. Xoá doc mà không xoá vector → retrieve ra "tài liệu ma" đã bị gỡ.

Cơ chế.

  • Gắn mỗi chunk với doc_id + content_hash + version + updated_at.
  • Khi doc đổi: so hash; nếu khác → xoá hết chunk cũ của doc_id rồi chunk + embed lại (đơn giản, an toàn), hoặc chỉ re-embed chunk đổi (tối ưu, phức tạp hơn).
  • Khi doc xoá: xoá mọi chunk theo doc_id (dễ với pgvector vì cùng transaction).
  • Versioning: giữ version để rollback hoặc trả lời "theo bản nào".

Code ngắn (re-embed theo doc).

typescriptReady
async function upsertDoc(docId: string, text: string) {  const hash = sha256(text);  const { rows } = await pool.query(    `SELECT content_hash FROM docs_meta WHERE doc_id = $1`, [docId]);  if (rows[0]?.content_hash === hash) return; // không đổi → bỏ qua  // 1) Embed TRƯỚC, ngoài transaction: lệnh gọi mạng chậm và có thể lỗi, không giữ transaction qua nó  const chunks = chunk(text);  const vectors = await Promise.all(chunks.map(embed)); // thật: gửi theo lô, có retry (mục 1)  // 2) Xoá cũ + nạp mới + cập nhật meta trong MỘT transaction: hoặc xong cả, hoặc không đổi gì  const client = await pool.connect();  try {    await client.query("BEGIN");    await client.query(`DELETE FROM documents WHERE doc_id = $1`, [docId]);    for (let i = 0; i < chunks.length; i++) {      await client.query(        `INSERT INTO documents (doc_id, source, content, embedding) VALUES ($1, $2, $3, $4)`,        [docId, docId, chunks[i], `[${vectors[i].join(",")}]`]);    }    await client.query(      `INSERT INTO docs_meta (doc_id, content_hash, updated_at)       VALUES ($1, $2, now())       ON CONFLICT (doc_id) DO UPDATE SET content_hash = $2, updated_at = now()`,      [docId, hash]);    await client.query("COMMIT");  } catch (e) {    await client.query("ROLLBACK");    throw e;  } finally {    client.release();  }}

Bản ngây thơ (xoá rồi chèn từng chunk bằng pool.query riêng) có hai lỗi: embed lỗi giữa chừng thì doc đã mất hết chunk cũ mà chưa có chunk mới (RAG trả "không đủ dữ liệu" cho doc đó), và truy vấn chạy đúng lúc đó thấy doc nửa cũ nửa mới. Mẫu này giả định mỗi doc_id chỉ có một worker upsert tại một thời điểm (xếp hàng theo doc_id, xem GĐ10): hai worker chạy song song trên cùng doc cần thêm một khoá riêng. Đã tsc --strict với pg 8.23; chưa chạy trên Postgres.

Pitfall / case thực tế.

  • Chỉ chèn thêm, không xoá cũ khi doc đổi → tồn tại cả bản cũ lẫn mới → retrieve mâu thuẫn.
  • Re-embed toàn kho mỗi lần một doc đổi → tốn tiền/thời gian khổng lồ. Dùng content_hash để chỉ đụng doc thay đổi.
  • Đổi model embedding = coi như mọi vector stale → cần re-embed toàn bộ có kế hoạch (blue/green: build kho mới song song rồi switch).
  • Xoá ở DB gốc nhưng vector ở hệ khác (Pinecone) không xoá → ma. pgvector tránh nhờ chung transaction.

12. Đánh giá RAG (evaluation)#

Định nghĩa. Đo chất lượng hệ RAG ở hai tầng: (a) retrieval quality — có lấy đúng chunk chứa đáp án không; (b) answer quality — câu trả lời có đúng, đủ, và bám nguồn không.

Tại sao quan trọng. "Nhìn có vẻ chạy" không đủ. Không đo thì mọi chỉnh sửa (đổi chunk size, thêm rerank, đổi k, đổi model) chỉ là đoán mò. Eval biến RAG từ "cầu may" thành kỹ thuật có số liệu để so sánh.

Cơ chế / chỉ số.

  • Retrieval:
    • Recall@k: trong k chunk lấy về, có chứa chunk "vàng" (đáp án) không? Tỷ lệ câu hỏi mà chunk đúng nằm trong top-k.
    • Precision@k, MRR (Mean Reciprocal Rank): đáp án đúng đứng thứ mấy — càng cao càng tốt (đặc biệt trước khi rerank).
  • Answer:
    • Faithfulness / groundedness: câu trả lời có bám vào context (không bịa)?
    • Answer relevancy / correctness: có trả lời đúng ý hỏi?
    • Đo bằng LLM-as-a-judge (một LLM chấm; mã mẫu có rubric và cổng CI ở GĐ22 mục 11) hoặc bộ eval như Ragas.
  • Cách làm: tạo golden set = danh sách (câu hỏi, tài liệu đúng, chuỗi đáp án). Chạy pipeline, tính recall@k + chấm answer. Đổi tham số → chạy lại → so số.

Code ngắn (recall@k trên golden set).

typescriptReady
// Gắn golden với tài liệu + chuỗi đáp án, KHÔNG gắn với id của chunkasync function recallAtK(golden: { q: string; docId: string; answer: string }[], k = 5) {  let hit = 0;  for (const g of golden) {    const res = await retrieveTopK(await embed(g.q), k);    if (res.some((r) => r.doc_id === g.docId && r.content.includes(g.answer))) hit++;  }  return hit / golden.length; // vd 0.86 = 86%}

Vì sao không ghi goldChunkId: id chunk sinh lúc ingest, đổi cỡ chunk hay re-embed là đổi hết id, golden set hỏng ngay đúng lúc bạn cần nó để so hai cấu hình chunking. Chuỗi đáp án nên ngắn và nằm gọn trong một chunk (cắt giữa chừng thì includes trượt); chuẩn hoá khoảng trắng và Unicode (NFC) cả hai phía trước khi so.

Đo recall của index xấp xỉ so với tìm chính xác#

Recall@k ở trên đo cả pipeline. Chỗ ANN (mục 5) làm rơi kết quả cần đo riêng: so top-k của index với top-k chính xác (tắt index scan, buộc quét toàn bảng) trên cùng truy vấn, ở các mức ef_search.

typescriptReady
async function annOverlap(qVec: number[], k: number, efSearch: number) {  const lit = `[${qVec.join(",")}]`;  const sql = "SELECT id FROM documents ORDER BY embedding <=> $1::vector LIMIT $2";  const c = await pool.connect();  try {    await c.query("BEGIN");    await c.query("SELECT set_config('hnsw.ef_search', $1, true)", [String(efSearch)]);    const ann = (await c.query(sql, [lit, k])).rows.map((r) => r.id);    await c.query("SELECT set_config('enable_indexscan', 'off', true)"); // true = chỉ trong transaction    const exact = (await c.query(sql, [lit, k])).rows.map((r) => r.id);    await c.query("COMMIT");    return ann.filter((id) => exact.includes(id)).length / k; // 1 = index không bỏ sót gì  } catch (e) {    await c.query("ROLLBACK");    throw e;  } finally {    c.release();  }}// Lấy ~100 vector truy vấn thật, tính trung bình annOverlap ở ef_search = 40, 100, 200:// chọn mức nhỏ nhất mà trung bình còn đạt mục tiêu; ef_search lớn hơn thì chậm hơn.

Đã tsc --strict với pg 8.23 và @types/pg; chưa chạy vì không dựng Postgres/pgvector trong lần soạn này, nên không có con số nào để trích: tự đo trên dữ liệu của bạn. Kết quả phụ thuộc kích thước bảng và phân bố vector; trên bảng vài chục dòng planner có thể không dùng index, khi đó ann và exact giống hệt nhau và phép đo vô nghĩa.

Pitfall / case thực tế.

  • Không có golden set → tinh chỉnh theo cảm tính, tưởng tốt hơn nhưng thật ra tệ đi.
  • Chỉ đo answer, bỏ retrieval. Answer sai có thể do retrieve trượt (chunk đúng không lọt top-k) — sửa prompt vô ích. Tách hai tầng để biết lỗi ở đâu.
  • Golden set quá nhỏ / không đại diện → chỉ số đẹp nhưng production vẫn hỏng.
  • LLM-judge không cố định seed/prompt → điểm dao động, khó so sánh giữa các lần.

Thực hành — RAG over docs#

Mục tiêu: ingest một tập tài liệu Markdown → lưu vào pgvector → một endpoint POST /ask trả lời câu hỏi có trích nguồn.

Bước 1 — Hạ tầng.

  • Postgres + CREATE EXTENSION vector;
  • Bảng documents(id, doc_id, source, content, embedding vector(1536)) + index HNSW vector_cosine_ops; bảng docs_meta(doc_id, content_hash, updated_at).

Bước 2 — Ingest (script offline).

textReady
đọc *.md → chunk(size≈1000, overlap≈150) → embed từng chunk (batch)        → INSERT (doc_id, source=đường dẫn+heading, content, embedding)        → ghi content_hash vào docs_meta
  • Lưu source đủ để trích dẫn (tên file + section). Bỏ qua doc nếu hash không đổi.

Bước 3 — Endpoint hỏi-đáp.

typescriptReady
// POST /ask  { question }app.post("/ask", async (req, res) => {  const { question } = req.body;  const qVec = await embed(question);  const top = await retrieveTopK(qVec, 20);        // retrieve rộng  const ranked = await rerank(question,             // rerank hẹp (tuỳ chọn)    top.map((t) => t.content), 4);  const picked = ranked.map((text) => top.find((t) => t.content === text)!);  const context = buildContext(picked, 6000);       // canh ngân sách token  const answer = await chat(`${system}\n\nNgữ cảnh:\n${context}\n\nHỏi: ${question}`);  res.json({    answer,    sources: picked.map((p, i) => ({ n: i + 1, source: p.source })),  });});
  • System prompt: chỉ dùng context, ép [n] citation, fallback "không đủ dữ liệu".

Bước 4 — Eval.

  • Golden set ~20–50 (câu hỏi, tài liệu đúng, chuỗi đáp án; không gắn với id chunk). Đo recall@5 và chấm answer bằng LLM-judge. Thử đổi chunk size / bật-tắt rerank / đổi k → so số, giữ cấu hình tốt.

Bước 5 — Vòng đời.

  • Cron re-ingest: doc đổi → so hash → xoá chunk cũ theo doc_id → embed lại.
Lời giải và cách kiểm tra: RAG over docs

Tự làm trước, rồi mới mở. Mức đã kiểm: rag-fake.ts và rag-fake.test.ts đã chạy (node --test, Node 24.21, không cần gói ngoài, tsc --strict sạch); bản pgvector và SQL code tham chiếu, chưa chạy (không có API key, không gọi API embedding hay LLM thật, chưa chạy pgvector). Lời giải làm hai việc: (1) pipeline chunk, index, retrieve, rerank, chống stale chạy được hoàn toàn cục bộ với embedding giả (băm token vào vector, xác định, không có ngữ nghĩa thật); (2) bản pgvector tham chiếu cho phần lưu trữ. Embedding giả chỉ chứng minh logic pipeline và cách đo, không chứng minh chất lượng ngữ nghĩa: câu hỏi chia sẻ từ với chunk vàng thì tìm ra, câu đồng nghĩa không chung từ nào thì không đoán trước được. Khi có key, chỉ thay fakeEmbed và fakeRerank.

Sơ đồ.

textReady
INGEST (offline) *.md ─► chunkMarkdown ─► embed(batch) ─► [BEGIN; DELETE doc_id; INSERT chunks; (hash đổi?)  heading+size+overlap          upsert docs_meta; COMMIT]QUERY (online) câu hỏi ─► embed ─► vector top-20 ┐        └──────────► keyword top-20 ┴─► RRF ─► rerank ─► top-3 ─► prompt                                                  │              (đánh số [n])   điểm vector cao nhất < ngưỡng ─► "không đủ dữ liệu" (không gọi LLM)

Hướng làm.

  1. Chunk theo heading Markdown trước (source = file + heading để trích dẫn), chỉ cắt cứng khi một mục dài hơn maxChars, có overlap.
  2. Index trong bộ nhớ: mỗi chunk giữ docId, source, text, vec; upsert so content_hash, đổi thì xoá chunk cũ của docId rồi nạp lại.
  3. Retrieve rộng bằng hai đường (vector và từ khoá), trộn bằng RRF, rerank hẹp.
  4. Chặn "không đủ dữ liệu" trước khi gọi LLM bằng ngưỡng điểm do chính golden set đo ra, không hardcode.
  5. Đo recall@k ở tầng retrieval với k nhỏ hơn nhiều so với số chunk (tập chỉ 6 chunk mà k = 20 thì recall luôn bằng 1, vô nghĩa).

Code tham chiếu (rag-fake.ts, Node 24 chạy trực tiếp file .ts, không cần gói ngoài):

typescriptReady
import { createHash } from "node:crypto";const DIM = 256;export interface Chunk { id: string; docId: string; source: string; text: string; vec: number[] }export const tokenize = (s: string): string[] =>  s.toLowerCase().normalize("NFC").match(/[\p{L}\p{N}]+/gu) ?? [];// Embedding giả: băm từng token vào một ô, cộng ±1, rồi normalize về độ dài 1.export function fakeEmbed(text: string): number[] {  const v = new Array<number>(DIM).fill(0);  for (const t of tokenize(text)) {    const h = createHash("sha256").update(t).digest();    v[h.readUInt32BE(0) % DIM] += h[4] & 1 ? 1 : -1;  }  const norm = Math.hypot(...v) || 1;  return v.map((x) => x / norm);}const dot = (a: number[], b: number[]) => a.reduce((s, x, i) => s + x * b[i], 0);export function chunkMarkdown(docId: string, md: string, maxChars = 800, overlap = 100) {  const out: Omit<Chunk, "vec">[] = [];  let heading = "(đầu file)";  let buf = "";  let n = 0;  const flush = () => {    const body = buf.trim();    buf = "";    for (let i = 0; body && i < body.length; i += maxChars - overlap) {      out.push({ id: `${docId}#${n++}`, docId, source: `${docId} > ${heading}`, text: body.slice(i, i + maxChars) });      if (i + maxChars >= body.length) break; // đã chạm cuối: không sinh chunk trùng đuôi    }  };  for (const line of md.split("\n")) {    const m = /^#{1,3}\s+(.*)/.exec(line);    if (m) { flush(); heading = m[1]; } else buf += line + "\n";  }  flush();  return out;}export function rrfMerge(a: string[], b: string[], k = 60): string[] {  const score = new Map<string, number>();  for (const ids of [a, b]) ids.forEach((id, r) => score.set(id, (score.get(id) ?? 0) + 1 / (k + r)));  return [...score].sort((x, y) => y[1] - x[1]).map(([id]) => id);}export class MemoryIndex {  chunks: Chunk[] = [];  private hashes = new Map<string, string>();  upsert(docId: string, md: string): boolean {    const hash = createHash("sha256").update(md).digest("hex");    if (this.hashes.get(docId) === hash) return false; // không đổi: bỏ qua    this.remove(docId); // xoá cả bản cũ, không chỉ thêm bản mới    for (const c of chunkMarkdown(docId, md)) this.chunks.push({ ...c, vec: fakeEmbed(c.text) });    this.hashes.set(docId, hash);    return true;  }  remove(docId: string) {    this.chunks = this.chunks.filter((c) => c.docId !== docId);    this.hashes.delete(docId);  }  vectorSearch(q: string, k: number) {    const qv = fakeEmbed(q);    return this.chunks.map((c) => ({ c, score: dot(qv, c.vec) })).sort((x, y) => y.score - x.score).slice(0, k);  }  keywordSearch(q: string, k: number) {    const qt = new Set(tokenize(q));    return this.chunks      .map((c) => ({ c, score: tokenize(c.text).filter((t) => qt.has(t)).length }))      .filter((x) => x.score > 0).sort((x, y) => y.score - x.score).slice(0, k);  }}// Rerank giả: tỉ lệ từ của câu hỏi có trong chunk. Thật: cross-encoder đọc cặp (câu hỏi, chunk).export function fakeRerank(q: string, cands: Chunk[], topN: number): Chunk[] {  const qt = new Set(tokenize(q));  const score = (c: Chunk) => {    const ct = new Set(tokenize(c.text));    return [...qt].filter((t) => ct.has(t)).length / (qt.size || 1);  };  return [...cands].sort((a, b) => score(b) - score(a)).slice(0, topN);}export function retrieve(idx: MemoryIndex, q: string, o: { k?: number; topN?: number; rerank?: boolean } = {}) {  const { k = 20, topN = 3, rerank = true } = o;  const vec = idx.vectorSearch(q, k).map((x) => x.c);  const kw = idx.keywordSearch(q, k).map((x) => x.c);  const byId = new Map([...vec, ...kw].map((c) => [c.id, c] as const));  const merged = rrfMerge(vec.map((c) => c.id), kw.map((c) => c.id)).map((id) => byId.get(id)!);  return rerank ? fakeRerank(q, merged, topN) : merged.slice(0, topN);}export function ask(idx: MemoryIndex, q: string, minScore: number) {  const best = idx.vectorSearch(q, 1)[0];  if (!best || best.score < minScore) return { answer: "không đủ dữ liệu", sources: [] as { n: number; source: string }[] };  const picked = retrieve(idx, q);  const context = picked.map((c, i) => `[${i + 1}] (${c.source}) ${c.text}`).join("\n\n");  const prompt = `Chỉ dùng ngữ cảnh. Mỗi khẳng định kèm [số nguồn]. Thiếu dữ liệu thì nói "không đủ dữ liệu".\n\nNgữ cảnh:\n${context}\n\nCâu hỏi: ${q}`;  return { prompt, sources: picked.map((c, i) => ({ n: i + 1, source: c.source })) }; // prompt đưa cho LLM thật}// Tầng retrieval: hit nếu top-k có chunk của tài liệu vàng VÀ chứa câu trả lời.// Không dựa vào id hay source của chunk, nên đổi cỡ chunk hoặc đổi tên heading không làm hỏng golden set.export interface Gold { q: string; docId: string; answer: string }export function recallAtK(idx: MemoryIndex, golden: Gold[], k: number, rerank: boolean) {  const hit = golden.filter((g) =>    retrieve(idx, g.q, { k, topN: k, rerank }).some((c) => c.docId === g.docId && c.text.includes(g.answer))).length;  return hit / golden.length;}

Kịch bản kiểm (rag-fake.test.ts, chạy bằng node --test rag-fake.test.ts):

typescriptReady
import test from "node:test";import assert from "node:assert/strict";import { MemoryIndex, retrieve, recallAtK, rrfMerge, type Gold } from "./rag-fake.ts";const corpus: Record<string, string> = {  "auth.md": "# Đăng nhập\n## Đổi mật khẩu\nVào Cài đặt, chọn Bảo mật, bấm Đổi mật khẩu và nhập mật khẩu mới hai lần.\n## Quên mật khẩu\nBấm Quên mật khẩu ở màn hình đăng nhập, hệ thống gửi liên kết đặt lại qua email.",  "billing.md": "# Thanh toán\n## Hoàn tiền\nYêu cầu hoàn tiền trong 14 ngày kể từ ngày thanh toán, tiền về thẻ sau 5 đến 7 ngày làm việc.\n## Hoá đơn\nHoá đơn điện tử gửi qua email vào ngày đầu mỗi tháng.",  "api.md": "# API\n## Giới hạn tốc độ\nMỗi khoá API được 60 yêu cầu mỗi phút, vượt quá sẽ nhận mã 429.\n## Xác thực\nGửi khoá API trong header Authorization dạng Bearer.",};// Golden set gắn với tài liệu + câu trả lời, không gắn với id/source của chunk (đổi cách chunk là id đổi).const golden: Gold[] = [  { q: "Làm sao đổi mật khẩu", docId: "auth.md", answer: "chọn Bảo mật" },  { q: "Hoàn tiền trong bao nhiêu ngày", docId: "billing.md", answer: "14 ngày" },  { q: "Lỗi 429 là gì", docId: "api.md", answer: "429" },  { q: "Gửi khoá API ở đâu", docId: "api.md", answer: "Bearer" },];const build = () => { const i = new MemoryIndex(); for (const [d, md] of Object.entries(corpus)) i.upsert(d, md); return i; };test("RRF: mục có mặt ở cả hai danh sách thắng", () => {  assert.deepEqual(rrfMerge(["A", "B", "C"], ["B", "D", "A"]), ["B", "A", "D", "C"]);});test("retrieve: top-1 sau rerank là chunk vàng, có source để trích dẫn", () => {  const idx = build();  for (const g of golden) {    const top = retrieve(idx, g.q, { topN: 1 })[0];    assert.ok(top.docId === g.docId && top.text.includes(g.answer), `${g.q} -> ${top.source}`);    assert.ok(top.source.startsWith(g.docId)); // source đủ để trích dẫn  }});test("recall@k đo ở k nhỏ, in ra để so sánh có/không rerank", () => {  const idx = build();  for (const k of [1, 2]) console.log(`k=${k} vector+rrf: ${recallAtK(idx, golden, k, false)}  +rerank: ${recallAtK(idx, golden, k, true)}`);});test("doc đổi: không còn chunk bản cũ; doc xoá: không còn chunk ma", () => {  const idx = build();  assert.equal(idx.upsert("billing.md", corpus["billing.md"]), false); // hash không đổi  assert.equal(idx.upsert("billing.md", corpus["billing.md"].replace("14 ngày", "30 ngày")), true);  const texts = idx.chunks.filter((c) => c.docId === "billing.md").map((c) => c.text).join(" ");  assert.ok(texts.includes("30 ngày") && !texts.includes("14 ngày"));  idx.remove("api.md");  assert.equal(idx.chunks.some((c) => c.docId === "api.md"), false);});

Kết quả đã quan sát (node --test rag-fake.test.ts): 4 test pass; test thứ ba in k=1 vector+rrf: 1 +rerank: 1 và k=2 vector+rrf: 1 +rerank: 1. Tập đồ chơi này quá dễ nên recall đã bằng 1 ở mọi cấu hình, không chứng minh rerank có ích: số có ý nghĩa chỉ có khi golden set đủ khó trên dữ liệu thật. Đổi một đáp án trong golden thành chuỗi không có trong tài liệu thì test 2 fail và recall tụt còn 0,75, tức golden set bắt được retrieval trượt. Golden set gắn với docId cộng chuỗi đáp án nên đổi cỡ chunk, đổi tên heading hay đổi cách đánh id không làm hỏng nó (gắn với chunkId hay source thì hỏng). Nếu một assert fail, nó fail ở tầng nào cũng cho biết lỗi nằm ở đâu: RRF (test 1), retrieve (test 2), vòng đời dữ liệu (test 4). Cách đọc recall@k: nếu recall@1 thấp mà recall@2 cao thì chunk vàng đã vào danh sách nhưng xếp hạng kém, đó là việc của rerank; nếu cả hai thấp thì chunk vàng không lọt top-k, sửa chunking hoặc embedding chứ sửa prompt vô ích.

Giới hạn của chunkMarkdown. Nó coi mọi dòng bắt đầu bằng # là heading, kể cả dòng comment trong khối code có hàng rào (```): một comment bash # cài đặt sẽ cắt chunk sai chỗ. Với tài liệu có nhiều khối code, thêm cờ inFence bật tắt ở dòng bắt đầu bằng ``` và bỏ qua việc nhận heading khi cờ đang bật.

Chọn ngưỡng "không đủ dữ liệu" (làm tay, cần chạy mới ra số). In vectorSearch(q, 1)[0].score cho các câu trong golden set và cho vài câu ngoài miền ("Thời tiết hôm nay thế nào"); đặt minScore nằm giữa điểm cao nhất của nhóm ngoài miền và điểm thấp nhất của nhóm trong miền. Ngưỡng này gắn với model embedding đang dùng: đổi model thì đo lại (mục 2).

Bản pgvector tham chiếu (chưa chạy pgvector; cú pháp đối chiếu README pgvector và PostgreSQL docs). Embed trước, rồi mới mở transaction, để không giữ transaction qua lệnh gọi mạng; xoá và nạp trong cùng một transaction để không bao giờ có trạng thái "đã xoá bản cũ mà chưa có bản mới":

textReady
CREATE EXTENSION IF NOT EXISTS vector;CREATE TABLE docs_meta (  doc_id       text PRIMARY KEY,  content_hash text NOT NULL,  updated_at   timestamptz NOT NULL DEFAULT now());CREATE TABLE documents (  id        bigserial PRIMARY KEY,  doc_id    text NOT NULL,  source    text NOT NULL,  content   text NOT NULL,  embedding vector(1536) NOT NULL);CREATE INDEX ON documents (doc_id);CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
typescriptReady
async function replaceDoc(pool: Pool, docId: string, hash: string, rows: { source: string; content: string; vec: number[] }[]) {  const client = await pool.connect();  try {    await client.query("BEGIN");    await client.query("DELETE FROM documents WHERE doc_id = $1", [docId]);    for (const r of rows) {      await client.query(        "INSERT INTO documents (doc_id, source, content, embedding) VALUES ($1, $2, $3, $4::vector)",        [docId, r.source, r.content, `[${r.vec.join(",")}]`],      );    }    await client.query(      `INSERT INTO docs_meta (doc_id, content_hash) VALUES ($1, $2)       ON CONFLICT (doc_id) DO UPDATE SET content_hash = EXCLUDED.content_hash, updated_at = now()`,      [docId, hash],    );    await client.query("COMMIT");  } catch (e) {    await client.query("ROLLBACK");    throw e;  } finally {    client.release();  }}

Nghiệm thu khi có Postgres và pgvector: SELECT count(*) FROM documents WHERE doc_id = 'billing.md' không đổi số khi chạy lại cùng nội dung (hash giống nên không đụng); sau khi sửa file, chạy ingest lại thì SELECT content FROM documents WHERE doc_id = 'billing.md' không còn câu cũ; EXPLAIN ANALYZE của truy vấn top-k dùng ORDER BY embedding <=> $1 LIMIT 5 thấy Index Scan using ... hnsw (trên bảng đủ lớn; bảng vài chục dòng planner có thể chọn quét tuần tự, bình thường). Lỗi hay gặp: embed bằng model A, truy vấn bằng model B; cột vector(1536) nhưng model trả 3072 chiều (lỗi khác chiều khi insert); ORDER BY dùng <-> trong khi index là vector_cosine_ops (không dùng index); bỏ DELETE khi doc đổi (còn cả bản cũ lẫn mới); đặt k = 20 trên tập 6 chunk rồi tưởng recall bằng 1 là tốt; ghi nhận recall@k nhưng bỏ qua tầng answer (cần LLM-judge khi có key).

Done khi#

  • Giải thích được embedding, dimension, và vì sao cùng model cho ingest & query là bắt buộc.

    Đáp án

    Embedding biến text thành vector cố định chiều sao cho text gần nghĩa thì vector gần nhau; dimension là độ dài vector (1536 cho text-embedding-3-small, 3072 cho -large, 128-3072 cho gemini-embedding-2). Mỗi model có không gian riêng: vector của model A so với model B vô nghĩa, nên ingest và query phải cùng model (và cùng cách định dạng query/document, ví dụ tiền tố của gemini-embedding-2). Đổi model thì re-embed toàn bộ. Ghi model và số chiều trong metadata. Xem GĐ23 mục 1.

  • Phân biệt cosine / dot / euclidean và biết khi normalize thì chúng tương đương.

    Đáp án

    Cosine đo góc (bỏ qua độ dài), dot product tính cả độ dài, L2 là khoảng cách thẳng. Khi mọi vector có độ dài 1: a·b = cos và |a-b|² = 2 - 2(a·b), nên ba thước đo cho cùng thứ tự xếp hạng; dot nhanh hơn. Tự kiểm bằng số: [1,2] và [2,4] có cosine 1 nhưng dot 10. Sai thường gặp: dùng dot trên vector chưa normalize (tài liệu "to" thắng giả tạo), hoặc toán tử query khác ops của index. Xem GĐ23 mục 2.

  • Chunk có size + overlap hợp lý, giữ metadata nguồn trên mỗi chunk.

    Đáp án

    Cỡ vài trăm token (hoặc 500-2000 ký tự) tuỳ loại tài liệu, overlap 10-20% để câu nằm ở mép vẫn trọn ở một chunk; ưu tiên cắt theo cấu trúc (heading, đoạn) rồi mới cắt cứng. Mỗi chunk giữ doc_id, source (file + heading hoặc số trang), content_hash để trích nguồn và cập nhật. Tự kiểm: văn bản 2500 ký tự, size 1000, overlap 150 ra 3 chunk. Xem GĐ23 mục 3.

  • Dựng được bảng pgvector + index HNSW đúng ops, insert & query top-k bằng SQL.

    Đáp án

    Cột vector(n) khớp số chiều của model; CREATE INDEX ... USING hnsw (embedding vector_cosine_ops) đi với ORDER BY embedding <=> $1 LIMIT k (<-> cho vector_l2_ops, <#> cho vector_ip_ops); insert bằng literal '[...]'. Cột hơn 2000 chiều không index được với vector (dùng halfvec hoặc giảm chiều). Tự kiểm: EXPLAIN ANALYZE thấy index scan trên bảng đủ lớn; đổi toán tử thì thấy seq scan. Mẫu SQL: bài thực hành, và GĐ23 mục 7.

  • Hiểu HNSW vs IVFFlat và đánh đổi tốc độ/recall/RAM; biết ANN là gần đúng.

    Đáp án

    HNSW: đồ thị nhiều tầng, recall cao, build chậm, tốn RAM, không cần dữ liệu có sẵn; chỉnh hnsw.ef_search. IVFFlat: chia cụm (lists), build nhanh, ít RAM, phải build sau khi nạp dữ liệu, chỉnh ivfflat.probes, recall thường thấp hơn. ANN bỏ sót thỉnh thoảng nên recall dưới 100% là bình thường; muốn exact trên tập nhỏ thì bỏ index. Xem GĐ23 mục 5.

  • Vẽ được pipeline RAG 2 pha (ingest / query) và giải thích từng bước.

    Đáp án

    Ingest (offline): chunk, embed, store vector + text + metadata. Query (online): embed câu hỏi bằng cùng model, retrieve top-k, (rerank), ghép prompt có đánh số nguồn, LLM sinh câu trả lời kèm citation. Vẽ lại bằng sơ đồ ở bài thực hành rồi tự giải thích từng bước. Sai thường gặp: quên rằng bước embed câu hỏi phải cùng model với bước embed chunk. Xem GĐ23 mục 6.

  • Endpoint /ask trả lời có citation và có fallback "không đủ dữ liệu".

    Đáp án

    Prompt đánh số [n] (source) text, system ép "mỗi khẳng định kèm [số nguồn]" và "thiếu dữ liệu thì nói không đủ dữ liệu"; response trả thêm sources: [{n, source}]. Tốt hơn câu dặn trong prompt: chặn bằng ngưỡng điểm retrieval trước khi gọi LLM (code ở bài thực hành). Tự kiểm: hỏi một câu ngoài miền thì nhận "không đủ dữ liệu" và sources rỗng. Xem GĐ23 mục 10.

  • Biết khi nào cần hybrid search và rerank, và vì sao vector thuần đôi khi trượt.

    Đáp án

    Hybrid khi truy vấn có mã, tên riêng, số phiên bản, thuật ngữ hiếm (vector làm mượt "SKU-8842" và "SKU-8843"); trộn bằng RRF vì không cần cùng thang điểm. Rerank khi top-k thô có chunk "gần gần" và context hẹp; luôn retrieve rộng (k khoảng 20) rồi rerank hẹp (3-5), và đo xem có đáng latency hay không. Tự kiểm: tính tay RRF ở mục 8 ra [B, A, D, C]. Xem GĐ23 mục 8 và mục 9.

  • Quản context: chọn top-n nhỏ, canh token, tránh "lost in the middle".

    Đáp án

    Đưa top-n nhỏ sau rerank (3-6 chunk) thay vì cả top-k, canh ngân sách ký tự/token, đặt chunk quan trọng ở đầu và cuối để tránh "lost in the middle", tính cả system và câu hỏi vào ngân sách, đánh số để trích nguồn. Sai thường gặp: nhồi 50 chunk cho chắc: đắt hơn, chậm hơn, và chunk đúng ở giữa bị bỏ qua. Xem GĐ23 mục 10.

  • Có quy trình re-embed/xoá theo content_hash, không để stale/ma.

    Đáp án

    Doc đổi: so hash, khác thì xoá hết chunk cũ theo doc_id rồi nạp lại trong một transaction (embed trước, ngoài transaction); doc xoá: xoá theo doc_id; đổi model: dựng kho mới song song rồi chuyển. Tự kiểm: sửa một câu trong file, chạy ingest lại, câu cũ không còn trong documents; chạy lại lần nữa không đổi gì. Test mẫu: bài thực hành, test thứ tư. Xem GĐ23 mục 11.

  • Có golden set + đo recall@k và answer quality; mọi thay đổi đều so số.

    Đáp án

    Golden set 20-50 bộ (câu hỏi, tài liệu đúng, chuỗi đáp án; không gắn với id chunk vì id đổi mỗi lần chunk lại), gồm câu đồng nghĩa, câu có mã/tên riêng, và câu ngoài miền. Đo hai tầng riêng: recall@k ở retrieval (top-k có chunk của tài liệu đúng và chứa đáp án không), rồi faithfulness và correctness của câu trả lời (LLM-judge cố định prompt và temperature). Mỗi thay đổi (chunk size, k, rerank, model) chạy lại và so với số cũ; chọn k nhỏ hơn nhiều so với số chunk, nếu không recall luôn đẹp. Xem GĐ23 mục 12.

  • Viết được truy vấn full-text tiếng Việt không dấu bằng unaccent + GIN, và nêu vì sao ts_rank không phải BM25 (mục 8).

    Đáp án

    Tạo cấu hình vi_unaccent (COPY = simple, ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple), index GIN (to_tsvector('vi_unaccent', content)), truy vấn dùng đúng biểu thức đó với plainto_tsquery('vi_unaccent', $1). Không viết unaccent(content) trong index vì hàm đó STABLE, không IMMUTABLE. ts_rank chỉ nhìn tần suất, độ gần và vị trí của từ khớp trong một tài liệu, không dùng thống kê toàn kho (IDF) như BM25; muốn BM25 thật thì dùng công cụ search riêng. Tự kiểm khi có Postgres: tìm "dang nhap" ra đoạn chứa "đăng nhập", và EXPLAIN thấy plan dùng documents_fts_idx khi bảng đủ lớn. Sai thường gặp: quên REINDEX sau khi đổi mapping của cấu hình. Xem GĐ23 mục 8.

  • Kiểm tra được index xấp xỉ có làm rơi kết quả không: lọc theo tenant và recall so với tìm chính xác (mục 5, 12).

    Đáp án

    Với lọc theo tenant: EXPLAIN (ANALYZE, BUFFERS) truy vấn của tenant nhỏ nhất, so số dòng thực tế với LIMIT; ít hơn trong khi tenant có đủ chunk nghĩa là post-filter đang làm rơi, thử hnsw.iterative_scan = relaxed_order, partial index hoặc partition. Với recall của index: chạy cùng truy vấn hai lần trong một transaction, lần hai enable_indexscan = off để ra top-k chính xác, tính phần giao với top-k của index ở ef_search = 40, 100, 200 trên ~100 truy vấn thật, chọn mức nhỏ nhất đạt mục tiêu recall. Sai thường gặp: đo trên bảng vài chục dòng (planner quét tuần tự nên kết quả luôn giống nhau), hoặc để tenant_id lấy từ request thay vì từ phiên đã xác thực. Xem GĐ23 mục 5 và mục 12.

  • Quyết định được một bài toán có nên dùng RAG hay không (mục 6).

    Đáp án

    Không dùng khi kho nhỏ vừa context window (nhồi thẳng, có prompt caching), khi câu hỏi cần tổng hợp hoặc tính toán trên cả kho (dùng SQL), khi dữ liệu có cấu trúc tra theo khoá (gọi API hoặc tool), hoặc khi chỉ cần khớp từ khoá chính xác (full-text). Dùng khi kho lớn, chủ yếu là văn bản tự do và câu hỏi nhắm vào vài đoạn cụ thể. Tự kiểm: với "tháng này có bao nhiêu đơn hoàn tiền" bạn chọn SQL; với "chính sách hoàn tiền nói gì về hàng lỗi" trên 500 trang tài liệu bạn chọn RAG. Xem GĐ23 mục 6.