Skip to content

14.08-PGVector实践

要点

  • pgvector 是 PostgreSQL 的向量检索扩展——复用现有数据库,不需要新增组件
  • 安装简单,SQL 语法熟悉,对 PG 用户几乎零学习成本
  • 两种索引:IVFFlat(适合大规模、省内存)和 HNSW(查询快、内存占用高)
  • 结合 PG 的全文检索能力,可以做混合检索,兼顾语义匹配和关键词精确匹配
  • 规模限制:百万级以下表现好,更大规模性能下降明显,需要迁移到专用向量数据库
  • 适合「已经有 PostgreSQL」的团队作为 RAG 起步方案

1. 什么时候选 pgvector

在动手之前,先判断 pgvector 是否适合你的场景。

适用场景

  • 团队已经在用 PostgreSQL,不想引入新组件
  • 数据量在百万级以下,查询并发不高
  • 需要向量检索和事务、JSON 查询、全文检索组合使用
  • 快速验证 RAG 方案,后续可能迁移到专用向量数据库

不适用场景

  • 数据量超过千万级,需要极致性能
  • 需要分布式部署、多副本高可用
  • 团队没有 PostgreSQL 经验,愿意投入学习专用向量数据库

完成这篇教程后,你能得到

  • 一个可运行的 pgvector 环境
  • 一套完整的向量表设计和索引策略
  • 向量写入、查询、混合检索的 TypeScript 实现
  • 性能调优和扩展性判断的决策依据

2. 环境搭建与扩展启用

2.1 安装 pgvector

根据环境选择安装方式:

bash
# macOS + Homebrew
brew install pgvector

# Ubuntu/Debian(以 PostgreSQL 16 为例)
sudo apt install postgresql-16-pgvector

# Docker(推荐,开箱即用)
docker run -d \
  --name pgvector \
  -e POSTGRES_PASSWORD=password \
  -p 5432:5432 \
  pgvector/pgvector:pg16

预期结果:Docker 方式启动后,执行 docker ps 应看到 pgvector/pgvector:pg16 容器运行中。

2.2 启用扩展

连接到数据库后执行:

sql
CREATE EXTENSION IF NOT EXISTS vector;

验证安装

sql
SELECT * FROM pg_extension WHERE extname = 'vector';

预期输出:返回一行记录,extnamevectorextversion 为当前版本号(如 0.7.0)。

常见错误

  • ERROR: could not open extension control file:pgvector 未安装或路径不在 PostgreSQL 搜索路径中。Docker 用户检查镜像是否正确,本地用户检查 pg_config --sharedirpg_config --pkglibdir 下是否有 vector 相关文件。
  • ERROR: extension "vector" already exists:扩展已启用,可忽略。

3. 表设计:从标量到向量的建模

3.1 向量表结构设计

向量表的核心是把文档 chunk 和它的 embedding 绑定存储,同时保留足够的元数据支持过滤查询。

sql
-- 文档向量表
CREATE TABLE document_chunks (
  id TEXT PRIMARY KEY,                    -- 格式:{docId}-chunk-{index}
  document_id TEXT NOT NULL,
  document_title TEXT NOT NULL,
  chunk_index INTEGER NOT NULL,
  content TEXT NOT NULL,                  -- chunk 原文
  embedding VECTOR(768) NOT NULL,         -- 向量,维度必须和 embedding 模型匹配
  metadata JSONB DEFAULT '{}',            -- 额外元数据(标签、分类等)
  user_id TEXT,                           -- 所属用户(权限控制)
  tenant_id TEXT,                         -- 所属租户(多租户隔离)
  created_at TIMESTAMP DEFAULT NOW(),

  -- 复合索引:按文档查 chunk
  UNIQUE (document_id, chunk_index)
);

-- 按文档查 chunk 的索引
CREATE INDEX idx_chunks_document ON document_chunks(document_id);

-- 按用户查 chunk 的索引(权限过滤)
CREATE INDEX idx_chunks_user ON document_chunks(user_id);

