NHT

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.

Nguyen Hoang TuanNguyen Hoang Tuan24 thg 6, 20268 phút đọc

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_idpublished 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ó embeddingNULL khô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ằng WHERE embedding IS NOT NULL, hoặc đặt cột NOT NULL sau 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à CONCURRENTLY khi 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.

Hợp tác ngay

Tác giả

Nguyen Hoang Tuan

Nguyen Hoang Tuan

Full-stack developer focused on practical backend architecture, web performance, and production delivery.

Bài viết liên quan