向量搜尋 (Vector Search) 與嵌入技術 (Embeddings) 淺談:通過 Postgres (PGVector) 實作的深入解析

學習如何使用 pgvector 在 PostgreSQL 實現向量搜索。本文涵蓋 Embedding 原理、餘弦相似度與歐幾里德距離、IVFFlat 與 HNSW 索引比較、向量資料庫選型建議,並提供完整的 Bun/TypeScript 實作範例,適合開發者快速上手 AI 驅動的語義搜索。

CChia1104
文章18 分鐘閱讀

vector search

什麼是向量搜索?

向量搜索(Vector Search)本質上是在多維空間中尋找相似點的過程。它將複雜的資料(文字、圖片、音訊)轉換成數學向量,透過計算向量間的距離來判斷相似度。

核心概念:Embedding(嵌入向量)

Embedding 是經過正規化的單位向量,通常由 AI 模型生成,每個數值介於 -11 之間。例如,OpenAI 的 text-embedding-3-small 模型會將一段文字轉換成 1536 維的向量陣列。這些向量能捕捉語義資訊,使得「台北是首都」和「台灣的行政中心」在向量空間中距離很近。

通常做向量搜尋有下面幾種方式:

  • Cosine Similarity(餘弦相似度):餘弦相似度測量兩個向量的方向相似程度,忽略向量的長度(magnitude)。這使它特別適合文本語義搜索,因為我們關心的是詞彙的相對頻率,而非絕對出現次數。
  • Euclidean Distance(歐幾里德距離):測量兩點在多維空間中的直線距離,即我們常理解的幾何距離。數值越小表示向量越相近,0 表示完全相同。適用於需要考慮向量絕對大小的應用場景。

Cosine Similarity(餘弦相似度)

餘弦相似度是一種常用的衡量兩個向量方向相似度的指標。其公式如下:

cosine_similarity

  • 餘弦相似度公式

    cosine_similarity(a,b)=cos(θ)=abab\text{cosine\_similarity}(\mathbf{a}, \mathbf{b}) = \cos(\theta) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \|\mathbf{b}\|}
    • 其中,ab\mathbf{a} \cdot \mathbf{b} 表示兩個向量的點積。
    • a\|\mathbf{a}\|b\|\mathbf{b}\| 分別是向量 a\mathbf{a}b\mathbf{b} 的範數(即向量的長度)。
  • 餘弦距離公式

    餘弦距離是餘弦相似度的補數,用於衡量兩個向量之間的距離:

    cosine_distance=1cosine_similarity\text{cosine\_distance} = 1 - \text{cosine\_similarity}
    • 值域:-1(完全相反)到 1(完全相同),0 表示正交(無關聯)。

應用場景: 文本相似度比對、推薦系統、RAG(Retrieval-Augmented Generation)。

Euclidean Distance(歐幾里德距離)

歐幾里德距離測量多維空間中兩點的直線距離,是最直觀的距離度量方式,適合需要考慮向量絕對大小的場景。

euclidean-distance

  • 歐幾里德距離公式

    euclidean_distance(a,b)=i=1n(aibi)2\text{euclidean\_distance}(\mathbf{a}, \mathbf{b}) = \sqrt{\sum_{i=1}^{n} (a_i - b_i)^2}
    • 其中,aia_ibib_i 分別是向量 a\mathbf{a}b\mathbf{b} 的第 ii 個分量。
    • nn 是向量的維度。

值域: 0 到 ∞,數值越小表示越相似。 應用場景: 圖像特徵匹配、數值資料比對、異常檢測。

為什麼選擇 Postgres 來實現向量搜索?

在向量資料庫的選型上,pgvector 作為 PostgreSQL 的擴展,具有以下核心優勢:

優勢說明
無縫整合與現有關聯式資料庫無縫結合,無需引入新的資料庫系統
SQL 原生支援使用熟悉的 SQL 語法進行向量查詢,學習成本低
混合查詢同時支援向量相似度搜索和傳統 SQL 條件過濾
成熟生態基於 PostgreSQL 的穩定性和豐富的工具鏈
成本效益開源免費,無需額外的向量資料庫訂閱費用

pgvector 的限制與適用場景

索引維度上限: pgvector 的索引功能最多支援 2000 維向量。這是由於 PostgreSQL 預設頁面大小(8KB)的限制。雖然可以儲存更高維度的向量,但無法為其建立索引,會導致查詢效能大幅下降。

效能: 在超大規模(十億級)向量檢索場景下,效能不如專用向量資料庫如 Milvus。

解決 2000 維限制的方案:

