主题
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';预期输出:返回一行记录,extname 为 vector,extversion 为当前版本号(如 0.7.0)。
常见错误:
ERROR: could not open extension control file:pgvector 未安装或路径不在 PostgreSQL 搜索路径中。Docker 用户检查镜像是否正确,本地用户检查pg_config --sharedir和pg_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:每层每个节点的连接数,越大越精确但占用内存越多,默认 16ef_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 选择决策
决策逻辑:
- 不确定时先用 HNSW:查询快,开发体验好
- 内存监控发现占用过高:切换到 IVFFlat,调整
lists参数 - 数据量超过 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_hnsw 或 idx_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,避免返回不相关内容userId、tenantId、documentId是可选过滤条件,支持权限控制和范围限定
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_search或probes,观察召回率提升 - 查询延迟过高:减小
ef_search或probes,观察延迟下降 - 建议在测试环境用真实数据做 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_id或document_id分区,每个分区独立索引 - 读写分离:查询走只读副本,写入走主库
- 部分索引:只对活跃数据建索引,历史数据归档
迁移信号:
- 查询延迟超过 500 毫秒,调参后仍无改善
- 索引构建时间超过可接受范围(如超过 1 小时)
- 内存占用导致其他业务受影响
- 需要多副本、分布式部署
迁移目标:下一篇将介绍 Qdrant 实践——功能更全面的专用向量数据库,支持分布式、过滤条件、多租户等企业级特性。
验收清单
完成这篇教程后,用以下清单验收:
- [ ] pgvector 扩展已启用,
SELECT * FROM pg_extension WHERE extname = 'vector'返回记录 - [ ] 向量表已创建,embedding 维度和使用的 embedding 模型匹配
- [ ] 至少创建了一个向量索引(HNSW 或 IVFFlat),
EXPLAIN ANALYZE显示索引被使用 - [ ] 向量写入功能正常,
SELECT COUNT(*) FROM document_chunks返回预期数量 - [ ] 相似度查询功能正常,返回结果按相似度排序,相似度计算正确
- [ ] 混合检索(可选)已配置,全文检索和向量检索分数融合生效
- [ ] 性能调优参数已根据实际数据量调整,查询延迟在可接受范围
- [ ] 明确了扩展性边界,知道什么情况下该迁移到专用向量数据库
下一步:如果你的数据量超过百万级,或者需要分布式部署、更丰富的过滤条件,继续阅读下一篇 Qdrant 实践。