✦ Puxiaoshuai · Time is a river painted on scrolls · Walk to the water’s end, sit and watch the clouds rise

postgresql2026.09.04 · 24 min read

PostgreSQL

给一张关系表加一列 vector、配一个 HNSW 索引,PostgreSQL 就能在同一张表里既做用户/会话/消息的关联查询,又做「在这个会话里搜」的语义检索——关联条件和相似度排序写在同一条 SQL 里。这篇讲清为什么这会让你省掉 MySQL + Milvus 两套库,以及什么量级下你仍然需要专门的向量库

L

Leo

2026.09.04 · Updated 2026.09.10

3 views
PostgreSQL

PostgreSQL 也能干语义检索了

为什么写这篇:为了「搜得到」,你被迫养了两套数据库

做 RAG 或「聊天历史/知识库记忆」类应用,业务天然是两半拼起来的:

  • 一半很结构化:用户、会话、消息、权限、标签……它们之间有外键、要事务、要 JOIN,还得级联删除;
  • 一半很不结构化:用户问的问题、AI 答的内容,要靠「语义相似」而不是「关键词相等」召回。

传统的解法是拆两套库:关系数据进 MySQL,向量数据进 Milvus。一个业务对象(比如一条消息)被劈成两份,于是麻烦全来了:

  1. 双写与漂移:存 MySQL 一份、再调向量库 API 塞一份。哪天一方写失败、一方写成功,数据就对不齐了;
  2. 跨库 id 映射:MySQL 的 message_id 要原样搬进向量库当主键,删改都要两处同步,漏一处就成了永远召不回的「幽灵向量」;
  3. 关系过滤很弱:想在「张三的会话 #12 里」做语义检索,Milvus 只能用它的标量过滤模拟,复杂一点的条件(join 出会话标题再按标题过滤)就得先在 MySQL 查一批 id 再回向量库查;
  4. 两套运维: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 原生标量过滤,复杂条件难表达
运维 / 备份一套 PGMySQL + 向量库两套
数据规模百万级向量体验良好亿级、纯向量海量更从容
召回速度 / 并发足够业务型 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 主库,迁移只会更从容。

L

Leo

Blogger

Independent developer / Blogger and the maintainer of the original blog “大道至简”. Migrating years of posts and shiyu from WordPress to Next.js.

Reader comments

COMMENTS · 0

Leave a comment

Comments are shown after moderation · be kind0/100