PostgreSQL
给一张关系表加一列 vector、配一个 HNSW 索引,PostgreSQL 就能在同一张表里既做用户/会话/消息的关联查询,又做「在这个会话里搜」的语义检索——关联条件和相似度排序写在同一条 SQL 里。这篇讲清为什么这会让你省掉 MySQL + Milvus 两套库,以及什么量级下你仍然需要专门的向量库
Leo
2026.09.04 · 更新于 2026.09.10
PostgreSQL 也能干语义检索了
为什么写这篇:为了「搜得到」,你被迫养了两套数据库
做 RAG 或「聊天历史/知识库记忆」类应用,业务天然是两半拼起来的:
- 一半很结构化:用户、会话、消息、权限、标签……它们之间有外键、要事务、要
JOIN,还得级联删除; - 一半很不结构化:用户问的问题、AI 答的内容,要靠「语义相似」而不是「关键词相等」召回。
传统的解法是拆两套库:关系数据进 MySQL,向量数据进 Milvus。一个业务对象(比如一条消息)被劈成两份,于是麻烦全来了:
- 双写与漂移:存 MySQL 一份、再调向量库 API 塞一份。哪天一方写失败、一方写成功,数据就对不齐了;
- 跨库 id 映射:MySQL 的
message_id要原样搬进向量库当主键,删改都要两处同步,漏一处就成了永远召不回的「幽灵向量」; - 关系过滤很弱:想在「张三的会话 #12 里」做语义检索,Milvus 只能用它的标量过滤模拟,复杂一点的条件(join 出会话标题再按标题过滤)就得先在 MySQL 查一批 id 再回向量库查;
- 两套运维:MySQL 要备份,Milvus 要备份;成本、监控、故障面全部翻倍。
pgvector 把这个结构问题直接消掉了:它只是一个 PostgreSQL 扩展,把 vector 变成了一种普普通通的列类型——和 text、timestamptz 平起平坐。于是你只有一套数据,外键、事务、级联删除、JOIN、备份全都不变,语义检索只是多了一种「排序和过滤」的方式,而且能写进同一条 SQL。
一句话先说结论:
向量不是另一种数据库,只是表里的一种列类型。 当你的向量和关系数据长在同一个业务对象上、又需要一起过滤时,用一个带 pgvector 的 PostgreSQL 就够了。
接下来按「起库 → 建模 → 写入 → 单表检索 → 关联+语义混合检索」层层递进,每段都给最小可跑代码。
第一步:起一个带 pgvector 的 PostgreSQL
社区维护了开箱即用的镜像,不需要自己编译扩展:
docker run -d --name pg-vector \
-p 5432:5432 \
-e POSTGRES_USER=user \
-e POSTGRES_PASSWORD=123456 \
-e POSTGRES_DB=hello_pg \
pgvector/pgvector:pg16
进容器确认扩展可用:
CREATE EXTENSION IF NOT EXISTS vector; -- 让 vector 类型可用
SELECT vector_dims('[1,2,3]'::vector); -- 顺手看下维数函数,返回 3
要点:
- 安装方式零负担:官方镜像已经装好扩展,
CREATE EXTENSION vector一句开启,等价于「给 MySQL 装全文索引插件」的体感; - vector(n) 是一个定长浮点数组列,
n必须等于你选的 embedding 模型的输出维度,后面每次写入都会校验; - pgvector 还顺带带来几个配套类型(
halfvec半精度、sparsevec稀疏向量),入门用不上,知道存在即可。
第二步:建模——关系照旧,只是多了一列
用一个最常见的场景:聊天会话历史。三张关系表 users → conversations → messages,消息表上额外挂一列向量。
-- 用户表
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
-- 会话表(一个用户有多个会话)
CREATE TABLE conversations (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT
);
-- 消息表(一个会话有多条消息)——带向量列
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL
REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
embedding vector(1024), -- 与 embedding 模型输出维度一致
created_at TIMESTAMPTZ DEFAULT now()
);
-- 向量索引:加速「按余弦距离排序取前几名」的检索
CREATE INDEX idx_messages_embedding
ON messages USING hnsw (embedding vector_cosine_ops);
注意 embedding vector(1024) 这里,只有这一列属于「向量数据库」,其余全是普通关系建模。
要点:
- 外键 + 级联还在:删用户 → 级联删会话 → 级联删消息,向量数据跟着关系数据一起被删干净。双写方案里最容易漏的「清理向量库」,在这里是
ON DELETE CASCADE自动完成的; - check 约束、时间戳照用不误:向量列不挤占任何原有能力,
role照常枚举校验; - HNSW 索引要跟操作符配对:声明
vector_cosine_ops就是告诉索引「按余弦距离服务」,后面ORDER BY embedding <=> $1才能吃上它。
第三步:写入——embedding 在应用里算,向量当字符串塞进列
embedding 由外部模型生成(比如 text-embedding-v3,输出 1024 维),PostgreSQL 只负责存和搜,不负责算。写入时把数组转成字符串、::vector 强转即可:
import { OpenAIEmbeddings } from '@langchain/openai';
import pg from 'pg';
const pool = new pg.Pool({
connectionString: 'postgresql://user:123456@localhost:5432/hello_pg',
});
const embeddings = new OpenAIEmbeddings({
model: 'text-embedding-v3', // 输出 1024 维,与表定义一致
apiKey: process.env.OPENAI_API_KEY,
});
async function createMessage(conversationId, role, content, withEmbedding = false) {
if (withEmbedding) {
const vector = await embeddings.embedQuery(content); // number[]
return (await pool.query(
`INSERT INTO messages (conversation_id, role, content, embedding)
VALUES ($1, $2, $3, $4::vector)
RETURNING id`,
[conversationId, role, content, JSON.stringify(vector)] // 数组转字符串
)).rows[0];
}
// 不需要检索的消息,也可以不写向量,列为 NULL
return (await pool.query(
`INSERT INTO messages (conversation_id, role, content) VALUES ($1, $2, $3)`,
[conversationId, role, content]
)).rows[0];
}
要点:
- 写入路径跟普通 INSERT 没区别:只是多传一个参数、多一个
::vector强转,天然在同一个事务里,失败就整体回滚,不存在「关系库写了、向量库没写」的漂移; - pg 驱动返回的 vector 是字符串,形如
[0.0123, 0.0456, …],要比较/回传时记得parse回数组; - 维度不符会直接报错:
vector(1024)列写一个 768 维的数组,PostgreSQL 当场拒绝——把「两套库维度悄悄不一致」这种隐患消在入口; - 不是每条都要向量:只想存、不想被搜到的记录(比如纯
system提示词)可以不填,列为NULL,检索时用embedding IS NOT NULL排除即可。
第四步:最朴素的语义检索——按距离取前 K
熟悉 pgvector 后,先从「全表里找语义最近的几条」开始,理解距离与相似度:
SELECT id, content,
1 - (embedding <=> $1::vector) AS similarity -- 距离 → 相似度
FROM messages
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1::vector -- 余弦距离升序
LIMIT 5;
把查询文本喂给同一个 embedding 模型得到向量,作为 $1 传入,返回的是与它最相似的 5 条消息。
要点:
<=>是余弦距离,数值越小越相似;相似度 = 1 − 距离,方便直接当打分用(排序与相似度天然单调一致);- pgvector 还提供
<->(欧氏距离)与<#>(负内积),换相似度口径只要换运算符,SQL 结构不动; ORDER BY distance LIMIT n是触发 HNSW 索引的黄金姿势,这也是为什么上面的建表索引能真正加速这条查询。
第五步:杀手锏——关系条件与语义排序写进同一条 SQL
单独一个「全表语义 TopK」,Milvus 也能干。真正拉开差距的是下一步:把外键、JOIN、文本过滤这些关系能力,和向量距离排在同一个 SQL 里。
场景 A:在「某一个会话的历史」里搜
用户问「我上次是不是问过向量索引?」,你要在他当前的会话里召回,而不是在整个库里捞:
SELECT role, content,
1 - (embedding <=> $1::vector) AS similarity
FROM messages
WHERE conversation_id = $2 -- 关系过滤,跟普通 SQL 一模一样
AND embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT 5;
对比双库方案:你得先确认会话存在 → 查出该会话所有 id → 再调向量库接口按 id 集合过滤——一个查询拆成两跳、两套 API。这里就是一条带 WHERE 的 SQL。
场景 B:JOIN 出上下文,还能再叠加条件
在「张三的会话」里做语义检索,并且只召回 AI 的回复、把会话标题一起带出来:
SELECT c.title AS conversation_title, m.content,
1 - (m.embedding <=> $1::vector) AS similarity
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.user_id = $2 -- 走外键 JOIN 收敛范围
AND m.role = 'assistant' -- 关系条件照加
ORDER BY m.embedding <=> $1::vector
LIMIT 10;
这条 SQL 在双库架构下几乎没法优雅表达:你要先在 MySQL 里把该用户的会话标题、assistant 消息 id 全查出来,传到 Milvus 做标量过滤,再把命中的 id 反查回 MySQL 拼上下文——顺序检索三趟,还写不出跨库事务。而在单库里,它只是一条普通 SQL,结果行本身就带着可用的上下文元数据。
要点:
- 向量检索不再是「另一种查询」,而是「排序子句的一种」——
WHERE/JOIN/GROUP BY/CHECK等既有语义对向量列全部照常生效,不用学一套向量库自创的过滤语法; - 召回的粒度和关系粒度一致:你可以只搜某个用户、某个会话、某种 role,过滤是在数据库层面做的,语义精确;
- 这正是聊天记忆 / 知识库 RAG最常需要的形态:既要有权限与归属边界,又要语义相似召回,单库一条 SQL 全部满足。
第六步:进阶——向量 + 全文检索同库双召回
单靠向量有短板:专有名词、型号、拼写、编号这类需要「精确命中」的,语义向量往往召不回来。而 PostgreSQL 本来就内置全文检索(tsvector),现在可以在同一条 SQL 里做双路召回再合并:
SELECT content,
1 - (embedding <=> $1::vector) AS vector_score, -- 语义分
ts_rank(to_tsvector('simple', content), plainto_tsquery('simple', $2))
AS text_score -- 关键词分
FROM messages
WHERE conversation_id = $3
ORDER BY (1 - (embedding <=> $1::vector)) * 0.7
+ ts_rank(to_tsvector('simple', content), plainto_tsquery('simple', $2)) * 0.3 DESC
LIMIT 10;
语义分 + 关键词分做加权融合,专有名词靠全文命中,泛化提问靠向量命中——一个库,两套召回,一套权重调参。
要点:
- 不用引入第二套全文搜索引擎(ES 那层也因此可以晚点再考虑):
tsvector和vector都住在同一行数据里; - 权重比例(0.7 / 0.3)按业务调即可,先跑通再加粗调;
- 更讲究的话可以两条路分别 TopK 再做 RRF 融合,但双路召回的口径、日志、事务都还是同一套数据库。
第七步:在 ORM 里怎么共存——向量当普通列映射,距离交给原生 SQL
TypeORM / Prisma 这类 ORM 对 vector 类型基本无感知,但这不影响共存:实体上把它当成一个数组列声明,建表和关系映射照常;只有真正算距离的那条查询需要写原生 SQL。
@Entity('messages')
export class Message {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'conversation_id' })
conversationId: number;
@Column({ type: 'text' })
content: string;
@Column('vector', { length: 1024, nullable: true }) // 就是“一列”
embedding: number[] | null;
@ManyToOne(() => Conversation, { onDelete: 'CASCADE' })
conversation: Conversation;
}
检索时用 repository 的原生查询,把 <=> 交给数据库:
const rows = await em.query(
`SELECT id, content,
1 - (embedding <=> $1::vector) AS similarity
FROM messages
WHERE conversation_id = $2 AND embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT $3`,
[JSON.stringify(vector), conversationId, limit],
);
要点:
- 建表、关系、级联这些 ORM 能管的部分照常归 ORM,向量只是一列,不影响任何实体关联;
<=>、HNSW 这些数据库原生能力 ORM 不会替你写,但写原生 SQL 成本极低——因为整个业务里需要碰它的只有「算距离/召回」那几条;- 别为了让 ORM「全权代理」而自造 ORM 层向量封装,收益远小于维护成本。
不惑之选:什么量级下,你仍然需要一个专门的向量库
pgvector 不是银弹。它把「过滤 + 语义」的常规问题优雅地解决了,但下面这些诉求会把它逼到墙角:
| 维度 | 一个 PostgreSQL(+pgvector) | MySQL + Milvus(或独立向量库) |
|---|---|---|
| 数据一致性 | 一份数据,事务/级联天然一致 | 两套存储,需自己保证双写一致 |
| 关系过滤 + JOIN | 一等公民,SQL 原生 | 标量过滤,复杂条件难表达 |
| 运维 / 备份 | 一套 PG | MySQL + 向量库两套 |
| 数据规模 | 百万级向量体验良好 | 亿级、纯向量海量更从容 |
| 召回速度 / 并发 | 足够业务型 RAG | 纯向量高 QPS 更优(可上 GPU/内存) |
| 高级向量特性 | 常用算子齐备 | 分片、多路召回、多模态等更丰富 |
| 学习与迁移成本 | 就是写 SQL | 多学一套 API 和部署 |
怎么选,画成一张决策图最直观:
要点:
- 业务型 RAG、聊天记忆、带归属/权限过滤的召回 → 数据量通常百万级以内,单 PG 是更省心的一档,且没有双写漂移问题;
- 纯向量的海量检索、超高 QPS、GPU/内存型检索、多模态/分片等高级玩法 → 向量库的价值才真正兑现;
- 好消息是这两档不是二选一的死局:PG 继续当关系与业务的主库,向量同步一份到专用向量库做高并发召回——先用 PG 跑通业务,等量级真的上去了再拆,而不是一上来就维护两套。
收个尾:先别急着再养一套向量库
回到开头的问题——为什么「关联查询 + 语义检索」这个组合会让你养两套库?因为很多人的心智里,语义检索 = 向量数据库。而 pgvector 帮你看清了本质:语义检索只是一种对向量列的排序方式,它应该、也可以长在离业务数据最近的地方。
于是同一个聊天历史场景里,一切都在同一张表、同一个事务、同一条 SQL 内闭环:
- 关系模型原样保留:
users → conversations → messages,外键、级联、CHECK全在; - 语义检索随叫随到:想搜整个库就
ORDER BY distance,想搜某个会话就加一行WHERE conversation_id = ?,想带上上下文就JOIN回来; - 全文检索同库可用:专有名词让
tsvector兜底,泛化提问让向量兜底; - 双库方案里所有「两处写、两处删、两处备份」的心智负担,被一个扩展归零。
给你的行动清单: 聊天记忆 / 会话历史召回这类「关系 + 语义」业务,直接用 pgvector/pgvector 起库、加一列 vector、建一个 HNSW 索引,把第一版跑通。真到了亿级纯向量、要高并发 GPU 检索的那天,再考虑引入专门的向量库——而且到那时,你已经有了一个数据完全一致、随时可校验的 PG 主库,迁移只会更从容。
读者留言
COMMENTS · 0发表留言