# 用 PostgreSQL + pgvector 搭建向量语义检索：一份可实操分享教程

> 原文：Building Vector Similarity Search in PostgreSQL with pgvector  
> 作者：Bala Priya C，Machine Learning Mastery，2026-05-18  
> 原文链接：https://machinelearningmastery.com/building-vector-similarity-search-in-postgresql-with-pgvector/  
> 本文来源：基于上次 `/gsummary` 缓存摘要与已保存的浏览器 DOM 全文重新生成。  
> 缓存摘要：`/home/lin/.hermes/projects/hermes-gsummary-workflow/runs/outputs/20260519-221821-Building-Vector-Similarity-Search-in-PostgreSQL-with-pgvector-2992498-415826240-summary.md`  
> 全文缓存：`/home/lin/Downloads/mll_pgvector_article.txt`

## 改写后的分享主题

原始需求是“把文章总结为分享教程”。我将它改写成一个更适合团队分享和落地演示的问题：

**如何在已有 PostgreSQL 体系内，用 pgvector 搭建一套最小可用、可验证、可扩展的向量语义检索闭环？**

## 一句话结论

pgvector 让 PostgreSQL 可以直接存储 embedding，并用 SQL 距离操作符、HNSW/IVFFlat 索引完成语义相似检索；适合已经使用 PostgreSQL、希望降低新组件运维成本的中小规模语义搜索/RAG 场景。

## 你能从这篇教程得到什么

完成后你应该能讲清楚并动手验证：

- embedding 为什么要先选模型再建表。
- `vector(n)` 的维度为什么不能随便写。
- `<->`、`<=>`、`<#>` 等距离操作符分别适合什么场景。
- HNSW 和 IVFFlat 的取舍是什么。
- 为什么 operator class 不匹配会导致“SQL 正常但性能退化”。
- 如何用 `EXPLAIN` 验证索引是否真的被使用。
- 如何把向量检索和普通 SQL 过滤条件组合起来。

## 适合场景与不适合场景

适合：

- 业务数据已经在 PostgreSQL 中。
- 向量规模处于从几千到百万级的验证/早期生产阶段。
- 需要把语义检索和 `WHERE`、`JOIN`、权限、租户、价格、状态等关系型条件一起使用。
- 团队希望先少引入一个独立向量数据库组件。

不适合直接套用：

- 已经是超大规模向量检索，例如千万级以上且延迟极敏感。
- 需要复杂分片、多副本、专门向量召回服务治理。
- 已经有成熟向量数据库平台和运维体系。

## 核心概念：什么是向量语义检索

关键词搜索依赖字面匹配。比如用户搜索：

```text
something warm and breathable for high-altitude trekking
```

如果商品描述里写的是“轻量中层保暖衣”“高海拔徒步装备”，传统关键词检索可能匹配不到。

向量语义检索的做法是：

1. 用 embedding 模型把商品描述、文档段落或 FAQ 转成一组浮点数。
2. 用同一个模型把用户查询也转成向量。
3. 在数据库中比较查询向量和已存向量的距离。
4. 距离越近，语义越相似。

最小流程是：

```text
文本数据
→ embedding 模型
→ 向量写入 PostgreSQL
→ 查询文本转向量
→ SQL 按向量距离排序
→ 返回 Top-K 相似结果
```

## 第 1 步：先选 embedding 模型，再设计表结构

pgvector 的一个硬约束是：向量维度写在列定义里。

例如：

```sql
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT,
    description TEXT,
    price NUMERIC(8, 2),
    embedding vector(1536)
);
```

这里的 `vector(1536)` 必须和模型输出维度一致。

原文列出的模型选择包括：

- OpenAI `text-embedding-3-small` / `text-embedding-3-large`：常见维度为 1536 / 3072。
- Cohere Embed v4：多语言、多模态，文本和图片可在共享向量空间中表示。
- EmbeddingGemma：Google 开源模型，308M 参数，768 维，支持 Matryoshka truncation 和 100+ 语言。
- BAAI/BGE-M3：开源、自托管，支持 1000+ 语言和较长文本序列。
- Sentence Transformers：轻量、本地可跑，适合开发和原型验证。

