~/ / AI 文章 / 在 VPS 上跑本機 LLM / 用 pgvector 在 PostgreSQL 中儲存並搜尋 Ollama 的 embedding

用 pgvector 在 PostgreSQL 中儲存並搜尋 Ollama 的 embedding

Ollama 的 /api/embed 回傳的 embedding,要先存放在某處才能用來搜尋。pgvector 是為 PostgreSQL 加上向量相似度搜尋的開源擴充功能,因此向量可以和文字及其他資料放在同一個資料庫、同一台伺服器上。本文依據 pgvector 0.8.7 版的 README,說明安裝、建立資料表、選擇距離與索引,以及會改變結果的設定。接著把搜尋結果傳給 Ollama 的 /api/chat,讓回答指出它的來源,並檢查這些引用。我們用 Ollama 產生的真實向量,在 PostgreSQL 16 上的 pgvector 0.8.7 執行了這些 SQL,並以 qwen2.5:7b 在 Ollama 0.40.11 上執行了聊天請求。本頁的四個工具會在你的瀏覽器中寫出 SQL 與請求,並檢查回答。

發布: · 約 12 分鐘讀完 · 這裡的事實已於 2026-11-05 對照 pgvector 0.8.7 版的 README 與原始碼,以及 Ollama 的文件與原始碼(0.40.11)確認過。採用前請先查看最新的文件。

pgvector 為 PostgreSQL 增加了什麼

pgvector 的 README 將它描述為 Postgres 的開源向量相似度搜尋。它支援精確與近似的最近鄰搜尋,支援單精度、半精度、二進位與稀疏向量,也支援 L2 距離、內積、餘弦距離、L1 距離、漢明距離與 Jaccard 距離。由於向量就是一般的 PostgreSQL 欄位,因此也能享有 ACID 相容性、時間點復原(point-in-time recovery)與 JOIN。

要搜尋你的文件時,每個區塊(chunk)存成一列,包含它的文字與向量;用同一個模型把問題轉成 embedding,再依各列與問題向量的距離排序。

安裝

pgvector 支援 PostgreSQL 13 及更新版本。README 列出了幾種安裝方式,包括以下這些:

方式README 的說明
從原始碼建置git clone --branch v0.8.7,接著執行 make 與 make install(可能需要 sudo)
Dockerpgvector/pgvector 映像檔是在 Postgres 映像檔上加入 pgvector。可用 pg18-trixie 或 0.8.7-pg16 這類標籤選擇 Postgres 版本。
APT(Debian、Ubuntu)從 PostgreSQL APT Repository 執行 sudo apt install postgresql-18-pgvector,其中的 18 換成你的 Postgres 版本
Homebrewbrew install pgvector,只會加到 postgresql@18 與 postgresql@17 這兩個 formula

接著,在每個要使用的資料庫中,用 CREATE EXTENSION vector; 啟用一次。本文使用的是 Docker 映像檔 pgvector/pgvector:0.8.7-pg16。

建立資料表

vector(n) 型別的欄位只能存放剛好 n 個維度的向量,而 n 必須等於你的 embedding 模型所產生向量的長度。長度不同的向量會被拒絕:我們把 2 維的向量插入 vector(3) 欄位時,PostgreSQL 回傳了 "expected 3 dimensions, not 2"。all-minilm 回傳 384 維,所以它的欄位是 vector(384)。

README 給出的每個向量儲存空間為 4 × 維度數 + 8 位元組,每個元素都是單精度浮點數,且一個欄位最多可有 16,000 個維度。索引支援的維度較少,下文會說明。halfvec 型別以 2 × 維度數 + 8 位元組儲存半精度的值。

維度數vectorhalfvec
3841,544 位元組776 位元組
7683,080 位元組1,544 位元組
10244,104 位元組2,056 位元組

在我們的測試資料表上,pg_column_size 對 vector(384) 回傳 1,544 位元組,對 halfvec(384) 回傳 776 位元組,與公式相符。這只是向量本身;文字欄位與索引還會佔用額外的空間。

載入 Ollama 的向量

