HARRY-10 — GĐ20 — Embeddings + Vector Databases + RAG
GĐ20 — 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ộ".
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, Geminitext-embedding-004/gemini-embedding-001~768–3072. Nhiều chiều hơn = biểu diễn tinh vi hơn nhưng tốn RAM/băng thông và chậm hơn khi so sánh. OpenAI-3hỗ trợ cắt chiều (Matryoshka) qua tham sốdimensionsđể đánh đổi độ chính xác lấy kích thước nhỏ hơn. - 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).
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); // 1536Pitfall / 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
inputmột lần) và có retry.
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ần1càng cùng hướng (giống nghĩa),0là 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.
pgvectorký 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.
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.Pitfall / case thực tế.
- Lệch metric giữa index và query. Nếu tạo index HNSW theo
vector_cosine_opsnhư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.8mộ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:
- Giới hạn input của model embedding (mục 1) — không thể embed cả cuốn.
- Độ 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.
- 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ự).
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.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ọn | Bản chất | Dùng khi |
|---|---|---|
| pgvector | Extension 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). |
| Pinecone | Managed SaaS thuần vector | Muốn không lo vận hành, scale lớn, serverless; chấp nhận trả tiền + phụ thuộc vendor. |
| Qdrant | Vector DB (Rust), self-host hoặc cloud | Cần filter metadata mạnh, hiệu năng cao, muốn open-source tự host. |
| Weaviate | Vector 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).
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector, chạy 1 lần trên DBPitfall / 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
listscụ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ỉnhhnsw.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ỉnhivfflat.probes— nhiều probe = recall cao hơn nhưng chậm hơn.
Code ngắn.
-- 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ệu
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 200);
SET ivfflat.probes = 10;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).
opscủ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. - RAM nổ với HNSW khi vài triệu vector 3072 chiều — cân nhắc giảm dimension (Matryoshka) hoặc chuyển hạ tầng.
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):
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):
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:
- Chunk tài liệu (mục 3).
- Embed mỗi chunk (mục 1).
- Store vector + text gốc + metadata (doc_id, nguồn, trang) vào vector DB.
- Embed câu hỏi bằng cùng model đã dùng ở bước 2.
- Retrieve top-k (vd k=5) chunk gần vector câu hỏi nhất (mục 5, 7).
- Rerank (mục 9) để lọc lại thứ tự cho chính xác — tuỳ chọn nhưng đáng giá.
- Augment: dựng prompt = hướng dẫn hệ thống + các chunk (context) + câu hỏi.
- Generate: LLM đọc context và trả lời, kèm trích nguồn.
Code ngắn (khung pha query).
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.text}`)
.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.
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).
-- Schema
CREATE TABLE documents (
id bigserial PRIMARY KEY,
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);import { Pool } from "pg";
const pool = new Pool();
// INSERT: pgvector nhận literal dạng '[0.1,0.2,...]'
async function insertChunk(source: string, content: string, v: number[]) {
await pool.query(
`INSERT INTO documents (source, content, embedding) VALUES ($1, $2, $3)`,
[source, content, `[${v.join(",")}]`]
);
}
// RETRIEVE top-k gần nhất theo cosine distance
async function retrieveTopK(qVec: number[], k = 5) {
const { rows } = await pool.query(
`SELECT id, source, content, 1 - (embedding <=> $1) AS score
FROM documents
ORDER BY embedding <=> $1
LIMIT $2`,
[`[${qVec.join(",")}]`, k]
);
return rows; // [{ 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ô hayARRAY[...]. 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ếuORDER BYkhô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ế.
- BM25 / full-text (Postgres
tsvector/ts_rank): chấm điểm theo tần suất & độ hiếm của từ khớp. 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).
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)-- Full-text side (Postgres)
SELECT id FROM documents
WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', $1)
ORDER BY ts_rank(to_tsvector('simple', content),
plainto_tsquery('simple', $1)) DESC
LIMIT 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 (
GINtrêntsvector) → keyword side chậm.
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).
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-english-v3.0",
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).
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).
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
await pool.query(`DELETE FROM documents WHERE doc_id = $1`, [docId]); // xoá cũ
for (const c of chunk(text)) {
await insertChunkWithDoc(docId, c, await embed(c)); // nạp mới
}
await pool.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]);
}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) hoặc bộ eval như Ragas.
- Cách làm: tạo golden set = danh sách (câu hỏi, đáp án/chunk đúng). 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).
async function recallAtK(golden: {q: string; goldChunkId: 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.id === g.goldChunkId)) hit++;
}
return hit / golden.length; // vd 0.86 = 86%
}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 HNSWvector_cosine_ops; bảngdocs_meta(doc_id, content_hash, updated_at).
Bước 2 — Ingest (script offline).
đọ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.
// 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, chunk đúng). Đo
recall@5và 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.
Done khi
- [ ] Giải thích được embedding, dimension, và vì sao cùng model cho ingest & query là bắt buộc.
- [ ] Phân biệt cosine / dot / euclidean và biết khi normalize thì chúng tương đương.
- [ ] Chunk có size + overlap hợp lý, giữ metadata nguồn trên mỗi chunk.
- [ ] Dựng được bảng pgvector + index HNSW đúng
ops, insert & query top-k bằng SQL. - [ ] Hiểu HNSW vs IVFFlat và đánh đổi tốc độ/recall/RAM; biết ANN là gần đúng.
- [ ] Vẽ được pipeline RAG 2 pha (ingest / query) và giải thích từng bước.
- [ ] Endpoint
/asktrả lời có citation và có fallback "không đủ dữ liệu". - [ ] Biết khi nào cần hybrid search và rerank, và vì sao vector thuần đôi khi trượt.
- [ ] Quản context: chọn top-n nhỏ, canh token, tránh "lost in the middle".
- [ ] Có quy trình re-embed/xoá theo content_hash, không để stale/ma.
- [ ] Có golden set + đo
recall@kvà answer quality; mọi thay đổi đều so số.
