📝 本文首发于 栏轩·阁
欢迎访问阅读原文,获取更好的阅读体验。
为什么用 pgvector,而不是 Redis
我在初学的时候先用 Redis + RediSearch 实现向量检索,遇到了几个痛点:
- Redis 的向量能力是后加的:RediSearch 模块对向量的支持相对有限,索引类型少,精度和召回率不如专门的向量方案
- 数据类型受限:Redis 的 value 结构决定了存向量要么序列化 blob、要么拆字段,查询和调试都不直观
- 维护两个存储:业务数据在 PostgreSQL,向量在 Redis,两套存一起还得考虑数据一致性,架构复杂度翻倍
pgvector 的优势:
- 向量就是 PostgreSQL 的一个字段类型 ,跟
TEXT、INTEGER没区别 - 一条 SQL 里可以同时查业务字段和向量相似度,不需要跨数据源
- 支持 L2 欧氏距离、余弦距离、内积距离
- 支持 IVFFlat 和 HNSW 索引,百万级数据毫秒响应
环境搭建:Docker Compose 一键部署
pgvector 是 PostgreSQL 的扩展插件,原生 PostgreSQL 镜像不带它。官方提供了 pgvector/pgvector 镜像,开箱即用:
yaml
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg17
container_name: pgvector-compose
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123456
POSTGRES_DB: vectordb
ports:
- "5432:5432"
volumes:
- ./pgdata:/var/lib/postgresql/data
如果你已有 PostgreSQL,手动加插件
如果你是在已有的 PostgreSQL 上中途加向量能力,几步搞定:
bash
# 1. 进入容器或服务器
docker exec -it your-postgres bash
# 2. 安装 pgvector 扩展(Debian/Ubuntu)
apt-get update && apt-get install -y postgresql-17-pgvector
# 3. 重启 PostgreSQL
pg_ctl restart
然后连上去启用扩展即可(见下一节)。
SQL 基本操作
我使用 DBX 作为数据库客户端。虽然是官方镜像,但扩展默认未启用,需要先手动开启:
sql
CREATE EXTENSION IF NOT EXISTS vector;
建表
向量在 PostgreSQL 里就是一个特殊的字段类型 vector(dim)。假设我们要存一个 3 维向量(便于手算理解),建表如下:
sql
-- 创建一个商品表,embedding 是 3 维向量
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
price DECIMAL(10,2),
embedding VECTOR(3)
);
插入数据
插入向量时,直接用 [ ] 包住浮点数即可,非常直观:
sql
INSERT INTO products (name, price, embedding) VALUES
('苹果', 5.00, '[1.0, 0.0, 0.0]'),
('香蕉', 3.50, '[0.0, 1.0, 0.0]'),
('樱桃', 8.00, '[0.0, 0.0, 1.0]'),
('苹果派', 12.00, '[0.9, 0.1, 0.1]');
核心操作------相似度查询
这是 pgvector 最精华的地方。两个最常用的距离运算符:
| 运算符 | 含义 | 值越小表示 |
|---|---|---|
<-> |
L2 欧氏距离 | 向量越接近 |
<=> |
余弦距离 | 方向越相似(不受向量长度影响) |
找与苹果([1,0,0])最相似的商品:
sql
SELECT
name,
price,
embedding <=> '[1.0, 0.0, 0.0]' AS distance
FROM products
ORDER BY distance ASC;
输出:
name price distance
苹果 5.00 0
苹果派 12.00 0.1732050949521497
香蕉 3.50 1.4142135623730951
樱桃 8.00 1.4142135623730951
可以看到:
- 苹果与自己距离为 0(完全匹配)
- 苹果派 (
[0.9,0.1,0.1])与苹果方向最接近,排在第二 - 香蕉和樱桃距离都是 1.414,明显不相似
你还可以任意加 WHERE 条件,比如筛选价格低于 10 元的:
sql
SELECT name, price, embedding <-> '[1.0, 0.0, 0.0]' AS distance
FROM products
WHERE price < 10.0
ORDER BY distance ASC;
性能优化:索引
当数据量增大到万级以上,全表扫描就不够快了。pgvector 提供了两种索引:
IVFFlat(倒排文件索引)
原理是把向量空间划分为多个桶(cluster),查询时只搜索最近的几个桶,而不是全部数据:
sql
-- 先设置 probes(查询桶数,默认 1)
SET ivfflat.probes = 1;
-- 创建索引(lists = 4 表示分 4 个桶,建议 lists = 行数 / 1000)
CREATE INDEX ON products
USING ivfflat (embedding vector_l2_ops)
WITH (lists = 4);
如何理解 IVFFlat?
假设我有 1000 个商品,创建 IVFFlat 索引(lists = 4)后:
- pgvector 先将 1000 个向量按距离聚成 4 个桶
- 查询时,先算目标向量离哪 1 个桶最近(
probes = 1) - 然后只在这个桶内做精确搜索
这种方式牺牲一点点精度来换取几十倍的性能提升。
选择合适的 operator class:
vector_l2_ops--- 配合<->欧氏距离vector_cosine_ops--- 配合<=>余弦距离vector_ip_ops--- 配合<#>内积距离
Java 实战:SpringBoot + MyBatisPlus 整合
理论讲完了,来看看怎么在 Java 项目里用 pgvector。我会以商品相似度检索为例,完整走一遍 CRUD + 向量查询。
项目环境
- Spring Boot 4.1.0
- MyBatis-Plus 3.5.17(使用
mybatis-plus-spring-boot4-starter) - pgvector-java 0.1.6
- PostgreSQL 17 + pgvector 插件
- JDK 21
1. 引入依赖
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus Spring Boot 4 Starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>3.5.17</version>
</dependency>
<!-- PostgreSQL 驱动(注意:不要加 runtime scope) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- pgvector Java 客户端 -->
<dependency>
<groupId>com.pgvector</groupId>
<artifactId>pgvector</artifactId>
<version>0.1.6</version>
</dependency>
</dependencies>
⚠️ 注意 :PostgreSQL 驱动不要加
<scope>runtime</scope>,因为 pgvector 的PGvector类在编译时就依赖了 PostgreSQL JDBC 的接口PGBinaryObject,设为 runtime 会导致编译失败。
2. 配置数据源
yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/vectordb
username: postgres
password: 123456
driver-class-name: org.postgresql.Driver
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
type-handlers-package: com.example.pgvectordemo.typehandler
3. 实体类 ------ 关键:向量字段的 TypeHandler
先看成品,再解释原理:
java
@TableName("products")
public class Product {
@TableId(type = IdType.AUTO)
private Integer id;
private String name;
private BigDecimal price;
// 关键:指定自定义 TypeHandler
@TableField(typeHandler = VectorTypeHandler.class)
private float[] embedding;
// getter / setter / constructor ...
}
embedding 字段的类型是 Java 的 float[],但数据库里是 PostgreSQL 的 vector(3)。MyBatis 默认不认识 vector 类型,所以需要告诉它两者之间怎么转换------这就是 TypeHandler 的作用。
什么是 TypeHandler?
TypeHandler 是 MyBatis 的类型转换器,负责 Java 类型 ↔ JDBC 类型 的双向翻译:
写入数据库: Java 对象 ──→ TypeHandler ──→ JDBC PreparedStatement
读取数据库: JDBC ResultSet ──→ TypeHandler ──→ Java 对象
MyBatis 内置了很多常见类型的 TypeHandler(比如 StringTypeHandler、IntegerTypeHandler),但 float[] ↔ PostgreSQL vector 这种组合不存在,所以得手写一个。
TypeHandler 完整代码 + 逐行解析
java
@MappedTypes(float[].class) // ① 这个 Handler 处理哪个 Java 类型
@MappedJdbcTypes(JdbcType.OTHER) // ② 对应的 JDBC 类型(OTHER 表示非标准类型)
public class VectorTypeHandler extends BaseTypeHandler<float[]> {
// ↑ ③ 泛型参数:声明处理的是 float[]
BaseTypeHandler<float[]> 要求子类实现 4 个抽象方法,对应不同的读写场景:
| 方法 | 何时调用 | 做什么 |
|---|---|---|
setNonNullParameter |
INSERT / UPDATE 时 | 把 Java 值设到 SQL 的 ? 占位符上 |
getNullableResult(rs, String) |
按列名查询结果时 | 从 ResultSet 读到 Java |
getNullableResult(rs, int) |
按列索引查询结果时 | 同上,按数字索引 |
getNullableResult(CallableStatement, int) |
调用存储过程时 | 一般用不到,但必须实现 |
写入方法详解
java
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
float[] parameter, JdbcType jdbcType) throws SQLException {
// 参数说明:
// ps --- JDBC 预编译语句,就是那个带 ? 的 SQL
// i --- 第几个 ? 占位符(从 1 开始)
// parameter --- Java 层的 float[] 值
// jdbcType --- JDBC 类型(这里就是 JdbcType.OTHER)
// 第一步:把 float[] 包装成 PGvector 对象
PGvector vector = new PGvector(parameter);
// 等效于做了:[1.0, 0.0, 0.0] → PGvector 实例
// 第二步:通过 JDBC 的 setObject 传给 PostgreSQL
ps.setObject(i, vector);
// PGvector 内部实现了 PGobject 接口,
// JDBC 驱动会自动调用它的 getValue() 得到 "[1.0,0.0,0.0]"
// 然后 PostgreSQL 就能正确识别为 vector 类型
}
整个写入数据流:
Java: float[] {1.0, 0.0, 0.0}
↓ new PGvector(...)
PG: PGvector 对象
↓ ps.setObject()
JDBC: "[1.0,0.0,0.0]" ::vector
↓ 网络传输
PostgreSQL: INSERT INTO products (embedding) VALUES ('[1.0,0.0,0.0]')
读取方法详解
java
@Override
public float[] getNullableResult(ResultSet rs, String columnName) throws SQLException {
// 参数说明:
// rs --- 查询结果集,指向当前行
// columnName --- 列名,比如 "embedding"
// 为什么不直接 (PGvector) rs.getObject(columnName) ?
// 因为 JDBC 不认识 vector 类型,getObject() 返回的其实是个 String
// 所以直接用 getString() 读取原始文本 "[1.0,0.0,0.0]"
String value = rs.getString(columnName);
return parseVector(value);
}
// getNullableResult(rs, int columnIndex) 逻辑完全一样,只是按数字取列
// getNullableResult(CallableStatement, int) 是给存储过程用的
关键问题:为什么读的时候不直接用 PGvector 对象?
PostgreSQL JDBC 驱动的 getObject() 方法默认不认识 vector 类型------除非你手动调用 PGvector.registerTypes(conn) 注册类型映射。但这样就要在每次获取连接时做额外处理,比较麻烦。
更稳定的方案 是:直接读字符串 "[1.0,0.0,0.0]",然后手动解析。
解析方法详解
java
private float[] parseVector(String value) {
// value 格式:"[1.0, 0.0, 0.0]"
if (value == null) return null;
String trimmed = value.trim();
// 去掉首尾的方括号
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
trimmed = trimmed.substring(1, trimmed.length() - 1);
}
// 现在 trimmed = "1.0, 0.0, 0.0"
if (trimmed.isEmpty()) return new float[0];
String[] parts = trimmed.split(",");
// parts = ["1.0", " 0.0", " 0.0"]
float[] result = new float[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = Float.parseFloat(parts[i].trim());
// Float.parseFloat(" 0.0") → 0.0
}
return result; // float[] {1.0, 0.0, 0.0}
}
整个读取数据流:
PostgreSQL: 返回 '[1.0,0.0,0.0]'::vector
↓ 网络传输
JDBC: PgObject.getValue() = "[1.0, 0.0, 0.0]"
↓ rs.getString("embedding")
String: "[1.0, 0.0, 0.0]"
↓ parseVector() 手动解析
Java: float[] {1.0, 0.0, 0.0}
TypeHandler 如何注册生效?
TypeHandler 有 3 种注册方式,我们用的事务最简单的一种:
yaml
mybatis-plus:
type-handlers-package: com.example.pgvectordemo.typehandler
只要在 application.yml 里配了这个路径,MyBatis 启动时就会自动扫描该包下的所有 @MappedTypes 注解,注册进去。
然后实体类里通过 @TableField(typeHandler = VectorTypeHandler.class) 指定这个字段用哪个 Handler,MyBatis 执行 SQL 时就会自动调用对应的方法。
小结:什么情况需要自定义 TypeHandler?
只要你的 Java 类型和数据库类型没法直接对应,就需要写 TypeHandler。常见场景:
| 场景 | Java 类型 | 数据库类型 |
|---|---|---|
| 向量检索(本文) | float[] |
PostgreSQL vector |
| JSON 字段 | 自定义对象 / Map |
PostgreSQL jsonb |
| 枚举 | Enum 对象 |
VARCHAR 或 INTEGER |
| 数组 | List<String> |
PostgreSQL TEXT[] |
| 加密字段 | String(密文) |
VARCHAR |
原理都一样:继承 BaseTypeHandler<T>,实现 4 个方法,配好注解和扫描路径即可。
4. Mapper:基础 CRUD + 向量查询
MyBatis-Plus 的 BaseMapper 提供 insert、selectById、updateById 等基础方法。我们额外写两个向量相似度查询:
java
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
// 余弦相似度
@Select("""
SELECT id, name, price, embedding
FROM products
ORDER BY embedding <=> #{targetEmbedding}::vector
LIMIT #{topN}
""")
List<Product> findSimilarByCosine(@Param("targetEmbedding") String targetEmbedding,
@Param("topN") int topN);
// 欧氏距离
@Select("""
SELECT id, name, price, embedding
FROM products
ORDER BY embedding <-> #{targetEmbedding}::vector
LIMIT #{topN}
""")
List<Product> findSimilarByEuclidean(@Param("targetEmbedding") String targetEmbedding,
@Param("topN") int topN);
}
注意参数要转成 '[1.0,0.0,0.0]'::vector 格式传入。
5. Service
继承 ServiceImpl 获得完整 CRUD,同时封装向量查询方法:
java
@Service
public class ProductService extends ServiceImpl<ProductMapper, Product> {
public List<Product> findSimilarByCosine(float[] targetVector, int topN) {
return baseMapper.findSimilarByCosine(
arrayToPgvectorString(targetVector), topN);
}
public List<Product> findSimilarByEuclidean(float[] targetVector, int topN) {
return baseMapper.findSimilarByEuclidean(
arrayToPgvectorString(targetVector), topN);
}
private String arrayToPgvectorString(float[] arr) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < arr.length; i++) {
if (i > 0) sb.append(",");
sb.append(arr[i]);
}
sb.append("]");
return sb.toString();
}
}
6. 启动运行验证
项目启动后自动运行 Demo,输出结果如下:
========== pgvector + MyBatis-Plus Demo Start ==========
✅ 成功插入 4 条商品数据
📋 全部商品列表:
Product{id=1, name='苹果', price=5.00, embedding=[1.0, 0.0, 0.0]}
Product{id=2, name='香蕉', price=3.50, embedding=[0.0, 1.0, 0.0]}
Product{id=3, name='樱桃', price=8.00, embedding=[0.0, 0.0, 1.0]}
Product{id=4, name='苹果派', price=12.00, embedding=[0.9, 0.1, 0.1]}
🔍 余弦相似度查询:与「苹果」最相似的商品
Top1: 苹果 (embedding=[1.0, 0.0, 0.0])
Top2: 苹果派 (embedding=[0.9, 0.1, 0.1])
Top3: 香蕉 (embedding=[0.0, 1.0, 0.0])
📐 欧氏距离查询:与「苹果」距离最近的商品
Top1: 苹果 (embedding=[1.0, 0.0, 0.0])
Top2: 苹果派 (embedding=[0.9, 0.1, 0.1])
Top3: 香蕉 (embedding=[0.0, 1.0, 0.0])
========== pgvector + MyBatis-Plus Demo End ==========
完整的 demo 项目代码在博客同目录下的 pgvector-demo/ 文件夹中。
踩坑记录
| 问题 | 原因 | 解决 |
|---|---|---|
编译找不到 PGBinaryObject |
PostgreSQL driver scope 为 runtime | 去掉 scope,改为默认 compile |
PGvector.fromSqlType() 不存在 |
这是 0.0.x 版本的老 API,0.1.x 已移除 | 改用 rs.getString() 手解析字符串 |
ServiceImpl 类找不到 |
3.5.13+ 移到了新包 | Spring Boot 4 请用 com.baomidou.mybatisplus.spring.service.impl.ServiceImpl |
总结
- pgvector 让 PostgreSQL 原生支持向量检索,不需要额外搭一套向量数据库,一条 SQL 搞定业务字段和相似度查询
- 部署简单:官方 Docker 镜像开箱即用,已有 PG 也能手动加插件
- 查询灵活:支持 L2 欧氏距离、余弦距离、内积,可以混合 WHERE 条件
- 性能可靠:IVFFlat / HNSW 索引支持百万级规模
- Java 整合不复杂 :核心就是写一个 TypeHandler 做
float[]↔vector的转换
如果你正在做 RAG、图片相似搜索、推荐系统等需要向量检索的功能,不妨试试 pgvector------毕竟,能少维护一个中间件就少一个。