Vector Search DB nhìn từ bản chất toán — RAG chỉ là đại số tuyến tính

 

RAG (Retrieval-Augmented Generation) nghe rất “AI”, nhưng nếu kéo nó về đúng bản chất thì phần lõi — Vector Search — chỉ là vài phép toán đại số tuyến tính mà ai học xong năm nhất đại học đều làm được. Bài này tôi mổ xẻ Vector Search DB từ góc nhìn toán học: câu chữ biến thành điểm trong không gian, “gần nghĩa” biến thành một con số, và “tìm kiếm” biến thành argmax.

Bài này nối tiếp Nâng cao khả năng tìm kiếm MySQL với Vector Embeddings — ở đó tôi lưu vector trong MySQL và tính khoảng cách bằng tay. Lần này ta nhìn vì sao phép tính đó hoạt động, và nó nằm ở đâu trong pipeline RAG.

Vector Search DB nhìn từ bản chất toán


RAG là một pipeline tuyến tính

Trước khi vào toán, hãy nhìn toàn cảnh. RAG không có gì huyền bí — nó là 5 bước nối đuôi nhau:

1. Text        → Câu hỏi / nội dung
2. Embedding   → Biến text thành vector
3. Vector DB   → Lưu trữ các vector
4. Top-k       → Tìm các vector gần nhất với query
5. LLM Answer  → Trả lời dựa trên context + query

Mấu chốt: bước 4 mới là nơi “vector search” xảy ra, và nó thuần toán. Ba bước còn lại chỉ là chuẩn bị và tiêu thụ.


1. Biến ngôn ngữ thành bài toán hình học

Ý tưởng nền tảng: mỗi câu, mỗi đoạn văn được embedding biến thành một điểm trong không gian nhiều chiều (thực tế 768, 1024, 1536… chiều — nhưng bản chất không đổi).

Lấy ví dụ rút gọn về 3 chiều cho dễ hình dung:

query   q  = [0.25, 0.65, 0.12]
doc₁    d₁ = [0.20, 0.70, 0.10]
doc₂    d₂ = [0.80, 0.10, 0.30]

Câu hỏi “Document nào gần query nhất?” giờ trở thành một câu hỏi hình học thuần tuý: điểm nào nằm gần điểm q nhất trong không gian? Ta đo độ gần đó bằng cosine similarity.


2. Cosine Similarity — đo độ gần về hướng

Điểm tinh tế: cosine similarity không đo khoảng cách, nó đo góc giữa hai vector — tức độ gần về hướng (ý nghĩa), không quan tâm độ dài.

                        q · d
similarity(q, d) = ─────────────
                     ‖q‖ × ‖d‖

Trong đó tử số là dot product — đo hai vector có cùng đi về một hướng không:

q · d = q₁d₁ + q₂d₂ + … + qₙdₙ

Tính thử với ví dụ

Với d₁:

q · d₁  = 0.25×0.20 + 0.65×0.70 + 0.12×0.10
        = 0.05 + 0.455 + 0.012 = 0.517
‖q‖     = √(0.25² + 0.65² + 0.12²) = 0.707
‖d₁‖    = √(0.20² + 0.70² + 0.10²) = 0.735

similarity(q, d₁) = 0.517 / (0.707 × 0.735) ≈ 0.517 / 0.520 ≈ 0.99

Với d₂:

q · d₂  = 0.25×0.80 + 0.65×0.10 + 0.12×0.30
        = 0.20 + 0.065 + 0.036 = 0.301
‖q‖     = √(0.25² + 0.65² + 0.12²) = 0.707
‖d₂‖    = √(0.80² + 0.10² + 0.30²) = 0.860

similarity(q, d₂) = 0.301 / (0.707 × 0.860) ≈ 0.301 / 0.608 ≈ 0.50

Trực quan hoá hai con số này:

{
  "type": "bar",
  "data": {
    "labels": ["similarity(q, d₁)", "similarity(q, d₂)"],
    "datasets": [
      {
        "label": "Cosine similarity với query q",
        "data": [0.99, 0.50],
        "backgroundColor": ["rgba(46,204,113,0.75)", "rgba(231,76,60,0.75)"],
        "borderColor": ["#2ecc71", "#e74c3c"],
        "borderWidth": 1
      }
    ]
  },
  "options": {
    "title": { "display": true, "text": "d₁ (≈0.99) gần query hơn hẳn d₂ (≈0.50) về mặt ý nghĩa" },
    "legend": { "display": false },
    "scales": {
      "yAxes": [{ "ticks": { "beginAtZero": true, "max": 1 }, "scaleLabel": { "display": true, "labelString": "cosine similarity (0 → 1)" } }]
    }
  }
}

Kết luận: similarity(q, d₁) ≈ 0.99 còn similarity(q, d₂) ≈ 0.50 ⇒ hệ thống chọn d₁.

