Quý trước, một client nhờ tôi thêm tính năng “hỏi đáp tài liệu” vào một app Rails. Phản xạ đầu tiên là Pinecone hoặc Weaviate — một vector database managed, thêm một mảnh hạ tầng, thêm một hoá đơn hàng tháng, thêm một SDK phải theo dõi cập nhật. Tôi thử nó một buổi chiều rồi xoá luôn cái gem. Postgres đã nằm sẵn trên cùng cái box đó, idle gần như cả ngày, và extension pgvector chỉ cách một câu CREATE EXTENSION. Bài này là ghi chép về cái thay thế dịch vụ managed kia: một setup retrieval một box, dùng pgvector để lưu trữ, ruby_llm để embedding và chat, và khoảng 180 dòng Ruby. Tôi sẽ nói về kiến trúc thực sự ra sao, các con số tôi đo được trên production traffic, ba cú vấp mỗi cú ngốn của tôi một cuối tuần, và ngưỡng mà tôi sẽ đầu hàng để trả tiền cho Pinecone.

”RAG trên Postgres” thực ra là gì

Retrieval-augmented generation là hai thao tác database nhàm chán được khoác lên một cái acronym. Bạn lấy một chunk văn bản nguồn, nhờ một embedding model biến nó thành vector float 1.536 chiều, rồi lưu vào một column. Lúc query, bạn embed câu hỏi của user theo đúng cách đó, tìm các vector gần nhất theo cosine similarity, rồi nhét đoạn text khớp vào prompt trước khi gọi LLM.

Stack “Postgres RAG pgvector” nghĩa là column kiểu vector(1536), similarity search là ORDER BY embedding <=> $1 LIMIT k, và không có database thứ hai. Bạn được free transactions, backups, replicas, và các ActiveRecord model đang có. Cái giá là bạn bị kẹt với bất cứ performance nào pgvector cho bạn trên phần cứng hiện có — và trên một Hetzner CPX21 với 50.000 chunks, nó hoàn toàn ổn.

ruby_llm là chất keo. Nó cho bạn một interface nhất quán cho embeddings của OpenAI, Anthropic, Gemini, và Ollama, nên đổi provider không phải viết lại indexer. Tôi đã viết về cách tích hợp trong bài đưa AI vào một Rails app sẵn có với ruby_llm; bài này giả định cái scaffolding đó đã có sẵn.

Cơ chế, từ đầu đến cuối

Toàn bộ hệ thống có bốn phần chuyển động. Không phần nào thông minh cả. Cái thông minh, nếu có, là không thêm phần thứ năm.

StageLàm gìNằm ở đâu
ChunkerCắt doc nguồn thành cửa sổ ~500 token, overlap 50 tokenapp/services/rag/chunker.rb
EmbedderGọi RubyLLM.embed cho mỗi chunk, lưu vector(1536)method Document#embed!
RetrieverORDER BY embedding <=> ?::vector LIMIT 8scope Document.nearest(query_vec)
ComposerBuild prompt với chunks lấy được, stream chat completionRagChat#answer(question)

Scope retriever là phần duy nhất đáng show code, vì lần đầu ai cũng làm phức tạp hoá nó lên:

scope :nearest, ->(vec, k: 8) {
  select("documents.*, embedding <=> '#{vec}'::vector AS distance")
    .order(Arel.sql("embedding <=> '#{vec}'::vector"))
    .limit(k)
}

Index làm cho query nhanh là HNSW, không phải IVFFlat. Sự khác biệt còn quan trọng hơn cả những gì documentation gợi ý:

CREATE INDEX ON documents
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

Trên 50.000 chunks, query HNSW trả về trong 4-9ms. Cùng query đó với IVFFlat index lists = 100 trả về trong 35-60ms và càng tệ khi corpus lớn dần. Build chậm hơn — khoảng 8 phút cho 50k rows trên CPX21 — nhưng build một lần, query mãi mãi. Recall đến giờ đủ tốt, chưa ai phàn nàn rằng một chunk liên quan bị bỏ sót.

Ba cú vấp ngốn của tôi mỗi cú một cuối tuần

Cú đầu là chunk size. Tôi bắt đầu ở 1.000 token vì mọi tutorial đều dùng số đó, và recall tệ thảm hại. Model sẽ retrieve một chunk có câu trả lời đúng nhưng bị chôn trong 800 token context không liên quan, và LLM bám lấy nhầm câu. Hạ xuống 500 token với overlap 50 token gần như nhân đôi precision của câu trả lời, đo qua một eval set nhỏ 40 câu hỏi tôi đã viết sẵn ground-truth answers.

Cú thứ hai là mismatch embedding model. Tôi index bằng text-embedding-3-small (1.536 dims) và rồi sáu tuần sau, quyết định thử text-embedding-3-large (3.072 dims) mà không re-embed corpus. Vector query và vector lưu nằm ở hai không gian khác nhau. pgvector không từ chối — nó vui vẻ trả về nearest neighbors theo phép tính Euclidean vô nghĩa. Kết quả retrieval trông có vẻ hợp lý nhưng sai hoàn toàn. Giờ tôi ghi tên model vào một column embedding_model và từ chối query nếu nó không khớp model đang config.

Database sẽ làm đúng những gì bạn yêu cầu. Đó thường là vấn đề.

— Một câu cửa miệng về Postgres

