SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Xây pipeline RAG trên VPS riêng với pgvector

Tự host 4 bước đầu của RAG trên một VPS: pgvector, HNSW, Ollama với model 137 triệu tham số và SQL kiểm tra truy xuất vector hoạt động.

Một pipeline RAG tự host hoạt động như thế nào

Một pipeline RAG (retrieval augmented generation) có 5 giai đoạn: chia tài liệu thành các chunk, tạo embedding cho các chunk, lưu vector, truy xuất các vector gần nhất cho một câu hỏi, rồi gửi các chunk đó đến language model để viết câu trả lời. Trên VPS bạn đã thuê, 4 giai đoạn đầu chạy ngay trên máy. PostgreSQL với extension pgvector lưu các vector, còn một embedding model nhỏ được phục vụ bằng Ollama chuyển văn bản thành vector. Chỉ giai đoạn cuối mới phải rời khỏi máy.

Đó là cơ sở của hướng dẫn này. Chia chunk chỉ cần CPU xử lý. Embedding sử dụng model có 137 million parameter, chiếm vài trăm megabyte RAM. Dữ liệu được lưu trong một bảng Postgres, và bạn có thể tính kích thước bằng số học trước khi ghi một row nào. Với corpus có hàng trăm nghìn chunk, toàn bộ phần này chạy được trên một VPS thông thường. Generation thì khác, vì mỗi câu hỏi đều phát sinh chi phí và chi phí đó tiếp tục tồn tại về sau.

Những phần nào của pipeline RAG thực sự tốn tiền

Hướng dẫn RAG từ đầu đến cuối của DigitalOcean sử dụng vector database được quản lý và embedding model được host sẵn. Phần chi phí chỉ đưa ra các khuyến nghị định tính: cache các truy vấn lặp lại, giữ số chunk được truy xuất ở mức thấp, rerank trước khi generation. Những khuyến nghị này là đúng. Tuy nhiên, hướng dẫn bỏ qua một lựa chọn có thể thay đổi cách tính chi phí: chạy embedding model trên server mà bạn đã trả tiền để vận hành.

Hãy tính bằng token thay vì đô la, vì số lượng token không trở nên lỗi thời khi bảng giá thay đổi. Giả sử có corpus gồm 100,000 chunk, mỗi chunk 400 token, 10,000 câu hỏi được gửi đến corpus, 8 chunk được gửi cho model trong mỗi câu trả lời, phần câu hỏi và instruction gồm 100 token, và câu trả lời gồm 400 token.

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

Embedding toàn bộ corpus cần 40 triệu token và chỉ thực hiện một lần. Phân bổ cho 10,000 câu hỏi thì tương đương 4,000 token cho mỗi câu hỏi. Nếu có một trăm nghìn câu hỏi thì con số này giảm xuống còn 400. Generation thì không giảm. Mỗi câu hỏi bạn trả lời sẽ luôn tốn 3,300 token đầu vào và 400 token đầu ra.

Vì vậy, chi phí tập trung ở stage được lặp lại. Hãy tự chạy bước embedding, vì bạn chỉ phải trả chi phí một lần và VPS vẫn đang chạy. Hãy mua generation, vì đây là nơi model tốt hơn thực sự đáng để chi tiền. Cache cũng quan trọng vì cùng lý do: cache hit bỏ qua stage duy nhất có chi phí không bao giờ được phân bổ dần. Sự khác nhau giữa KV cache và prompt cache quyết định phần nào trong hai phần này có thể được tái sử dụng. Prompt RAG có một instruction block ổn định, theo sau là một chunk block thay đổi. Đây là cấu trúc hưởng lợi nhiều nhất từ việc cache.

Chunking: vì sao kích thước cố định kèm overlap là mặc định phù hợp

Chunk là đơn vị được truy xuất, nên kích thước của nó quyết định mọi bước xử lý phía sau. Chunk phải đủ nhỏ để embedding của nó tập trung vào một nội dung, vì embedding là một điểm duy nhất trong không gian. Do đó, chunk bao quát 4 chủ đề sẽ nằm giữa các chủ đề này và không gần chủ đề nào. Chunk cũng phải đủ lớn để tự trả lời một câu hỏi, vì language model chỉ thấy chunk chứ không thấy phần tài liệu xung quanh.