Lưu ý nhỏ khi tính tay: mẫu số ‖q‖ × ‖d‖ phải nhân đủ trước khi chia. 0.517 / (0.707 × 0.735) = 0.517 / 0.520 ≈ 0.99, không phải 0.94 — sai số kiểu này rất dễ mắc nếu làm tròn hoặc bỏ bước nhân mẫu. Điều đáng nhớ là thứ tự tương đối không đổi: d₁ vẫn gần q hơn d₂, nên argmax vẫn chọn đúng.

Nói theo toán, toàn bộ “vector search” gói gọn trong một dòng:

Search = argmax  similarity(q, d)
            d

Vector DB (FAISS, Milvus, Qdrant, pgvector, hay chính MySQL trong bài trước) chỉ là bộ máy tính argmax này thật nhanh trên hàng triệu vector — bằng index như HNSW, IVF thay vì quét tuyến tính.


3. RAG với Vector Search — mắt xích quyết định chất lượng

Ghép lại: kết quả retrieval được đưa thẳng vào prompt của LLM.

Retrieved Context = top-k documents theo similarity(q, d)
Answer            = LLM(Query + Retrieved Context)

Chất lượng câu trả lời phụ thuộc rất nhiều vào bước retrieval. Đây là chuỗi lý tưởng:

Query đúng → Vector đúng → Similarity đúng → Context đúng → Answer tốt

Và đây là cái bẫy chết người — nếu sai ngay từ retrieval:

Wrong Retrieval → Wrong Context → Fluent Wrong Answer  ⚠️

LLM không biết là context sai. Nó vẫn viết một câu trả lời trôi chảy, tự tin — nhưng dựa trên tài liệu sai. Đây là lý do nhiều hệ thống RAG “nghe rất mượt mà vẫn trả lời bậy”: lỗi không nằm ở model, nằm ở phép toán chọn tài liệu.


  Keyword Search Vector Search
Cơ chế match words match meaning
Tìm chữ giống nhau trên bề mặt sự gần nhau trong không gian nghĩa

Ví dụ kinh điển: “refund đơn hàng”“chính sách hoàn tiền” khác nhau hoàn toàn về mặt chữ — keyword search sẽ trượt. Nhưng nếu embedding tốt, hai vector này nằm rất gần nhau trong không gian nghĩa, nên vector search vẫn khớp.

Đây chính xác là hạn chế của LIKE '%...%' trong SQL mà tôi đã chỉ ra ở bài MySQL Vector Embeddings: tìm theo chữ thì cứng, tìm theo nghĩa mới đúng nhu cầu thật của người dùng.

Một ví dụ từ chính dự án của tôi: hệ thống SEO keyword cho thương mại điện tử

Bài toán này tôi gặp trong e-commerce: một sàn có hàng chục nghìn sản phẩm, cần bộ keyword SEO được phân loại theo dòng sản phẩm, kèm xử lý từ đồng nghĩa (synonym)word block (chặn/gom các cụm không mong muốn). Chính dự án đó cho tôi thấy rõ ranh giới keyword ↔ vector:

  • Phân loại theo dòng sản phẩm thực chất là gom các keyword gần nhau về nghĩa vào một cụm — đúng bài toán mà vector search giải rất tự nhiên: các biến thể của cùng một ý nằm sát nhau trong không gian nghĩa.
  • Từ đồng nghĩa là chỗ keyword search lộ điểm yếu nhất. Với cách match chữ, tôi phải liệt kê tay từng cặp đồng nghĩa (“hoàn tiền” = “refund” = “trả lại tiền”…) — một bảng ánh xạ phình to mãi và không bao giờ đủ. Embedding thì “hiểu” quan hệ đồng nghĩa đó sẵn trong vector, khỏi liệt kê.
  • Word block lại là điểm mạnh của keyword: chặn một cụm chính xác thì match chữ vừa nhanh vừa chắc chắn, không sợ “vô tình gần nghĩa” như vector.

Dưới đây là cách tôi tổ chức nó (một kiến trúc điển hình, ai làm lại cũng dễ hình dung).

a) Lưu dữ liệu: keyword + metadata + vector đi cùng nhau

Mỗi keyword không chỉ là một dòng text — nó đi kèm metadata phân loạivector embedding. Dùng PostgreSQL với extension pgvector là gọn nhất (nếu buộc dùng MySQL thì quay lại cách lưu JSON/BLOB trong bài trước):

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE seo_keywords (
    id           BIGSERIAL PRIMARY KEY,
    keyword      TEXT NOT NULL,
    product_line TEXT,                 -- dòng sản phẩm: "giày chạy bộ", "laptop gaming"...
    is_blocked   BOOLEAN DEFAULT FALSE,-- word block: cụm cần chặn
    synonyms     TEXT[],               -- đồng nghĩa khai báo tay (nếu có)
    embedding    VECTOR(1536),         -- vector từ OpenAI text-embedding-3-small
    created_at   TIMESTAMPTZ DEFAULT now()
);