若需使用超過 2000 維的向量模型(如 OpenAI 的 text-embedding-3-large),可採用以下方案:

  1. Matryoshka Embeddings(推薦): OpenAI 的 text-embedding-3 系列支援在 API 呼叫時直接指定較低維度(如 1536、1024 或 512),無需額外降維處理,且精度損失極小。
  2. 降維處理: 使用 PCA(主成分分析)將向量從 3072 維降至 2000 維以下。
  3. Binary Quantization: pgvector 支援透過二進位量化處理更高維度資料,但會犧牲部分精度。

最佳適用場景:

中小型專案: 向量數量在千萬級以內,對延遲要求不極端苛刻。 混合查詢需求: 需要結合結構化資料(如使用者 ID、時間戳)和向量相似度進行複雜查詢。 快速原型開發: 團隊已熟悉 PostgreSQL,希望快速驗證 AI 功能。 預算受限: 無法負擔專用向量資料庫的運營成本。

Postgres 中向量搜索的實作步驟

  1. 安裝pgvector擴展

    CREATE EXTENSION IF NOT EXISTS vector;

    這裡我們可以直接用 pgvector 的 image,起來後再執行該方法。

  2. 創建包含向量的表

根據需求選擇 OpenAI 的 Embedding 模型:

模型維度可調整適用場景
text-embedding-3-small1536(可調至 512)通用文本檢索,性價比最高
text-embedding-3-large3072(可調至 256)高精度需求,複雜語義
text-embedding-ada-0021536舊版模型,不建議新專案使用

重要提醒: text-embedding-ada-002 已於 2025 年 1 月標記為不推薦使用(deprecated),預計於 2025 年 6 月後正式淘汰。建議新專案直接使用 text-embedding-3-smalltext-embedding-3-large,可獲得更高精度。

這裡要注意 pgvector 的索引功能最多只支援 2000 維。若使用 text-embedding-3-large 的預設 3072 維,建議透過 API 的 dimensions 參數調整至 1536 或更低。

CREATE TABLE items (
    id serial PRIMARY KEY,
    embedding vector(1536) -- 假設每個向量有 1536 維
);
  1. 插入向量數據

    INSERT INTO items (embedding) VALUES
    ('[0.1, 0.2, ..., 0.3]'),
    ('[0.4, 0.5, ..., 0.6]'),
    ...;
  2. 執行向量搜索

    使用餘弦相似度來查詢最相似的向量:

    SELECT id, embedding
    FROM items
    ORDER BY embedding <=> '[query_vector]' -- pgvector 內建的餘弦相似度操作符
    LIMIT 10;

    若是用歐幾里德距離做計算的話可以換成 <->

建立索引

pgvector 支援兩種索引類型:

  1. IVFFlat(倒排檔案索引):
  • 將向量空間分割成多個群集(lists),搜索時僅查詢部分群集
  • 適合中等規模資料(百萬級)
  • 建立速度快,記憶體佔用低
CREATE INDEX ON documents 
USING ivfflat (embedding vector_cosine_ops) 
WITH (lists = 100);
  1. HNSW(分層可導航小世界圖):
  • 基於圖結構的近似最近鄰演算法,查詢速度極快
  • 適合需要低延遲的即時應用
  • 記憶體佔用較高,但召回率更好
CREATE INDEX ON documents 
USING hnsw (embedding vector_cosine_ops) 
WITH (m = 16, ef_construction = 64);

索引效能比較(基於 100 萬向量基準測試):

指標IVFFlatHNSW
建立時間(秒)1284,065
索引大小(MB)257729
查詢速度(QPS)2.640.5
召回率穩定性中等

IVFFlat 的召回率會隨著資料量增長而下降,需要定期重建索引。

索引選擇建議:

  • 資料量 < 10 萬: 暴力搜索(無索引)即可滿足需求
  • 資料量 10 萬 ~ 50 萬: 優先選擇 IVFFlat,建立速度快且效能足夠
  • 資料量 > 50 萬: 建議使用 HNSW,享受更快的查詢速度和穩定的召回率
  • 延遲敏感應用: 直接選擇 HNSW(如聊天機器人、即時推薦系統)
  • 批次處理場景: IVFFlat 即可(如離線資料分析)

HNSW 參數調優:

-- 推薦的生產環境設定
CREATE INDEX ON documents 
USING hnsw (embedding vector_cosine_ops) 
WITH (
    m = 16,                 -- 預設值,適合大多數場景
    ef_construction = 64    -- 預設值,平衡建立速度與品質
);

-- 查詢時動態調整精度(不需重建索引)
SET hnsw.ef_search = 100;  -- 範圍 80-120,數值越高召回率越高但速度越慢

Bun 完整範例

這裡我們用 OpenAI 的 model 來計算 embeddings,再透過 pgvector 來計算相似度。

這裡我們用 text-embedding-ada-002 做計算 embeddings 的 model

