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.
Bài trụ của series đã khẳng định: RAG về bản chất là hai lệnh gọi HTTP bọc quanh một câu SELECT. Bài này hiện thực hoá điều đó — ta dựng một pipeline Retrieval-Augmented Generation chạy được bằng Node.js thuần, không LangChain, không framework, không phép màu. Chỉ là các mắt xích, mỗi cái là một hàm bạn hoàn toàn có thể tự viết — để khi một framework che mất một mắt xích, bạn biết chính xác nó đang làm gì.
Ví dụ xuyên suốt đúng chất blog này: một trợ lý Q&A trên tài liệu API của GoGoDuk và kho kiến thức địa chỉ tiếng Việt. "Geocode một địa chỉ phố ở Hà Nội thế nào?" phải truy hồi đúng đoạn tài liệu và trả lời từ dữ liệu của ta, không phải từ trí nhớ của model. Tất cả gói gọn trong ~150 dòng TypeScript bạn có thể dán thẳng vào service.
RAG là gì (và vấn đề nó giải quyết)
Một LLM chỉ biết những gì có trong dữ liệu huấn luyện. Hỏi nó về tài liệu nội bộ, changelog tuần trước, hay đơn hàng của một khách — nó sẽ từ chối, hoặc tệ hơn, bịa ra một câu nghe rất hợp lý. RAG khắc phục bằng cách truy hồi đúng dữ kiện trước rồi dán vào prompt, để model trả lời từ văn bản bạn đưa cho nó thay vì từ trí nhớ.
Cái bạn đánh đổi rất cụ thể: thay vì fine-tune model trên dữ liệu của mình (đắt, chậm, vừa train xong dữ liệu đã cũ), bạn giữ dữ liệu trong database và lấy đúng phần liên quan cho mỗi request. Thêm tài liệu mới là một lệnh insert, không phải train lại. Đó là lý do RAG là bài toán hệ thống, không phải ML — và là lý do backend engineer chính là người hợp nhất để dựng nó.
Pipeline: ingest → chunk → embed → store → retrieve → generate
Toàn bộ chỉ gồm một nhánh ingest chạy một lần và một nhánh query chạy mỗi request. Vẽ ra một lần là code tự khắc hiện hình:
INGEST (offline, mỗi tài liệu một lần)
doc ──▶ chunk ──▶ embed từng đoạn ──▶ lưu {text, embedding} vào pgvector
QUERY (mỗi request)
câu hỏi ──▶ embed ──▶ vector search top-k ──▶ dựng prompt ──▶ LLM ──▶ stream trả lờiSáu động từ, hai trong số đó là lệnh gọi API ngoài (embed, generate), một là câu SELECT (retrieve). Phần còn lại chỉ là xử lý chuỗi. Ta dựng nhánh ingest trước, rồi tới nhánh query.
Bước 1–3: chunk và embed tài liệu
Model có giới hạn context và bạn trả tiền theo token, nên đừng embed cả tài liệu 40 trang thành một khối — truy hồi sẽ trả về nguyên cục và chôn vùi câu trả lời. Bạn chunk nó thành các đoạn vài trăm token, có chồng lấn (overlap) nhỏ để một câu bị cắt ngang ranh giới không bị mất.
Một chunker mộc mạc nhưng trung thực — tách theo đoạn văn, gom đến hạn mức kích thước, mang theo overlap:
function chunk(text: string, size = 800, overlap = 120): string[] {
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
const chunks: string[] = [];
let buf = "";
for (const p of paras) {
if (buf.length + p.length > size && buf) {
chunks.push(buf);
buf = buf.slice(Math.max(0, buf.length - overlap)); // mang theo overlap
}
buf += (buf ? "\n\n" : "") + p;
}
if (buf) chunks.push(buf);
return chunks;
}Chiến lược chunking là cả một hang sâu riêng — fixed vs semantic vs recursive splitting thay đổi recall đo được, và một bài sau trong series sẽ benchmark chúng. Tạm thời, ~800 ký tự với ~120 overlap là mặc định ổn cho tài liệu dạng văn bản.
Tiếp theo, biến mỗi đoạn thành một embedding — một mảng số thực độ dài cố định nắm bắt ngữ nghĩa. Một lệnh gọi API cho mỗi batch đoạn:
// Trung lập nhà cung cấp: endpoint embeddings nào cũng trả number[] cho mỗi input.
async function embed(inputs: string[]): Promise<number[][]> {
const res = await fetch("https://api.example.com/v1/embeddings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EMBEDDINGS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "text-embedding-3-small", input: inputs }),
});
if (!res.ok) throw new Error(`embed failed: ${res.status}`);
const json = await res.json();
return json.data.map((d: { embedding: number[] }) => d.embedding);
}Hãy gom input theo batch — embed 64 đoạn trong một lệnh gọi rẻ và nhanh hơn nhiều so với 64 lệnh gọi riêng. Một model embedding nhỏ điển hình trả về vector 1536 chiều; nhớ con số này, cột trong database phụ thuộc vào nó.
Bước 4–5: lưu vào pgvector và lấy top-k
Ta lưu vector cạnh chính văn bản của nó trong Postgres bằng pgvector. Nếu bạn thắc mắc vì sao là Postgres chứ không phải một vector database chuyên dụng, đó đúng là câu hỏi mà pgvector hay vector database trả lời — với kho dữ liệu cỡ này, bớt một thành phần phải vận hành là thắng.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE doc_chunks (
id bigserial PRIMARY KEY,
source text NOT NULL, -- đoạn này lấy từ tài liệu nào
content text NOT NULL, -- nội dung đoạn sẽ dán vào prompt
embedding vector(1536) NOT NULL -- phải khớp số chiều của model
);
CREATE INDEX ON doc_chunks
USING hnsw (embedding vector_cosine_ops);Insert là một dòng cho mỗi đoạn. Để ý phép ép kiểu ::vector — pgvector nhận mảng dưới dạng chuỗi literal:
async function store(source: string, chunks: string[]) {
const vectors = await embed(chunks);
for (let i = 0; i < chunks.length; i++) {
await db.query(
`INSERT INTO doc_chunks (source, content, embedding) VALUES ($1, $2, $3::vector)`,
[source, chunks[i], JSON.stringify(vectors[i])],
);
}
}Truy hồi là trái tim của RAG và chỉ là một câu query. Embed câu hỏi, rồi hỏi Postgres những đoạn gần nhất theo cosine distance (<=>):
async function retrieve(question: string, k = 5): Promise<string[]> {
const [qVec] = await embed([question]);
const { rows } = await db.query(
`SELECT content
FROM doc_chunks
ORDER BY embedding <=> $1::vector
LIMIT $2`,
[JSON.stringify(qVec), k],
);
return rows.map((r) => r.content);
}Trên kho vài nghìn đoạn với index HNSW, câu SELECT này trả về trong vài mili-giây — lệnh gọi embed đứng trước nó (vài chục ms) mới là phần chiếm phần lớn độ trễ truy hồi, không phải database. k = 5 là điểm khởi đầu hợp lý: đủ ngữ cảnh để trả lời, đủ ít để prompt không tốn kém.
Một lưu ý thành thật: vector search thuần truy hồi theo ngữ nghĩa, nên một truy vấn cần khớp từ khoá chính xác (mã SKU, tên hàm) có thể xếp hạng thấp bất ngờ. Cách chữa là kết hợp với keyword search trong PostgreSQL full-text search cho địa chỉ tiếng Việt — một hybrid của cả hai — và đó là một bài riêng ở phần sau của series.
Dựng prompt với ngữ cảnh đã truy hồi
Giờ ráp prompt: một system instruction ghim model vào đúng ngữ cảnh được cung cấp, các đoạn đã truy hồi, và câu hỏi. Lệnh từ chối khi ngữ cảnh không chứa câu trả lời là dòng quan trọng nhất để giữ trợ lý trung thực:
function buildPrompt(question: string, contexts: string[]): string {
const context = contexts.map((c, i) => `[${i + 1}] ${c}`).join("\n\n");
return [
"Bạn là trợ lý hỗ trợ GoGoDuk. CHỈ trả lời từ ngữ cảnh bên dưới.",
"Nếu ngữ cảnh không chứa câu trả lời, hãy nói bạn không biết. Trích nguồn dạng [1].",
"",
"Ngữ cảnh:",
context,
"",
`Câu hỏi: ${question}`,
].join("\n");
}Với năm đoạn 800 ký tự, prompt này rơi vào khoảng 1.000–1.500 token — chính là chi phí đơn vị cho mỗi câu hỏi, và là con số cần theo dõi khi bạn tăng k.
Bước 6: gọi LLM và stream câu trả lời
Gửi prompt, stream token về để người dùng thấy chữ trong vài trăm ms thay vì chờ trọn câu trả lời. Việc nối các token stream đến tận trình duyệt qua SSE là một chủ đề riêng ở phần sau của series; đây là vòng lặp sinh nội dung phía server:
async function* answer(question: string) {
const contexts = await retrieve(question);
const prompt = buildPrompt(question, contexts);
const res = await fetch("https://api.example.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-haiku-4-5",
max_tokens: 512,
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
for await (const token of parseSSE(res.body)) {
yield token; // chuyển tiếp tới caller / client ngay khi tới
}
}Đó là toàn bộ pipeline. Một câu hỏi thật — "Geocode một địa chỉ phố ở Hà Nội với GoGoDuk thế nào?" — chảy qua embed → top-k → prompt → stream, và trở về thành một câu trả lời có căn cứ, trích [2] từ đoạn tài liệu geocoding, với thời gian tới token đầu tiên dưới một giây. So với hỏi thẳng model trần, khác biệt một trời một vực: model trần bịa ra một endpoint; bản RAG trích đúng cái có thật.
Các lỗi thường gặp & cách debug
Phần lớn bug của RAG là bug truy hồi khoác áo bug sinh nội dung. Khi câu trả lời sai, hãy kiểm tra các đoạn đã truy hồi trước khi đụng vào prompt:
- Câu trả lời không nằm trong top-k. Log lại
contentđã truy hồi ở mỗi request. Nếu đoạn đúng không bao giờ xuất hiện, vấn đề nằm ở thượng nguồn — chunk quá to (câu trả lời bị pha loãng),kquá nhỏ, hoặc truy vấn dạng từ khoá mà semantic search xếp hạng kém. In các đoạn ra trước; gần như luôn là ở đây. - Lệch số chiều. Embed bằng model 1536 chiều vào cột
vector(768)sẽ lỗi ngay lúc insert. Số chiều của cột phải bằng đầu ra của model, và đổi model thì cách duy nhất là embed lại toàn bộ. - Model vẫn trả lời từ dữ liệu huấn luyện. Siết chặt lệnh "chỉ trả lời từ ngữ cảnh, nếu không thì nói không biết". Thiếu nó, model vô tư lấp chỗ trống bằng trí nhớ — đúng cái hallucination mà RAG sinh ra để chặn.
- Chi phí tăng dần. Mỗi câu hỏi là một lệnh gọi embed cộng một lệnh gọi generate, tính tiền theo token. Theo dõi token, chi phí và độ trễ cho từng request ngay từ ngày đầu — đúng kỷ luật mà bài trụ nhấn mạnh, và rất hợp với kiểu phân tích usage trong phân tích usage API thời gian thực bằng ClickHouse.
Giờ bạn đã có một pipeline RAG hiểu tường tận từ đầu đến cuối — không framework nào chen giữa bạn và các mắt xích. Các bài sau trong series sẽ đào sâu những phần ta mới lướt qua: chiến lược chunking giúp tăng recall đo được, lưu và index embedding trong Postgres cho đúng, và hybrid search kết hợp từ khoá với vector. Lưu lại bản đồ series và cùng dựng bài tiếp theo với mình.
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
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
Tích hợp cổng thanh toán VNPay vào API NestJS: Hướng dẫn từ A đến Z
Hướng dẫn tích hợp cổng thanh toán VNPay vào API NestJS end-to-end: tạo URL thanh toán, ký HMAC-SHA512 vnp_SecureHash, xử lý Return URL và IPN, xác thực chữ ký và đảm bảo idempotency cho luồng thanh toán đơn hàng.
2 Jun 2026
Kiến trúc Modular trong NestJS: Xây dựng API Production Quy Mô Lớn
Hướng dẫn thực tế về kiến trúc modular trong NestJS để xây dựng API production: quản lý module, phân tách tầng dịch vụ, xác thực DTO, dependency injection và khả năng bảo trì.