实践建议：

- Demo 阶段可以用小模型或手写向量理解流程。
- 生产前必须固定模型名称、版本、维度和文本切分策略。
- 换模型通常意味着全量重新 embedding 和重建索引，不要把它当成普通配置变更。

## 第 2 步：安装并启用 pgvector

pgvector 支持 PostgreSQL 13 及以上版本。

Debian / Ubuntu 示例：

```bash
sudo apt install postgresql-18-pgvector
```

把 `18` 替换成你的 PostgreSQL 大版本。

源码安装示例：

```bash
cd /tmp
git clone --branch v0.8.2 https://github.com/pgvector/pgvector.git
cd pgvector
make
sudo make install
```

macOS：

```bash
brew install pgvector
```

在目标数据库中启用扩展：

```sql
CREATE EXTENSION IF NOT EXISTS vector;
```

验证：

```sql
SELECT extname, extversion
FROM pg_extension
WHERE extname = 'vector';
```

预期：返回一行 `vector` 扩展记录。

## 第 3 步：创建一个 3 维 demo 表

真实系统通常是 768、1536、3072 维。为了分享时能看懂，我们先用 3 维向量模拟户外装备商品。

```sql
DROP TABLE IF EXISTS gear;

CREATE TABLE gear (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT,
    description TEXT,
    embedding vector(3)
);
```

写入样例数据：

```sql
INSERT INTO gear (name, category, description, embedding) VALUES
('Merrell Moab 3 GTX', 'Footwear', 'Waterproof hiking boot for all-day trail comfort', '[0.82, 0.15, 0.44]'),
('Salomon Speedcross 6', 'Footwear', 'Aggressive trail runner for muddy and technical terrain', '[0.79, 0.21, 0.38]'),
('Black Diamond Spot 400', 'Lighting', 'Rechargeable headlamp with 400 lumens and waterproofing', '[0.11, 0.88, 0.22]'),
('Petzl ACTIK CORE', 'Lighting', 'Lightweight headlamp for hiking and camping', '[0.09, 0.91, 0.19]'),
('Osprey Atmos AG 65', 'Backpacks', 'Anti-gravity backpack for multi-day backcountry trips', '[0.55, 0.30, 0.77]'),
('Gregory Baltoro 75', 'Backpacks', 'High-volume pack for extended wilderness expeditions', '[0.58, 0.28, 0.81]');
```

这些手写向量只用于教学：

- Footwear 更靠近第一个维度。
- Lighting 更靠近第二个维度。
- Backpacks 更靠近第三个维度。

真实系统里，这些向量来自 embedding API 或本地模型。

## 第 4 步：执行第一条相似检索 SQL

假设用户查询“trail footwear for rough terrain”，应用层将它转成向量：

```text
[0.80, 0.19, 0.40]
```

用 L2 距离查询：

```sql
SELECT
    name,
    category,
    description,
    embedding <-> '[0.80, 0.19, 0.40]' AS distance
FROM gear
ORDER BY distance
LIMIT 3;
```

预期结果形态：

```text
name                    category    distance
Salomon Speedcross 6    Footwear    0.0300
Merrell Moab 3 GTX      Footwear    0.0600
Osprey Atmos AG 65      Backpacks   0.4599
```

解读：

- `<->` 是 L2 / 欧几里得距离。
- 距离越小，语义越接近。
- 两个 Footwear 商品排在最前，符合预期。

## 第 5 步：选择正确的距离度量

pgvector 常见操作符：

- `<->`：L2 / 欧几里得距离。
- `<=>`：余弦距离。
- `<#>`：负内积，想得到实际 similarity 时要注意取反。
- `<+>`：L1 / 曼哈顿距离。
- `<~>`：Hamming distance，二进制向量。
- `<%>`：Jaccard distance，二进制向量。

对多数 LLM 文本 embedding，默认建议：

```text
优先从余弦距离 <=> 开始。
```

