Tiền đề của bài viết này là một cuối tuần, không phải một quý. Nếu bạn đã có sẵn một app Rails chạy Postgres và một bảng đầy text — bài viết, ticket hỗ trợ, mô tả sản phẩm, gì cũng được — bạn có thể thêm semantic search mà không cần dựng vector database, không cần đăng ký SaaS, cũng không cần viết lại cả stack. Thứ cản phần lớn mọi người là cái giả định rằng “vector search” đồng nghĩa với một mảng hạ tầng mới. Không phải vậy. pgvector là một extension của Postgres, embedding của bạn chỉ là một cột nữa, và truy vấn là ORDER BY. Bài này là cái khung tôi muốn đưa cho chính mình của vài năm trước: semantic search thực sự là gì, quyết định duy nhất thực sự quan trọng (chunking và dimension), những cái bẫy ngốn mất ngày thứ Bảy của bạn, và một hình hài cụ thể cho phần triển khai. Không có câu chuyện khách hàng nào ở đây, vì cái pattern này không cần đến nó.

”Semantic search” ở đây nghĩa là gì, và không phải là gì

Semantic search xếp hạng các dòng theo ý nghĩa thay vì mức trùng token theo mặt chữ. Bạn chuyển mỗi đoạn text thành một vector — một mảng số thực do mô hình embedding sinh ra — và lúc truy vấn, bạn embed chuỗi tìm kiếm theo đúng cách đó, rồi tìm các vector gần nhất. “Gần nhất” thường là theo cosine distance. Một đoạn text về “hủy đăng ký” vẫn nổi lên cho truy vấn “làm sao để ngừng bị tính tiền”, dù chúng chẳng chia sẻ từ khóa nào.

Đó là cái được. Phạm vi, cho một cuối tuần, cố tình hẹp lại: bạn đang thêm một tín hiệu xếp hạng thứ hai vào một app vốn đã chạy được. Bạn không thay thế keyword search hiện có, và bạn cũng chưa xây RAG.

Cách nói thật lòng: pgvector cho bạn phần retrieval “đủ tốt” mà chạy ngay bên trong cái database bạn vốn đã trả tiền. Với phần lớn app B2B có vài chục nghìn dòng, đó là toàn bộ cuộc chơi. Tôi đã viết kỹ hơn về phía lưu trữ trong Postgres RAG với pgvector — bỏ Pinecone, ship trên một box; bài này là nửa còn lại, phần search.

Cơ chế cốt lõi: embed, lưu, truy vấn, index

Bốn mảnh chuyển động. Làm đúng bốn cái này thì phần còn lại chỉ là đường ống.

MảnhQuyết địnhMặc định hợp lý
ModelMô hình embedding nào sinh ra vectorMột model hosted nhỏ (OpenAI text-embedding-3-small, hoặc một model embedding của Workers AI) — 768–1536 dims
CộtVector nằm ở đâuCột vector(N) trên bảng cần tìm kiếm, N = số chiều output của model
DistanceĐo “gần nhất” thế nàoCosine (toán tử <=>), vector đã chuẩn hóa
IndexTránh full scan ra saoHNSW cho bảng đọc nhiều; bỏ luôn index nếu dưới ~10k dòng

Migration thì chẳng có gì đặc biệt:

enable_extension "vector"

add_column :articles, :embedding, :vector, limit: 1536

add_index :articles, :embedding,
  using: :hnsw,
  opclass: :vector_cosine_ops

Phía model chỉ là một method. Bạn embed lúc ghi, lưu kết quả, và không bao giờ tính lại trừ khi text thay đổi:

class Article < ApplicationRecord
  def refresh_embedding!
    update_column(:embedding, EmbeddingService.embed(body))
  end

  scope :semantic, ->(query, limit: 20) {
    vector = EmbeddingService.embed(query)
    nearest_neighbors(:embedding, vector, distance: "cosine").limit(limit)
  }
end

Bản thân truy vấn mới là phần làm người ta bất ngờ: nó chỉ là ORDER BY embedding <=> $1 LIMIT 20. Không có index riêng nào phải tra cứu, không có datastore thứ hai phải giữ đồng bộ, không có cửa sổ eventual-consistency. Vector là một cột; search là SQL.

Thao tác tốn kém là embed, không phải search. Embed toàn bộ kho dữ liệu cũ là một khoản chi API và một job backfill chạy một lần. Embed một truy vấn lúc tìm kiếm tốn một lần gọi API mỗi lần search — cache lại nếu truy vấn của bạn lặp lại.

Những cái bẫy sẽ ngốn mất ngày thứ Bảy của bạn

Phần model gói gọn trong một buổi chiều. Cả ngày biến mất ở bốn chỗ có thể đoán trước.

Lệch dimension. Cột vector(N) của bạn cố định cứng N. Đổi model embedding là số chiều đổi — 1536 sang 768 — và mọi vector đã lưu giờ thành rác, không so sánh được nữa. Chọn model trước, ghim nó lại, và coi việc đổi model như một migration re-embed toàn bộ.

Backfill kiểu đồng bộ. Embed 50.000 dòng trong một Rake task gọi API trong vòng lặp sẽ khiến bạn bị rate-limit và treo cả tiếng đồng hồ. Chia batch, đẩy sang background job, và tôn trọng giới hạn của nhà cung cấp. (Về chuyện chọn queue, xem Sidekiq vs Solid Queue năm 2026.)

