如何在自己的 VPS 上建置 RAG pipeline
在單一 VPS 上完成 chunk、embedding、pgvector 儲存與檢索,涵蓋 PostgreSQL schema、HNSW 規模、137M 參數本地模型,以及驗證檢索結果的 SQL。
自架 RAG pipeline 的架構
RAG pipeline(retrieval augmented generation,檢索增強生成)包含 5 個階段:將文件切分成區塊、將區塊轉換為 embedding、儲存向量、針對問題擷取最相近的區塊,以及將這些區塊傳送給 language model 產生答案。在你已租用的 VPS 上,前 4 個階段都能在該主機執行。PostgreSQL 搭配 pgvector extension 儲存向量,Ollama 提供的小型 embedding model 則將文字轉換為向量。只有最後一個階段必須離開該主機。
這種分工是本指南的核心理由。文件切分只是一般的 CPU 工作。Embedding 使用的是 137 million parameter model,只需占用幾百 MB 的 RAM。儲存部分是 PostgreSQL table,你可以在寫入第一筆資料前,先用算術運算估算其大小。對於包含數十萬個區塊的語料庫,這些工作都能在一般 VPS 上執行。生成則不同,因為每次提問都會產生成本,而且會持續產生成本。
RAG pipeline 中哪些部分實際上會產生費用
DigitalOcean 的端到端 RAG 教學採用託管向量資料庫與代管 embedding model,成本章節則以定性方式說明:對重複查詢使用快取、減少擷取的 chunk 數量,以及在 generation 前先進行 rerank。這些建議是正確的。但其中略過了一個會改變計算方式的選項:在你已經付費使用的伺服器上執行 embedding model。
請計算 tokens,而不是金額。價格表變更時,token 數量不會過時。假設語料庫包含 100,000 個 chunk,每個 chunk 為 400 tokens;共提出 10,000 個問題;每個答案將 8 個 chunk 傳給 model;問題與 instruction block 共 100 tokens;答案為 400 tokens。
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 需要 40 百萬個 tokens,而且只需執行一次。分攤到這 10,000 個問題上,平均每個問題是 4,000 個 tokens。若提出 100,000 個問題,平均值會降至 400。Generation 永遠不會下降。每個問題都會固定產生 3,300 個輸入 tokens 與 400 個輸出 tokens。
因此,費用取決於會重複執行的階段。自行處理 embedding,因為只需付費一次,而且 VPS 本來就會持續執行。購買 generation,因為更好的 model 在這個階段才值得投入實際費用。快取的作用也相同:快取命中時,可以略過唯一無法攤銷成本的階段。KV cache 與 prompt cache 的差異決定了你能重複使用哪一半;RAG prompt 具有固定的 instruction block,後面接著會變動的 chunk block,這種結構最能從中受益。
分塊:為什麼固定大小搭配重疊是正確的預設值
分塊是你要檢索的單位,因此大小會決定後續所有處理。分塊必須小到讓其 embedding 只代表一個主題,因為 embedding 是空間中的單一點;涵蓋 4 個主題的分塊會落在這些主題之間,與其中任何一個都不相近。分塊也必須大到能自行回答問題,因為語言模型看到的是分塊,而不是分塊周圍的文件內容。
先從 300 個單字、重疊 50 個單字開始。英文每個單字約有 1.3 個 token,因此 300 個單字約為 400 個 token。設定重疊是因為句子若落在邊界上,否則會被切成兩半,而這兩半都無法回答問題。
如果文件具有結構,應先依結構切分。先依標題切分,再依段落切分;只有在單一區段仍然過長時,才在該區段內套用固定大小規則。若分塊從句子中間開始,最終答案的閱讀效果會很差,因為模型會引用你提供給它的內容。
在能夠量測之前,不要先調整分塊方式。固定大小搭配重疊具有決定性,且重新執行的成本低,因此可作為基準,之後再設法超越。先建立後文的評分查詢,再一次只修改一項設定。
在同一台主機上執行嵌入,以及 RAM 與延遲成本
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text截至 2026 年 8 月,nomic-embed-text具有 137 million parameters,下載大小為 274 MB。在設計資料表之前,先確認它實際回傳的內容。
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]))'這會輸出 768。欄位型別必須與該數值完全相符。
這個模型有 2 個容易被忽略的設定。
Task prefix 不是選用項目。 Nomic 的 model card 指出,輸入「必須包含 task instruction prefix」。文件前面要加上 search_document: ,問題前面要加上 search_query: 。省略這些前綴不會導致錯誤:仍會取得向量,但檢索品質會下降,而且任何 log 都不會說明原因。
過長的輸入會靜默截斷。 /api/embed endpoint 接受 truncate 欄位,預設值為 true;Ollama 封裝的模型則標示支援 2K context。超過此長度的 chunk 會在限制處被截斷,仍會完成嵌入,因此尾端內容無法搜尋。測試期間請傳入 "truncate": false,讓過大的 chunk 直接失敗,而不是繼續通過。
請批次傳送請求,並讓模型常駐記憶體。
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/nullinput接受清單。單一請求攜帶 32 個 chunk,效能優於傳送 32 個請求,因為 HTTP 往返與模型查找只需各執行 1 次,而不是 32 次。keep_alive控制請求完成後模型在記憶體中保留的時間,預設為 5 minutes。時間到期後,下一個請求必須再次支付載入時間。
請在自己的主機上測量 2 個重要數值。這些數值取決於 vCPU 數量,因此不會與任何公開數據完全一致。
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/nullollama ps會輸出已載入模型的 resident size。只要 keep_alive仍保留模型,這就是持續被占用的 RAM。將 time輸出除以 batch size,即可得到每個 chunk 所需的秒數。再乘以 chunk 數量,即可估算單次 indexing 成本。若只使用 CPU,100,000 個 chunk 的語料庫預計需要數小時,而不是數分鐘。這沒有問題,因為只需執行 1 次,也能在 nice -n 19下於夜間執行。如果數小時仍不可接受,真正的問題是租用 GPU 是否能回本;這是相對於 API token 成本的損益平衡計算,而不是偏好問題。
如果這台主機已經提供 chat model,embedding model 就是第二個常駐模型,RAM 使用量會累加。在 VPS 上執行 Ollama說明 generation side 的容量規劃,自架模型在並行使用者下的行為則說明多人同時提出請求時會發生什麼事。embedding model 的容量足夠小,可以與任一模型並存。
indexing script,從開始到完成
在 Ubuntu 24.04 上,直接執行虛擬環境外的 pip install會以 error: externally-managed-environment停止,因為系統 Python 屬於 apt。
python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvectorimport 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()由你提供:任何能遍歷檔案或資料列,並產生文件 ID 與文字的程式。其他部分就是整個 pipeline。
儲存空間:pgvector schema,以及其大小
Ubuntu 24.04 會隨附版本為 0.6.0 的 postgresql-16-pgvector,版本早於 halfvec 型別。請使用 PostgreSQL 專案自己的 repository 取得目前版本的建置套件。
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套件名稱中的數字必須符合伺服器的 major version。接著建立 role、database 和 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) 必須符合模型的輸出。將 1024 維向量插入該欄位時,Postgres 會以 expected 768 dimensions, not 1024 拒絕,這是整個流程中最清楚的錯誤訊息。產生的 fts 欄位幾乎不增加維護成本,之後還能支援關鍵字搜尋。
儲存空間可直接計算。pgvector 將 vector 記錄為 4 * dimensions + 8 bytes,將 halfvec 記錄為 2 * dimensions + 8。以下的維度數量都是各模型公布的輸出大小。
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 維向量占用 3,080 bytes,因此 100,000 個 chunk 會占用 294 MiB 的向量資料。相同資料集若使用 1536 維的 hosted model 產生向量,會需要 587 MiB,對應的 index 也會按比例增長。半精度會將兩者都減半:halfvec(768) 會以 147 MiB 儲存該資料集。這是否會降低 recall,以下的 scoring query 可在單次執行中回答。
以上數字只涵蓋向量欄位。文字、資料列額外負擔與 index 都會另外占用空間,因此請測量實際的 table。
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;如果你希望使用相同的 extension,並在其外層提供 API 與使用者帳戶,自架的 Supabase stack 已啟用 pgvector 的 Postgres,且本指南中的所有 query 都能直接使用。
索引:重要的 HNSW 設定
少於幾千筆資料時,略過索引。精確搜尋會讀取每一筆資料,在這個規模下速度已經足夠,而且召回率是完美的。當循序掃描不再夠快時,再加入索引,並了解其中的取捨:近似索引只會回傳近似正確的鄰近項目。
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 = 16 和 ef_construction = 64 是 pgvector 的預設值。提高這些值可改善召回率,但會增加建置時間與索引大小。除非確定模型產生的是單位長度向量,否則請搭配 <=> 運算子使用 vector_cosine_ops,因為 cosine distance 會忽略向量長度,而 inner product 不會。
監看建置程序。當圖超過 maintenance_work_mem 時,pgvector 會顯示提示:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.這不是錯誤,建置仍會完成,但會改用慢得多的路徑。請在建置索引的 session 中提高 maintenance_work_mem,不要修改伺服器預設值,因為該設定只套用於單次維護操作;全域值過高可能導致伺服器耗盡記憶體。長時間建置時,請從第二個 session 追蹤進度。
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;接著將完成的索引與伺服器可用的記憶體比較。
SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;HNSW 搜尋會遍歷圖,因此會存取散落在索引各處的頁面,而不是讀取連續範圍。無法放入記憶體的索引會讓每次查詢都觸發磁碟讀取,而使用者最容易感受到的是延遲尾端。伺服器的唯一容量規則是:索引加上實際提供服務的資料列,應能放入 RAM。free -m 和上方的大小是需要比較的兩個數值。
在查詢執行時,hnsw.ef_search 是控制召回率的設定,預設值為 40。
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;較高的值會搜尋更多圖中的內容,找到更好的鄰近項目,但也會增加延遲。這是 session 設定,因此可以只針對單一查詢提高,不必修改索引。
如果查詢完全沒有使用索引,執行計畫會顯示出來。
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;這裡使用循序掃描通常與儲存方式有關。768 維向量的大小為 3,080 bytes,大於 Postgres 可內嵌儲存的大小,因此資料會移至 TOAST table(用於儲存過大值的 out-of-line 儲存區)。pgvector 自身的說明指出,planner 不會將 out-of-line 儲存納入成本估算,這可能使循序掃描看起來比實際成本更低。ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; 會讓向量維持 inline 儲存。這只會套用至變更後寫入的資料列,因此現有資料列需要重寫資料表。
檢索:一個查詢,兩種訊號
向量搜尋會找出與問題含義相同的文字,但不擅長處理精確字串,例如料號、錯誤代碼或姓氏。關鍵字搜尋則相反,而 Postgres 已內建這項功能。將兩者合併在同一個查詢中,不必再執行第二套系統。
倒數排名融合是最簡單且實用的合併方法。每筆結果只要出現在某個清單中,就會從該清單取得 1 / (60 + rank),兩個分數再相加。這不需要正規化分數,因為它讀取的是排名位置,而不是距離。
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 是使用相同模型產生的問題嵌入,並以 search_query: 前綴建立。$2 是文字形式的問題。兩者都由應用程式繫結。websearch_to_tsquery 能接受帶有標點符號的實際使用者問題,不會因此失敗;to_tsquery 則無法處理。另有一點需要注意:在 HNSW 掃描上加入 WHERE 篩選條件,回傳的資料列可能少於要求的數量,因為系統會先搜尋索引,再執行篩選。SET hnsw.iterative_scan = relaxed_order; 會讓 pgvector 持續掃描,直到取得足夠的資料列。
如何判斷檢索品質?
這是幾乎所有 RAG 指南都略過的步驟,也是唯一能告訴你其他選擇是否有幫助的步驟。不需要評估框架,只需要 30 個問題,以及各問題所對應答案區塊的 id。
手動撰寫這些問題。選取人們確實會針對這個語料庫提出的問題,逐一執行、閱讀回傳內容,並記錄原本應該排名第一的區塊 id。30 個問題無法判定細微差異,但能抓出真正重要的差異,因為這些差異通常很明顯。
CREATE TABLE gold (
id bigserial PRIMARY KEY,
question text NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
embedding vector(768) NOT NULL
);使用 search_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 除以 questions,就是 recall at 10:答案有多少次位於你傳給模型的視窗內。MRR(mean reciprocal rank)會將 1 除以正確區塊的排名位置後取平均;找不到答案時則計為 0。因此,答案排在第一名時所得的分數會高於排在第八名。當你變更區塊大小、替換 embedding model 或加入關鍵字搜尋時,這兩個數值都會變動。現在你可以看出它們變動的方向。
優先維持 recall at 10 高於其他指標,因為產生器無法使用從未收到的區塊。當 recall at 10 為 0.9,但答案仍然錯誤時,問題出在 prompt 或 model,而不是檢索。這項區分能省下數天的猜測時間。
分開檢查 index。Approximate search 會犧牲 recall,而 pgvector 能讓你看出犧牲多少:使用 exact search 執行相同查詢,再比較各 id。
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;10 個 id 中有 9 個相同,表示 ef_search 沒有問題。只有 4 個相同時,就提高它。
Reranking 與生成:API 創造價值的地方
Reranker 是另一類模型。它會同時讀取問題與一個片段,為這個配對評分。這比獨立比較兩個計算出的 embedding 更準確,但速度太慢,無法套用到整個語料庫。這正是它適合在此處使用的原因。它只需處理 retrieval 傳回的 40 個候選項目,而不是資料表中的 100,000 個片段。因此,代管式 reranking API 每個問題只會針對 40 組短配對計費,並在進入昂貴階段前排除最差的誤判結果。
Generation 是持續產生費用的部分,而有兩個因素可以控制成本。減少傳送的片段數量,先使用 recall at 10 找出在不遺漏答案的情況下,最少需要傳送多少片段。讓 prompt 的前段逐 byte 保持穩定,使供應商的 prompt cache 能夠命中,並將 retrieval 取得的片段放在這個穩定部分之後。也要依問題快取已完成的答案,因為最便宜的 generated token,就是上週已經生成過的 token。
規劃伺服器規模,以及何時這樣做已經不夠
這裡的每項規模規則都應透過測量取得,而不是估算。
- RAM 是主要限制:
ollama ps所提供的常駐模型大小,加上 HNSW 索引大小與shared_buffers,並為連線和頁面快取預留足夠餘裕。 - 磁碟需要容納兩倍的
pg_total_relation_size('chunks'),因為重建索引時兩份資料會同時存在。 - CPU 決定重新建立索引所需的時間,可用實測的每個區塊秒數乘以區塊數計算。
- 重新建立索引的頻率通常高於預期,因為變更 embedding model 會使所有已儲存的向量失效。
這套設計何時會不夠,可以預先看出來。當 HNSW 索引大小超過你能購買的 RAM 時,查詢延遲會轉變為磁碟搜尋,任何設定都無法恢復效能。當一個資料表服務多個租戶,且每次查詢都依租戶進行篩選時,修正方式是將資料表分割;但這需要實際投入工作。當索引寫入與使用者查詢爭用同一台伺服器的資源時,應先將 embedding worker 移到第二台伺服器,再考慮搬移資料庫。在上述情況發生前,使用你已租用的 VPS 執行 Postgres 搭配 pgvector,仍足以支撐正式環境;上面的數字也能告訴你距離限制還有多遠。
FAQ
我可以在一台 VPS 上執行 RAG pipeline,還是需要向量資料庫?
對於數十萬個 chunk 的語料庫,一台 VPS 已經足夠。在 768 維的情況下,100,000 個 chunk 的向量資料量為 294 MiB,另外還有文字與 HNSW index;這些通常可放入一般方案的 RAM。限制在於記憶體,而不是資料列數,因為 HNSW 搜尋會在 index 中跳躍存取;當 index 無法完整放入 RAM 後,延遲就會惡化。比較 index 上的 pg_relation_size 與 free -m,即可了解目前的狀況。
我需要 GPU 來嵌入文件嗎?
如果只嵌入一次,之後再執行查詢,就不需要。像 nomic-embed-text 這類擁有 137 million parameters 的 model 可在 CPU 上執行;大型語料庫完整處理一次需要數小時,可以安排在夜間執行。當文件持續到達,或希望在同一台主機上執行 generation 時,GPU 才開始具有實際效益。在自己的伺服器上,以 /api/embed 測量一個 batch 所需的時間,再乘以 chunk 數量,因為不同 vCPU 數量的差異太大,公開數據通常沒有參考價值。
為什麼我的向量查詢使用 sequential scan,而不是 HNSW index?
使用 EXPLAIN (ANALYZE, BUFFERS) 讀取執行計畫。常見原因是儲存方式:pgvector 指出,planner 不會將 out of line storage 納入成本估算,因此 serial scan 看起來比實際情況便宜;而 768 維的向量大小為 3,080 bytes,預設會存放在 TOAST table 中。ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; 會讓新資料列保持 inline。其他兩個原因是 operator 與 index 不相符,因為使用 vector_cosine_ops 建立的 index 只會搭配 <=> 使用;以及查詢沒有 ORDER BY ... LIMIT,因為 approximate index 只處理依序排列的 nearest neighbour 查詢。
我如何知道 retrieval 的效果是否良好?
建立一組包含 30 個問題的 gold set,每個問題都配對能回答該問題的 chunk id,並將問題的 embeddings 與其一併儲存。接著測量 recall at 10,也就是正確 chunk 出現在前 10 個結果中的頻率;再測量 MRR,這項指標會獎勵將正確 chunk 排在第一位的結果。這兩個數值可以顯示 chunk 大小、embedding model 或 rank fusion 的變更是否有效。沒有這些數值時,你只是在調整設定,並根據少數幾個回答的主觀印象做判斷。