Cú thứ ba là re-embed mỗi lần save. Phiên bản đầu của tôi gọi Document#embed! trong after_save callback. Sửa một typo trong một cuốn manual 300 trang trigger 600 lần gọi API embedding. Giờ tôi diff content đã chunked và chỉ re-embed những chunk có text thực sự thay đổi. Việc đó cắt hoá đơn OpenAI từ $40/tháng (~1tr VNĐ) xuống còn khoảng $3 (~76k VNĐ).

Hai tuần, từ con số không đến “ask the docs” trên production

Timeline thực tế cho một dev solo làm việc này trên một Rails app sẵn có ngắn hơn nhiều người tưởng, vì phần lớn công việc là plumbing bạn đã biết.

Ngày 1-2: cài pgvector lên production Postgres, viết migration, thêm column vector(1536) và column embedding_model, đấu ruby_llm với một OpenAI key. Gọi thử một embed từ Rails console và verify một round-trip.

Ngày 3-4: viết chunker. Của tôi là 30 dòng Ruby dùng recursive split đơn giản theo heading, rồi đoạn, rồi câu, rồi cuối cùng là hard token cap. Tôi tránh langchain.rb ở đây vì abstraction nó cung cấp không bõ cái dependency cho một function nhỏ thế này.

Ngày 5-7: build indexer. Một Sidekiq job cho mỗi document, mỗi job spawn sub-jobs cho từng chunk. Throttle để không vượt rate limit của embedding provider. Thêm guard embedding_model. Thêm một rake task reindex! xoá sạch và rebuild, vì bạn sẽ cần đến nó.

Ngày 8-9: viết retriever và một eval harness nho nhỏ. Bốn mươi câu hỏi với expected source chunks viết tay. Đo recall@8. Tune chunk size và k theo con số đó, không phải theo cảm giác.

Ngày 10-12: tích hợp chat endpoint. Streaming interface của ruby_llm tới bất cứ model nào client muốn — tôi mặc định Claude Haiku để rẻ, GPT-4o-mini làm fallback. Các chunk retrieved đi vào system prompt với chỉ thị rõ ràng “trích dẫn số chunk”. Stream câu trả lời ra browser qua Turbo Streams.

Ngày 13-14: deploy, watch logs, fix ba thứ bạn đã miss. Trường hợp tôi: bug timezone trong cron indexer, một embedding API timeout không được retry, và một chunk chứa từ “deprecated” nhiều đến mức nó nhiễm độc mọi kết quả có nó.

Định nghĩa “xong”

Hệ thống xong khi ba con số đứng vững. Recall@8 trên eval set trên 0.85 — nghĩa là chunk liên quan nằm trong top 8 retrieve được ít nhất 34/40 lần. Latency end-to-end trung vị từ câu hỏi user đến token đầu tiên của câu trả lời stream dưới 1.5 giây, đo ở tầng application log. Chi phí hàng tháng — embeddings cộng chat completions cộng phần Postgres storage tăng thêm — dưới $20 (~510k VNĐ) cho một corpus 50.000 chunks và khoảng 500 câu hỏi/ngày.

Nếu bất cứ con số nào trong ba con số đó vỡ, hệ thống cần làm lại, bất kể một câu trả lời cụ thể nào đó có vẻ hay đến mức nào. Tôi không tin đánh giá chủ quan về chất lượng RAG; eval set mới là thứ nói với tôi tôi vừa ship một regression hay không.

Khi pgvector trên một box là câu trả lời sai

Nếu corpus của bạn đang tiến gần hai triệu chunks, bạn sẽ vượt khả năng HNSW trên một Postgres node trước khi vượt sự kiên nhẫn của mình. Nếu bạn cần multi-tenant isolation với hard query budget per-tenant, planner của pgvector không có cái knob đó. Nếu team bạn đã chạy sẵn một vector DB vì lý do khác, đừng nhét Postgres vào để chống lại họ. Và nếu embedding model của bạn thay đổi mỗi tuần vì còn đang trong giai đoạn nghiên cứu, cái giá rebuild là một khoản thuế thật mà dịch vụ managed giấu đi một phần.

Còn lại tất cả — và “tất cả còn lại” bao trùm gần như mọi B2B SaaS tôi đã ship cho khách Việt Nam lẫn nước ngoài — pgvector trên Postgres sẵn có là con đường ít hối tiếc nhất. Với các SaaS Việt build để bán cross-border đang là xu hướng 2026, càng đỡ phải gánh thêm một dòng chi phí USD hàng tháng.

Một claim tôi sẵn sàng rút lại nếu bị chứng minh sai

Số lượng sản phẩm SaaS thực sự cần một vector database riêng trong hai năm đầu, theo kinh nghiệm làm client của tôi, là không. Mỗi câu chuyện “chúng tôi chọn Pinecone vì sẽ scale” mà tôi đã audit hoá ra đều đang chạy dưới 100.000 vectors trên một plan $70/tháng (~1,8tr VNĐ). Postgres với pgvector hoàn toàn có thể phục vụ cùng query đó trong vài mili giây trên cái box mà họ vẫn đang trả tiền. Nếu bạn chỉ cho tôi một Rails app production dưới ba năm tuổi mà workload vector thực sự vượt quá khả năng một CPX31 với HNSW có thể phục vụ, tôi sẽ sửa lại bài này và credit bạn trong đó.