设计理由

  • 主键用 TEXT 而非自增 ID:chunk 的 ID 需要和文档 ID 关联,格式 {docId}-chunk-{index} 支持幂等写入和批量更新
  • embedding 维度必须匹配模型:768 对应 OpenAI text-embedding-3-small 等模型,维度不匹配会报 vector dimension mismatch 错误
  • 保留 user_id 和 tenant_id:RAG 系统通常需要权限过滤,只返回用户有权访问的 chunk
  • metadata 用 JSONB:支持灵活扩展,后续可加标签、分类、来源等字段,无需改表结构

3.2 向量类型的三种操作符

pgvector 提供三种距离计算操作符,对应不同的相似度度量:

操作符含义适用场景
<=>余弦距离文本语义相似度(默认推荐)
<->L2 距离(欧氏距离)图像特征、数值向量
<#>负内积距离点积相似度(需归一化)

本教程使用余弦距离 <=>,计算结果为 1 - cosine_similarity,所以相似度 = 1 - (embedding <=> query_vector)

4. 索引策略:HNSW vs IVFFlat

向量索引是 pgvector 性能的关键。两种索引各有取舍,选错会导致查询慢或内存爆。

4.1 HNSW 索引(推荐首选)

HNSW(分层导航小世界)是查询最快的索引,但内存占用高。

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

参数说明

  • m:每层每个节点的连接数,越大越精确但占用内存越多,默认 16
  • ef_construction:构建时搜索范围,越大索引质量越高但构建越慢,默认 64

优点:查询速度快,不需要训练数据,插入新数据后立即生效

缺点:内存占用高(每个节点需要存储连接),构建速度慢

4.2 IVFFlat 索引

IVFFlat(倒排文件索引)适合数据量大、内存有限的场景。

sql
CREATE INDEX idx_chunks_embedding_ivf
  ON document_chunks
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

参数说明

  • lists:聚类数,越大检索越精确但越慢

经验值

  • 数据量 < 100 万:lists = sqrt(行数),例如 10 万行用 lists = 316
  • 数据量 > 100 万:lists = 4 * sqrt(行数)

缺点:需要先有数据才能建索引(训练阶段),新插入的数据如果不在已有聚类中,查询效果会下降。

4.3 选择决策

决策逻辑

  1. 不确定时先用 HNSW:查询快,开发体验好
  2. 内存监控发现占用过高:切换到 IVFFlat,调整 lists 参数
  3. 数据量超过 500 万且持续增长:直接考虑 IVFFlat 或迁移到专用向量数据库

验证索引效果

sql
-- 检查索引是否被使用
EXPLAIN ANALYZE
SELECT id, content, 1 - (embedding <=> '[0.1,0.2,...]') AS similarity
FROM document_chunks
ORDER BY embedding <=> '[0.1,0.2,...]'
LIMIT 5;

预期输出:执行计划中应看到 Index Scan using idx_chunks_embedding_hnswidx_chunks_embedding_ivf,而非 Seq Scan。如果看到 Seq Scan,说明索引未生效,检查索引是否创建成功、查询条件是否匹配索引操作符。

5. 写入向量:从 Embedding 到数据库

5.1 单条和批量写入

写入向量的核心是把 embedding 数组格式化为 pgvector 接受的字符串格式 [1,2,3]

typescript
// src/services/rag/pgvector-store.ts
import { Pool } from 'pg'

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
})

// 格式化向量(pg 要求 [1,2,3] 格式)
function formatVector(vector: number[]): string {
  return `[${vector.join(',')}]`
}

