📝本文首发于 栏轩·阁
欢迎访问阅读原文,获取更好的阅读体验。
为什么用 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镜像,开箱即用:
version:'3.8'services:postgres:image:pgvector/pgvector:pg17container_name:pgvector-composeenvironment:POSTGRES_USER:postgresPOSTGRES_PASSWORD:123456POSTGRES_DB:vectordbports:-"5432:5432"volumes:-./pgdata:/var/lib/postgresql/data如果你已有 PostgreSQL,手动加插件
如果你是在已有的 PostgreSQL 上中途加向量能力,几步搞定:
# 1. 进入容器或服务器dockerexec-ityour-postgresbash# 2. 安装 pgvector 扩展(Debian/Ubuntu)apt-getupdate&&apt-getinstall-ypostgresql-17-pgvector# 3. 重启 PostgreSQLpg_ctl restart然后连上去启用扩展即可(见下一节)。
SQL 基本操作
我使用DBX作为数据库客户端。虽然是官方镜像,但扩展默认未启用,需要先手动开启:
CREATEEXTENSIONIFNOTEXISTSvector;建表
向量在 PostgreSQL 里就是一个特殊的字段类型vector(dim)。假设我们要存一个 3 维向量(便于手算理解),建表如下:
-- 创建一个商品表,embedding 是 3 维向量CREATETABLEproducts(idSERIALPRIMARYKEY,nameTEXT,priceDECIMAL(10,2),embedding VECTOR(3));插入数据
插入向量时,直接用[ ]包住浮点数即可,非常直观:
INSERTINTOproducts(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])最相似的商品:
SELECTname,price,embedding<=>'[1.0, 0.0, 0.0]'ASdistanceFROMproductsORDERBYdistanceASC;输出:
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 元的:
SELECTname,price,embedding<->'[1.0, 0.0, 0.0]'ASdistanceFROMproductsWHEREprice<10.0ORDERBYdistanceASC;性能优化:索引
当数据量增大到万级以上,全表扫描就不够快了。pgvector 提供了两种索引:
IVFFlat(倒排文件索引)
原理是把向量空间划分为多个桶(cluster),查询时只搜索最近的几个桶,而不是全部数据:
-- 先设置 probes(查询桶数,默认 1)SETivfflat.probes=1;-- 创建索引(lists = 4 表示分 4 个桶,建议 lists = 行数 / 1000)CREATEINDEXONproductsUSINGivfflat(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. 引入依赖
<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. 配置数据源
spring:datasource:url:jdbc:postgresql://localhost:5432/vectordbusername:postgrespassword:123456driver-class-name:org.postgresql.Drivermybatis-plus:configuration:log-impl:org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case:truetype-handlers-package:com.example.pgvectordemo.typehandler3. 实体类 —— 关键:向量字段的 TypeHandler
先看成品,再解释原理:
@TableName("products")publicclassProduct{@TableId(type=IdType.AUTO)privateIntegerid;privateStringname;privateBigDecimalprice;// 关键:指定自定义 TypeHandler@TableField(typeHandler=VectorTypeHandler.class)privatefloat[]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[]↔ PostgreSQLvector这种组合不存在,所以得手写一个。
TypeHandler 完整代码 + 逐行解析
@MappedTypes(float[].class)// ① 这个 Handler 处理哪个 Java 类型@MappedJdbcTypes(JdbcType.OTHER)// ② 对应的 JDBC 类型(OTHER 表示非标准类型)publicclassVectorTypeHandlerextendsBaseTypeHandler<float[]>{// ↑ ③ 泛型参数:声明处理的是 float[]BaseTypeHandler<float[]>要求子类实现 4 个抽象方法,对应不同的读写场景:
| 方法 | 何时调用 | 做什么 |
|---|---|---|
setNonNullParameter | INSERT / UPDATE 时 | 把 Java 值设到 SQL 的?占位符上 |
getNullableResult(rs, String) | 按列名查询结果时 | 从 ResultSet 读到 Java |
getNullableResult(rs, int) | 按列索引查询结果时 | 同上,按数字索引 |
getNullableResult(CallableStatement, int) | 调用存储过程时 | 一般用不到,但必须实现 |
写入方法详解
@OverridepublicvoidsetNonNullParameter(PreparedStatementps,inti,float[]parameter,JdbcTypejdbcType)throwsSQLException{// 参数说明:// ps — JDBC 预编译语句,就是那个带 ? 的 SQL// i — 第几个 ? 占位符(从 1 开始)// parameter — Java 层的 float[] 值// jdbcType — JDBC 类型(这里就是 JdbcType.OTHER)// 第一步:把 float[] 包装成 PGvector 对象PGvectorvector=newPGvector(parameter);// 等效于做了:[1.0, 0.0, 0.0] → PGvector 实例// 第二步:通过 JDBC 的 setObject 传给 PostgreSQLps.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]')读取方法详解
@Overridepublicfloat[]getNullableResult(ResultSetrs,StringcolumnName)throwsSQLException{// 参数说明:// rs — 查询结果集,指向当前行// columnName — 列名,比如 "embedding"// 为什么不直接 (PGvector) rs.getObject(columnName) ?// 因为 JDBC 不认识 vector 类型,getObject() 返回的其实是个 String// 所以直接用 getString() 读取原始文本 "[1.0,0.0,0.0]"Stringvalue=rs.getString(columnName);returnparseVector(value);}// getNullableResult(rs, int columnIndex) 逻辑完全一样,只是按数字取列// getNullableResult(CallableStatement, int) 是给存储过程用的关键问题:为什么读的时候不直接用PGvector对象?
PostgreSQL JDBC 驱动的getObject()方法默认不认识vector类型——除非你手动调用PGvector.registerTypes(conn)注册类型映射。但这样就要在每次获取连接时做额外处理,比较麻烦。
更稳定的方案是:直接读字符串"[1.0,0.0,0.0]",然后手动解析。
解析方法详解
privatefloat[]parseVector(Stringvalue){// value 格式:"[1.0, 0.0, 0.0]"if(value==null)returnnull;Stringtrimmed=value.trim();// 去掉首尾的方括号if(trimmed.startsWith("[")&&trimmed.endsWith("]")){trimmed=trimmed.substring(1,trimmed.length()-1);}// 现在 trimmed = "1.0, 0.0, 0.0"if(trimmed.isEmpty())returnnewfloat[0];String[]parts=trimmed.split(",");// parts = ["1.0", " 0.0", " 0.0"]float[]result=newfloat[parts.length];for(inti=0;i<parts.length;i++){result[i]=Float.parseFloat(parts[i].trim());// Float.parseFloat(" 0.0") → 0.0}returnresult;// 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 种注册方式,我们用的事务最简单的一种:
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[] | PostgreSQLvector |
| JSON 字段 | 自定义对象 /Map | PostgreSQLjsonb |
| 枚举 | Enum对象 | VARCHAR或INTEGER |
| 数组 | List<String> | PostgreSQLTEXT[] |
| 加密字段 | String(密文) | VARCHAR |
原理都一样:继承BaseTypeHandler<T>,实现 4 个方法,配好注解和扫描路径即可。
4. Mapper:基础 CRUD + 向量查询
MyBatis-Plus 的BaseMapper提供insert、selectById、updateById等基础方法。我们额外写两个向量相似度查询:
@MapperpublicinterfaceProductMapperextendsBaseMapper<Product>{// 余弦相似度@Select(""" SELECT id, name, price, embedding FROM products ORDER BY embedding <=> #{targetEmbedding}::vector LIMIT #{topN} """)List<Product>findSimilarByCosine(@Param("targetEmbedding")StringtargetEmbedding,@Param("topN")inttopN);// 欧氏距离@Select(""" SELECT id, name, price, embedding FROM products ORDER BY embedding <-> #{targetEmbedding}::vector LIMIT #{topN} """)List<Product>findSimilarByEuclidean(@Param("targetEmbedding")StringtargetEmbedding,@Param("topN")inttopN);}注意参数要转成'[1.0,0.0,0.0]'::vector格式传入。
5. Service
继承ServiceImpl获得完整 CRUD,同时封装向量查询方法:
@ServicepublicclassProductServiceextendsServiceImpl<ProductMapper,Product>{publicList<Product>findSimilarByCosine(float[]targetVector,inttopN){returnbaseMapper.findSimilarByCosine(arrayToPgvectorString(targetVector),topN);}publicList<Product>findSimilarByEuclidean(float[]targetVector,inttopN){returnbaseMapper.findSimilarByEuclidean(arrayToPgvectorString(targetVector),topN);}privateStringarrayToPgvectorString(float[]arr){StringBuildersb=newStringBuilder("[");for(inti=0;i<arr.length;i++){if(i>0)sb.append(",");sb.append(arr[i]);}sb.append("]");returnsb.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——毕竟,能少维护一个中间件就少一个。