在 VPS 上執行向量資料庫:RAM 與效能比較
應用程式與索引在同一台 VPS,網路延遲幾乎不是問題。比較 pgvector、Qdrant、Chroma 與暴力搜尋,並依向量數量與維度正確估算 RAM。
在 VPS 上執行向量資料庫的實際成本
在 VPS 上執行向量資料庫,可以消除各家託管服務商試圖解決的問題。應用程式與索引位於同一台機器,因此搜尋請求會通過 loopback socket,而不是經過網路。剩下的是原本真正的成本:將文字轉換為向量。除此之外,還有兩項成本:建立索引所需的時間,以及服務期間持續由索引佔用的 RAM。
這會改變需要關注的決策。你不再需要處理區域與 endpoint round trip。現在需要關注的是向量數量、維度與 4 bytes 的乘積,因為這決定索引是否能容納在你每月租用的記憶體中。
單台伺服器上的毫秒實際耗用位置
追蹤一次相似度查詢在自架技術堆疊中的完整流程。
- 嵌入模型會將查詢文字轉換為向量。在 CPU 上,短字串通常需要數十至數百毫秒。在 GPU 上則只需個位數毫秒。
- 向量會傳送至儲存庫。透過 loopback TCP 或 Unix domain socket 傳送時,只需不到 1 毫秒。
- 儲存庫會掃描索引,並回傳最近的資料列。
- 程式會讀取相符文字,組合成 prompt。
上述步驟中,步驟 1 通常耗時最長。託管服務供應商競爭的主要是步驟 2,但在單台伺服器上,這部分的耗時幾乎不存在。不要猜測各步驟的比例,請在自己的伺服器上測量兩端的耗時。
curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
-w 'embed: %{time_total}s\n' \
-d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'接著在搜尋查詢前,於 psql 中執行 \timing on。如果第一個指令輸出 embed: 0.184312s,而 psql 回應 Time: 4.201 ms,表示調整索引並不是正確的工作:延遲來自嵌入模型。使用 Ollama 在本機執行嵌入模型會將步驟 1 放在與步驟 2 和 3 相同的 CPU 上,因此兩個部分會競用相同的核心與 RAM。此儲存庫外圍的擷取與檢索迴圈,請參閱自架 RAG 管線指南。RAG 是 retrieval augmented generation 的縮寫,指搜尋自己的文件,並將最符合的內容貼入 prompt。
約 100,000 個向量以下,直接掃描全部向量
完整掃描會將查詢向量與每個已儲存向量進行比較。依定義,召回率是完美的。不需要索引或建置步驟,也不會因資料變更而產生過時的索引。
從運算量即可判斷這種方式何時不再適用。每次查詢都會讀取 n * d * 4 位元組,其中 n 是向量數量,d 是維度。在 100,000 個 768 維向量的情況下,每次查詢會讀取 307 MB;現代 CPU 可在幾十毫秒內串流處理。若有 5 million 個向量,每次查詢就要讀取 15 GB,這已經不能算是一般查詢。
因此,將向量儲存在 SQLite 中,並使用 NumPy 進行比較。
import sqlite3, numpy as np
db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")
def add(body, vec):
v = np.asarray(vec, dtype=np.float32)
v /= np.linalg.norm(v)
db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
db.commit()
def search(query_vec, k=5):
rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
q = np.asarray(query_vec, dtype=np.float32)
q /= np.linalg.norm(q)
scores = mat @ q
return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]兩邊都會縮放為單位長度,因此點積就是 cosine similarity,分數越高代表匹配程度越高。在啟動時載入一次 mat,而不是每次查詢都載入,這樣 SQLite 的讀取就能完全移出熱路徑。
先在自己的主機上測量,再決定是否排除這種做法。
import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")必須正視的限制是:這需要由單一程序將完整矩陣保留在 RAM 中,也不支援中繼資料篩選,且沒有並行寫入的處理方案。當其中任何一項成為問題時,就應改用其他方案。SQLite 本身是成熟的伺服器端儲存元件,SQLite production guide 有詳細說明;如果你的實際工作負載是掃描欄位,而不是提供資料列,那麼 DuckDB 和 SQLite 的比較 會更有參考價值。
已執行 Postgres 時使用 pgvector
如果應用程式已經使用 Postgres 資料庫,pgvector 新增的管理面最少。它是 extension,不是獨立服務。向量會儲存在描述該向量的資料列旁邊之一般資料表中,因此篩選搜尋只需使用 WHERE 子句,不必維護第二套需要保持同步的系統。
Ubuntu 24.04 已在 universe component 中提供此套件。
sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'截至 2026 年 8 月,該套件的版本為 pgvector 0.6.0,明顯落後於 upstream。尤其是 iterative index scans 需要 0.8,因此請改從 PostgreSQL 專案自己的 repository 取得。
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17-pgvector將 17 替換成伺服器的 major version;sudo -u postgres psql -tAc 'SHOW server_version' 會輸出該版本。若安裝了為錯誤 major version 建置的 extension package,CREATE EXTENSION 會失敗,因為 Postgres 只會在目前執行版本的 share directory 中尋找:
ERROR: could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directorySchema 是一般 SQL,只新增一種型別。
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
body text NOT NULL,
embedding vector(768)
);
SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;<=> 是 cosine distance,<-> 是 L2(Euclidean)distance,而 <#> 是 negative inner product。請使用 embedding model 訓練時所採用的距離。若選錯,系統不會回報錯誤,但結果只會在沒有明顯提示的情況下變差。
沒有 index 時,該查詢會逐列對所有資料列執行精確搜尋。這是 Postgres 版本的上述 brute force,並具有同樣完美的 recall。提高 max_parallel_workers_per_gather 可讓更多核心處理查詢。請先執行這項設定,再建立 index,因為這樣才能先取得 recall baseline,供後續評估 index。
Qdrant:索引規模超過資料庫所適用的情況
Qdrant 是以 Rust 撰寫的專用向量資料庫。當索引規模大到不希望建置作業與應用程式的 Postgres 爭用資源時,或需要 pgvector 未提供的 payload filtering 與 quantisation 時,Qdrant 就有使用價值。
docker run -d --name qdrant \
-p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
-e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
-v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
qdrant/qdrant6333 埠提供 REST(representational state transfer)API,以及位於 /dashboard 的儀表板;6334 埠提供 gRPC。公開 VPS 有兩項細節必須注意。Qdrant 官方文件指出,服務預設「不啟用加密或驗證」,而快速入門中的 -p 6333:6333 會繫結至所有介面。Docker 會自行寫入轉送規則,因此即使設定了 ufw 規則,該連接埠仍可能被公開。請繫結至 127.0.0.1,並設定 API key。若 Qdrant instance 可透過公開 IP 存取且未設定 key,等同於將文件公開提供給任何人。
curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"正常回應應如 {"result":{"collections":[]},"status":"ok","time":0.00002}。取得 {"status":{"error":"Unauthorized"}} 表示標頭名稱或 key 錯誤;完全沒有回應則表示 container 未執行,或繫結在其他位置。該 container 的配置是否適合目前的主機,屬於一般的 stateful service 判斷,因此Docker 與主機資料庫的比較同樣適用於此處。
Chroma 及其用途
Chroma 是從零開始建立可運作 retrieval 示範的最快途徑。
pip install chromadb
chroma run --path /srv/chroma該服務會監聽 8000 埠,而 chromadb.HttpClient(host="localhost", port=8000) 會連線至此。Chroma 提供預設的 embedding function,因此第一個 prototype 完全不需要額外的 model server。
必須誠實看待這項取捨。Chroma 之所以使用方便,是因為它隱藏了本指南要處理的決策:採用哪種 distance metric,以及結果會佔用多少 RAM。這對 prototype 而言是正確做法,但對你需要處理告警的正式系統則不適用。如果資料原本已經存放在 Postgres,將資料移至 Chroma 會增加一個 process 和一個 synchronization 問題,目的是解決 pgvector 根本沒有的問題。
索引需要多少 RAM
從原始向量開始計算,因為這是下限,任何調校都無法降低這個需求。
bytes = number_of_vectors * dimensions * 4每個維度使用 4 bytes,代表一個 32-bit 浮點數。Qdrant 的容量規劃文件會再乘以 1.5,納入 metadata 及最佳化期間建立的暫存 segment:
memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5以下公式套用在一百萬個向量,以及實際 embedding model 會產生的維度:
The data behind this chart
[
{
"label": "384 dims",
"raw_gib": 1.43,
"with_overhead_gib": 2.15
},
{
"label": "768 dims",
"raw_gib": 2.86,
"with_overhead_gib": 4.29
},
{
"label": "1024 dims",
"raw_gib": 3.81,
"with_overhead_gib": 5.72
},
{
"label": "1536 dims",
"raw_gib": 5.72,
"with_overhead_gib": 8.58
},
{
"label": "3072 dims",
"raw_gib": 11.44,
"with_overhead_gib": 17.17
}
]這些是公式計算出的 gibibyte (GiB),不是實測值。請將它們視為必須預留的記憶體空間。以一百萬個 chunk 計算,768 維的 model(例如 nomic-embed-text)約需要 4.29 GiB,使用 8 GB 方案後仍有空間留給 Postgres。同一批資料若使用 3072 維,則需要 17.17 GiB,無法容納。
真正的調整槓桿是圖表的第一欄,而不是最後一欄。維度減半,後續每個環節的 bytes 都會永久減半。768 維 model 即使在公開 leaderboard 上的評分稍低,通常仍是在 VPS 上較好的工程選擇。接著,pgvector 的 halfvec type 會儲存 16-bit 浮點數,讓 bytes 再減半,並透過 expression 建立索引:
CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);選擇 model 前,還有一項限制需要注意。pgvector 的 vector type 最多接受 16,000 個維度,但其 HNSW 和 IVFFlat 索引最多只能涵蓋 2,000 個維度。超過這個上限時,可以將資料轉型為 halfvec,上限會提高到 4,000 個維度;否則就無法建立索引。
建置期間 m 與 ef_construction 的成本
HNSW(hierarchical navigable small world)是 pgvector 和 Qdrant 都會採用的索引。它是一種分層圖形結構。每個向量都是一個節點,並連結至鄰近節點。搜尋時會沿著這些連結逐步接近查詢,而不是讀取所有資料。
m 表示每個節點保留的連結數。Faiss 文件將 HNSW 記憶體用量表示為每個向量 (d * 4 + m * 2 * 4) bytes,並建議將 m 設定在 4 到 64 之間。以下以 768 維、1000000 個向量計算。
The data behind this chart
[
{
"label": "m = 8",
"link_bytes_per_vector": 64,
"total_gib": 2.92
},
{
"label": "m = 16 (default)",
"link_bytes_per_vector": 128,
"total_gib": 2.98
},
{
"label": "m = 32",
"link_bytes_per_vector": 256,
"total_gib": 3.1
},
{
"label": "m = 64",
"link_bytes_per_vector": 512,
"total_gib": 3.34
}
]這項差異的實際大小值得注意。將預設值 m = 16 提高到 m = 64,每個向量會增加 512 bytes 的連結資料;相對於 3072 bytes 的向量資料,總用量會從 2.98 GiB 增加到 3.34 GiB。增幅約為 12%。在這個維度下,m 並不是主要的記憶體用量。向量才是。
m 真正增加的是建置時間與插入時間,因為加入節點時,系統必須尋找並連結指定數量的鄰近節點。ef_construction 是建置器放置每個節點時考慮的候選清單大小。提高這個值會產生品質較好的圖形,但建置速度會變慢,而且完全不會改變完成後的索引大小。
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);maintenance_work_mem 決定建置需要幾分鐘或幾小時,因為 pgvector 會在記憶體足夠時於記憶體中組建圖形。記憶體不足時,pgvector 會顯示:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.
HINT: Increase maintenance_work_mem to speed up builds.這則訊息是 pgvector 輸出的最重要內容。它表示建置已切換到速度慢得多的處理路徑。請取消建置,將設定值提高到超過上述計算的 RAM 數值,再重新開始。可從第二個工作階段監看進度:
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;HNSW 會回報 initializing,接著回報 loading tuples。如果建置長時間停留在低百分比,而磁碟並不忙碌,問題通常是 maintenance_work_mem,不是查詢卡住。
有兩項事實值得納入規劃。pgvector 的 README 表示,HNSW 相較於 IVFFlat「建置時間較長且使用較多記憶體」,但可換取更好的速度與 recall 取捨。此外,HNSW 可以在空資料表上建立;IVFFlat 則必須先對代表性資料執行 k-means,因此在空資料表上建立會導致 recall 不佳。對於全新的 schema,HNSW 是可以預先建立的選擇。
ef_search:建立索引後調整的參數
m 和 ef_construction 會固定在索引中。ef_search 不會。它決定搜尋遍歷圖形時保留的候選數量,您可以針對每個工作階段或查詢個別調整。
SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;預設值為 40。提高此值會提升召回率,但延遲也會增加。降低此值則兩者都會下降。這是唯一不必重建索引即可調整的召回率控制項。因此,請使用一組已知正確答案的固定查詢進行測試,並在召回率不再改善時停止調整。
有一個陷阱。ef_search 與選擇性高的 WHERE 子句互動不佳,因為索引會傳回固定數量的候選項目,之後才套用篩選條件。如果篩選條件排除大多數資料列,即使資料表中仍有符合條件的資料列,結果數量也可能少於 LIMIT。pgvector 0.8 透過反覆掃描提供解決方案:
SET hnsw.iterative_scan = relaxed_order;接著,索引會重新掃描以取得更多候選項目,直到滿足限制為止,最多可達 hnsw.max_scan_tuples;其預設值為 20000。strict_order 會維持精確的距離排序,但需要更多資源。Ubuntu 0.6.0 套件不具備這項功能;套用篩選條件時出現資料列遺漏,就是判斷缺少此功能的方式。
索引為何必須容納在 RAM 中
HNSW 搜尋是在圖形上逐步走訪。每一跳都會讀取與前一個節點位置無關的節點,因此存取模式接近隨機讀取,預讀無法提供幫助。圖形位於 RAM 時,每一跳都是一次記憶體參照。圖形無法容納在 RAM 中後,每一跳都可能變成一次磁碟讀取,而搜尋若接觸幾百個節點,就會產生幾百次讀取。
Qdrant 的文件將這項關係具體化:「如果 RAM 中儲存的向量數量減半,搜尋延遲大致會加倍。」請根據這句話進行規劃。
當索引確實無法容納在 RAM 中時,每個選項都是取捨,應明確選擇。
- 將向量 memory-map,讓作業系統快取常用頁面,並將不常用頁面留在磁碟上。底層需要使用快速 NVMe(non-volatile memory express)儲存裝置,才能維持可接受的效能。
- 進行量化,將每個維度從 4 bytes 儲存為 1 byte。這會使向量所需的 bytes 減少四分之三,但召回率會有小幅且可測量的損失。
- 在 pgvector 中轉型為
halfvec,可將 bytes 減半,且召回率損失低於使用 1-byte 量化。 - 使用較小的模型產生 embedding。這是成本最低的修正方式,也是最常被跳過的方式,因為必須重新為整個語料庫產生 embedding。
失敗模式與你會看到的字串
could not open extension control file。 執行中 Postgres 主要版本所需的 pgvector 套件尚未安裝。使用 sudo -u postgres psql -tAc 'SHOW server_version' 輸出版本,並安裝相符的 postgresql-NN-pgvector。
ERROR: expected 768 dimensions, not 1536。 欄位類型與模型不一致。你更換了 embedding 模型,但未重新產生 embedding。這個問題無法部分修正,因為不同模型產生的向量完全不可比較,因此必須重新產生每一列資料。
查詢速度很慢,而且 EXPLAIN 顯示 sequential scan。 索引的 operator class 與查詢使用的 operator 不一致。vector_cosine_ops 只支援 <=>。對查詢執行 EXPLAIN ANALYZE,並尋找 Index Scan using ... on chunks。如果改看到 Seq Scan on chunks,請使用與實際查詢 operator 相符的 operator class 重建索引。
資料列少於 LIMIT,而且存在 WHERE 子句。 這就是前述的篩選陷阱。提高 hnsw.ef_search,或升級至 pgvector 0.8 並設定 hnsw.iterative_scan。
索引建立結束時程序消失,且 psql 中沒有錯誤。 maintenance_work_mem 設定為機器大部分的記憶體,而 shared_buffers 與應用程式也需要記憶體,最後會觸發 kernel 的 out-of-memory killer。sudo dmesg -T | grep -i 'killed process' 會顯示指出 postgres 的那一行。降低該設定,或在更大規格的方案上建立索引,再還原 dump。
選擇方案
如果你已經在使用 Postgres,且向量數量少於幾百萬筆,請使用 pgvector。索引與資料放在一起,篩選條件就是一個 WHERE 子句,現有的備份也已涵蓋索引內容。如果索引規模大到需要獨立的記憶體上限,或需要大量依據 payload 進行篩選,請在旁邊執行 Qdrant,但要接受多管理一項服務。
向量數量低於約 100000 筆時,先測量暴力掃描的效能,再安裝任何元件。在這個規模下,具備完美 recall 且不需要建置步驟的完整搜尋不是妥協,而是正確選擇。改用近似索引,代表必須承擔調校與 RAM 壓力,換取原本並未花費的幾毫秒。
FAQ
我需要專用的向量資料庫,還是 Postgres 就夠了?
如果資料已經儲存在 Postgres,pgvector 的適用時間通常比多數比較文章所說的更長。它會將向量儲存在一般欄位中,因此篩選式搜尋就是一個 WHERE 子句,現有的備份也涵蓋索引。當向量工作負載需要獨立的記憶體上限,或需要 pgvector 不提供的 payload 篩選與量化功能時,再改用 Qdrant 等專用儲存系統。
一台 VPS 可以容納多少向量?
請使用 number_of_vectors * dimensions * 4 bytes * 1.5 計算,不要直接猜測。1000000 個 768 維向量約占 4.3 GiB,因此 8 GB 方案還能為 Postgres 保留一些空間。1000000 個 3072 維向量約占 17 GiB,需要大得多的方案。影響這個數字最大的因素是 embedding model 的維度,因此選擇模型時要將記憶體成本納入考量。
為什麼索引與我在同一台機器上,向量搜尋仍然很慢?
在同一台機器上,網路不是問題,因此請檢查真正相關的兩項因素。第一,單獨測量 embedding 呼叫的耗時,因為在 CPU 上產生查詢向量通常比搜尋本身久得多。第二,確認索引是否位於 RAM 中。HNSW 搜尋會在圖形中隨機跳轉,因此圖形溢出到磁碟後,每次跳轉都可能變成一次磁碟讀取;Qdrant 官方建議指出,RAM 中可容納的向量數量減半時,搜尋延遲大致會加倍。
我應該建立 HNSW 索引嗎?
如果向量數量未達約 100000 個,通常不需要。完整掃描每次查詢會讀取 n * d * 4 個位元組;對於 100000 個 768 維向量,這是 307 MB。現代 CPU 可在數十毫秒內串流讀取,並提供完美召回率,且不需要建立索引。請先在自己的硬體上測量掃描時間。只有在實測掃描時間確實過慢時才建立索引,不要只因為基準測試文章如此建議就建立索引。
提高 m 實際上會增加哪些成本?
主要增加的是建立時間與插入時間,而不是記憶體用量。在 768 維的情況下,將預設值 m = 16 提高至 m = 64,每個向量會增加 512 bytes 的圖形連結;相較於 3072 bytes 的向量資料,總記憶體用量約增加 12%。不過,每次插入都必須尋找並連結 4 倍數量的鄰居。請先調整 ef_search,因為修改它不會增加成本,也不需要重新建立索引。