原因是文本 embedding 通常把语义编码在方向上，而不是向量长度上。

同一个查询换成余弦距离：

```sql
SELECT
    name,
    embedding <=> '[0.80, 0.19, 0.40]' AS cosine_distance
FROM gear
ORDER BY cosine_distance
LIMIT 3;
```

验证标准：Top-K 的类别和人工直觉一致；如果业务有标注样本，应比较 Recall@K、MRR 或人工相关性评分。

## 第 6 步：为什么需要索引

没有索引时，PostgreSQL 会对每一行计算一次距离：

```text
query vector vs row 1
query vector vs row 2
query vector vs row 3
...
```

几千行问题不大；到百万行时，延迟会明显变差。

pgvector 主要提供两种近似最近邻索引：HNSW 和 IVFFlat。

## 第 7 步：HNSW 和 IVFFlat 怎么选

### HNSW：默认优先考虑

HNSW 构建多层图结构，查询时从粗粒度层一路导航到相近节点。

优点：

- 召回率通常更好。
- 查询性能通常更好。
- 没有训练步骤，可以增量构建。
- 可以在空表上创建，再逐步写入数据。

代价：

- 构建更慢。
- 内存消耗更高。

创建 HNSW 索引：

```sql
CREATE INDEX gear_embedding_hnsw_cosine_idx
ON gear
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
```

参数说明：

- `m`：图中每个节点最大连接数。
- `ef_construction`：构建索引时的候选列表大小。

查询时可以调：

```sql
SET hnsw.ef_search = 100;
```

`ef_search` 越高，召回可能越好，但延迟也会上升。

### IVFFlat：构建快、资源低，但要关注退化

IVFFlat 会在索引构建时把向量空间划分成若干簇，查询时只搜索最接近的簇。

优点：

- 构建较快。
- 内存占用较低。

代价：

- 通常召回不如 HNSW。
- 需要在已有代表性数据后再建索引。
- 后续新增数据如果分布变化，召回可能逐步下降。

创建 IVFFlat 索引：

```sql
CREATE INDEX gear_embedding_ivfflat_cosine_idx
ON gear
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
```

查询时可以调：

```sql
SET ivfflat.probes = 10;
```

`probes` 越高，搜索的簇越多，召回更好但延迟更高。

## 第 8 步：operator class 必须和查询操作符匹配

这是本文最重要的生产坑。

如果查询用余弦距离：

```sql
ORDER BY embedding <=> '[0.80, 0.19, 0.40]'
```

索引必须用：

```sql
USING hnsw (embedding vector_cosine_ops)
```

对应关系：

- `<->` 对应 `vector_l2_ops`
- `<=>` 对应 `vector_cosine_ops`
- `<#>` 对应 `vector_ip_ops`
- `<+>` 对应 `vector_l1_ops`
- `<~>` 对应 `bit_hamming_ops`
- `<%>` 对应 `bit_jaccard_ops`

如果不匹配，PostgreSQL 可能不会报错，而是退回顺序扫描。SQL 看起来没问题，线上性能却会出问题。

## 第 9 步：用 EXPLAIN 验证索引是否命中

不要只看“查询能不能跑”，要看执行计划。

```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT name, category
FROM gear
ORDER BY embedding <=> '[0.80, 0.19, 0.40]'
LIMIT 3;
```

你需要确认：

- 是否出现对应索引扫描。
- 是否退化为 `Seq Scan`。
- `Buffers` 和实际耗时是否符合预期。

如果仍是顺序扫描，优先检查：

- 查询操作符和 operator class 是否匹配。
- 表数据量是否太小，优化器认为顺序扫描更便宜。
- `LIMIT`、排序表达式是否写成了索引可用的形式。
- HNSW/IVFFlat 索引是否真的存在。

## 第 10 步：和普通 SQL 过滤条件组合

pgvector 的工程优势在于它仍然是 PostgreSQL。

例如，只在 Footwear 分类中找相似商品：