-- Index ANN để search nhanh theo cosine (thay vì quét tuyến tính)
CREATE INDEX ON seo_keywords
    USING hnsw (embedding vector_cosine_ops);

Điểm mấu chốt: embedding nằm chung bảng với product_lineis_blocked. Nhờ vậy một câu query vừa lọc cứng (SQL WHERE) vừa xếp hạng theo nghĩa (vector) — đây chính là phần “hybrid” tôi nói ở cuối.

b) Sinh embedding bằng OpenAI API

Vector ở cột embedding được sinh bằng OpenAI Embeddings API. Tôi dùng model text-embedding-3-small — 1536 chiều, rẻ, đủ tốt cho tiếng Việt lẫn tiếng Anh; khi cần chất lượng cao hơn thì đổi sang text-embedding-3-large (3072 chiều). API cho phép gửi batch nhiều text một lần nên nạp cả bộ keyword rất nhanh:

from openai import OpenAI
client = OpenAI()

def embed(texts: list[str]) -> list[list[float]]:
    # Gửi cả batch trong 1 request để tiết kiệm round-trip + chi phí
    resp = client.embeddings.create(
        model="text-embedding-3-small",   # 1536 chiều
        input=texts,
    )
    return [d.embedding for d in resp.data]

# Nạp keyword vào DB
keywords = ["giày chạy bộ nam", "sneaker running", "giày thể thao chạy bộ"]
vectors  = embed(keywords)
# → insert (keyword, product_line, embedding) vào bảng seo_keywords

Vài điểm thực chiến đáng nhớ:

  • Chiều vector phải khớp giữa lúc nạp và lúc query. Đã chọn VECTOR(1536) thì query cũng phải dùng đúng model 1536 chiều — lệch chiều là cosine vô nghĩa.
  • Chuẩn hoá (normalize) không bắt buộc với embedding OpenAI vì chúng đã được chuẩn hoá độ dài ≈ 1; nhưng nếu tự train model khác thì nên normalize để cosine ổn định (xem lại phần công thức: cosine bỏ qua độ dài, nhưng chuẩn hoá giúp tính toán/so sánh nhất quán).
  • Cache embedding: keyword ít đổi, đừng gọi API lại mỗi lần. Lưu vector vào DB một lần, chỉ re-embed khi text thay đổi — tiết kiệm cả tiền lẫn độ trễ.

c) Truy vấn hybrid: lọc cứng trước, xếp hạng theo nghĩa sau

Khi có một keyword/câu tìm kiếm mới, tôi embed nó rồi để pgvector tính cosine, kết hợp với filter cứng theo product_line và loại bỏ is_blocked:

-- :q_vec là embedding của truy vấn (cũng sinh bằng text-embedding-3-small)
SELECT keyword, product_line,
       1 - (embedding <=> :q_vec) AS similarity   -- <=> là cosine distance
FROM   seo_keywords
WHERE  product_line = :line          -- lọc cứng theo dòng sản phẩm
  AND  is_blocked = FALSE            -- áp word block
ORDER  BY embedding <=> :q_vec        -- xếp hạng theo độ gần NGHĨA
LIMIT  10;                            -- top-k

Để ý: đây đúng là pipeline argmax similarity ở mục 2, nhưng bọc thêm hai lớp luật cứng của keyword search. WHERE xử lý phần “match words / chặn cụm” (điểm mạnh keyword), ORDER BY <=> xử lý phần “match meaning” (điểm mạnh vector).

Bài học rút ra: không phải cái nào cũng thay được cái nào. Từ đồng nghĩa / phân cụm theo nghĩa → nghiêng về vector; chặn/khớp cụm chính xác → giữ keyword. Hệ thống tốt thường lai (hybrid): keyword lọc thô + luật cứng (WHERE), rồi vector xếp hạng lại theo nghĩa (ORDER BY). Và toàn bộ phần “theo nghĩa” đó, bóc tới đáy, vẫn chỉ là cosine similarity mà ta tính tay ở trên.


💡 Insight quan trọng

Ba điều tôi muốn a nhớ nhất:

  • Vector Search DB không làm model thông minh hơn. Nó làm model được đọc đúng dữ liệu hơn.
  • Model vẫn là phần viết câu trả lời. Embedding + similarity chỉ lo khâu chọn đúng tài liệu để đưa vào.
  • ✅ Muốn nâng chất lượng RAG, đừng vội đổi model — hãy soi lại phép toán retrieval trước.

Liên hệ các bài trước


Bài học hôm nay

Muốn hiểu RAG ở mức advance, hãy kéo nó về toán tuyến tính trước:

text → embedding → vector → cosine similarity → top-k → context → answer

Khi nhìn như vậy, RAG bớt mơ hồ hẳn. Nó là một pipeline khá rõ ràng: biến câu chữ thành vector, đo độ gần về nghĩa, lấy đúng context, rồi mới để model trả lời. Debug RAG cũng vì thế mà có địa chỉ: sai ở đâu trong chuỗi đó, sửa đúng chỗ đó.