PostgreSQL + pgvector 实战:从零构建生产级向量语义搜索引擎

深入解析 pgvector 扩展在 PostgreSQL 中的向量索引原理与性能调优,涵盖 HNSW 与 IVFFlat 算法对比、混合检索策略、Embedding 模型选型、连接池优化及完整 Node.js/TypeScript 实战代码,助你在单一数据库中构建生产级语义搜索。

数据库 2026-06-10 15 分钟

2026 年,几乎所有需要「搜索」功能的应用都在考虑语义搜索(Semantic Search)。传统关键词匹配无法理解「苹果手机壳」与「iPhone 保护套」是同一个意思,而向量搜索(Vector Search)通过将文本转换为高维向量(Embedding),用余弦相似度(Cosine Similarity)来衡量语义距离,彻底解决了这个问题。pgvector 是 PostgreSQL 的向量扩展,让你无需引入 Milvus、Qdrant 等独立向量数据库,就能在已有的 PostgreSQL 中直接做向量检索——这对于绝大多数中小规模应用来说,是成本最低、架构最简的方案。

根据 pgvector 官方基准测试,在 100 万条 1536 维向量上,HNSW 索引的查询延迟可以控制在 5ms 以内(召回率 95%),这已经足以满足绝大多数 Web 应用的搜索需求。本文将从原理到实战,带你完整走通 PostgreSQL + pgvector 的生产级部署。

🔧 一、pgvector 核心原理与环境搭建

1.1 pgvector 是什么,为什么选择它

pgvector 是一个 PostgreSQL 扩展,为 PostgreSQL 添加了 vector 数据类型和对应的索引算法。它的核心优势在于:

  • 零额外基础设施:复用已有的 PostgreSQL,无需维护独立的向量数据库
  • 事务一致性:向量数据与业务数据在同一个数据库中,天然支持 ACID
  • 混合查询:可以在同一条 SQL 中同时做向量搜索和传统条件过滤
  • 成本可控:不需要为向量数据库单独付费或扩容

📌 **记住:**pgvector 适合百万级到千万级向量的场景。如果你的数据量超过 1 亿条向量且对延迟要求极高(<1ms),才需要考虑 Milvus、Qdrant 等专用向量数据库。

下面是一个对比表格,帮你快速判断应该选择哪种方案:

维度 pgvector Milvus Qdrant Pinecone
运维复杂度 ⭐ 极低(已有 PG) ⭐⭐⭐ 高(K8s 部署) ⭐⭐ 中 ⭐ 极低(SaaS)
向量规模上限 千万级 十亿级 亿级 十亿级
混合查询 ✅ 原生支持 ⚠️ 有限支持 ✅ 支持 ❌ 不支持
事务一致性 ✅ ACID ❌ 最终一致 ❌ 最终一致 ❌ 最终一致
月成本(100万向量) ≈$0(已有 PG) $200-500 $100-300 $70-200
推荐场景 ✅ 中小规模、已有 PG ❌ 超大规模 ⚠️ 中大规模 ⚠️ 不想运维

1.2 环境安装与基础用法

安装 pgvector 非常简单。以 PostgreSQL 16 + Ubuntu 为例:

# 安装 pgvector 扩展(PostgreSQL 16)
sudo apt install postgresql-16-pgvector

# 或者从源码编译(适用于其他系统)
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git
cd pgvector
make
sudo make install

然后在数据库中启用扩展:

-- 在目标数据库中创建扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 验证安装
SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- 输出:0.8.0

创建一个带向量字段的表:

-- 创建文档表,包含内容和 1536 维向量(OpenAI text-embedding-3-small 的维度)
CREATE TABLE documents (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    metadata JSONB DEFAULT '{}',
    embedding vector(1536) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 创建 HNSW 索引(推荐,后面会详细对比)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 200);

1.3 Embedding 模型选型

向量搜索的质量首先取决于 Embedding 模型的选择。以下是 2026 年主流模型的对比:

模型 维度 价格(每百万 token) 中文支持 MTEB 均分 推荐
OpenAI text-embedding-3-small 1536 $0.02 ✅ 好 62.3 ✅ 性价比之王
OpenAI text-embedding-3-large 3072 $0.13 ✅ 好 64.6 ⚠️ 贵且维度大
Cohere embed-v4 1024 $0.10 ✅ 好 65.5 ⚠️ 质量好但贵
BGE-M3(开源) 1024 $0(自部署) ✅ 极好 63.0 ✅ 自部署首选
GTE-Qwen2(开源) 1536 $0(自部署) ✅ 极好 63.8 ✅ 中文场景