```sql
SELECT
    name,
    category,
    embedding <-> '[0.80, 0.19, 0.40]' AS distance
FROM gear
WHERE category = 'Footwear'
ORDER BY distance
LIMIT 2;
```

预期结果：

```text
name                    category    distance
Salomon Speedcross 6    Footwear    0.0004
Merrell Moab 3 GTX      Footwear    0.0016
```

生产中可以继续加：

```sql
SELECT
    p.name,
    p.category,
    p.price,
    p.embedding <=> $1 AS distance
FROM products p
WHERE p.category = 'Footwear'
  AND p.price BETWEEN 50 AND 200
  AND p.status = 'active'
ORDER BY p.embedding <=> $1
LIMIT 10;
```

这就是为什么 pgvector 对已有 PostgreSQL 团队很有吸引力：语义检索可以和租户、权限、价格、状态、时间范围、JOIN 等条件合在一起。

## 实操案例 1：最小 SQL 演示脚本

适合分享现场 5 分钟演示。

```sql
CREATE EXTENSION IF NOT EXISTS vector;

DROP TABLE IF EXISTS gear;

CREATE TABLE gear (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT,
    description TEXT,
    embedding vector(3)
);

INSERT INTO gear (name, category, description, embedding) VALUES
('Merrell Moab 3 GTX', 'Footwear', 'Waterproof hiking boot for all-day trail comfort', '[0.82, 0.15, 0.44]'),
('Salomon Speedcross 6', 'Footwear', 'Aggressive trail runner for muddy and technical terrain', '[0.79, 0.21, 0.38]'),
('Black Diamond Spot 400', 'Lighting', 'Rechargeable headlamp with 400 lumens and waterproofing', '[0.11, 0.88, 0.22]'),
('Petzl ACTIK CORE', 'Lighting', 'Lightweight headlamp for hiking and camping', '[0.09, 0.91, 0.19]'),
('Osprey Atmos AG 65', 'Backpacks', 'Anti-gravity backpack for multi-day backcountry trips', '[0.55, 0.30, 0.77]'),
('Gregory Baltoro 75', 'Backpacks', 'High-volume pack for extended wilderness expeditions', '[0.58, 0.28, 0.81]');

SELECT
    name,
    category,
    embedding <-> '[0.80, 0.19, 0.40]' AS distance
FROM gear
ORDER BY distance
LIMIT 3;

CREATE INDEX gear_embedding_hnsw_cosine_idx
ON gear
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

EXPLAIN (ANALYZE, BUFFERS)
SELECT name, category
FROM gear
ORDER BY embedding <=> '[0.80, 0.19, 0.40]'
LIMIT 3;
```

验收标准：

- 表能创建。
- 数据能插入。
- Top-K 查询返回 Footwear 靠前的结果。
- HNSW 索引能创建。
- `EXPLAIN` 输出能用于判断是否命中索引。

失败处理：

- `type "vector" does not exist`：没有执行 `CREATE EXTENSION vector;`，或扩展没有安装。
- `expected 3 dimensions, not ...`：插入向量维度和 `vector(3)` 不一致。
- 索引不命中：检查 `<=>` 是否对应 `vector_cosine_ops`，以及表数据是否太少导致优化器选择顺序扫描。

## 实操案例 2：应用层写入 embedding 的结构

真实系统不要手写向量，而是在应用层调用 embedding 模型。

伪代码：

```python
def create_product(conn, product: Product) -> None:
    text = f"{product.name}\n{product.description}"
    embedding = embedding_client.embed(text)

    conn.execute(
        """
        INSERT INTO products (name, category, description, price, embedding)
        VALUES (%s, %s, %s, %s, %s)
        """,
        (product.name, product.category, product.description, product.price, embedding),
    )


def search_products(conn, query: str, limit: int = 10) -> list[ProductSearchResult]:
    query_embedding = embedding_client.embed(query)

    return conn.execute(
        """
        SELECT
            name,
            category,
            description,
            price,
            embedding <=> %s AS distance
        FROM products
        WHERE status = 'active'
        ORDER BY embedding <=> %s
        LIMIT %s
        """,
        (query_embedding, query_embedding, limit),
    ).fetchall()
```