export async function upsertChunks(chunks: Array<{
  id: string
  documentId: string
  documentTitle: string
  chunkIndex: number
  content: string
  embedding: number[]
  metadata: Record<string, unknown>
  userId?: string
  tenantId?: string
}>): Promise<void> {
  const client = await pool.connect()
  try {
    await client.query('BEGIN')

    for (const chunk of chunks) {
      await client.query(
        `INSERT INTO document_chunks
         (id, document_id, document_title, chunk_index, content, embedding, metadata, user_id, tenant_id)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
         ON CONFLICT (id) DO UPDATE SET
           content = EXCLUDED.content,
           embedding = EXCLUDED.embedding,
           metadata = EXCLUDED.metadata`,
        [
          chunk.id,
          chunk.documentId,
          chunk.documentTitle,
          chunk.chunkIndex,
          chunk.content,
          formatVector(chunk.embedding),
          JSON.stringify(chunk.metadata),
          chunk.userId,
          chunk.tenantId,
        ]
      )
    }

    await client.query('COMMIT')
  } catch (err) {
    await client.query('ROLLBACK')
    throw err
  } finally {
    client.release()
  }
}

预期结果:执行后数据库中应插入对应数量的记录,可通过 SELECT COUNT(*) FROM document_chunks 验证。

常见错误

  • vector dimension mismatch:embedding 维度和表定义不一致,检查 embedding 模型和表定义
  • duplicate key value violates unique constraint:主键冲突,检查 chunk ID 生成逻辑

5.2 批量写入优化(生产可用)

当一次写入超过 100 条 chunk 时,使用 COPY 命令批量写入,性能比逐条 INSERT 快 10-50 倍。

typescript
import { format } from 'pg-copy-streams'