Hãy bắt đầu với 300 từ và overlap 50 từ. Tiếng Anh trung bình có khoảng 1.3 token mỗi từ, nên 300 từ tương đương khoảng 400 token. Overlap cần thiết vì nếu không, câu nằm đúng ranh giới sẽ bị tách đôi, và không nửa nào có thể trả lời câu hỏi.

Trước tiên hãy tách theo cấu trúc nếu tài liệu có cấu trúc. Tách theo heading, sau đó theo paragraph, rồi chỉ áp dụng quy tắc kích thước cố định bên trong section vẫn còn quá dài. Chunk bắt đầu giữa một câu sẽ khiến câu trả lời cuối cùng khó đọc, vì model thường trích lại nội dung bạn đã cung cấp.

Đừng tinh chỉnh chunking trước khi có thể đo lường kết quả. Kích thước cố định kèm overlap có tính xác định và chạy lại với chi phí thấp, nên đây là baseline để bạn cải thiện. Trước tiên hãy xây dựng scoring query ở phần bên dưới, sau đó mỗi lần chỉ thay đổi một yếu tố.

Nhúng trên cùng máy và chi phí RAM, độ trễ

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text có 137 million parameters và dung lượng tải xuống 274 MB tính đến tháng 8 năm 2026. Hãy kiểm tra dữ liệu trả về trước khi thiết kế bảng dựa trên nó.

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

Lệnh đó in ra 768. Kiểu cột phải khớp chính xác với con số đó.

Có 2 thiết lập của model này thường gây nhầm lẫn.

Task prefix không phải là tùy chọn. Model card của Nomic nói rằng input "must include a task instruction prefix". Nhúng document phải thêm search_document: ở phía trước, còn câu hỏi phải thêm search_query: . Nếu bỏ qua, không có lỗi nào xảy ra: bạn vẫn nhận được vector, chất lượng retrieval giảm, và không có log line nào cho biết nguyên nhân.

Input dài bị cắt âm thầm. Endpoint /api/embed nhận field truncate và mặc định là true, còn model do Ollama đóng gói công bố context 2K. Chunk dài hơn giới hạn sẽ bị cắt tại giới hạn đó rồi vẫn được nhúng, nên phần cuối không thể tìm kiếm. Hãy gửi "truncate": false trong lúc test để chunk quá lớn bị lỗi thay vì vẫn được xử lý.

Hãy gom các request thành batch và giữ model trong memory.

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input nhận một list, và một request chứa 32 chunk nhanh hơn 32 request riêng, vì HTTP round trip và việc tìm model chỉ xảy ra 1 lần thay vì 32 lần. keep_alive kiểm soát thời gian model được giữ trong memory sau một request; mặc định là 5 phút. Khi hết thời gian, request tiếp theo lại phải trả chi phí load model.

Hãy đo 2 con số quan trọng trên chính máy của bạn. Các con số này phụ thuộc vào số lượng vCPU, nên không có số liệu công bố nào khớp hoàn toàn.

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps in ra dung lượng resident của model đã load. Đây là lượng RAM bạn phải dành riêng trong suốt thời gian keep_alive giữ model đó. Lấy giá trị time chia cho batch size để có số giây xử lý mỗi chunk. Nhân kết quả với số lượng chunk để tính chi phí lập chỉ mục một lần. Với kế hoạch chỉ dùng CPU, một corpus 100,000 chunk có thể mất hàng giờ thay vì vài phút. Điều đó không sao, vì tác vụ này chỉ chạy 1 lần và có thể chạy qua đêm dưới nice -n 19. Nếu hàng giờ là không chấp nhận được, câu hỏi thực sự là thuê GPU có giúp thu hồi chi phí hay không; đây là bài toán hòa vốn so với token API chứ không phải vấn đề sở thích.

Nếu máy đã chạy một chat model, embedding model sẽ là model thứ hai được giữ trong memory và tổng RAM sẽ tăng tương ứng. Chạy Ollama trên VPS trình bày cách sizing phần generation, còn model tự host hoạt động thế nào khi có nhiều user đồng thời trình bày tình huống nhiều người gửi request cùng lúc. Embedding model đủ nhỏ để chạy cùng một trong hai model đó.

Script lập chỉ mục từ đầu đến cuối

Trên Ubuntu 24.04, chạy pip install bên ngoài virtual environment sẽ dừng với error: externally-managed-environment, vì system Python thuộc về apt.

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector

OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"

def embed(texts, prefix="search_document: "):
    payload = {"model": MODEL,
               "input": [prefix + t for t in texts],
               "truncate": False,
               "keep_alive": "30m"}
    req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)["embeddings"]

def split(text, size=300, overlap=50):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]

with psycopg.connect("dbname=rag user=rag") as conn:
    register_vector(conn)
    for doc_id, text in documents():          # your loader
        pieces = split(text)
        for start in range(0, len(pieces), 32):
            batch = pieces[start:start + 32]
            vectors = embed(batch)
            with conn.cursor() as cur:
                cur.executemany(
                    "INSERT INTO chunks (doc_id, seq, body, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    [(doc_id, start + i, body, Vector(vec))
                     for i, (body, vec) in enumerate(zip(batch, vectors))])
        conn.commit()

documents() là phần bạn tự triển khai: bất kỳ code nào duyệt qua file hoặc row rồi trả về document id và text của document. Mọi phần còn lại là pipeline.

Lưu trữ: schema pgvector và kích thước của nó

Ubuntu 24.04 phát hành postgresql-16-pgvector ở phiên bản 0.6.0. Phiên bản này cũ hơn kiểu halfvec. Hãy dùng repository của dự án PostgreSQL để cài bản build hiện tại.

sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvector

Số trong tên package phải khớp với major version của server. Sau đó tạo role, database và extension.

sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'
CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    text NOT NULL,
  seq       int  NOT NULL,
  body      text NOT NULL,
  embedding vector(768) NOT NULL,
  fts       tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX chunks_fts ON chunks USING gin (fts);

vector(768) phải khớp với output của model. Nếu chèn một vector 1024 chiều vào column đó, Postgres sẽ từ chối và trả về expected 768 dimensions, not 1024. Đây là thông báo lỗi rõ ràng nhất trong toàn bộ pipeline này. Column fts được tạo sẽ không tốn chi phí duy trì đáng kể, đồng thời cho phép thêm tìm kiếm theo từ khóa sau này.

Dung lượng được tính bằng số học. Tài liệu pgvector ghi rằng vector chiếm 4 * dimensions + 8 byte còn halfvec chiếm 2 * dimensions + 8. Các số chiều bên dưới là kích thước output do từng model công bố.

ChartVector column size per 100,000 chunks, by embedding dimension
The data behind this chart
[
  {
    "label": "384 (all-minilm)",
    "bytes_per_vector": "1,544",
    "vector_mib_per_100k": 147,
    "halfvec_mib_per_100k": 74
  },
  {
    "label": "768 (nomic-embed-text)",
    "bytes_per_vector": "3,080",
    "vector_mib_per_100k": 294,
    "halfvec_mib_per_100k": 147
  },
  {
    "label": "1024 (mxbai-embed-large)",
    "bytes_per_vector": "4,104",
    "vector_mib_per_100k": 391,
    "halfvec_mib_per_100k": 196
  },
  {
    "label": "1536 (hosted API model)",
    "bytes_per_vector": "6,152",
    "vector_mib_per_100k": 587,
    "halfvec_mib_per_100k": 294
  }
]

Ở 768 chiều, mỗi vector chiếm 3,080 byte. Vì vậy, 100,000 chunk chiếm 294 MiB dữ liệu vector. Cùng corpus đó nếu được embedding bằng một hosted model 1536 chiều sẽ cần 587 MiB, và index của nó cũng tăng theo tỷ lệ tương ứng. Half precision giảm một nửa cả hai giá trị: halfvec(768) lưu corpus đó trong 147 MiB. Việc này có làm giảm recall hay không sẽ được query chấm điểm bên dưới trả lời trong một lần chạy.

Các số liệu trên chỉ bao gồm riêng vector column. Text, row overhead và index được tính thêm, vì vậy hãy đo kích thước table thực tế.

SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
       pg_size_pretty(pg_relation_size('chunks'))       AS heap,
       count(*) AS n_rows
FROM chunks;

Nếu muốn dùng cùng extension nhưng có thêm API và user account, stack Supabase self-hosted là Postgres đã bật pgvector sẵn. Mọi query trong hướng dẫn này đều hoạt động nguyên vẹn trên đó.

Lập chỉ mục: các thiết lập HNSW quan trọng