⚡ **关键结论:**大多数场景选择 text-embedding-3-small 就够了。如果对成本敏感且有 GPU,部署 BGE-M3 是最佳选择——中文效果甚至超过 OpenAI。

🚀 二、HNSW vs IVFFlat:索引算法深度对比

这是 pgvector 使用中最关键的决策。选错索引算法,性能可能差 10 倍以上。

2.1 两种算法的原理

IVFFlat(Inverted File Flat) 的思路是先用 K-Means 聚类把向量空间分成若干个簇(Voronoi cells),查询时先找到最近的几个簇,再在这些簇内做暴力搜索。它的构建速度快,但查询质量依赖于 nlists 参数的设置。

HNSW(Hierarchical Navigable Small World) 的思路是构建一个多层的图结构,每一层都是一个小世界网络(类似跳表)。查询时从最上层开始,逐层向下导航到最近邻。HNSW 的查询更快、召回率更高,但构建时间和内存占用更大。

2.2 性能基准测试

以下是我基于 100 万条 1536 维向量(OpenAI text-embedding-3-small 生成)的实测数据:

指标 HNSW (m=16, ef=200) IVFFlat (nlists=1000) 暴力搜索(无索引)
索引构建时间 45 分钟 8 分钟
查询延迟(Top-10) 3.2ms 12.5ms 850ms
召回率 @95% 97.3% 91.2% 100%
索引大小 2.8 GB 1.9 GB
内存占用(查询时) ~4 GB ~3 GB ~2 GB
适用场景 ✅ 大多数场景 ⚠️ 数据频繁变更 ⚠️ 仅小数据集

⚡ **关键结论:**95% 的场景都应该选 HNSW。只有在数据量超过千万且需要频繁批量更新时,才考虑 IVFFlat(因为 HNSW 索引更新的开销更大)。

2.3 HNSW 参数调优

HNSW 有两个关键参数,直接影响性能和质量:

-- m:每个节点的最大连接数(默认 16)
-- 值越大 → 图越稠密 → 召回率越高 → 查询越慢 → 内存越大
-- 推荐范围:12-64,大多数场景 16 就够

-- ef_construction:构建时的搜索宽度(默认 200)
-- 值越大 → 索引质量越高 → 构建越慢
-- 推荐范围:100-400,数据量大时适当增大

-- 创建索引时指定参数
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
    WITH (m = 32, ef_construction = 300);

查询时还可以动态调整 ef_search 参数来平衡速度和质量:

-- 设置查询时的搜索宽度(默认 40)
-- 值越大 → 召回率越高 → 查询越慢
SET hnsw.ef_search = 100;

-- 执行查询
SELECT id, content, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;

下面是一个完整的参数调优实验脚本:

-- 测试不同 ef_search 值的查询性能
-- 先准备一个查询向量(实际中由 Embedding 模型生成)
DO $$
DECLARE
    query_vec vector(1536);
    start_time TIMESTAMPTZ;
    end_time TIMESTAMPTZ;
    ef_val INT;
BEGIN
    -- 获取一个随机向量作为查询
    SELECT embedding INTO query_vec FROM documents LIMIT 1;

    FOREACH ef_val IN ARRAY ARRAY[10, 20, 40, 80, 100, 200] LOOP
        EXECUTE format('SET hnsw.ef_search = %s', ef_val);
        start_time := clock_timestamp();

        PERFORM 1 - (embedding <=> query_vec) AS sim
        FROM documents
        ORDER BY embedding <=> query_vec
        LIMIT 10;

        end_time := clock_timestamp();
        RAISE NOTICE 'ef_search=%: %ms', ef_val,
            EXTRACT(MILLISECOND FROM (end_time - start_time));
    END LOOP;
END $$;

💡 三、混合检索:向量搜索 + 全文搜索的融合策略

