· rag / pgvector / drizzle-orm
Cách xây dựng RAG pipeline với pgvector và Drizzle ORM
Hướng dẫn từng bước: lưu embeddings trong Neon Postgres, truy vấn bằng cosine similarity qua Drizzle ORM, và kết nối GPT-4o để trả lời — tất cả bằng TypeScript.
Bởi Ethan
2.329 từ · 12 phút đọc
Kết thúc hướng dẫn này, bạn sẽ có một RAG pipeline hoàn chỉnh bằng TypeScript: documents lưu kèm embeddings trong Postgres, truy xuất theo cosine similarity, và đưa vào GPT-4o để tạo câu trả lời. Bạn không cần một vector database riêng — pgvector chạy ngay trong Postgres sẵn có của bạn.
Bài này dành cho ai
Lập trình viên TypeScript đã quen với Postgres và Drizzle ORM nhưng chưa từng làm vector search. Nếu bạn đã dùng Pinecone và muốn biết pgvector có đáng chuyển sang không, hãy đọc thẳng phần so sánh ở cuối bài.
Bạn sẽ xây dựng gì
Một class DocumentStore với các khả năng:
- Embed bất kỳ đoạn text nào bằng
text-embedding-3-smallcủa OpenAI - Lưu embedding vào Postgres kèm theo đoạn text gốc
- Trả về
kdocuments giống nhất cho một query - Tạo câu trả lời có căn cứ bằng GPT-4o, sử dụng các documents đó làm context
Tất cả đều chạy trên một database Postgres duy nhất. Không cần thêm infrastructure nào khác.
Yêu cầu trước khi bắt đầu
- Node.js 20+ hoặc Bun 1.1+
- OpenAI API key có quyền truy cập
text-embedding-3-smallvàgpt-4o - Một Postgres database — free tier của Neon đủ dùng cho toàn bộ hướng dẫn; 0.5 GB storage và serverless compute, không cần thẻ tín dụng
- pgvector extension ≥ 0.8.2 (Neon đã cài sẵn)
Nếu đây là lần đầu bạn kết nối Drizzle với Postgres, Hướng dẫn cài đặt Drizzle ORM với Postgres và pgvector hướng dẫn thiết lập project ban đầu — bài này tiếp tục từ đó.
Cài dependencies:
npm install drizzle-orm@^0.31.0 postgres@^3.x openai@^4.x
npm install -D drizzle-kit@^0.22.0
Đặt các biến môi trường:
DATABASE_URL=postgresql://user:pass@host/dbname
OPENAI_API_KEY=sk-...
Bước 1: Kích hoạt extension pgvector
Đây là bước nhiều người hay bỏ qua nhất. Drizzle không tự chạy CREATE EXTENSION IF NOT EXISTS vector; — nếu bỏ qua bước này, kiểu cột vector sẽ thất bại khi migration với lỗi khó hiểu (type "vector" does not exist).
Tạo một file migration SQL riêng trước bất kỳ schema nào Drizzle tạo ra:
-- migrations/0000_enable_pgvector.sql
CREATE EXTENSION IF NOT EXISTS vector;
Nếu dùng drizzle-kit migrate, đặt tên file sao cho nó sắp xếp trước các file output của Drizzle (tiền tố số 0000_ là đủ). Chạy một lần trước khi apply migration schema:
psql $DATABASE_URL -f migrations/0000_enable_pgvector.sql
Lưu ý quan trọng: Nếu bạn dùng managed provider đã cài pgvector sẵn (như Neon), bước này vẫn cần chạy — extension tồn tại trên server nhưng chưa được bật trong database của bạn cho đến khi bạn CREATE EXTENSION.
Bước 2: Định nghĩa schema
Drizzle ORM ≥ 0.31.0 cung cấp kiểu cột vector tích hợp sẵn. Không cần wrapper customType, không cần raw SQL — nó là first-class:
// src/db/schema.ts
import { pgTable, serial, text, index } from 'drizzle-orm/pg-core';
import { vector } from 'drizzle-orm/pg-core';
export const documents = pgTable(
'documents',
{
id: serial('id').primaryKey(),
content: text('content').notNull(),
metadata: text('metadata'),
embedding: vector('embedding', { dimensions: 1536 }).notNull(),
},
(table) => [
index('documents_embedding_idx')
.using('hnsw', table.embedding.op('vector_cosine_ops'))
.with({ m: 16, ef_construction: 64 }),
],
);
Một vài điểm đáng lưu ý:
Tại sao 1536 chiều? text-embedding-3-small xuất ra vector 1536 chiều. Schema phải khớp đúng với output của model — nếu sai sẽ lỗi ngay khi insert.
Tại sao HNSW, không phải IVFFlat? HNSW (Hierarchical Navigable Small World) xây index theo từng bước nên vẫn query nhanh ngay cả khi dataset đang tăng dần. IVFFlat đòi hỏi bạn biết trước kích thước xấp xỉ của dataset (để đặt tham số lists) và cần rebuild hoàn toàn để duy trì độ chính xác khi thêm dữ liệu. Với hầu hết trường hợp dưới 10 triệu vectors, HNSW là lựa chọn mặc định phù hợp.
vector_cosine_ops: chỉ cho pgvector tối ưu index cho truy vấn cosine distance. Nếu bạn chuyển sang Euclidean (vector_l2_ops) hay inner product (vector_ip_ops), operator class phải khớp theo.
Generate và apply migration:
npx drizzle-kit generate
npx drizzle-kit migrate
Lỗi thường gặp: Nếu drizzle-kit migrate thoát với type "vector" does not exist, Bước 1 chưa được thực hiện. Hãy quay lại bật extension trước.
Bước 3: Tạo embeddings
Hãy dùng text-embedding-3-small, không phải text-embedding-ada-002. ada-002 là model cũ; OpenAI vẫn hỗ trợ nhưng đã được thay thế bởi nhóm 3-small/3-large, cho chất lượng retrieval tốt hơn với chi phí thấp hơn.
// src/embeddings.ts
import OpenAI from 'openai';
const openai = new OpenAI();
export async function embed(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
Đơn giản vậy thôi — hàm trả về một mảng số thường, đúng là những gì Drizzle’s vector column cần.
Lỗi thường gặp: text-embedding-3-small trả về đúng 1536 floats. Nếu bạn dùng model khác mà số chiều không khớp với định nghĩa cột, Postgres sẽ từ chối insert với lỗi dimension mismatch. Kiểm tra bằng response.data[0].embedding.length === 1536 nếu có gì đó không ổn.
Bước 4: Lưu documents
// src/store.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { documents } from './db/schema';
import { embed } from './embeddings';
const client = postgres(process.env.DATABASE_URL!);
const db = drizzle(client);
export async function storeDocument(content: string, metadata?: string) {
const embedding = await embed(content);
await db.insert(documents).values({
content,
metadata,
embedding,
});
}
Không có gì bất ngờ. embed() trả về number[], và Drizzle tự serialize sang định dạng vector wire format của Postgres.
Lỗi thường gặp: Drizzle sẽ serialize embedding thành chuỗi dạng mảng ngoặc vuông ([0.1, 0.2, ...]). Nếu bạn gặp lỗi serialization, hãy đảm bảo drizzle-orm ≥ 0.31.0 — các phiên bản cũ hơn không nhận biết kiểu vector và sẽ fallback sang JSON encoding, mà Postgres từ chối.
Bước 5: Tìm kiếm theo cosine similarity
Toán tử <=> của pgvector tính cosine distance. Giá trị càng nhỏ thì càng giống nhau — 0 là giống hệt, 2 là khác biệt tối đa. Similarity = 1 − distance.
Drizzle ≥ 0.31.0 cung cấp helper cosineDistance để bạn không phải viết raw SQL:
// src/search.ts
import { cosineDistance, desc, sql } from 'drizzle-orm';
import { db } from './store';
import { documents } from './db/schema';
import { embed } from './embeddings';
export async function searchDocuments(query: string, topK = 5) {
const queryEmbedding = await embed(query);
const similarity = sql<number>`1 - (${cosineDistance(documents.embedding, queryEmbedding)})`;
const results = await db
.select({
id: documents.id,
content: documents.content,
metadata: documents.metadata,
similarity,
})
.from(documents)
.where(sql`${cosineDistance(documents.embedding, queryEmbedding)} < 0.3`)
.orderBy(desc(similarity))
.limit(topK);
return results;
}
Mệnh đề .where lọc để cosine distance < 0.3, tức similarity > 0.7. Điều chỉnh ngưỡng này tùy ý — ngưỡng thấp hơn trả về kết quả tin cậy hơn nhưng giảm recall.
Lưu ý khi lọc metadata: Nếu bạn vừa lọc theo metadata vừa giới hạn topK rows, HNSW index sẽ được probe trước (trả về ef_search candidates), rồi mới áp dụng metadata filter. Nếu phần lớn candidates không vượt qua filter, bạn có thể nhận được ít hơn topK kết quả — pgvector không probe lại để bù thêm. Cách xử lý: tăng ef_search (xem phần tuning), pre-filter trong một subquery, hoặc chấp nhận số kết quả có thể thay đổi.
Lỗi thường gặp: Query chạy nhưng trả về 0 rows dù ngưỡng distance rất lớn. Nguyên nhân phổ biến nhất là HNSW index được build trên bảng trống — cần ít nhất một row trước khi có thể query. Hãy insert một document trước, rồi mới query.
Bước 6: Kết nối GPT-4o
Lấy top-k kết quả tìm kiếm, ghép nội dung thành context, và yêu cầu GPT-4o trả lời câu hỏi của người dùng:
// src/rag.ts
import OpenAI from 'openai';
import { searchDocuments } from './search';
const openai = new OpenAI();
export async function ask(question: string): Promise<string> {
const results = await searchDocuments(question, 5);
if (results.length === 0) {
return "I don't have enough context to answer that.";
}
const context = results
.map((r, i) => `[${i + 1}] ${r.content}`)
.join('\n\n');
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content:
'You are a helpful assistant. Answer the user\'s question using only the provided context. ' +
'If the context doesn\'t contain the answer, say so. ' +
'Cite your sources with [1], [2], etc.',
},
{
role: 'user',
content: `Context:\n\n${context}\n\nQuestion: ${question}`,
},
],
});
return response.choices[0].message.content ?? '';
}
System prompt giữ GPT-4o bám sát context được truy xuất. Nếu không có, model sẽ thoải mái bổ sung từ kiến thức training — hữu ích cho chat thông thường, nhưng sai về bản chất với RAG.
Lỗi thường gặp: Các đoạn context dài có thể vượt quá context window của GPT-4o với dataset lớn. Nếu bạn lưu documents nhiều trang thành một row duy nhất, hãy chia thành các chunks trước khi embed (theo đoạn văn hoặc fixed-token chunks), rồi ghép lại các chunks liên quan khi retrieval.
Bước 7 (tùy chọn): Tinh chỉnh HNSW index
Các giá trị mặc định m: 16, ef_construction: 64 hoạt động tốt với hầu hết dataset. Nếu bạn muốn cân nhắc giữa recall và tốc độ, đây là ý nghĩa của từng tham số:
m: số neighbors mỗi node liên kết trong graph.mcao hơn = recall tốt hơn, tốn bộ nhớ hơn. Khoảng 5–48; 16 là điểm khởi đầu phù hợp.ef_construction: độ rộng tìm kiếm khi build index. Cao hơn = index tốt hơn, build chậm hơn. 64 là hợp lý cho ≤1 triệu rows.ef_search(lúc query, không phải lúc định nghĩa schema): kiểm soát số candidates được tìm kiếm. Mặc định là 40. Tăng lên để có recall cao hơn trên filtered queries:
SET hnsw.ef_search = 100;
Hoặc per-session trong một Drizzle transaction:
await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL hnsw.ef_search = 100`);
return tx.select(...);
});
HNSW recall giảm khi dùng metadata filter mạnh — tăng ef_search để khắc phục. Xem thêm lưu ý trong Bước 5.
pgvector vs. các vector database chuyên dụng
Nếu bạn đang cân nhắc chọn lớp vector, đây là đánh giá thực tế:
pgvector chạy trong Postgres, nên bạn không cần thêm infrastructure, không có độ trễ đồng bộ giữa relational tables và vector index, và được hưởng toàn bộ hệ sinh thái backup/observability của Postgres. Recall có thể tuning qua ef_search — bạn đánh đổi latency lấy độ chính xác. Với dưới ~10 triệu vectors, hiệu năng query cạnh tranh được với các database chuyên dụng. Vượt qua ngưỡng đó, hoặc nếu workload của bạn thuần túy là vector search không cần relational joins, database chuyên dụng có thể vượt trội hơn.
Pinecone được quản lý hoàn toàn — không tốn công vận hành, tính phí theo serverless, và time-to-first-query nhanh. Nhược điểm là phụ thuộc vào một vendor, recall model không minh bạch (bạn không thể tuning internal index), và cần một service thứ hai để đồng bộ với relational data.
Qdrant được thiết kế chuyên cho vector search với recall cao và throughput lớn, hỗ trợ cả self-hosted lẫn managed. Phát huy tốt nhất với dataset rất lớn và filtered queries phức tạp. Đổi lại là phải vận hành thêm một service.
pgvector là điểm khởi đầu hợp lý nếu bạn đã dùng Postgres và dataset vừa phải trong ~10 triệu vectors. Chỉ chuyển sang database chuyên dụng khi bạn đã thực sự gặp bottleneck hiệu năng — đừng làm trước.
Để so sánh toàn diện hơn, Vector database tốt nhất 2026 benchmark Qdrant, Pinecone, Cloudflare Vectorize và pgvector cạnh nhau.
Lựa chọn thay thế: Nếu bạn đang dùng Supabase, pgvector cũng có mặt ở đó. Supabase có free tier tương tự và bộ cài pgvector như nhau. Toàn bộ code Drizzle trong bài này hoạt động y chang — chỉ cần đổi
DATABASE_URLlà xong.
Kết hợp lại
Ví dụ sử dụng đầy đủ:
// src/main.ts
import { storeDocument } from './store';
import { ask } from './rag';
async function main() {
// Index some documents
await storeDocument(
'Drizzle ORM 0.31.0 ships native vector column support with cosineDistance helper.',
JSON.stringify({ source: 'drizzle-changelog', date: '2024-05' }),
);
await storeDocument(
'pgvector 0.8.2 supports two index types: HNSW builds the index incrementally and suits growing datasets; IVFFlat requires knowing the approximate final size upfront for the lists parameter. The pgvector docs present them as alternatives with documented tradeoffs — HNSW offers better query performance while IVFFlat has a cheaper build cost.',
JSON.stringify({ source: 'pgvector-readme', date: '2024-04' }),
);
// Query
const answer = await ask('What index type should I use with pgvector?');
console.log(answer);
}
main();
Kết quả kỳ vọng (nội dung đã diễn đạt lại):
pgvector 0.8.2 supports two index types: HNSW and IVFFlat [2].
HNSW builds incrementally and suits growing datasets; IVFFlat requires knowing the approximate final size upfront.
For most use cases, HNSW is a better default because it handles dataset growth without a rebuild.
Tóm lại
Bạn đã có một RAG pipeline hoàn chỉnh trong chưa đến 150 dòng TypeScript: schema định nghĩa trong Drizzle, migrations apply lên Neon Postgres, embeddings qua text-embedding-3-small, cosine search với cosineDistance, và GPT-4o tạo câu trả lời có căn cứ.
Ba điểm hay gây nhầm lẫn nhất: kích hoạt pgvector extension trước khi migration, dùng đúng model embedding (3-small, không phải ada-002), và hiểu rằng <=> là distance chứ không phải similarity.
Từ đây, các bước tiếp theo tự nhiên là chia documents dài thành chunks trước khi embed, thêm các cột metadata để filtering, và kết nối hàm ask() vào một API route. Nếu muốn đi xa hơn, Xây dựng AI agent TypeScript hướng dẫn cách đưa pipeline retrieval như thế này vào agentic loop đầy đủ với tool calling và memory.
Free tier của Neon đủ dùng cho toàn bộ hướng dẫn này. Nâng cấp chỉ khi bạn chạm mức giới hạn storage — với workload embeddings, đó là khoảng 300K vectors 1536 chiều.