用 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 是根據哪份文件回答的。


幾個容易踩的雷

  1. 維度要對死vector(1536) 的數字必須等於你 embedding 模型的輸出維度。換模型 (例如換成 3072 維的 text-embedding-3-large) 卻忘了改欄位定義、重建索引,寫入會直接爆。這也是為什麼 embedding 要抽成可替換零件,維度寫成設定。
  2. 切塊策略決定成敗:固定長度會把一句話從中間切斷,語意就破了。進階做法是按段落或語意邊界切,讓相鄰的塊重疊幾十個字,避免答案剛好卡在切縫上。先用固定長度上線,再回頭慢慢調。
  3. 別把整個知識庫塞進 prompt:RAG 的重點就是只餵相關的那幾塊。檢索的 k 設太大,不但貴,還會稀釋掉真正相關的內容,答案反而變差。
  4. embedding 要前後一致:存的時候和查詢的時候,必須用同一個 embedding 模型。不然兩邊的向量不在同一個空間,算出來的距離沒有意義。

RAG 沒那麼玄:一張有向量欄位的表、一句 <=> 查詢、一次帶出處的生成,就是一個能查證來源的檢索問答。向量資料庫服務有它適合的情境 (超大規模、多租戶隔離),但在那之前,你手上的 Postgres 就能把 RAG 跑起來,還少維運一個東西。

想再深化,就從切塊策略和重排序 (re-ranking) 下手,那是把 RAG 從堪用做到好用的關鍵。


如果你正在把 RAG 或 AI 檢索接進公司既有的系統、或想討論知識庫該怎麼切塊跟排序,歡迎找我聊聊