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ằnggemini-embedding-2(GA, 128–3072 chiều, không cótask_type, dùng tiền tố prompt) hoặcgemini-embedding-001(cótask_type, còn dùng đến 2028-05-14); OpenAItext-embedding-3-small1536 chiều và-large3072 chiều, cả hai có tham sốdimensions; pgvector 0.8.x: index HNSW/IVFFlat chovectortối đa 2000 chiều,halfvec4000,bit64000; lọcWHEREá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_rankkhông dùng thông tin toàn cục), OWASP Top 10 for LLM Applications 2025.unaccent(text)làSTABLE: đọc từ fileunaccent--1.1.sqlcủ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-textunaccent,EXPLAIN, đo recall theoef_search; codepg/@google/genaimớ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; Geminigemini-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-3hỗ trợ cắt chiều (Matryoshka) qua tham sốdimensions, Gemini quaoutputDimensionality, để đá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-2khô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-001vẫ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).
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
inputmộ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-2hỗ 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ủatext-embedding-3-*: chưa xác minh. Cohere nêurerank-v4.0-pro,rerank-v4.0-fastvàrerank-v3.5là đ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:
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ầ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.
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_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ự).
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ọ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).
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
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.
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).
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. - Quá giới hạn chiều của index. pgvector chỉ index được cột
vectortới 2000 chiều (halfvectới 4000,bittới 64000). Cộtvector(3072)(ví dụtext-embedding-3-largehoặcgemini-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ằnghalfvec(3072)hoặc index trên biểu thức ép kiểu, xem khối SQLhalfvecngay 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
WHEREkhông làm index nhanh hơn. Với index xấp xỉ, pgvector lọc sau khi quét index:hnsw.ef_searchmặ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ơnLIMIT. 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ằngEXPLAIN ANALYZEthay 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ả:
- Đọc dòng
Index Scan using ... hnswrồi sorows=thực tế của node vớiLIMIT: 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 Filtercho 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_idphả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):
Pha QUERY (online, mỗi câu hỏi):
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).
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).
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ế.
- 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_cdkhô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).
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.
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.
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 đó.
- Vì sao dùng cấu hình thay vì viết
unaccent(content)trong index: hàmunaccent(text)được khai báoSTABLE(đã đọc file SQL contrib của PostgreSQL 17.9), không phảiIMMUTABLE, nên Postgres từ chối dùng nó trong biểu thức index; cònto_tsvector('cấu_hình_hằng', ...)thì được. simplekhô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ảiREINDEX. - Mức đã kiểm: SQL chỉ đối chiếu trang
unaccentvàText Search Controlscủ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).
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).
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).
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).
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.
Đã 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 HNSWvector_cosine_ops; bảngdocs_meta(doc_id, content_hash, updated_at).
Bước 2 — Ingest (script offline).
- 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.
- 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@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.
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ơ đồ.
Hướng làm.
- 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ơnmaxChars, có overlap. - Index trong bộ nhớ: mỗi chunk giữ
docId,source,text,vec;upsertsocontent_hash, đổi thì xoá chunk cũ củadocIdrồi nạp lại. - Retrieve rộng bằng hai đường (vector và từ khoá), trộn bằng RRF, rerank hẹp.
- 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.
- Đo
recall@kở tầng retrieval vớiknhỏ hơn nhiều so với số chunk (tập chỉ 6 chunk màk = 20thì 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):
Kịch bản kiểm (rag-fake.test.ts, chạy bằng node --test rag-fake.test.ts):
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":
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 chogemini-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ủagemini-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 = cosvà|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ácopscủ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 150ra 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ớiORDER BY embedding <=> $1 LIMIT k(<->chovector_l2_ops,<#>chovector_ip_ops); insert bằng literal'[...]'. Cột hơn 2000 chiều không index được vớivector(dùnghalfvechoặc giảm chiều). Tự kiểm:EXPLAIN ANALYZEthấ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ỉnhivfflat.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
/asktrả 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êmsources: [{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àsourcesrỗ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_idrồi nạp lại trong một transaction (embed trước, ngoài transaction); doc xoá: xoá theodoc_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 trongdocuments; 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@kvà 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ọnknhỏ 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ì saots_rankkhô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), indexGIN (to_tsvector('vi_unaccent', content)), truy vấn dùng đúng biểu thức đó vớiplainto_tsquery('vi_unaccent', $1). Không viếtunaccent(content)trong index vì hàm đóSTABLE, khôngIMMUTABLE.ts_rankchỉ 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àEXPLAINthấy plan dùngdocuments_fts_idxkhi bảng đủ lớn. Sai thường gặp: quênREINDEXsau 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ớiLIMIT; í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 haienable_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_idlấ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.