export async function bulkUpsertChunks(chunks: Chunk[]): Promise<void> {
  const client = await pool.connect()
  try {
    // 创建临时表
    await client.query(`
      CREATE TEMP TABLE temp_chunks (LIKE document_chunks) ON COMMIT DROP
    `)

    // COPY 写入临时表
    const stream = client.query(copy.from(`
      COPY temp_chunks (id, document_id, document_title, chunk_index, content, embedding, metadata)
      FROM STDIN CSV
    `))

    for (const chunk of chunks) {
      stream.write([
        chunk.id,
        chunk.documentId,
        chunk.documentTitle,
        chunk.chunkIndex,
        chunk.content.replace(/"/g, '""'),  // CSV 转义
        formatVector(chunk.embedding),
        JSON.stringify(chunk.metadata),
      ].join(',') + '\n')
    }
    stream.end()

    // 合并到主表
    await client.query(`
      INSERT INTO document_chunks
      SELECT * FROM temp_chunks
      ON CONFLICT (id) DO UPDATE SET
        content = EXCLUDED.content,
        embedding = EXCLUDED.embedding,
        metadata = EXCLUDED.metadata
    `)
  } finally {
    client.release()
  }
}

性能对比:写入 1000 条 chunk,逐条 INSERT 约 3-5 秒,COPY 批量写入约 0.1-0.3 秒。

6. 相似度查询:找到最相关的 chunk

6.1 基础查询实现

查询的核心是计算用户问题向量和所有 chunk 向量的余弦距离,按距离排序返回 top-K。

typescript
export async function searchChunks(
  queryVector: number[],
  options: {
    topK?: number
    threshold?: number       // 最低相似度阈值
    userId?: string          // 权限过滤
    tenantId?: string        // 租户过滤
    documentId?: string      // 文档过滤
  }
): Promise<SearchResult[]> {
  const { topK = 5, threshold = 0.7, userId, tenantId, documentId } = options

  const conditions: string[] = []
  const params: unknown[] = [formatVector(queryVector)]
  let paramIndex = 2

  if (userId) {
    conditions.push(`user_id = $${paramIndex++}`)
    params.push(userId)
  }
  if (tenantId) {
    conditions.push(`tenant_id = $${paramIndex++}`)
    params.push(tenantId)
  }
  if (documentId) {
    conditions.push(`document_id = $${paramIndex++}`)
    params.push(documentId)
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''

  const query = `
    SELECT
      id,
      document_id,
      document_title,
      chunk_index,
      content,
      metadata,
      1 - (embedding <=> $1) AS similarity
    FROM document_chunks
    ${whereClause}
    ${whereClause ? 'AND' : 'WHERE'} 1 - (embedding <=> $1) >= $${paramIndex++}
    ORDER BY embedding <=> $1
    LIMIT $${paramIndex++}
  `
  params.push(threshold, topK)

  const result = await pool.query(query, params)

  return result.rows.map((row) => ({
    id: row.id,
    documentId: row.document_id,
    documentTitle: row.document_title,
    chunkIndex: row.chunk_index,
    content: row.content,
    metadata: row.metadata,
    similarity: row.similarity,
  }))
}

关键点

  • <=> 是余弦距离操作符(1 - cosine_similarity),所以相似度 = 1 - (embedding <=> $1)
  • threshold 参数过滤掉相似度低于阈值的 chunk,避免返回不相关内容
  • userIdtenantIddocumentId 是可选过滤条件,支持权限控制和范围限定

6.2 查询性能验证

验证索引生效

sql
EXPLAIN ANALYZE
SELECT id, content, 1 - (embedding <=> $1) AS similarity
FROM document_chunks
ORDER BY embedding <=> $1
LIMIT 5;

预期输出:应看到 Index Scan using idx_chunks_embedding_hnsw,执行时间应在 1-10 毫秒级别。如果看到 Seq Scan 或执行时间超过 100 毫秒,说明索引未生效或数据量过大。

7. 混合检索:向量 + 关键词

纯向量检索擅长语义匹配(「退款」和「返款」),但对专有名词(产品名、人名、订单号)不敏感。结合 PostgreSQL 的全文检索,可以兼顾两者。

7.1 全文检索配置

sql
-- 添加 tsvector 列
ALTER TABLE document_chunks ADD COLUMN content_tsv TSVECTOR;

-- 自动生成 tsvector(使用 simple 配置,支持中文分词需 zhparser 扩展)
CREATE INDEX idx_chunks_tsv ON document_chunks USING gin(content_tsv);

-- 更新触发器:内容变化时自动更新 tsvector
CREATE TRIGGER update_content_tsv
  BEFORE INSERT OR UPDATE OF content ON document_chunks
  FOR EACH ROW
  EXECUTE FUNCTION tsvector_update_trigger(content_tsv, 'simple', content);

注意:PostgreSQL 默认的 simple 配置按空格分词,对中文支持有限。生产环境建议安装 zhparser 扩展或使用 pg_jieba 扩展支持中文分词。

7.2 混合查询实现

混合检索的核心是融合向量相似度和关键词匹配分数,常用加权求和。

sql
-- 混合查询:向量相似度 + 关键词匹配
SELECT
  id,
  content,
  1 - (embedding <=> $1) AS vector_score,
  ts_rank(content_tsv, plainto_tsquery('simple', $2)) AS keyword_score,
  -- 融合分数(权重可调)
  0.7 * (1 - (embedding <=> $1)) + 0.3 * ts_rank(content_tsv, plainto_tsquery('simple', $2)) AS combined_score
FROM document_chunks
WHERE content_tsv @@ plainto_tsquery('simple', $2)  -- 必须有关键词匹配
   OR 1 - (embedding <=> $1) >= 0.8                 -- 或者向量相似度够高
ORDER BY combined_score DESC
LIMIT 5;

分数融合策略

  • 0.7 * vector_score + 0.3 * keyword_score:偏重语义匹配,适合大多数场景
  • 0.5 * vector_score + 0.5 * keyword_score:语义和关键词并重,适合专有名词多的文档
  • 可以根据实际效果调整权重,通过 A/B 测试确定最优比例

适用场景

  • 文档包含大量专有名词(产品名、人名、订单号)
  • 用户查询既可能是自然语言描述,也可能是精确关键词
  • 纯向量检索召回率低,需要关键词补充

8. 文档生命周期管理

删除文档时需要清理所有相关 chunk,避免孤儿数据占用空间。

typescript
export async function deleteDocument(documentId: string): Promise<void> {
  await pool.query('DELETE FROM document_chunks WHERE document_id = $1', [documentId])
}

生产可用建议

  • 使用软删除(添加 deleted_at 字段),避免误删后无法恢复
  • 删除大量数据后执行 VACUUM ANALYZE,释放空间并更新统计信息
  • 考虑添加定时任务,定期清理已删除文档的孤儿 chunk

9. 性能调优:从参数到配置

9.1 查询参数调优

HNSW 和 IVFFlat 索引都有查询时的搜索范围参数,越大越精确但越慢。

sql
-- HNSW 查询时的搜索范围
SET hnsw.ef_search = 100;  -- 默认 40,越大越精确但越慢

-- IVFFlat 查询时的探测列表数
SET ivfflat.probes = 10;  -- 默认 1,越大越精确但越慢

可以在查询前动态设置,只影响当前事务:

typescript
await client.query('SET LOCAL hnsw.ef_search = 100')
const result = await client.query(searchQuery, params)

调优决策

  • 召回率低于预期:增大 ef_searchprobes,观察召回率提升
  • 查询延迟过高:减小 ef_searchprobes,观察延迟下降
  • 建议在测试环境用真实数据做 A/B 测试,找到精确度和延迟的平衡点

9.2 VACUUM 和 ANALYZE

向量索引需要定期维护,删除大量数据后尤其重要。

sql
VACUUM ANALYZE document_chunks;

原因:PostgreSQL 的 MVCC 机制导致删除的数据不会立即释放,VACUUM 清理死元组,ANALYZE 更新统计信息帮助查询优化器选择正确的执行计划。

生产建议:配置自动 VACUUM,根据数据更新频率调整间隔。高并发写入场景建议每天执行一次手动 VACUUM ANALYZE。

9.3 内存配置

PostgreSQL 的内存配置直接影响索引构建和查询性能。

sql
# PostgreSQL 配置
shared_buffers = '4GB'         # 系统内存的 25%
effective_cache_size = '12GB'  # 系统内存的 75%
maintenance_work_mem = '2GB'   # 索引构建用

配置理由

  • shared_buffers:PostgreSQL 用来缓存数据页,设为系统内存的 25% 是经验值
  • effective_cache_size:告诉查询优化器系统有多少内存可用于缓存,不影响实际分配
  • maintenance_work_mem:VACUUM、CREATE INDEX 等维护操作使用的内存,设大可以加速索引构建

10. 扩展性边界:何时该迁移

pgvector 的规模限制是选择它时最需要清醒认识的代价。

向量数性能建议
< 10 万优秀正常使用,HNSW 索引
10 万 - 100 万良好HNSW 索引,确保足够内存
100 万 - 1000 万一般考虑 IVFFlat 或表分区
> 1000 万较差迁移到专用向量数据库

突破限制的尝试

  • 表分区:按 tenant_iddocument_id 分区,每个分区独立索引
  • 读写分离:查询走只读副本,写入走主库
  • 部分索引:只对活跃数据建索引,历史数据归档

迁移信号

  • 查询延迟超过 500 毫秒,调参后仍无改善
  • 索引构建时间超过可接受范围(如超过 1 小时)
  • 内存占用导致其他业务受影响
  • 需要多副本、分布式部署

迁移目标:下一篇将介绍 Qdrant 实践——功能更全面的专用向量数据库,支持分布式、过滤条件、多租户等企业级特性。

验收清单

完成这篇教程后,用以下清单验收:

  • [ ] pgvector 扩展已启用,SELECT * FROM pg_extension WHERE extname = 'vector' 返回记录
  • [ ] 向量表已创建,embedding 维度和使用的 embedding 模型匹配
  • [ ] 至少创建了一个向量索引(HNSW 或 IVFFlat),EXPLAIN ANALYZE 显示索引被使用
  • [ ] 向量写入功能正常,SELECT COUNT(*) FROM document_chunks 返回预期数量
  • [ ] 相似度查询功能正常,返回结果按相似度排序,相似度计算正确
  • [ ] 混合检索(可选)已配置,全文检索和向量检索分数融合生效
  • [ ] 性能调优参数已根据实际数据量调整,查询延迟在可接受范围
  • [ ] 明确了扩展性边界,知道什么情况下该迁移到专用向量数据库

下一步:如果你的数据量超过百万级,或者需要分布式部署、更丰富的过滤条件,继续阅读下一篇 Qdrant 实践。

基于 MIT 协议开源