用 Postgres + pgvector 做一個會標出處的 RAG
RAG (檢索增強生成) 聽起來很進階,但拆開來就三件事:把文件切塊存起來、依問題找出相關的塊、把塊餵給模型生成答案。
市面上有不少向量資料庫服務,但如果你的資料量還沒到上千萬筆,一個你已經在用的 Postgres 加上 pgvector 就夠了,還能少維運一個外部服務。
這篇帶你從零做一個能跑、而且答案會標出處的 RAG:用 Postgres 存、用 Claude 生成、引用直接對回原文。
為什麼「找相關的塊」不能用 LIKE
傳統的 WHERE content LIKE '%關鍵字%' 是字面比對:使用者問「怎麼退款」,但文件裡寫的是「退費流程」,LIKE 就找不到。
向量檢索比的是語意:把每段文字轉成一串數字 (embedding),語意相近的向量在空間裡就靠得近。「退款」和「退費」的向量會很接近,即使一個字都不一樣。pgvector 就是讓 Postgres 能存這種向量、還能算距離。
步驟一:一張表加一個擴充
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
source text NOT NULL, -- 出處 (檔名或 URL),之後拿來標引用
content text NOT NULL, -- 這一塊的原文
embedding vector(1536) -- 向量,維度看你用的 embedding 模型
);
-- 給向量欄位建索引,查詢才快 (cosine 距離)
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
vector(1536) 的 1536,是 OpenAI text-embedding-3-small 的維度。換 embedding 模型,這個數字就要跟著改,這是第一個容易踩的雷,後面再談。
步驟二:切塊、轉向量、存進去
先把 embedding 包成一個可替換的函式,延續上一篇的抽象做法。Anthropic 官方沒有 embedding 服務,這一步一定得外接。這裡拿 OpenAI 當例子:
// embedding 是可替換的零件,這裡拿 OpenAI 當例子
import OpenAI from "openai";
const openai = new OpenAI();
async function embed(text: string): Promise<number[]> {
const resp = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
return resp.data[0].embedding;
}
切塊加寫入,切塊策略先用最簡單的固定長度,夠用:
import { Client } from "pg";
function chunkText(text: string, size = 500): string[] {
const chunks: string[] = [];
for (let i = 0; i < text.length; i += size) {
chunks.push(text.slice(i, i + size));
}
return chunks;
}
async function ingest(source: string, text: string, db: Client): Promise<void> {
for (const chunk of chunkText(text)) {
const vec = await embed(chunk);
await db.query(
"INSERT INTO chunks (source, content, embedding) VALUES ($1, $2, $3)",
[source, chunk, `[${vec.join(",")}]`],
);
}
}
pgvector 吃的向量格式就是 [0.1,0.2,...] 這樣的字串,把陣列 join 起來丟進去就好。
步驟三:依問題找出最相關的塊
把問題也轉成向量,用 pgvector 的 <=> (cosine 距離) 排序,取最近的幾塊:
async function retrieve(
question: string,
db: Client,
k = 4,
): Promise<{ source: string; content: string }[]> {
const qVec = await embed(question);
const { rows } = await db.query(
`SELECT source, content
FROM chunks
ORDER BY embedding <=> $1::vector
LIMIT $2`,
[`[${qVec.join(",")}]`, k],
);
return rows;
}
<=> 是 pgvector 的 cosine 距離運算子,越小越相近。就這麼一句查詢,不用另外架一個服務。
步驟四:帶出處生成答案
這是重點。把檢索到的塊當成 Claude 的 document,開啟 citations,Claude 回答時就會標明哪句話來自哪一塊:
import Anthropic from "@anthropic-ai/sdk";
async function answer(
question: string,
chunks: { source: string; content: string }[],
client: Anthropic,
): Promise<string> {
// 每一塊當成一個可被引用的 document
const documents: Anthropic.DocumentBlockParam[] = chunks.map(
({ source, content }) => ({
type: "document",
source: { type: "text", media_type: "text/plain", data: content },
title: source,
citations: { enabled: true },
}),
);
const response = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [
{ role: "user", content: [...documents, { type: "text", text: question }] },
],
});
// 開了 citations 後,回應會被切成多個 text block,
// 有引用的 block 會帶 citations,指回是哪個 document
const parts: string[] = [];
for (const block of response.content) {
if (block.type !== "text") continue;
parts.push(block.text);
for (const c of block.citations ?? []) {
parts.push(`[出處:${c.document_title}]`);
}
}
return parts.join("");
}
串起來:
const db = new Client({ connectionString: "postgresql://..." });
await db.connect();
const claude = new Anthropic();
const chunks = await retrieve("怎麼退款?", db);
console.log(await answer("怎麼退款?", chunks, claude));
// → 「退款需在 7 天內申請…[出處:退費政策.md]」
答案不只生成出來,還指得回原文。這在企業應用裡很關鍵,因為使用者 (還有法遵) 要能查證 AI 是根據哪份文件回答的。
幾個容易踩的雷
- 維度要對死:
vector(1536)的數字必須等於你 embedding 模型的輸出維度。換模型 (例如換成 3072 維的text-embedding-3-large) 卻忘了改欄位定義、重建索引,寫入會直接爆。這也是為什麼 embedding 要抽成可替換零件,維度寫成設定。 - 切塊策略決定成敗:固定長度會把一句話從中間切斷,語意就破了。進階做法是按段落或語意邊界切,讓相鄰的塊重疊幾十個字,避免答案剛好卡在切縫上。先用固定長度上線,再回頭慢慢調。
- 別把整個知識庫塞進 prompt:RAG 的重點就是只餵相關的那幾塊。檢索的
k設太大,不但貴,還會稀釋掉真正相關的內容,答案反而變差。 - embedding 要前後一致:存的時候和查詢的時候,必須用同一個 embedding 模型。不然兩邊的向量不在同一個空間,算出來的距離沒有意義。
RAG 沒那麼玄:一張有向量欄位的表、一句 <=> 查詢、一次帶出處的生成,就是一個能查證來源的檢索問答。向量資料庫服務有它適合的情境 (超大規模、多租戶隔離),但在那之前,你手上的 Postgres 就能把 RAG 跑起來,還少維運一個東西。
想再深化,就從切塊策略和重排序 (re-ranking) 下手,那是把 RAG 從堪用做到好用的關鍵。
如果你正在把 RAG 或 AI 檢索接進公司既有的系統、或想討論知識庫該怎麼切塊跟排序,歡迎找我聊聊。