04 · RAG production với PostgreSQL + pgvector
RAG (Retrieval-Augmented Generation — tìm tài liệu liên quan rồi mới đưa cho model đọc) là cách phổ biến nhất để LLM trả lời bằng dữ liệu của công ty. Demo RAG mất một buổi chiều; RAG chạy đúng trong production mất vài tháng, và gần như mọi lỗi nằm ở phần tìm kiếm, không nằm ở LLM. Với team đã chạy PostgreSQL, pgvector là lựa chọn tự nhiên — bài này đo xem nó hỏng ở đâu.
Đã verify gì, chưa verify gì. Lab chạy thật trên PostgreSQL 16.13 + pgvector 0.6.0 (cluster tạm cổng 55440, không đụng DB nào khác): 50 000 vector 384 chiều dạng cụm, đo recall/latency theoef_search, bẫy WHERE + HNSW, bộ nhớ khi build index, giới hạn số chiều, full-text tiếng Việt +unaccent, hybrid RRF. Vector là dữ liệu tổng hợp, không phải embedding thật (máy lab không tải được model embedding) — đo được hành vi của index, không đo được chất lượng ngữ nghĩa. Chưa verify: iterative index scan (pgvector ≥ 0.8),halfvec(≥ 0.7), reranker, contextual retrieval — ghi nguồn tại chỗ. Máy lab không có repopostgresql-learningnên không chạy trên PostgreSQL 18.4 như quy ước của repo.
Câu trả lời 60 giây
"RAG là một search engine đặt trước LLM: chất lượng câu trả lời bị chặn trên bởi recall của bước tìm — đoạn đúng không được lấy ra thì model không thể trả lời đúng, và thường sẽ trả lời sai một cách tự tin. Tôi dùng pgvector vì dữ liệu, quyền và vector nằm chung một transaction, không phải đồng bộ hai hệ thống. Nhưng HNSW là tìm gần đúng: trong lab 50k vector, recall@10 chỉ 0,81 ởef_searchmặc định 40, lên 0,98 ở 200. Bẫy lớn nhất là lọc: thêmWHERE tenant = ...cho tenant chiếm 1% dữ liệu thì top-10 trả về 0–2 dòng thay vì 10, vì index lọc sau khi đã lấy ứng viên. Nên tôi đo recall trên bộ câu hỏi thật, dùng hybrid với full-text cho mã lỗi/mã sản phẩm, và xử lý lọc theo tenant bằng index riêng hoặc quét chính xác."
Thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| embedding | Vector số (vài trăm đến vài nghìn chiều) biểu diễn ý nghĩa đoạn văn; gần nhau = giống nghĩa. |
| chunk | Mẩu tài liệu được cắt ra để embed và truy xuất. |
| ANN (approximate nearest neighbor) | Tìm láng giềng gần gần đúng — nhanh hơn nhiều, đổi lại có thể bỏ sót. |
| HNSW | Hierarchical Navigable Small World — index dạng đồ thị nhiều tầng cho ANN. |
| IVFFlat | Index chia vector thành cụm (lists), chỉ quét vài cụm gần nhất (probes). |
| recall@k | Trong k kết quả trả về, bao nhiêu phần là kết quả đúng (so với tìm chính xác). |
| hybrid search | Kết hợp tìm theo từ khoá (full-text/BM25) và theo vector. |
| RRF (reciprocal rank fusion) | Gộp nhiều danh sách xếp hạng: điểm = Σ 1/(k + hạng). |
| reranker | Model chấm lại độ liên quan của từng cặp (câu hỏi, đoạn) — chậm hơn nhưng chính xác hơn embedding. |
1. Bài toán gốc và bối cảnh ra đời
LLM không biết chính sách đổi trả của shop bạn, không biết đơn hàng hôm qua, và kiến thức dừng ở knowledge cutoff. Ba cách đưa kiến thức vào:
| Cách | Giới hạn |
|---|---|
| Fine-tune model trên tài liệu | Đắt, chậm cập nhật (đổi chính sách phải train lại), không trích dẫn được nguồn, không phân quyền theo người đọc, vẫn hallucinate |
| Nhồi toàn bộ tài liệu vào prompt | Giới hạn cửa sổ, chi phí mỗi lần gọi, context rot (bài 03) |
| RAG (Lewis et al., Facebook AI Research, 2020) | Chất lượng phụ thuộc bước tìm; thêm một hệ thống phải vận hành |
RAG thắng vì cập nhật tức thì (sửa tài liệu → re-embed chunk đó), trích dẫn được, và lọc được theo quyền người hỏi. Làn sóng vector
database 2022–2023 (Pinecone, Weaviate, Qdrant, Milvus) đi cùng ChatGPT; pgvector (Andrew Kane, 2021) đưa kiểu vector và index
ANN vào PostgreSQL.
RAG tối ưu cho: câu hỏi mà câu trả lời nằm trong một vài đoạn tài liệu (FAQ, chính sách, tài liệu kỹ thuật, ticket cũ). Không tối ưu cho: câu hỏi tổng hợp ("doanh thu tháng 9 theo kênh" → SQL), câu hỏi cần đọc toàn bộ kho ("tóm tắt mọi khiếu nại quý này" → map-reduce/batch), tra cứu chính xác theo mã ("đơn 1001" → tool SQL), hay dữ liệu thay đổi từng giây (gọi API sống).
2. Mô hình tư duy cốt lõi
RAG = search engine + LLM đọc hiểu. Model chỉ đọc được những gì search trả về; chất lượng câu trả lời ≤ recall của retrieval. Embedding biến "tìm theo nghĩa" thành "tìm điểm gần nhất trong không gian vector", và ANN đổi độ chính xác lấy tốc độ.
flowchart LR
subgraph ING["Ingest (offline, khi tài liệu đổi)"]
D["Tài liệu"] --> C["Chunking"] --> E1["Embedding model"] --> DB[("PostgreSQL: chunk + metadata + vector")]
end
subgraph QRY["Query (mỗi câu hỏi)"]
Q["Câu hỏi"] --> E2["Embedding (cùng model!)"] --> R["ANN top-k + full-text + lọc quyền"]
DB --> R
R --> RR["Rerank (tuỳ chọn)"] --> P["Prompt: câu hỏi + top đoạn + nguồn"] --> L["LLM"] --> A["Câu trả lời + trích dẫn"]
end
Hệ quả của abstraction:
- Muốn cải thiện RAG, đo retrieval trước (recall@k trên bộ câu hỏi có đáp án), rồi mới đo câu trả lời.
- Query và tài liệu phải dùng cùng một embedding model — đổi model là embed lại toàn bộ kho.
- ANN trả về "gần nhất theo index", không phải "đúng nhất" — phải biết recall thực tế của cấu hình mình.
- Quyền đọc phải được áp trong bước tìm, không phải sau khi LLM đã đọc.
3. Internals và quyết định thiết kế
3.1 Chunking
| Chiến lược | Được | Mất |
|---|---|---|
| Cố định N token, chồng lấn (vd 400 token, overlap 50) | Đơn giản, đoán được chi phí | Cắt ngang bảng, danh sách, câu; chunk mất ngữ cảnh ("Điều 3 áp dụng cho..." — điều 3 của văn bản nào?) |
| Theo cấu trúc (heading, mục, hàm) | Chunk có nghĩa trọn vẹn | Kích thước không đều; cần parser theo định dạng |
| Contextual chunk (thêm 1–2 câu mô tả chunk nằm ở đâu trong tài liệu, do LLM sinh) | Anthropic báo cáo (9/2024, "Contextual Retrieval"): giảm tỉ lệ truy xuất hỏng top-20 49% khi kết hợp contextual embedding + contextual BM25, 67% khi thêm rerank | Tốn một lần gọi LLM mỗi chunk lúc ingest (dùng prompt cache cho toàn văn bản để giảm) |
Nguyên tắc: chunk đủ nhỏ để embedding "tập trung" một ý, đủ lớn để tự đứng được; luôn lưu kèm document_id, tiêu đề, vị trí,
phiên bản, quyền, để trích dẫn và lọc.
3.2 Embedding và khoảng cách
Embedding model chọn theo: hỗ trợ tiếng Việt (model đa ngôn ngữ), số chiều (384–3072; chiều càng nhiều càng tốn RAM/disk), giá, và có chạy được nội bộ không (dữ liệu nhạy cảm). Anthropic không cung cấp embedding model riêng; tài liệu của họ gợi ý nhà cung cấp bên thứ ba — nghĩa là pipeline RAG thường có hai nhà cung cấp AI.
Kích thước lưu trữ đo trong lab (kiểu vector = float4):
bytes_vec384 | bytes_vec1536
--------------+---------------
1544 | 6152
1 triệu chunk × 1536 chiều ≈ 6,2 GB chỉ riêng cột vector, chưa kể index (thường cỡ bảng hoặc lớn hơn — xem 3.3). Khoảng cách: dùng
cosine (<=>, ops vector_cosine_ops) cho embedding văn bản; nếu vector đã chuẩn hoá độ dài 1 thì inner product
(<#>) cho cùng thứ tự và rẻ hơn.
3.3 Index: exact, IVFFlat, HNSW
| Index | Cách chạy | Được | Mất |
|---|---|---|---|
| Không index (seq scan) | Tính khoảng cách với mọi dòng | Recall 100%, luôn đúng với WHERE | O(N) — lab: 24,8 ms/query ở 50k, tăng tuyến tính |
| IVFFlat | K-means chia lists cụm; query quét probes cụm gần nhất | Build nhanh, ít RAM | Cần dữ liệu có sẵn để train cụm; dữ liệu trôi theo thời gian thì cụm lệch, phải rebuild |
| HNSW (Malkov & Yashunin, 2016) | Đồ thị nhiều tầng: tầng trên thưa để nhảy xa, tầng dưới dày để tinh chỉnh; tham số m (số cạnh), ef_construction, ef_search | Trade-off recall/latency tốt nhất; thêm dữ liệu không cần train lại | Build chậm và ngốn RAM; index to; kết quả build không tất định |
Lab: recall vs ef_search (50 000 vector, 384 chiều, 200 cụm, HNSW m=16, ef_construction=64, 50 query):
nạp 50000 vector 384 chiều: 6.2s
exact (seq scan) p50 = 24.8 ms/query
build HNSW maintenance_work_mem= 16MB: 53.1s, index 98 MB (bảng 78 MB)
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 7450 tuples
build HNSW maintenance_work_mem=512MB: 6.3s, index 98 MB (bảng 78 MB)
ef_search recall@10 p50 ms p95 ms
10 0.664 0.78 1.25
20 0.730 0.75 0.97
40 0.808 0.77 1.04
100 0.916 1.02 1.72
200 0.976 1.12 2.23
Đọc kết quả:
- HNSW nhanh hơn quét chính xác ~30 lần (0,77 ms vs 24,8 ms), nhưng ở mặc định
ef_search = 40chỉ tìm đúng 81% láng giềng thật. Tăng lên 200 được 97,6% mà latency vẫn dưới 1,2 ms — ở quy mô này, tăng ef_search gần như miễn phí. - Index (98 MB) lớn hơn cả bảng (78 MB). Muốn query nhanh, index phải nằm trong RAM.
maintenance_work_memnhỏ làm build chậm 8,4 lần (53 s vs 6,3 s) và PostgreSQL chỉ báo bằng một NOTICE — dễ bị bỏ qua trong log migration.- Chạy lab hai lần, recall ở
ef_search = 40ra 0,794 và 0,808: HNSW gán tầng ngẫu nhiên nên mỗi lần build ra đồ thị hơi khác. Đừng so sánh hai cấu hình bằng một lần build.
Dữ liệu tổng hợp dễ hơn hay khó hơn thật? Vector lab chia 200 cụm rõ rệt; embedding văn bản thật thường có cấu trúc kém rõ hơn và nhiều chiều hơn (768–1536), nên recall ở cùng tham số có thể thấp hơn. Con số đúng cho hệ thống của bạn chỉ có được bằng cách đo trên embedding thật của bạn — chưa verify.
3.4 Lọc (WHERE) + HNSW: bẫy lớn nhất
Multi-tenant là chuẩn trong SaaS: mỗi shop chỉ được tìm trong tài liệu của mình. Lab: tenant shop_nho chiếm ~1% (470/50 000 chunk):
Tenant 'shop_nho' có 470 chunk. Query top-10 có WHERE tenant='shop_nho':
HNSW ef_search=40 : số dòng trả về / 20 query = [1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 2, 0, 0]
plan: Limit | -> Index Scan using tmp_chunks_hnsw on tmp_chunks | Order By: (emb <=> '[...]'::vector) | Filter: (tenant = 'shop_nho'::text)
lọc trước + exact : số dòng = [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10], p50 0.9 ms
LIMIT 10 nhưng nhận 0–2 dòng. Plan cho thấy lý do: index scan lấy ~ef_search ứng viên gần nhất trên
toàn bảng, rồi Filter mới bỏ dòng không thuộc tenant. 40 ứng viên × 1% ≈ 0,4 dòng. Không có lỗi, không có cảnh báo; với
người dùng của shop nhỏ, bot "không tìm thấy gì" hoặc trả lời không có tài liệu. Tệ hơn: nếu lọc quyền được làm sau bước tìm ở tầng
ứng dụng, cùng cơ chế này dẫn tới rò dữ liệu tenant khác nếu ai đó quên lọc.
| Cách xử lý | Khi nào hợp | Cái giá |
|---|---|---|
Lọc trước + quét chính xác (index B-tree trên tenant) | Tenant nhỏ (vài nghìn chunk) — lab: 10/10 dòng, 0,9 ms | Chậm với tenant lớn; planner có thể không tự chọn (lab phải SET enable_indexscan = off) |
| Partial index / partition theo tenant lớn | Vài tenant lớn chiếm phần lớn dữ liệu | Nhiều index phải quản lý |
Tăng ef_search | Bộ lọc không quá hiếm (≥10%) | Không cứu được bộ lọc 1% |
Iterative index scan (pgvector ≥ 0.8.0: SET hnsw.iterative_scan = relaxed_order) | Mặc định nên bật khi có lọc | Có trần hnsw.max_scan_tuples; relaxed_order có thể lệch thứ tự nhẹ. Chưa verify — lab dùng 0.6.0 |
3.5 Hybrid search và tiếng Việt
Embedding giỏi "nghĩa gần", kém với chuỗi chính xác: mã lỗi E1234, SKU, tên riêng. Full-text search giỏi đúng chỗ đó. PostgreSQL
không có từ điển tiếng Việt, nên cấu hình simple + unaccent là mức tối thiểu. Lab:
--- 1. PostgreSQL không có từ điển tiếng Việt: config simple chỉ tách theo khoảng trắng, không bỏ dấu
simple_tsv
-------------------------------------------------------------
'bị':5 'e1234':7 'hoàn':1 'hàng':4 'lỗi':6 'tiền':2 'đơn':3
khop_khong_dau
----------------
f
--- 2. Thêm unaccent: người gõ không dấu vẫn khớp
khop_sau_unaccent
-------------------
t
Người dùng gõ "hoan tien" không khớp "Hoàn tiền" nếu thiếu unaccent. (Ghi chú: simple tách theo khoảng trắng nên
"hoàn tiền" là hai token rời; không có tách từ ghép tiếng Việt — muốn tốt hơn cần công cụ tách từ bên ngoài hoặc search engine có plugin
tiếng Việt.)
Gộp hai danh sách bằng RRF (k = 60), chạy thật với 5 đoạn FAQ và vector 3 chiều viết tay (chỉ minh hoạ cơ chế, không nói về chất lượng):
id | body | hang_keyword | hang_vector | rrf
----+--------------------------------------------------+--------------+-------------+---------
1 | Chính sách hoàn tiền trong 30 ngày kể từ khi nhậ | 1 | 3 | 0.03227
3 | Đổi trả sản phẩm lỗi do nhà sản xuất | 3 | 1 | 0.03227
2 | Mã lỗi E1234: thanh toán thẻ bị từ chối bởi ngân | 2 | 4 | 0.03175
4 | Hướng dẫn trả lại hàng và nhận lại tiền | 4 | 2 | 0.03175
RRF chỉ dùng thứ hạng, không dùng điểm — nên không cần chuẩn hoá thang điểm giữa ts_rank và khoảng cách cosine, đổi lại
mất thông tin "cách biệt lớn hay nhỏ". Toàn bộ SQL ở phụ lục.
3.6 Rerank và agentic RAG
Mẫu phổ biến: lấy rộng (top 50–100 từ hybrid) → reranker cross-encoder chấm lại → đưa 3–10 đoạn tốt nhất vào prompt. Reranker đắt hơn
mỗi cặp nhưng chỉ chạy trên vài chục ứng viên. Ở mức agent, retrieval trở thành một tool (search_kb(query, filters)):
model tự viết lại câu truy vấn, tìm nhiều lần, đọc thêm tài liệu gốc — tốt hơn với câu hỏi nhiều bước, đổi lại nhiều vòng gọi hơn
(bài 02).
4. Đảm bảo và giới hạn
| Khía cạnh | pgvector đảm bảo | Không đảm bảo |
|---|---|---|
| Nhất quán | Vector, chunk, quyền nằm chung transaction MVCC — commit là thấy, rollback là mất, không có dual-write | — |
| Recall | Seq scan: 100% | HNSW/IVFFlat: gần đúng (lab 0,66–0,98 tuỳ ef_search); khác nhau giữa các lần build |
| Lọc | Kết quả luôn thoả WHERE | Đủ LIMIT dòng khi lọc hiếm (bản < 0.8) |
| Số chiều index | HNSW trên vector: tới 2 000 chiều | Lab: ERROR: column cannot have more than 2000 dimensions for hnsw index với 3 072 chiều. Bản ≥ 0.7 có halfvec (tới 4 000 chiều theo tài liệu — chưa verify) |
| Scale | Ổn tới vài triệu–vài chục triệu vector nếu index vừa RAM | Scale ngang tự động; build index song song nhiều máy |
| Độ trễ | Dưới vài ms khi index nằm trong RAM (lab) | Khi index bị đẩy ra đĩa, latency tăng hàng chục lần — chưa đo |
Về CAP: pgvector kế thừa PostgreSQL — một primary nhận ghi, replica đọc bất đồng bộ (đọc từ replica có thể thấy chunk cũ vài giây). Vector DB chuyên dụng phân tán thường chọn eventual consistency để scale ghi.
5. Failure modes
| Lỗi | Hệ quả | Phát hiện | Phục hồi / phòng |
|---|---|---|---|
| Lọc hiếm + HNSW (lab 3.4) | Tenant nhỏ "không tìm thấy gì" | Metric số kết quả trả về < k theo tenant | Iterative scan (≥0.8), exact cho tenant nhỏ, partition |
| Build index thiếu RAM (lab) | Migration chậm 8×, khoá bảng lâu nếu không CONCURRENTLY | NOTICE trong log, thời gian migration | Tăng maintenance_work_mem trong session build; CREATE INDEX CONCURRENTLY |
| Đổi embedding model (lab) | expected 384 dimensions, not 1536; tệ hơn: cùng số chiều khác model → không lỗi, kết quả rác | Lưu embedding_model cạnh vector; kiểm khi query | Cột mới + backfill + index mới + chuyển đọc (mục 6) |
| Chunk cũ (tài liệu sửa, chunk không re-embed) | Trả lời theo chính sách hết hạn — rủi ro kiểu Air Canada | So updated_at tài liệu với chunk | Ingest theo sự kiện (observer/queue), lưu phiên bản tài liệu |
| Chunk cắt ngang câu trả lời | Model thấy nửa điều khoản | Eval retrieval: đoạn đúng có nằm trọn trong chunk | Chunk theo cấu trúc, overlap, trả thêm chunk kề bên |
| Rò quyền | Khách A đọc được tài liệu nội bộ / của khách B | Test bảo mật với câu hỏi chéo tenant | Lọc quyền trong SQL (RLS), không lọc sau LLM |
| RAG poisoning (OWASP LLM08) | Tài liệu/review do người ngoài viết chứa chỉ dẫn độc, được retrieve vào prompt | Nguồn dữ liệu được đánh dấu tin cậy/không | Không cho nội dung người dùng tạo vào cùng kho với chính sách; coi đoạn retrieve là dữ liệu (bài 08) |
| Bloat index do UPDATE/DELETE nhiều | Index phình, chậm dần | Kích thước index theo thời gian | Vacuum, REINDEX CONCURRENTLY định kỳ — xem dead tuple & index bloat |
6. Vận hành trong production
Metric
- Retrieval: recall@k và MRR trên bộ câu hỏi có đoạn đúng được gán nhãn (50–200 câu là đủ bắt đầu); chạy lại mỗi khi đổi chunking, embedding model, tham số index.
- Câu trả lời: tỉ lệ "không đủ thông tin", tỉ lệ có trích dẫn, faithfulness (câu trả lời có được đoạn trích hỗ trợ không — bài 07).
- Hạ tầng: latency retrieval p95, số kết quả < k, kích thước index vs RAM, thời gian ingest một tài liệu.
Tham số
| Tham số | Gợi ý |
|---|---|
hnsw.ef_search | Đặt theo query bằng SET LOCAL trong transaction; bắt đầu 100, đo recall |
m, ef_construction | Mặc định 16/64 là điểm khởi đầu tốt; tăng khi recall không đủ dù ef_search cao |
maintenance_work_mem | Đủ để đồ thị vừa RAM lúc build (lab: 512MB cho 50k×384); đặt trong session migration, không đặt toàn cục |
max_parallel_maintenance_workers | Build HNSW song song (pgvector ≥ 0.6) |
Capacity planning (ước lượng)
Từ lab: 50k × 384 chiều → bảng 78 MB, index 98 MB (~1 960 byte/vector cho index). Nội suy tuyến tính (ước lượng, chưa đo ở quy mô lớn):
2 triệu chunk × 1536 chiều → cột vector ~12 GB, index HNSW ~15 GB. Cần RAM cho cả index + working set của các bảng khác; nếu không vừa, cân nhắc
giảm chiều (model nhỏ hơn, halfvec), lượng tử hoá, hoặc tách riêng.
Migration đổi embedding model (zero-downtime)
flowchart LR A["Thêm cột emb_v2 vector(1024) NULL + cột embedding_model"] --> B["Job nền embed lại theo lô, ghi emb_v2"] B --> C["CREATE INDEX CONCURRENTLY trên emb_v2"] C --> D["Chạy eval retrieval với emb_v2"] D --> E["Feature flag chuyển đọc sang emb_v2"] E --> F["Ingest ghi cả hai cột một thời gian"] F --> G["Xoá cột và index cũ"]
Chi tiết index không khoá bảng: CREATE INDEX CONCURRENTLY; nguyên tắc migration nhiều bước: migration PostgreSQL zero-downtime.
Gánh nặng cho team
Pipeline ingest là một hệ thống dữ liệu thật: parser nhiều định dạng (PDF, HTML, Word), hàng đợi, retry, re-embed khi đổi model, quyền theo tài liệu. Người trực cần biết trả lời "vì sao bot không tìm thấy tài liệu X" — cần công cụ tra: tài liệu X có bao nhiêu chunk, embed lúc nào, bằng model nào, câu hỏi này retrieve ra những chunk nào.
7. So sánh và ma trận quyết định
| Trục | pgvector | Vector DB chuyên dụng (Qdrant, Weaviate, Milvus, Pinecone) | Elasticsearch / OpenSearch kNN | Không RAG: long context + cache | Agentic search (SQL/grep qua tool) |
|---|---|---|---|---|---|
| Nhất quán với dữ liệu nghiệp vụ | Cùng transaction | Dual-write, eventual | Dual-write | — | Đọc thẳng nguồn |
| Lọc + phân quyền | SQL/RLS đầy đủ; bẫy lọc hiếm (<0.8) | Lọc payload tốt, tối ưu sẵn | Tốt | — | Theo quyền của tool |
| Hybrid | Tự ghép FTS + RRF bằng SQL | Nhiều bản có sẵn | BM25 mạnh nhất, có sẵn | — | — |
| Scale | Tới chục triệu (vừa RAM) | Trăm triệu+ , scale ngang | Scale ngang | Giới hạn cửa sổ | Theo nguồn |
| Vận hành thêm | Không (đã có PostgreSQL) | Một hệ thống mới | Một cụm mới (nếu chưa có) | Không | Không |
| Tuyển người | Dễ (biết Postgres) | Trung bình | Trung bình | Dễ | Dễ |
Dùng pgvector khi: đã chạy PostgreSQL, dưới vài chục triệu chunk, cần quyền/transaction chặt, team nhỏ. Tránh / cân nhắc khác khi: hàng trăm triệu vector, ghi embedding liên tục với throughput cao, cần lọc phức tạp trên tenant rất nhỏ mà không nâng được pgvector, hoặc đã có sẵn Elasticsearch làm search chính. Không cần RAG khi: kho tài liệu nhỏ (vài chục nghìn token) — nhồi vào prompt với cache rẻ và đơn giản hơn.
8. Góc nhìn tech lead: tác động tổ chức
- Lock-in thật nằm ở embedding model, không ở database: vector không mang sang model khác được. Exit strategy: luôn lưu văn bản gốc của chunk + metadata + tên/phiên bản model, để có thể embed lại bằng model khác bất cứ lúc nào; ngân sách embed lại toàn kho phải được biết trước.
- Quản trị dữ liệu: tài liệu nào được đưa vào kho, ai duyệt, quyền đọc theo vai trò; tài liệu nội bộ và nội dung do khách viết không nằm chung kho.
- Sở hữu: team nội dung (CSKH, pháp chế) sở hữu tính đúng của tài liệu; team kỹ thuật sở hữu pipeline và recall. Không có chủ sở hữu nội dung thì bot sẽ trích dẫn chính sách 2 năm trước.
- ADR mẫu: "Dùng pgvector trên cluster hiện có; HNSW m=16/ef_construction=64;
ef_searchđặt theo query; lọc tenant bằng SQL trước khi xếp hạng; eval retrieval chạy trong CI khi đổi chunking/model; ngưỡng xem xét tách vector DB riêng: > 20 triệu chunk hoặc index > 50% RAM."
9. Anti-patterns và bài học thực tế
- RAG cho câu hỏi tổng hợp. "Tháng này bán bao nhiêu" không nằm trong đoạn văn nào — cho agent tool SQL chỉ đọc có giới hạn.
- Không đo retrieval, chỉ nhìn câu trả lời. Không biết lỗi nằm ở tìm hay ở đọc.
- Tin mặc định
ef_search = 40. Lab: bỏ sót ~19% láng giềng thật. - Lọc quyền sau khi LLM đọc hoặc lọc trong PHP sau khi lấy top-k — vừa rò rỉ, vừa trả thiếu kết quả.
- Trộn vector của hai model trong một cột. Cùng số chiều thì không lỗi, chỉ ra kết quả vô nghĩa.
- Không cho phép "không tìm thấy". Prompt phải cho model nói "tài liệu không đề cập" thay vì đoán; đo tỉ lệ này.
- Chunk mất ngữ cảnh. "Phí đổi trả là 30 000đ" — cho sản phẩm nào, từ ngày nào? Thêm tiêu đề tài liệu/mục vào chunk (contextual retrieval).
Điều người vận hành lâu năm đều biết: 80% cải thiện RAG đến từ dữ liệu (làm sạch, chunk đúng cấu trúc, metadata, bỏ tài liệu trùng/cũ) và hybrid search, không đến từ đổi embedding model đắt hơn.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- HNSW hay IVFFlat?
- HNSW cho đa số trường hợp: recall/latency tốt, không cần train, thêm dữ liệu dần được. IVFFlat khi RAM build hạn chế và dữ liệu tĩnh; nhớ rebuild khi phân bố dữ liệu đổi.
- Vì sao không dùng vector DB riêng?
- Vì mất tính nhất quán (dual-write), thêm hệ thống vận hành, và quyền phải đồng bộ hai nơi. Ở quy mô dưới chục triệu vector, pgvector đủ; khi vượt, tách ra có lý do rõ ràng.
- Làm sao biết recall của hệ thống?
- Lấy mẫu query thật, chạy tìm chính xác (seq scan) làm ground truth, so với kết quả index — như lab. Với chất lượng ngữ nghĩa, cần bộ câu hỏi có đoạn đúng do người gán nhãn.
- Multi-tenant làm sao cho đúng?
- Lọc tenant trong SQL (lý tưởng là RLS), xử lý bẫy lọc hiếm (iterative scan / exact cho tenant nhỏ / partition), test chéo tenant trong CI.
- Long context 1M có giết RAG không?
- Không với kho lớn và nhiều người dùng: chi phí mỗi lần gọi, TTFT, context rot, và phân quyền. Với kho nhỏ, cố định, dùng lại nhiều → long context + cache có thể đơn giản hơn RAG.
- Hybrid search có đáng không?
- Có, gần như luôn luôn cho dữ liệu nghiệp vụ có mã, tên riêng, số hiệu — embedding yếu với chuỗi chính xác. RRF là cách gộp đơn giản, không cần chuẩn hoá điểm.
Phụ lục: lab đã chạy
Môi trường: Ubuntu 24.04, PostgreSQL 16.13 (apt), pgvector 0.6.0 (postgresql-16-pgvector), cluster tạm cổng 55440, Python 3.11 + psycopg 3.3.6 +
numpy. Output gốc: labs/ai/out/04-*.txt.
# Cluster PostgreSQL tạm, không đụng DB đang chạy
sudo apt-get install -y postgresql-16-pgvector
sudo -u postgres /usr/lib/postgresql/16/bin/initdb -D /var/lib/postgresql/ailab/data -A trust
sudo -u postgres /usr/lib/postgresql/16/bin/pg_ctl -D /var/lib/postgresql/ailab/data -o '-p 55440 -k /tmp' -l /tmp/ailab.log start
psql -h /tmp -p 55440 -U postgres -c "CREATE DATABASE ailab"
# Recall/latency, bẫy WHERE, maintenance_work_mem, đổi số chiều (~2 phút)
DSN="host=/tmp port=55440 user=postgres dbname=ailab" python3 labs/ai/py/pgvector_lab.py
# Full-text tiếng Việt + unaccent + hybrid RRF
psql -h /tmp -p 55440 -U postgres -d ailab -f labs/ai/sql/hybrid_search_vi.sql
Truy vấn hybrid (RRF) dùng trong lab:
WITH q AS (SELECT plainto_tsquery('simple', unaccent('lỗi E1234 hoàn tiền')) AS tsq,
'[0.8,0.3,0.05]'::vector AS v),
kw AS (SELECT id, row_number() OVER (ORDER BY ts_rank(tsv, q.tsq) DESC) AS r
FROM tmp_faq, q WHERE tsv @@ to_tsquery('simple', replace(q.tsq::text, '&', '|'))),
vs AS (SELECT id, row_number() OVER (ORDER BY emb <=> q.v) AS r
FROM tmp_faq, q ORDER BY emb <=> q.v LIMIT 4)
SELECT f.id, kw.r AS hang_keyword, vs.r AS hang_vector,
COALESCE(1.0/(60+kw.r),0) + COALESCE(1.0/(60+vs.r),0) AS rrf
FROM tmp_faq f LEFT JOIN kw USING (id) LEFT JOIN vs USING (id)
WHERE kw.r IS NOT NULL OR vs.r IS NOT NULL
ORDER BY rrf DESC;
Chưa verify: chất lượng ngữ nghĩa với embedding thật; iterative index scan (0.8), halfvec (0.7); hành vi khi index không vừa RAM;
reranker; contextual retrieval. Nguồn: tài liệu pgvector (README, mục Filtering / Iterative Index Scans); Anthropic Engineering, "Introducing Contextual
Retrieval" (9/2024); Malkov & Yashunin, "Efficient and robust approximate nearest neighbor search using HNSW graphs" (2016); Lewis et al.,
"Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks" (2020); OWASP Top 10 for LLM Applications 2025 (LLM08).
Bài trước: 03 · Context engineering · Bài tiếp: 05 · Workflow patterns và multi-agent.