验收标准：

- 写入前记录模型名称和版本。
- `embedding` 长度等于数据库 `vector(n)` 的 `n`。
- 查询和写入使用同一个模型。
- embedding API 失败时不要写入半成品数据。

失败处理：

- embedding 服务超时：应用层重试，超过阈值进入失败队列。
- 模型版本切换：新增列或新表灰度，不要直接覆盖旧 embedding。
- 维度不匹配：在应用层写入前先校验长度，避免数据库报错才发现。

## 实操案例 3：模型切换和回滚方案

不要直接把旧列覆盖掉。更安全的做法是并行保留新旧 embedding。

```sql
ALTER TABLE products
ADD COLUMN embedding_v2 vector(3072);
```

迁移流程：

1. 新增 `embedding_v2` 列。
2. 后台批量生成 v2 embedding。
3. 为 `embedding_v2` 创建新索引。
4. 用相同查询集对比 v1/v2 的 Top-K 质量和延迟。
5. 灰度切流量。
6. 保留旧列一段时间，确认无问题后再清理。

验收标准：

- v2 覆盖率达到目标，例如 99.9%。
- v2 查询延迟不高于可接受阈值。
- v2 Top-K 质量不低于 v1。
- 有明确回滚开关。

失败处理：

- v2 召回变差：停止切流，继续使用 v1。
- 索引构建耗时过长：低峰构建，必要时分表或分批。
- 存储膨胀明显：评估 half-precision、归档旧 embedding 或拆分冷热数据。

## 生产检查清单

上线前至少检查：

- 模型：名称、版本、维度已记录。
- Schema：`vector(n)` 和模型维度一致。
- 写入：embedding 生成失败不会产生脏数据。
- 查询：距离操作符符合模型特性。
- 索引：operator class 和查询操作符一致。
- 执行计划：`EXPLAIN (ANALYZE, BUFFERS)` 已验证。
- 质量：有人工样本或评测集评估 Top-K 结果。
- 延迟：有 p50 / p95 / p99 压测数据。
- 内存：HNSW 索引内存消耗已评估。
- 演进：换模型、重建索引、回滚方案已准备。

## 常见坑

### 坑 1：先建表，后选模型

`vector(1536)` 不是随便写的。模型一换，维度可能就变了，后面要全量重算。

### 坑 2：操作符和索引类型不匹配

查询用 `<=>`，索引用 `vector_l2_ops`，可能不报错，但可能退化为顺序扫描。

### 坑 3：只看延迟，不看召回

HNSW 的 `ef_search`、IVFFlat 的 `probes` 都是在延迟和召回之间取舍。不能只看查询快不快。

### 坑 4：IVFFlat 建在空表或小样本上

IVFFlat 依赖构建时的数据分布。数据不代表真实分布，后续召回可能很差。

### 坑 5：把 pgvector 当成万能向量数据库

pgvector 的优势是简单、低运维成本、和关系数据结合自然。但超大规模向量检索仍可能需要专门向量数据库。

## 5 分钟分享讲稿

可以按这个顺序讲：

1. 传统关键词搜索为什么不够：自然语言意图和商品描述不一定同词。
2. embedding 把语义转成向量：相似文本在向量空间里距离更近。
3. pgvector 的价值：向量列、距离操作符、HNSW/IVFFlat 索引直接在 PostgreSQL 内完成。
4. 最小 SQL demo：建表、插入 3 维样例、Top-K 查询。
5. 生产坑：模型维度、距离度量、operator class、`EXPLAIN` 验证。

## 结尾总结

pgvector 不是“更高级的 PostgreSQL 插件”这么简单，它是把语义检索拉回到已有关系数据库体系的一种工程折中。真正要做好的不是安装扩展，而是管住四件事：**模型维度、距离度量、索引匹配、执行计划验证**。

如果团队已经有 PostgreSQL，而且只是要先落地语义搜索或 RAG 检索原型，pgvector 是一个简单够用、运维成本低的起点。