embeddings.ts
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY ?? "st-SECRETKEY",
});

// text-embedding-ada-002, text-embedding-3-small, text-embedding-3-large
const MODEL = "text-embedding-ada-002";

export const generateEmbedding = async (value: string) => {
  const input = value.replaceAll("\n", " ");

  const { data } = await openai.embeddings.create({
    model: MODEL,
    input,
    // 這裡可以設定 output 的維度,但只有 `text-embedding-3` 可以調整
    dimensions: 1536, 
  });

  return data[0]?.embedding;
};

我們這裡在定義初始化資料庫的指令,最後再透過 餘弦相似度 來查詢台灣的首都。

scripts.ts
import { SQL } from "bun";
import { faker } from "@faker-js/faker";
import { generateEmbedding } from "./embeddings";
import pgvector from "pgvector";

const sql = new SQL({ url: process.env.DATABASE_URL });

const initDb = async () => {
  /**
   * Create extension if not exists
   */
  await sql`CREATE EXTENSION IF NOT EXISTS vector;`;

  /**
   * Create table if not exists
   */
  await sql`CREATE TABLE IF NOT EXISTS documents (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    embedding VECTOR(1536) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  );`;
};

const seedDb = async () => {
  const documents = await Promise.all(
    Array.from({ length: 3 }, async () => {
      const content = faker.lorem.paragraph();
      return {
        title: faker.lorem.sentence(),
        content,
        embedding: pgvector.toSql(await generateEmbedding(content)),
      };
    })
  );

  await sql`INSERT INTO documents ${sql(documents)}`;

  console.log("Database seeded successfully");
};

const seedDbWithCapitals = async () => {
  const documents = await Promise.all([
    {
      title: "Taiwan",
      content: "The capital of Taiwan is Taipei",
      embedding: pgvector.toSql(await generateEmbedding("The capital of Taiwan is Taipei")),
    },
    {
      title: "Japan",
      content: "The capital of Japan is Tokyo",
      embedding: pgvector.toSql(await generateEmbedding("The capital of Japan is Tokyo")),
    },
    {
      title: "United States",
      content: "The capital of United States is Washington, D.C.",
      embedding: pgvector.toSql(await generateEmbedding("The capital of United States is Washington, D.C.")),
    },
  ]);

  await sql`INSERT INTO documents ${sql(documents)}`;

  console.log("Database seeded successfully");
};

const searchDb = async (query: string) => {
  const embedding = await generateEmbedding(query);
  if (!embedding) {
    throw new Error("Embedding generation failed");
  }
  const sqlEmbedding = pgvector.toSql(embedding);
  const results = await sql`SELECT * FROM documents ORDER BY embedding <=> ${sqlEmbedding} LIMIT 3`.values();
  return results;
};

const demo = async (search = "What is the capital of Taiwan?", seed?: true | "capitals") => {
  await initDb();
  if (seed) {
    if (seed === "capitals") {
      await seedDbWithCapitals();
    } else {
      await seedDb();
    }
  }
  const results = await searchDb(search);
  console.log(results);
};

const Script = {
  init: async (seed?: true | "capitals") => {
    await initDb();
    if (seed) {
      if (seed === "capitals") {
        await seedDbWithCapitals();
      } else {
        await seedDb();
      }
    }
  },
  initDb,
  seedDb,
  searchDb,
  demo,
};

export default Script;

最後在執行

import Script from "./src/scripts";

await Script.demo(
  "What is the capital of Taiwan?",
  // seed the database or not
  "capitals"
);

就可以看到結果

[
  [ 4, "Taiwan", "The capital of Taiwan is Taipei", "[0.000814143,-0.019345103,-0.0027284368,...,-0.02137615]",
    2025-05-08T08:29:22.591Z
  ], [ 7, "Japan", "The capital of Japan is Tokyo", "[0.000814143,-0.019345103,-0.0027284368,...,-0.02137615]",
    2025-05-08T08:29:48.303Z
  ], [ 6, "United States", "The capital of United States is Washington, D.C.", "[0.0072439546,-0.01591172,-0.016960844,...,-0.0050426666]",
    2025-05-08T08:29:22.591Z
  ], count: 3, command: "SELECT"
]

Vector Database

除了上面所提的 pgvector,還有另外兩個也是專門做像量資料處理的資料庫,分別是 MilvusWeaviate,不過他們各有不同的優勢,大家可以針對不同的情境做使用。

pgvector

pgvector 就如同前面所提的,我覺得他最大的優勢就是較好跟現有的關聯資料做整合,並且用一般的 SQL 語法就可以做到文本搜尋,上手曲線相對簡單,不過當然整體的功能就不如 Milvus 或 Weaviate 來得多了,並且他索引的最大維度只支援到 2000,若有更多資料就會不好做優化。