pgvector 把向量當成方括號中的文字來讀取,例如 '[1,2,3]',而 README 說所有元素都必須是有限值(不可為 NaN、Infinity 或 -Infinity)。/api/embed 回應中 embeddings 欄位的每個陣列已經是這種形式,而且回應會保持輸入的順序,所以第一段文字對應第一個向量。

README 示範了少量列用 INSERT、upsert 用 INSERT … ON CONFLICT,以及大量載入用 COPY。下方的轉換工具會把你的文字與回應配對,並寫出已跳脫引號的 INSERT 陳述式。我們用含有引號、反斜線、換行與 emoji 的文字執行了它的輸出,取回的內容與原文相同。

試試看: Embeddings to pgvector INSERT

範例使用虛構、長度為 1 的 4 維向量,方便閱讀。真實的 embedding 有數百或數千個維度。

3 列,4 維。文字讀為 /api/embed 的請求內容,向量讀為 Ollama /api/embed 的回應。

INSERT 陳述式

在 psql 或其他用戶端中執行。這些列會在同一個交易中插入。

BEGIN;

INSERT INTO documents (content, embedding) VALUES
  ('Embeddings turn text into numeric vectors.', '[0.620186,0.410123,0.550165,0.380114]'),
  ('pgvector stores vectors in PostgreSQL, next to the rest of your data.', '[0.578815,0.469039,0.518937,0.419142]'),
  ('Ollama''s /api/embed returns one vector per input, in the same order.', '[0.119982,0.809879,-0.329951,0.46993]');

COMMIT;

向量格式取自 pgvector README,於 2026-11-04 對照 pgvector 0.8.7 確認。 pgvector README

一切都在你的瀏覽器中完成。這個工具只會寫出 SQL,不會連線到任何地方。

選擇距離

Ollama 的文件建議在大多數語意搜尋的情境使用餘弦相似度,並說 /api/embed 回傳的向量長度為 1。對這樣的向量而言,餘弦距離、L2 距離與內積會把各列排成相同的順序,因為 L2 距離的平方等於 2 減去 2 倍的餘弦相似度。在我們用 all-minilm 做的測試中,三種方式的前三列都相同,而 L2 距離 0.931 與餘弦相似度 0.566 相符。

每種距離都有自己的運算子。搜尋時依該運算子排序,再用 LIMIT 取前幾列;索引則是針對某一個運算子類別(operator class)建立。

運算子回傳索引的運算子類別
<=>餘弦距離。餘弦相似度為 1 - (a <=> b)。vector_cosine_ops
<->L2(歐幾里得)距離vector_l2_ops
<#>內積的負值。乘以 -1 即為內積。vector_ip_ops
<+>L1 距離vector_l1_ops

README 解釋,<#> 回傳內積的負值,是因為 Postgres 只支援運算子上以 ASC 順序進行的索引掃描。對 halfvec 而言,運算子類別是 halfvec_cosine_ops、halfvec_l2_ops 等。

精確搜尋或使用索引

沒有索引時,pgvector 會進行精確的最近鄰搜尋,README 說這能提供完美的召回率(recall)。近似索引以犧牲一些召回率換取速度,README 並警告,加入索引後查詢可能回傳不同的結果。

只有當查詢含有 ORDER BY 與 LIMIT,而且 ORDER BY 本身就是以升冪排列的距離運算子時,才會使用索引。ORDER BY embedding <=> $1 LIMIT 5 可以使用索引;ORDER BY 1 - (embedding <=> $1) DESC 則不行。README 也提到,資料表很小時,全表掃描可能比較快。

項目HNSWIVFFlat
運作方式多層圖結構把向量分成多個 list,並搜尋最接近查詢的那些 list
與另一種相比速度與召回率的取捨較好,但建立較慢、使用較多記憶體建立較快、使用較少記憶體,但速度與召回率的取捨較差
何時建立可以在空的資料表上建立(沒有訓練步驟)資料表中已有一些資料之後
建立選項m(預設為 16)與 ef_construction(預設為 64)lists:100 萬列以內從 rows / 1000 開始,超過則用 sqrt(rows)
搜尋選項hnsw.ef_search,預設為 40ivfflat.probes,預設為 1;從 sqrt(lists) 開始
維度數vector 最多 2,000,halfvec 最多 4,000vector 最多 2,000,halfvec 最多 4,000

