Lưu và truy vấn embeddings trong PostgreSQL
Thêm pgvector vào Postgres sẵn có, lưu embeddings cạnh dữ liệu quan hệ, và truy vấn tương đồng nhanh — kèm schema, index và SQL.
Bài xây RAG từ đầu lưu embeddings vào Postgres chỉ trong hai dòng rồi đi tiếp. Bài này dừng lại kỹ ở bước đó, vì đây mới là phần một backend engineer thực sự làm chủ: một vector chỉ là một kiểu cột mới, và "tìm theo ngữ nghĩa" chỉ là một toán tử trong câu SELECT. Nếu bạn đã chạy Postgres, bạn không cần một database mới để bắt đầu — bạn cần pgvector, một schema và một index.
Ta dùng ví dụ xuyên suốt series: bảng documents chứa các đoạn tài liệu API GoGoDuk và kho kiến thức địa chỉ tiếng Việt, mỗi dòng mang theo text và một embedding 1536 chiều. Toàn bộ bên dưới là SQL thuần, chạy được ngay.
Cài pgvector & khai báo cột vector
pgvector là một extension của Postgres. Trên Postgres managed (RDS, Cloud SQL, Supabase, Neon) thường đã có sẵn — bạn chỉ cần bật. Self-hosted thì cài package rồi:
CREATE EXTENSION IF NOT EXISTS vector;Bạn có một kiểu cột mới: vector(N), với N là số chiều của embedding — do model bạn dùng quyết định (vd 1536 cho nhiều model embedding nhỏ, 768 cho model khác). Số chiều là một phần của kiểu, nên chọn một lần và giữ nguyên:
ALTER TABLE documents ADD COLUMN embedding vector(1536);Setup chỉ có vậy. Không phải dựng thêm service để deploy, bảo mật hay backup — vector nằm trong cùng database, và cùng bản backup, với dữ liệu của bạn.
Thiết kế schema: embeddings nằm cạnh dữ liệu thật
Quyết định hữu ích nhất là giữ embedding trên cùng một dòng với dữ liệu nó mô tả, không tách ra kho riêng. Đó là điều khiến một truy vấn semantic có lọc trở thành một query duy nhất thay vì hai hệ thống nói chuyện qua mạng — đúng luận điểm trong pgvector hay vector database.
CREATE TABLE documents (
id bigserial PRIMARY KEY,
source text NOT NULL, -- đoạn này lấy từ doc / dataset nào
tenant_id bigint NOT NULL, -- các cột quan hệ bình thường…
published boolean NOT NULL DEFAULT true,
content text NOT NULL, -- nội dung đoạn
embedding vector(1536), -- …và vector, ngay bên cạnh
created_at timestamptz NOT NULL DEFAULT now()
);tenant_id và published là cột thường. Vì nằm cùng dòng với embedding, query planner có thể cân nhắc bộ lọc quan hệ và vector search cùng lúc — không phải lấy danh sách ID từ một hệ thống rồi hydrate từ hệ thống khác.
Chèn embeddings (gom batch để tránh round-trip)
Bạn sinh embedding ở tầng service (một lệnh gọi API tới model embedding), rồi ghi như một giá trị bình thường. pgvector nhận vector dưới dạng chuỗi literal kiểu '[0.12, -0.03, ...]', nên từ phần lớn driver bạn truyền một mảng đã JSON-stringify rồi ép kiểu:
// Một round trip cho cả batch — KHÔNG phải mỗi dòng một INSERT.
const rows = chunks.map((c, i) => ({ source, content: c, embedding: vectors[i] }));
await db.query(
`INSERT INTO documents (source, content, embedding)
SELECT * FROM UNNEST($1::text[], $2::text[], $3::vector[])`,
[
rows.map((r) => r.source),
rows.map((r) => r.content),
rows.map((r) => JSON.stringify(r.embedding)), // '[...]' cho mỗi vector
],
);Thứ cần tránh là INSERT từng dòng trong vòng lặp: embed 1.000 đoạn là một lệnh gọi model bạn gom batch được, và lệnh ghi DB cũng nên là một câu multi-row. Cái làm bulk load chậm là round-trip, không phải tính toán.
Truy vấn tương đồng: các toán tử khoảng cách
pgvector thêm ba toán tử khoảng cách. Mỗi cái trả về một số để ORDER BY; nhỏ hơn nghĩa là gần hơn:
<->— khoảng cách L2 (Euclid). Khoảng cách đường thẳng giữa hai điểm.<=>— cosine distance (1 - cosine similarity). Bỏ qua độ lớn, so theo hướng. Lựa chọn thường dùng cho text embedding.<#>— inner product âm. Nhanh nhất; chỉ đúng khi vector đã chuẩn hoá về độ dài đơn vị.
Với tìm kiếm text embedding, dùng <=> trừ khi có lý do cụ thể khác. Câu query đúng như bạn nghĩ — embed câu hỏi trong app, truyền vector vào $1, và hỏi các dòng gần nhất:
-- Top-5 đoạn gần nhất về ngữ nghĩa với embedding câu hỏi ($1)
SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;Cái ORDER BY embedding <=> $1 đó chính là toàn bộ phần "tìm theo ngữ nghĩa". Không ngôn ngữ truy vấn mới, không database thứ hai — chỉ là một SELECT mà team bạn đã biết đọc.
Lọc dữ liệu quan hệ + vector trong một query
Đây là điểm mà một kho vector gắn ngoài khó làm gọn. Vì vector nằm cạnh các cột của bạn, bạn lọc và xếp hạng trong cùng một câu lệnh:
SELECT id, content
FROM documents
WHERE tenant_id = $2 AND published -- lọc quan hệ
ORDER BY embedding <=> $1 -- xếp hạng theo ngữ nghĩa
LIMIT 5;Một query, một transaction, không lệch nhất quán giữa "hệ thống vector" và "nguồn sự thật". Để lấy tài liệu đã publish của một tenant rồi xếp theo ngữ nghĩa, đó là toàn bộ tính năng.
Thêm index và đo tốc độ cải thiện
Không index, cái ORDER BY đó làm quét nearest-neighbour chính xác — đọc mọi dòng. Ổn ở 10k dòng, không dùng được ở vài triệu. pgvector giải bằng index xấp xỉ (HNSW là mặc định mạnh), đánh đổi một chút recall lấy tốc độ lớn. Build index theo đúng toán tử khoảng cách bạn truy vấn (ở đây là cosine):
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Núm chỉnh recall/tốc độ, đặt theo session hoặc transaction:
SET hnsw.ef_search = 40;Đo, đừng đoán. Chạy EXPLAIN ANALYZE cùng một query trước và sau:
EXPLAIN ANALYZE
SELECT id FROM documents ORDER BY embedding <=> $1 LIMIT 5;Hình dạng bạn sẽ thấy, trên kho ~1 triệu vector 1536 chiều với index nằm nóng trong RAM:
| | Plan | Độ trễ (p50) | | --- | --- | --- | | Không index | Seq Scan + Sort (đọc hết dòng) | hàng trăm ms → vài giây | | Index HNSW | Index Scan | một con số tới vài chục ms |
Hãy xem các con số như bậc độ lớn — chúng thay đổi theo m, ef_search, phần cứng, và việc index có vừa trong bộ nhớ không — nhưng bước nhảy là thật: quét chính xác tăng tuyến tính theo số dòng, index gần như không nhúc nhích. Tăng hnsw.ef_search để recall cao hơn, đổi lại độ trễ; cái núm đó là phần lớn việc tinh chỉnh bạn cần làm.
Những cái bẫy: số chiều, chuẩn hoá, embedding NULL
Vài thứ hay cắn ở production:
- Số chiều cố định theo model. Cột
vector(1536)từ chối embedding 768 chiều ngay lúc insert. Đổi model embedding là phải re-embed toàn bộ — không có reshape tại chỗ. Lưu thêm tên model để biết một dòng được embed bằng gì. - Chuẩn hoá quan trọng với
<#>. Inner product (<#>) chỉ xếp hạng đúng khi vector có độ dài đơn vị. Không chắc thì dùng cosine (<=>), nó tự chuẩn hoá giúp bạn. Trộn vector đã chuẩn hoá và vector thô trong một cột sẽ âm thầm làm sai xếp hạng. - Embedding NULL không báo lỗi — chỉ biến mất. Dòng có
embeddinglàNULLkhông khớp bất kỳ truy vấn nearest-neighbour nào, nên một đợt backfill làm dở sẽ lặng lẽ rớt dòng khỏi kết quả. Chặn bằngWHERE embedding IS NOT NULL, hoặc đặt cộtNOT NULLsau khi backfill xong, và theo dõi độ phủ trong lúc ingest. - Build index tranh tài nguyên với traffic. Build HNSW lớn trên bảng đang chạy mất vài phút tới vài giờ và tranh với production. Build lúc thấp điểm (và
CONCURRENTLYkhi có thể).
Đó là toàn bộ kỹ năng: một cột, ba toán tử, một index, và một SELECT team bạn đã hiểu. Có nó rồi, bước retrieval trong pipeline RAG không còn là hộp đen — và khi các truy vấn cần khớp từ khoá chính xác bắt đầu xếp hạng kém, nước đi tiếp theo là kết hợp nó với PostgreSQL full-text search bạn có thể đã chạy sẵn, hướng hybrid mà series sẽ bàn kế tiếp. Bản đồ cho tất cả nằm ở bài trụ của series.
Hệ thống của bạn đang gặp vấn đề hiệu năng hay mở rộng tải?
Tôi chuyên xây dựng hạ tầng bản đồ độ trễ thấp, streaming pipeline thời gian thực (Kafka, ClickHouse) và các hệ thống backend tối ưu. Hãy cùng hợp tác để nâng cấp sản phẩm của bạn.
Bài viết liên quan
23 Jun 2026
Xây RAG từ đầu với Node.js
Tự xây pipeline RAG chạy được bằng Node.js — ingest, embed, retrieve, prompt — không framework, giải thích từng mắt xích kèm code thật.
22 Jun 2026
pgvector hay vector database: nên chọn cái nào
Hướng dẫn chọn giữa pgvector và Pinecone/Qdrant/Weaviate theo chi phí, recall, vận hành và quy mô — kèm benchmark thật và một lựa chọn mặc định để bắt đầu.
21 Jun 2026
AI cho Backend Engineer: Hướng dẫn thực chiến
Hướng dẫn AI cho backend engineer: embeddings, vector search, RAG và LLM API — chúng là gì, đặt ở đâu trong hệ thống, và những con số chi phí, độ trễ cần theo dõi.
20 Jun 2026
PostgreSQL Partitioning: Tối ưu bảng 100 triệu dòng với Declarative Partitioning
Hướng dẫn phân vùng bảng PostgreSQL cho bảng 100 triệu dòng: chọn RANGE/LIST/HASH, tạo declarative partition, tận dụng partition pruning để query nhanh hơn, đánh index per-partition và dọn dữ liệu gần như miễn phí bằng DETACH/DROP PARTITION.