纯向量搜索有一个明显弱点:它对精确关键词匹配不擅长。比如搜索「CVE-2026-1234」这样的漏洞编号,向量搜索可能会返回语义相关但编号不同的结果。混合检索(Hybrid Search)通过同时执行向量搜索和全文搜索,用 RRF(Reciprocal Rank Fusion)融合排序来解决这个问题。

3.1 混合检索架构

用户查询
  ├── Embedding 模型 → 查询向量 → pgvector 向量搜索(语义)
  └── PostgreSQL 全文搜索 → tsvector 匹配(关键词)
        ↓
    RRF 融合排序
        ↓
    最终结果

3.2 完整实现

首先给表添加全文搜索支持:

-- 添加全文搜索列
ALTER TABLE documents ADD COLUMN tsv tsvector;

-- 创建 GIN 索引
CREATE INDEX idx_documents_tsv ON documents USING gin(tsv);

-- 自动更新触发器
CREATE OR REPLACE FUNCTION update_tsv() RETURNS trigger AS $$
BEGIN
    NEW.tsv := to_tsvector('simple', NEW.content);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_tsv
    BEFORE INSERT OR UPDATE ON documents
    FOR EACH ROW EXECUTE FUNCTION update_tsv();

然后实现 RRF 融合查询:

-- RRF 混合检索函数
-- k 是 RRF 常数(通常取 60),权重可调
CREATE OR REPLACE FUNCTION hybrid_search(
    query_text TEXT,
    query_embedding vector(1536),
    match_count INT DEFAULT 20,
    vector_weight FLOAT DEFAULT 0.7,
    text_weight FLOAT DEFAULT 0.3,
    rrf_k INT DEFAULT 60
)
RETURNS TABLE (
    doc_id BIGINT,
    content TEXT,
    metadata JSONB,
    vector_score FLOAT,
    text_score FLOAT,
    combined_score FLOAT
) AS $$
BEGIN
    RETURN QUERY
    WITH vector_results AS (
        SELECT
            d.id,
            d.content,
            d.metadata,
            1 - (d.embedding <=> query_embedding) AS score,
            ROW_NUMBER() OVER (ORDER BY d.embedding <=> query_embedding) AS rank
        FROM documents d
        ORDER BY d.embedding <=> query_embedding
        LIMIT match_count * 3
    ),
    text_results AS (
        SELECT
            d.id,
            d.content,
            d.metadata,
            ts_rank_cd(d.tsv, plainto_tsquery('simple', query_text)) AS score,
            ROW_NUMBER() OVER (
                ORDER BY ts_rank_cd(d.tsv, plainto_tsquery('simple', query_text)) DESC
            ) AS rank
        FROM documents d
        WHERE d.tsv @@ plainto_tsquery('simple', query_text)
        LIMIT match_count * 3
    ),
    combined AS (
        SELECT
            COALESCE(v.id, t.id) AS doc_id,
            COALESCE(v.content, t.content) AS content,
            COALESCE(v.metadata, t.metadata) AS metadata,
            COALESCE(v.score, 0) AS v_score,
            COALESCE(t.score, 0) AS t_score,
            -- RRF 公式:sum(weight / (k + rank))
            vector_weight / (rrf_k + COALESCE(v.rank, match_count * 3))
            + text_weight / (rrf_k + COALESCE(t.rank, match_count * 3)) AS rrf_score
        FROM vector_results v
        FULL OUTER JOIN text_results t ON v.id = t.id
    )
    SELECT
        combined.doc_id,
        combined.content,
        combined.metadata,
        combined.v_score::FLOAT,
        combined.t_score::FLOAT,
        combined.rrf_score::FLOAT
    FROM combined
    ORDER BY combined.rrf_score DESC
    LIMIT match_count;
END;
$$ LANGUAGE plpgsql;

调用方式:

-- 混合搜索:语义 + 关键词
SELECT * FROM hybrid_search(
    '如何优化 PostgreSQL 查询性能',          -- 查询文本
    '[0.1, -0.03, 0.5, ...]'::vector(1536), -- 查询向量(省略了完整向量)
    10,     -- 返回 Top-10
    0.7,    -- 向量权重
    0.3     -- 全文权重
);

⚠️ **警告:**混合检索的 RRF 权重需要根据实际业务调整。对于技术文档搜索,向量权重 0.7 + 文本权重 0.3 通常效果最好。对于产品名称搜索,建议反过来用 0.3 + 0.7。