在 pgvector 0.8.7 上,我們也確認了 vector(2001) 欄位無法建立 HNSW 索引、IVFFlat 沒有 L1 距離的運算子類別,以及 ef_construction 必須至少為 2 × m。若需要更多維度,README 的 FAQ 列出了 halfvec、二進位量化與其他選項。

試試看: pgvector SQL Builder

每個向量佔用 1,544 位元組,所以 10,000 列光是向量就約佔 15.4 MB(不含索引與其他欄位)。

1. 擴充功能與資料表

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id bigserial PRIMARY KEY,
  content text NOT NULL,
  embedding vector(384) NOT NULL
);

2. 索引

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

3. 搜尋

PREPARE 會把來自 /api/embed 的問題向量當成 $1。請以文字傳入,例如 '[0.01,-0.02,…]'。

PREPARE search_documents(vector(384)) AS
  SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
  FROM documents
  ORDER BY embedding <=> $1
  LIMIT 5;

-- EXECUTE search_documents('[0.0123, -0.0456, ...]');

限制與預設值取自 pgvector 0.8.7 的 README 與原始碼,於 2026-11-04 確認。 pgvector README

一切都在你的瀏覽器中完成。這個工具只會寫出 SQL,不會連線到任何地方。

搜尋為何可能回傳比 LIMIT 更少的列

使用 HNSW 時,結果數量受 hnsw.ef_search 限制,預設為 40。在一個有 200 列、建了 HNSW 索引的測試資料表上,LIMIT 100 回傳了 40 列;執行 SET hnsw.ef_search = 100 之後,則回傳 100 列。請把 hnsw.ef_search 設為至少等於你需要的列數。在交易中使用 SET LOCAL,只會改變單一查詢。

篩選也會減少結果。README 說,使用近似索引時,WHERE 條件是在掃描索引之後才套用:如果某個條件符合 10% 的列,預設 hnsw.ef_search 為 40 的 HNSW 平均只會回傳 4 列。自 0.8.0 版起,迭代式索引掃描(SET hnsw.iterative_scan = strict_order,或 relaxed_order)會掃描更多索引,直到找到足夠的列,上限為 hnsw.max_scan_tuples(預設 20,000)。

README 還補充,NULL 向量不會被建立索引,使用餘弦距離時零向量也不會。

把結果以編號的來源傳給 /api/chat

搜尋回傳最接近的列之後,模型需要在提示詞中看到它們。Ollama 的 /api/chat 接收一份訊息清單,每則訊息都有 role(system、user、assistant 或 tool)與 content。簡單的做法是:一則 system 訊息放規則,一則 user 訊息把各列依序列出並各加上編號,例如 [1] 與 [2],最後接上問題。有了編號,回答就能指出用了哪幾列,你也能把每個編號對應回原本的列。

若要讓引用有固定的格式,可以在 format 欄位中給一個 JSON Schema,內含回答與引用清單,每筆引用有來源編號與一小段引文。Ollama 的結構化輸出頁面建議也把 schema 寫進提示詞,並調低 temperature(例如設為 0),以得到較具確定性的輸出。在 psql 中,json_agg 函式會把查詢的列收集成一個 JSON 陣列,下方的工具可以直接讀取。

試試看: RAG Prompt Builder

範例的來源是依 json_agg 回傳的形式手寫的。

3 個來源,視為物件的 JSON 陣列讀取。

curl 指令

把請求送到這台機器上的 Ollama(localhost:11434)。

curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}'

請求本文(JSON)

{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}

在回應中,回答位於 message.content。如果 prompt_eval_count 比你預期的低很多,提示詞可能已被截斷。

請求欄位取自 Ollama 的 API 參考文件與原始碼,已於 2026-11-05 在 Ollama 0.40.11 上確認。 Ollama API: /api/chat

一切都在你的瀏覽器中完成。本工具只寫出請求,不連線到任何地方。

來源放不下時,Ollama 會截掉提示詞的開頭