Milvus

Milvus 是一個開源的專用向量資料庫,專為大規模高維向量相似度搜尋和 AI 應用設計,支援十億級向量。

  • 優點

    • 高效能:支援多種索引算法(HNSW、DiskANN 等),並利用 GPU 加速查詢。
    • 擴展性:分佈式架構,支援從小型到超大規模數據集(>10 億向量)。
    • 靈活性:支援多種距離度量(包括餘弦相似度),並允許多向量欄位和混合搜尋(結構化 + 非結構化)。
    • 生態系統:豐富的 SDK(Python、Java 等)和工具(如 Attu、CLI)。
  • 缺點

    • 設定和配置較複雜,學習曲線較陡。
    • 對語義搜尋的支援不如 Weaviate(更專注於向量相似度)。
    • 需要更多運算資源(特別是大規模部署時)。

Weaviate

Weaviate,是一個開源的向量資料庫,支援語義搜尋和混合搜尋(向量 + 關鍵字),並以 GraphQL 介面為特色。

scripts.ts
import { dataType, type WeaviateClient } from "weaviate-client";
import { vectorizer } from "weaviate-client";

export class Script {
  constructor(private client: WeaviateClient) {}

  /**
   * 直接透過 OpenAI 幫忙做即時的向量轉換
   */
  private async createCollection() {
    await this.client.collections.create({
      name: "Documents",
      vectorizers: vectorizer.text2VecOpenAI(), 
      properties: [
        { name: "title", dataType: dataType.TEXT },
        { name: "content", dataType: dataType.TEXT },
      ],
    });
  }

  async getDocuments() {
    return this.client.collections.get("Documents");
  }

  private generateData() {
    return [
      {
        title: "Taiwan",
        content: "The capital of Taiwan is Taipei.",
      },
      {
        title: "Japan",
        content: "The capital of Japan is Tokyo.",
      },
      {
        title: "United States",
        content: "The capital of the United States is Washington, D.C.",
      },
    ];
  }

  public async seedCollection() {
    const collection = await this.getDocuments();
    const data = this.generateData();
    await collection.data.insertMany(data);
    console.log(`Inserted ${data.length} documents`);
  }

  private async init() {
    await this.createCollection();
    await this.seedCollection();
    console.log("Collection initialized");
  }

  /**
   * 執行向量搜尋
   */
  public async search(query: string) {
    const collection = await this.getDocuments();
    const results = await collection.query.nearText(query, {
      limit: 3,
      returnMetadata: ["distance"],
    });
    console.log(results.objects.map((obj) => obj.properties));
    return results;
  }
}
  • 優點

    • 語義搜尋:內建機器學習模組,支援文字、圖像等多模態數據的語義理解。
    • 索引支援:使用自訂 HNSW 算法,支援完整的 CRUD 操作。
    • 靈活查詢:支援 GraphQL 查詢,允許結合向量搜尋和傳統關鍵字搜尋。
    • 高維支援:支援高達 65,535 維向量。
    • 模組化:可整合 OpenAI、HuggingFace 等模型,支援即時向量化。
  • 缺點

    • 對大規模數據的查詢速度可能不如 Milvus 在某些場景中的表現。
    • 資源需求較高,特別是在高併發場景。

Weaviate 較適合需要語義搜尋、多模態數據處理及與 AI 模型深度整合的應用場景,特別是構建推薦或知識檢索系統。

pgvector vs. Milvus vs. Weaviate

特性pgvectorMilvusWeaviate
部署方式PostgreSQL 擴展雲端 + 本地雲端 + 本地
開源授權PostgreSQL LicenseApache 2.0BSD 3-Clause
向量維度上限2,000(索引限制)32,76865,535
索引類型IVFFlat、HNSWHNSW、DiskANN、GPU 加速HNSW
混合查詢✅ SQL 原生支援⚠️ 需透過 API✅ GraphQL 支援
多模態支援✅ 文字、圖片、音訊✅ 文字、圖片
擴展性中等(單機垂直擴展)極強(分佈式架構)強(自動分片)
學習曲線低(SQL 即可)高(需學習專用 API)中(GraphQL)
查詢效能(QPS)2.6-40.5(視索引而定)> 100(GPU 加速)50-80
最佳使用場景中小型專案、混合查詢大規模 AI 應用語義搜索、RAG
如果你需要...推薦方案
與現有 PostgreSQL 整合pgvector
十億級向量 + GPU 加速Milvus
多模態 + 即時向量化Weaviate
最低學習成本pgvector
最高查詢效能Milvus
最佳語義搜索Weaviate
資料量 < 100 萬pgvector
資料量 > 1000 萬Milvus
需要混合搜尋(關鍵字+向量)Weaviate

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104