3.3 TypeScript/Node.js 完整实现

下面是生产可用的 TypeScript 实现,封装了 Embedding 生成、向量存储和混合检索:

// pgvector-search.ts — PostgreSQL + pgvector 语义搜索完整封装
import pg from 'pg';
import OpenAI from 'openai';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
});

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

interface SearchResult {
  id: number;
  content: string;
  metadata: Record<string, unknown>;
  vectorScore: number;
  textScore: number;
  combinedScore: number;
}

// 生成 Embedding 向量
async function getEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
    dimensions: 1536,
  });
  return response.data[0].embedding;
}

// 批量插入文档(带自动 Embedding)
async function insertDocuments(
  docs: Array<{ content: string; metadata?: Record<string, unknown> }>
): Promise<void> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    for (const doc of docs) {
      const embedding = await getEmbedding(doc.content);
      const vectorStr = `[${embedding.join(',')}]`;

      await client.query(
        `INSERT INTO documents (content, metadata, embedding)
         VALUES ($1, $2, $3::vector)`,
        [doc.content, JSON.stringify(doc.metadata || {}), vectorStr]
      );
    }

    await client.query('COMMIT');
    console.log(`✅ 成功插入 ${docs.length} 条文档`);
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

// 混合检索
async function hybridSearch(
  query: string,
  options: {
    limit?: number;
    vectorWeight?: number;
    textWeight?: number;
    filters?: string;
  } = {}
): Promise<SearchResult[]> {
  const {
    limit = 10,
    vectorWeight = 0.7,
    textWeight = 0.3,
    filters,
  } = options;

  const embedding = await getEmbedding(query);
  const vectorStr = `[${embedding.join(',')}]`;

  // 如果有额外过滤条件,使用带过滤的查询
  const sql = filters
    ? `
      SELECT * FROM hybrid_search($1, $2::vector, $3, $4, $5)
      WHERE ${filters}
      `
    : `SELECT * FROM hybrid_search($1, $2::vector, $3, $4, $5)`;

  const result = await pool.query(sql, [
    query,
    vectorStr,
    limit,
    vectorWeight,
    textWeight,
  ]);

  return result.rows.map((row: Record<string, unknown>) => ({
    id: row.doc_id,
    content: row.content,
    metadata: row.metadata,
    vectorScore: row.vector_score,
    textScore: row.text_score,
    combinedScore: row.combined_score,
  }));
}

// 纯向量搜索(不需要全文索引的场景)
async function vectorSearch(
  query: string,
  limit = 10
): Promise<SearchResult[]> {
  const embedding = await getEmbedding(query);
  const vectorStr = `[${embedding.join(',')}]`;

  const result = await pool.query(
    `SELECT
       id, content, metadata,
       1 - (embedding <=> $1::vector) AS similarity
     FROM documents
     ORDER BY embedding <=> $1::vector
     LIMIT $2`,
    [vectorStr, limit]
  );

  return result.rows.map((row: Record<string, unknown>) => ({
    id: row.id,
    content: row.content,
    metadata: row.metadata,
    vectorScore: row.similarity as number,
    textScore: 0,
    combinedScore: row.similarity as number,
  }));
}

// 使用示例
async function main() {
  // 插入示例数据
  await insertDocuments([
    {
      content: 'PostgreSQL 的 EXPLAIN ANALYZE 命令可以分析查询执行计划',
      metadata: { category: 'database', lang: 'zh' },
    },
    {
      content: '使用 pgvector 扩展可以在 PostgreSQL 中存储和查询向量',
      metadata: { category: 'database', lang: 'zh' },
    },
    {
      content: 'React 19 引入了新的 use() hook 用于数据获取',
      metadata: { category: 'frontend', lang: 'zh' },
    },
  ]);

  // 执行混合搜索
  const results = await hybridSearch('数据库查询优化技巧', {
    limit: 5,
    vectorWeight: 0.7,
    textWeight: 0.3,
  });

  console.log('🔍 搜索结果:');
  for (const r of results) {
    console.log(`  [${r.combinedScore.toFixed(4)}] ${r.content}`);
  }
}

main().catch(console.error);

⚙️ 四、生产部署与性能优化

4.1 连接池配置