規則、各列與問題都必須放得進上下文長度。Ollama 的上下文長度頁面指出,VRAM 少於 24 GiB 時預設為 4k 個 token,而它的 FAQ 寫的是 4096 個 token;在我們的測試機器上,ollama ps 顯示的是 4096。可以用 options 中的 num_ctx 或環境變數 OLLAMA_CONTEXT_LENGTH 來改變。

提示詞更長時,/api/chat 預設不會回傳錯誤。在 Ollama 的原始碼(0.40.11)中,伺服器會先整則丟棄較早的訊息,同時保留 system 訊息與最後一則訊息。如果剩下的提示詞仍然太長,就保留開頭的幾個 token(num_keep,預設為 4)與提示詞的結尾,並丟棄中間的部分。在我們用 qwen2.5:7b 與 num_ctx 1024 做的測試中,2,068 個 token 的提示詞被截成 514 個。system 訊息與最前面的幾個來源都不見了:答案在來源 [1] 時,模型回覆了一個不在任何來源中的暗號;答案在最後一個來源時,它答對了,卻沒有加上 system 訊息要求的來源編號。回應中沒有錯誤;伺服器日誌裡有警告 "truncating input prompt"。

Ollama 的原始碼中,聊天請求還有一個 truncate 欄位,但 /api/chat 頁面沒有列出。加上 "truncate": false 後,同一個請求回傳了 HTTP 400 與錯誤 "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it"。回應中的 prompt_eval_count 欄位是提示詞的 token 數;在被截斷的測試中是 514,而不是 2,068。

由於提示詞的結尾會被保留,請把問題放在來源之後。想知道來源何時放不下,可以送出 "truncate": false,然後調高 num_ctx 或送出較少的列。較大的 num_ctx 需要更多記憶體。

檢查引用

引用可以用機械的方式檢查:編號必須指向有送出的來源,而引文應該出現在那個來源中。在我們用四個來源與 qwen2.5:7b 做的測試中,JSON 回答裡的引文與來源逐字相符。但這不能證明回答正確:被問到 384 維向量的大小時,模型引用了正確的規則(4 × 維度數 + 8 位元組),卻仍然回答 1552 位元組,而不是 1,544。

沒有引用的回答也要看。來源中沒有答案時,模型有一次用自己的知識回答、沒有任何引用,另一次則說不知道,卻仍引用了一個無關的來源。如果回應中的 done_reason 是 "length",表示回答被 num_predict 截斷,可能少了引用。

下方的檢查工具只比對文字。來源是否真的支持某個句子,仍須由人來判斷。

試試看: RAG Citation Checker

範例回答是手寫的,用來呈現一段不在其來源中的引文。

4 個來源(字串的 JSON 陣列)。回答視為Ollama /api/chat 的回應讀取,形式為含引用的 JSON。

提示詞的 token 數(prompt_eval_count):306

  • 引用 #2 的引文不在來源 [4] 中。

引用

來源引文結果
[2]hnsw.ef_search is 40 by default原文相符
[4]4 bytes per dimension plus 8不在此來源中

這只檢查編號與引文是否與來源相符。它無法判斷回答是否正確,也無法判斷來源是否真的支持某個句子。

回應格式取自 Ollama 的 API 參考文件與原始碼,已於 2026-11-05 在 Ollama 0.40.11 上確認。 Ollama API: /api/chat

一切都在你的瀏覽器中完成。本工具不連線到任何地方。

小型伺服器上的記憶體

README 說,當圖結構能放進 maintenance_work_mem 時,HNSW 索引的建立會顯著加快;放不下時會顯示通知,並警告不要把 maintenance_work_mem 設得太高而耗盡伺服器的記憶體。和其他索引一樣,在載入初始資料之後再建立索引比較快。索引不一定要放得進記憶體,但放得進去的話效能可能比較好;SELECT pg_size_pretty(pg_relation_size('index_name')) 可以顯示索引的大小。

若要縮小工作集與索引,README 建議使用 halfvec、半精度索引或二進位量化。如果 Ollama 與 PostgreSQL 在同一台伺服器上執行,PostgreSQL 與模型會共用同一份記憶體;我們另一篇關於觀察 Ollama 記憶體的文章,說明了是什麼決定模型的用量。

來源

接著閱讀