Chunk quá thô, hoặc không chunk gì cả. Embed một tài liệu 5.000 từ thành một vector duy nhất sẽ làm trung bình hóa ý nghĩa của nó thành một mớ nhão. Một truy vấn về một đoạn văn sẽ khớp rất yếu với cái trung bình của cả tài liệu. Chia text dài thành các đoạn — theo paragraph hoặc cửa sổ ~500 token — và lưu một vector cho mỗi chunk.

Index quá sớm. HNSW index có chi phí build và đủ thứ núm vặn (m, ef_construction). Dưới ~10k dòng, một sequential scan trên cosine distance đã đủ nhanh tới mức index chỉ thêm độ phức tạp mà không đổi lấy lợi ích đo được nào. Thêm nó khi một truy vấn thực sự chậm, đừng làm trước phòng hờ.

Index quá sớm cũng chính là cái bẫy của tối ưu quá sớm, chỉ là đội thêm cái mũ Postgres. — Ghi chú cho bản thân, sau vài lần lạc quá sâu vào hố ef_construction

Cái bẫy meta: coi semantic search như một thứ thay thế cho search. Keyword search vẫn thắng ở các trường hợp khớp chính xác — mã SKU sản phẩm, tên riêng, mã lỗi. Nước đi trưởng thành là hybrid: chạy cả hai, trộn điểm số lại. Nhưng đó là cuối tuần thứ hai, không phải cuối tuần này.

Pattern này trông ra sao từ đầu đến cuối

Hình dung một app help-center: một bảng articlestitlebody, đã tìm kiếm được bằng full-text của Postgres. Bạn muốn thêm tìm kiếm “mờ” hiểu được ý định.

Hình hài cuối tuần:

  1. Sáng thứ Bảy — thêm extension và cột vector. Chọn model embedding và ghim số chiều của nó. Viết EmbeddingService.embed(text) như một lớp wrapper mỏng bọc một API. (Về các pattern wrapper và xử lý fallback, đưa AI vào một app Rails có sẵn với ruby_llm là checklist tôi sẽ theo.)

  2. Chiều thứ Bảy — chunk và backfill. Chia mỗi body thành các đoạn, embed từng đoạn trong các background job chạy theo batch, lưu một vector mỗi chunk trong một bảng article_chunks thuộc về articles. Để mắt tới rate limit.

  3. Sáng Chủ Nhật — nối truy vấn. Một scope semantic embed chuỗi tìm kiếm rồi trả về các chunk gần nhất, sau đó load các article cha. Dedupe theo article, vì nhiều chunk từ một tài liệu đều có thể cùng lọt top.

  4. Chiều Chủ Nhật — đánh giá. Tự tay viết 15–20 truy vấn thật, kèm theo câu trả lời bạn mong đợi phải đứng đầu. Chạy chúng. Nhìn bằng mắt xem article đúng có nằm trong top ba không. Đây là bộ regression của bạn — thô sơ, nhưng bắt được ngay một lần đổi model tệ.

Điều tôi sẽ lưu ý với một founder: khoản chi cho embedding ở quy mô này nhỏ và dễ đoán, nhưng nó lặp lại — mỗi article mới và mỗi truy vấn chưa cache đều tốn một phần nhỏ của một xu. Hãy tính nó như một hạng mục chi phí định kỳ, đừng coi là chi phí một lần. Nếu lượng truy vấn của bạn cao, thì các lần gọi API embedding — chứ không phải các truy vấn Postgres — mới là thứ bạn phải theo dõi.

Thứ bạn cố tình bỏ ra ngoài cuối tuần này: hybrid ranking, model re-ranking, query expansion. Chúng là những cải tiến thật sự. Nhưng chúng cũng là khác biệt giữa “đã ship” và “vẫn còn ngồi tinh chỉnh sau ba tuần”.

”Xong” trông như thế nào

Xong không phải là “search thấy có vẻ thông minh”. Xong là thứ có thể kiểm chứng được:

Nếu bạn demo được một truy vấn trả về câu trả lời đúng với không một từ khóa chung nào, và giải thích được vì sao từng điều trên đúng, thì bạn đã xong. Cám dỗ là cứ tinh chỉnh mãi cho tới khi nó hoàn hảo. Cưỡng lại đi. Retrieval đủ tốt mà đã ship thắng retrieval hoàn hảo mà chẳng bao giờ ra mắt.

Khi nào cách làm này không áp dụng

Bỏ qua pgvector khi kho dữ liệu của bạn thực sự khổng lồ — hàng trăm triệu vector với yêu cầu độ trễ thấp — nơi một vector store chuyên dụng xứng đáng với chi phí vận hành của nó. Bỏ qua nó khi “search” của bạn thật ra chỉ là lọc có cấu trúc (giá, ngày, trạng thái) mà WHERE của SQL đã làm gọn; embedding chẳng thêm được gì ở đó.

Và bỏ qua nó khi keyword search đã đủ tốt và chẳng ai than phiền. Semantic search là một tính năng thật với một chi phí định kỳ thật. Thêm nó chỉ vì nó đang là mốt là cách một cuối tuần biến thành một gánh nặng bảo trì.

Phần có thể kiểm chứng

Đây là khẳng định tôi đứng ra bảo vệ: với một app Rails dưới khoảng vài trăm nghìn tài liệu, thêm semantic search bằng pgvector không đòi hỏi bất kỳ hạ tầng mới nào ngoài một extension của Postgres và một API embedding — và truy vấn search là một ORDER BY đơn lẻ có index. Nếu bạn thấy mình đang phải dựng một vector database riêng để có kết quả chấp nhận được ở quy mô đó, thì nút thắt gần như chắc chắn nằm ở cách chunk hoặc lựa chọn model của bạn, chứ không phải ở Postgres.