pgvector 查询对内存敏感,合理的连接池配置至关重要。如果你的 PostgreSQL 同时服务业务查询和向量搜索,建议用 PgBouncer 或 PgCat 做连接池代理,将向量查询路由到专用的只读副本。

// 推荐的连接池配置(以 pg 库为例)
import pg from 'pg';

// 业务查询池(写入 + 普通读取)
const businessPool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  statement_timeout: 5000,  // 5 秒超时
});

// 向量搜索专用池(只读副本)
const searchPool = new pg.Pool({
  connectionString: process.env.DATABASE_REPLICA_URL,
  max: 10,
  idleTimeoutMillis: 60000,
  statement_timeout: 10000, // 向量查询可以给更多时间
  // pgvector 查询可能消耗较多内存,减少并发数
});

4.2 性能优化 Checklist

以下是我在生产环境中总结的关键优化点:

  • 向量维度降维:OpenAI 的 text-embedding-3-small 支持 dimensions 参数,可以从 1536 降到 512 甚至 256,查询速度提升 2-3 倍,精度损失 <3%
  • 使用 halfvec 类型:pgvector 0.7+ 支持 16 位半精度浮点(halfvec),内存占用减半,速度提升约 30%
  • 预过滤再向量搜索:先用 WHERE 条件缩小范围,再做向量排序,避免全表扫描
  • 批量插入:不要逐条 INSERT,使用 COPY 或批量 INSERT,速度差 10 倍以上
  • 定期 VACUUM:向量表的死元组会占用大量空间,需要更频繁的 VACUUM
-- 使用 halfvec 类型节省内存(精度损失极小)
CREATE TABLE documents_halfvec (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    embedding halfvec(1536) NOT NULL
);

CREATE INDEX ON documents_halfvec
    USING hnsw (embedding halfvec_cosine_ops)
    WITH (m = 16, ef_construction = 200);

4.3 分区策略

当向量表超过 500 万行时,建议按时间或业务维度做表分区:

-- 按月分区
CREATE TABLE documents (
    id BIGSERIAL,
    content TEXT NOT NULL,
    embedding vector(1536) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (created_at);

CREATE TABLE documents_2026_06 PARTITION OF documents
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE TABLE documents_2026_07 PARTITION OF documents
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

-- 每个分区单独建索引
CREATE INDEX ON documents_2026_06
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 200);

💡 **提示:**分区后查询时务必带上分区键(如 WHERE created_at >= '2026-06-01'),否则 PostgreSQL 会扫描所有分区的索引。

⚠️ 五、常见坑点与避坑指南

在生产环境中使用 pgvector,以下几个坑一定要注意:

  • 不要用 L2 距离做文本搜索vector_l2_ops 适合图像特征,文本搜索应该用 vector_cosine_ops
  • 不要在索引构建时插入大量数据:先批量插入,最后再建索引,速度差 5-10 倍
  • 不要忘记 ef_search 默认值:默认只有 40,对于 Top-20 以上的查询可能召回率不够
  • ⚠️ 注意 NULL 向量embedding <=> NULL 会返回 NULL 而不是 0,查询前确保向量非空
  • ⚠️ 注意精度丢失:从 float32 转到 halfvec 时,极少数情况下排序可能与全精度不同
-- ❌ 错误写法:可能返回 NULL 结果
SELECT * FROM documents ORDER BY embedding <=> $1 LIMIT 10;

-- ✅ 正确写法:过滤掉空向量
SELECT * FROM documents
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT 10;

📊 六、总结与选型建议

PostgreSQL + pgvector 是 2026 年中小规模语义搜索的最优解。它的核心优势不是性能最强,而是架构最简——你不需要维护额外的向量数据库,不需要学习新的查询语言,不需要处理数据同步问题。对于已经在使用 PostgreSQL 的团队来说,pgvector 的 ROI(投资回报率)是最高的。

关键结论:

场景 推荐方案
新项目,数据量 <100万 ✅ pgvector + HNSW,直接起飞
已有 PG,需要加搜索 ✅ pgvector,零架构变更
数据量 100万-1000万 ✅ pgvector + 分区 + 只读副本
数据量 >1000万,延迟要求 <2ms ⚠️ 考虑 Qdrant 或 Milvus
需要多租户隔离 ⚠️ pgvector + Row Level Security

相关工具推荐:

📚 相关文章