Với dưới vài nghìn dòng, không cần tạo index. Exact search đọc mọi dòng, nhưng ở quy mô đó vẫn đủ nhanh và recall là tuyệt đối. Thêm index khi sequential scan không còn đủ nhanh, đồng thời hiểu rõ đánh đổi: approximate index chỉ trả về các hàng xóm gần đúng.

SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

m = 16ef_construction = 64 là giá trị mặc định của pgvector. Tăng các giá trị này sẽ cải thiện recall, nhưng làm tăng thời gian build và kích thước index. Dùng vector_cosine_ops với toán tử <=>, trừ khi bạn biết model luôn tạo ra các vector có độ dài bằng 1, vì cosine distance bỏ qua độ dài vector còn inner product thì không.

Hãy theo dõi quá trình build. Khi graph vượt quá maintenance_work_mem, pgvector sẽ báo:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

Đây không phải lỗi và quá trình build vẫn hoàn tất, nhưng sẽ chuyển sang một nhánh xử lý chậm hơn nhiều. Tăng maintenance_work_mem trong session thực hiện build index và giữ nguyên giá trị mặc định của server, vì thiết lập này áp dụng cho từng maintenance operation; đặt giá trị global quá cao có thể khiến máy chủ hết memory. Theo dõi một quá trình build dài từ session thứ hai.

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;

Sau đó, so sánh index đã build xong với dung lượng memory của máy chủ.

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

HNSW search duyệt qua một graph, nên nó truy cập các page rải rác trong index thay vì đọc một dải liên tục. Nếu index không vừa trong memory, mọi query sẽ tạo ra các lần đọc từ disk, và chính độ trễ ở phần đuôi là điều người dùng nhận thấy. Đây là quy tắc sizing duy nhất cho server: index cộng với các row thực tế được phục vụ phải vừa trong RAM. free -m và kích thước ở trên là hai số cần so sánh.

Tại thời điểm query, hnsw.ef_search là nút điều chỉnh recall và có giá trị mặc định là 40.

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;

Giá trị cao hơn sẽ tìm kiếm nhiều phần hơn của graph, tìm được các hàng xóm tốt hơn và làm tăng latency. Đây là thiết lập cấp session, nên bạn có thể tăng nó cho một query mà không cần thay đổi index.

Nếu query hoàn toàn không dùng index, plan sẽ hiển thị điều đó.

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

Sequential scan trong trường hợp này thường liên quan đến storage. Vector có 768 chiều chiếm 3,080 byte, lớn hơn mức Postgres lưu inline, nên giá trị này được chuyển sang bảng TOAST (kho lưu trữ ngoài dòng cho các giá trị quá lớn). Ghi chú của pgvector cho biết planner không tính storage ngoài dòng vào cost estimate, nên serial scan có thể trông rẻ hơn thực tế. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; giữ vector inline. Thiết lập này chỉ áp dụng cho các row được ghi sau khi thay đổi, vì vậy các row hiện có cần được table rewrite.

Truy xuất: một truy vấn, hai tín hiệu

Tìm kiếm vector tìm được văn bản có cùng ý nghĩa với câu hỏi. Nó kém hiệu quả với các chuỗi chính xác như mã linh kiện, mã lỗi hoặc họ. Tìm kiếm từ khóa thì ngược lại, và Postgres đã hỗ trợ sẵn. Hãy kết hợp cả hai trong một truy vấn thay vì chạy thêm một hệ thống khác.

Reciprocal rank fusion là cách kết hợp đơn giản nhất nhưng vẫn hiệu quả. Mỗi kết quả nhận 1 / (60 + rank) từ mỗi danh sách mà nó xuất hiện, rồi cộng hai điểm số này. Cách này không cần chuẩn hóa điểm số vì nó sử dụng vị trí thay vì khoảng cách.

WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY distance) AS rank
  FROM (SELECT id, embedding <=> $1 AS distance
        FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
        FROM chunks c, websearch_to_tsquery('english', $2) q
        WHERE c.fts @@ q
        ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword  k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;

$1 là embedding của câu hỏi được tạo bằng cùng model, với tiền tố search_query: . $2 là câu hỏi ở dạng văn bản. Cả hai giá trị đều được bind từ ứng dụng của bạn. websearch_to_tsquery chấp nhận câu hỏi thực của người dùng mà không gặp lỗi vì dấu câu, trong khi to_tsquery thì không. Còn một điểm cần lưu ý: thêm bộ lọc WHERE trên một lần quét HNSW có thể trả về ít dòng hơn số bạn yêu cầu, vì index được tìm kiếm trước rồi bộ lọc mới chạy sau. SET hnsw.iterative_scan = relaxed_order; khiến pgvector tiếp tục quét cho đến khi có đủ số dòng.

Làm thế nào để biết retrieval có tốt không?

Đây là bước mà gần như mọi hướng dẫn về RAG đều bỏ qua. Đây cũng là bước duy nhất cho biết các lựa chọn khác có giúp ích hay không. Bạn không cần một evaluation framework. Bạn chỉ cần 30 câu hỏi và id của chunk trả lời được từng câu.

Tự viết các câu hỏi này. Chọn những câu mọi người thực sự hỏi về corpus này, chạy từng câu, đọc kết quả trả về và ghi lại id của chunk đáng lẽ phải được xếp hạng đầu tiên. 30 câu hỏi sẽ không giải quyết được các khác biệt nhỏ. Nhưng nó sẽ bắt được những khác biệt quan trọng, vì các khác biệt đó thường rất lớn.

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

Embed từng câu hỏi với prefix search_query: , lưu lại, sau đó chấm điểm toàn bộ tập trong một query.

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10 chia cho questions là recall at 10: tỷ lệ số lần câu trả lời nằm trong cửa sổ được gửi cho model. MRR (mean reciprocal rank) lấy trung bình của 1 chia cho vị trí của chunk đúng và tính lần không tìm thấy là 0. Vì vậy, chỉ số này ưu tiên việc xếp câu trả lời ở vị trí đầu tiên thay vì vị trí thứ tám. Cả hai chỉ số đều thay đổi khi bạn đổi kích thước chunk, thay embedding model hoặc thêm keyword search. Khi đó, bạn có thể thấy chúng thay đổi theo hướng nào.

Hãy ưu tiên recall at 10 hơn mọi thứ khác, vì generator không thể dùng một chunk mà nó chưa bao giờ nhận được. Khi recall at 10 là 0.9 nhưng câu trả lời vẫn sai, lỗi nằm ở prompt hoặc model, không nằm ở retrieval. Việc tách riêng hai khả năng này giúp tránh mất nhiều ngày phỏng đoán.

Kiểm tra index riêng. Approximate search làm giảm recall, và pgvector cho bạn thấy mức giảm đó: chạy cùng query bằng exact search rồi so sánh các id.

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

Chín trên mười id trùng nhau nghĩa là ef_search vẫn ổn. Bốn trên mười nghĩa là cần tăng giá trị này.

Xếp hạng lại và sinh nội dung: nơi API tạo ra giá trị

Reranker là một loại model khác. Nó đọc câu hỏi cùng với một chunk rồi chấm điểm cho cặp đó. Cách này tốt hơn việc so sánh hai embedding được tính độc lập, nhưng quá chậm để chạy trên toàn bộ corpus. Vì vậy, reranker phù hợp ở bước này. Nó chỉ xử lý 40 ứng viên do retrieval trả về, thay vì 100,000 chunk trong bảng. Do đó, API reranking được host chỉ tính phí cho 40 cặp ngắn trên mỗi câu hỏi và loại các false positive kém nhất trước khi chúng chuyển sang bước tốn kém hơn.

Generation là khoản chi phí định kỳ, và có hai cách chính để giảm chi phí. Gửi ít chunk hơn, dùng recall tại 10 để xác định số lượng nhỏ nhất có thể gửi mà không làm mất câu trả lời. Giữ phần đầu của prompt ổn định từng byte để prompt cache của nhà cung cấp có thể được sử dụng, rồi đặt các chunk đã retrieval ở sau phần ổn định đó. Đồng thời cache các câu trả lời đã hoàn tất theo câu hỏi, vì token được sinh rẻ nhất là token bạn đã sinh từ tuần trước.

Ước tính cấu hình máy chủ và khi nào thiết kế này không còn đủ

Mọi quy tắc ước tính cấu hình ở đây đều dựa trên số liệu đo được, không phải ước lượng.

  • RAM là giới hạn chính: kích thước model resident từ ollama ps, cộng với kích thước chỉ mục HNSW và shared_buffers, đồng thời phải chừa đủ dung lượng cho các kết nối và page cache.
  • Disk cần gấp đôi pg_total_relation_size('chunks'), vì khi rebuild chỉ mục, cả hai bản sao cùng tồn tại.
  • CPU quyết định thời gian reindex: số giây cho mỗi chunk mà bạn đo được nhân với số lượng chunk.
  • Reindex xảy ra thường xuyên hơn bạn nghĩ, vì khi thay đổi embedding model, mọi vector đã lưu đều không còn hợp lệ.

Thiết kế này sẽ không còn đủ ở một thời điểm có thể dự đoán trước. Khi chỉ mục HNSW không còn vừa với lượng RAM bạn có thể mua, độ trễ query sẽ chuyển thành thời gian tìm kiếm trên disk và không có setting nào khôi phục được hiệu năng. Khi một table phục vụ nhiều tenant và mọi query đều lọc theo tenant, cần partition table; đây là công việc thực sự đáng kể. Khi các thao tác ghi trong lúc indexing tranh chấp tài nguyên với query của người dùng trên cùng một máy chủ, hãy chuyển embedding worker sang server thứ hai trước khi chuyển database. Cho đến khi một trong các trường hợp đó xảy ra, Postgres với pgvector trên VPS bạn đang thuê vẫn đáp ứng cho production, và các con số trên cho biết giới hạn còn cách bao xa.

FAQ

Tôi có thể chạy pipeline RAG trên một VPS hay cần vector database?

Một VPS là đủ cho các corpus có vài trăm nghìn chunk. Với 768 chiều, 100,000 chunk cần 294 MiB dữ liệu vector, chưa tính văn bản và HNSW index. Lượng này vẫn nằm trong RAM của một gói VPS thông thường. Giới hạn nằm ở bộ nhớ thay vì số dòng, vì truy vấn HNSW phải nhảy qua nhiều vị trí trong index. Độ trễ sẽ tăng khi index không còn vừa trong RAM. So sánh pg_relation_size trên index với free -m để biết hệ thống của bạn đang ở mức nào.

Tôi có cần GPU để tạo embedding cho tài liệu không?

Không, nếu bạn tạo embedding một lần rồi mới truy vấn. Model có 137 triệu parameter như nomic-embed-text vẫn chạy được trên CPU. Việc xử lý toàn bộ corpus lớn có thể mất vài giờ, nên bạn có thể chạy qua đêm. GPU bắt đầu hữu ích khi tài liệu được đưa vào liên tục hoặc khi bạn muốn chạy generation trên cùng máy. Hãy đo thời gian một batch với /api/embed trên server của bạn rồi nhân với số lượng chunk. Số vCPU khác nhau quá nhiều nên một con số công bố sẵn sẽ không giúp ích.

Vì sao truy vấn vector của tôi dùng sequential scan thay vì HNSW index?

Đọc query plan bằng EXPLAIN (ANALYZE, BUFFERS). Nguyên nhân thường gặp là storage: pgvector lưu ý rằng planner không tính storage ngoài dòng trong cost estimate. Vì vậy, serial scan có vẻ rẻ hơn thực tế. Vector 768 chiều có kích thước 3,080 byte, nên mặc định được lưu trong bảng TOAST. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; giữ các row mới ở dạng inline. Hai nguyên nhân còn lại là operator không khớp với index. Index được tạo bằng vector_cosine_ops chỉ được dùng bởi <=>. Nguyên nhân cuối là query không có ORDER BY ... LIMIT, vì approximate index chỉ phục vụ các query tìm nearest neighbour có sắp xếp.

Làm thế nào để biết retrieval của tôi có tốt không?

Tạo một gold set gồm 30 câu hỏi. Ghép mỗi câu hỏi với id của chunk chứa câu trả lời, rồi lưu các question embedding cùng với chúng. Sau đó đo recall at 10, tức là tần suất chunk đúng xuất hiện trong top 10, và MRR, chỉ số thưởng cho việc xếp chunk đúng ở vị trí đầu tiên. Hai con số này cho biết việc thay đổi chunk size, embedding model hoặc rank fusion có cải thiện kết quả hay không. Nếu không có chúng, bạn chỉ đang thay đổi setting và dựa vào cảm nhận từ một số ít câu trả lời.