PostgreSQL 做向量检索:一条单库路线

做 Agent 项目,向量检索几乎是绕不开的一环。

RAG 知识库、对话记忆、工具调用前的上下文召回------流程都差不多:文本转成 Embedding,存起来,查询时按相似度找最相关的内容。

在学习 RAG 和 Agent 的过程中,我脑子里默认的技术组合一直是:MySQL 管业务数据,Milvus 管向量检索。一个存用户、文档元数据,一个存 Embedding、做相似度搜索,各干各的;写入时两边一起写,查询时先去向量库召回,再回关系库补全详情,最后拼起来返回。Milvus 社区成熟,和 LangChain 等框架集成也顺,这套双库方案学下来,感觉顺理成章。

实际动手也是这么做的,RAG 入库实战:一份原始资料,如何变成可检索的知识 里,向量数据就是存在 Milvus 里的。

最近接触到 PostgreSQL,配合 pgvector 扩展,发现它也能同时承担业务存储向量检索 ------设计表结构时多一个 vector 字段就行,不用单独再搭一套向量库。和我熟悉的 Milvus 双库方案相比,PostgreSQL 走的是单库路线:向量和业务数据放在同一张表里,写入走同一套事务,检索用 SQL 过滤加相似度排序一次查完,不用在应用层维护两套 ID 映射。

Milvus 作为专用向量库,在亿级大规模检索、混合搜索、独立扩容上依然很强------双库不是退路,而是另一种合理选择。

至于怎么选,看你更在意什么:

PostgreSQL + pgvector

  • 想降低架构复杂度,一个库同时管业务和向量
  • 写入要求强一致,不想处理双写和 ID 映射
  • 检索时要频繁按用户、会话、权限过滤
  • 向量规模在百万~千万级,希望快速落地

MySQL + Milvus

  • 业务已深度绑定 MySQL,向量层不想动主库
  • 向量千万级以上,或检索 QPS 很高
  • 需要语义 + 关键词混合检索
  • 能接受双写、跨库协调和多一套运维

下面从 PostgreSQL + pgvector 入手,带你走一遍单库方案的实际使用。

先把 PostgreSQL 跑起来

本地采用 Docker Compose 启动 PostgreSQL(带 pgvector)和 pgAdmin,并通过初始化脚本自动建表。

yaml 复制代码
services:
  # PostgreSQL with pgvector (AI 向量数据库)
  postgres:
    # 官方 pgvector 镜像,基于 PostgreSQL 16,内置向量扩展
    image: pgvector/pgvector:pg16
    container_name: pg_vector_db
    restart: always
    # 环境变量
    environment:
      POSTGRES_USER: copyer
      POSTGRES_PASSWORD: 123456
      POSTGRES_DB: copyer-test
    ports:
      - "5432:5432"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/postgres:/var/lib/postgresql/data
      # 初始化脚本(默认会执行 init-scripts sql 文件,多数用于创建初始表)
      - ./init-scripts:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U copyer -d copyer-test"]
      interval: 5s
      timeout: 5s
      retries: 5

  # PostgreSQL GUI (pgAdmin)
  pgAdmin:
    container_name: pgAdmin
    image: dpage/pgadmin4:latest
    # web 登录的账号和密码
    environment:
      PGADMIN_DEFAULT_EMAIL: copyer@163.com
      PGADMIN_DEFAULT_PASSWORD: copyer
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/pgadmin:/var/lib/pgadmin
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/login"]
      interval: 30s
      timeout: 20s
      retries: 3
    ports:
      - "8088:80"
    depends_on:
      - postgres

networks:
  default:
    name: common-network

很简单的几个配置,从镜像、容器名称、环境变量,数据挂载,端口映射,健康检查,以及网络这些方面来写的。

  • 镜像:用哪个「安装包」跑 PostgreSQL;这里选带 pgvector 的官方镜像,不用自己装扩展。
  • 环境变量:容器里数据库的用户名、密码、库名,相当于首次安装时的初始化配置(在 .env 管理更科学)。
  • 数据挂载:把容器里的数据目录映射到本地,删容器数据还在,下次启动还能接着用。
  • 初始化脚本:init-scripts 挂到容器的初始化目录,只有第一次建库时会自动执行里面的 SQL。
  • 端口映射:把容器内的 5432 暴露到本机,应用和 pgAdmin 才能连上。
  • 健康检查:Docker 定期探测数据库是否就绪。
  • pgAdmin:浏览器里管理数据库的界面,访问 http://localhost:8088。

接着在 init-scripts 目录下新增一个 create-story.sql SQL 文件。

sql 复制代码
-- 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 小说 chunk 向量表(对应 Milvus collection: story)
CREATE TABLE IF NOT EXISTS story (
    id VARCHAR(100) PRIMARY KEY,      -- 主键
    vector vector(1024) NOT NULL,     -- 向量
    book_id VARCHAR(100) NOT NULL,    -- 小说 id
    book_name VARCHAR(200) NOT NULL,  -- 小说名称
    chapter_num INTEGER NOT NULL,     -- 章节编号
    "index" INTEGER NOT NULL,         -- 片段
    content TEXT NOT NULL             -- 内容
);

-- 按书籍过滤
CREATE INDEX IF NOT EXISTS idx_story_book_id ON story (book_id);

-- 向量相似度检索(余弦距离,对应 Milvus MetricType.COSINE)
CREATE INDEX IF NOT EXISTS idx_story_vector_cosine
ON story USING hnsw (vector vector_cosine_ops);

启动容器

bash 复制代码
docker-compose up -d

就可以在 Docker Desktop 上看到

在浏览器打开 pgAdmin,登录并添加服务器连接,即可看到数据库和 story 表已就绪。

最小验证:写入与检索

环境和表都准备好了,先用几条手写数据试一下 pgvector 能不能跑通------文本转向量、写入表、按相似度查出来,走通这条最小链路就行,不必接 RAG 全流程。

安装依赖

bash 复制代码
pnpm add pg dotenv @langchain/openai
# pg 连 PostgreSQL,
# dotenv 读环境变量,
# @langchain/openai 调 embedding 接口

插入 :准备几条示例文本,调 embedding 接口取向量,写入 story 表:

javascript 复制代码
// src/insert.mjs
import { config } from "dotenv";
import pg from "pg";
import { OpenAIEmbeddings } from "@langchain/openai";

config();

const pool = new pg.Pool({
  host: process.env.PG_HOST ?? "localhost",
  port: Number(process.env.PG_PORT ?? 5432),
  user: process.env.PG_USER ?? "copyer",
  password: process.env.PG_PASSWORD ?? "123456",
  database: process.env.PG_DATABASE ?? "copyer-test",
});

const embeddings = new OpenAIEmbeddings({
  model: process.env.AI_EMBEDDING_MODEL ?? "text-embedding-v3",
  dimensions: 1024,
  apiKey: process.env.AI_EMBEDDING_KEY,
  configuration: { baseURL: process.env.AI_EMBEDDING_BASE_URL },
});

function toVectorLiteral(values) {
  return `[${values.join(",")}]`;
}

// 模拟数据
const samples = [
  {
    id: "demo_001",
    book_id: "demo",
    book_name: "pgvector 试水",
    chapter_num: 1,
    index: 0,
    content: "PostgreSQL 是一款开源关系型数据库,支持事务和复杂查询。",
  },
  {
    id: "demo_002",
    book_id: "demo",
    book_name: "pgvector 试水",
    chapter_num: 1,
    index: 1,
    content: "pgvector 扩展让 PostgreSQL 可以直接存储向量并做相似度检索。",
  },
  {
    id: "demo_003",
    book_id: "demo",
    book_name: "pgvector 试水",
    chapter_num: 2,
    index: 0,
    content: "Docker Compose 可以本地一键启动 PostgreSQL 和 pgAdmin。",
  },
];

for (const item of samples) {
  const vector = await embeddings.embedQuery(item.content);
  await pool.query(
    `
    INSERT INTO story (id, vector, book_id, book_name, chapter_num, "index", content)
    VALUES ($1, $2::vector, $3, $4, $5, $6, $7)
    ON CONFLICT (id) DO UPDATE SET vector = EXCLUDED.vector, content = EXCLUDED.content
  `,
    [
      item.id,
      toVectorLiteral(vector),
      item.book_id,
      item.book_name,
      item.chapter_num,
      item.index,
      item.content,
    ],
  );
}

await pool.end();

查询 :把问题转成向量,用 <=> 算余弦距离,按相似度排序取 topK------业务和向量在同一张表里,一条 SQL 搞定:

javascript 复制代码
// src/query.mjs
const question = "PostgreSQL 怎么做向量检索?";
const queryVector = await embeddings.embedQuery(question);

const { rows } = await pool.query(
  `
  SELECT id, content, 1 - (vector <=> $1::vector) AS score
  FROM story
  WHERE book_id = $2
  ORDER BY vector <=> $1::vector
  LIMIT 3
`,
  [toVectorLiteral(queryVector), "demo"],
);

rows.forEach((row, i) => {
  console.log(
    `[${i + 1}] score=${Number(row.score).toFixed(4)}  ${row.content}`,
  );
});

await pool.end();

这就是 PostgreSQL + pgvector 单库方案的核心:写入是 INSERT,检索是 SQL 排序(order),不需要单独维护一套向量库。

当 Agent 需要「记住聊天」

不管用的是 Codex、豆包还是 DeepSeek,每次回来,聊天记录都还在。左侧是历史会话,中间是往来消息------这种「接着聊」的体验,靠的就是把聊天留下来

"留下来",本质上就三个问题:

  • 谁在用
  • 聊的是哪一个会话
  • 具体说了什么。

到「存文本、按时间翻历史」这一步,关系型数据库足够了------MySQL 完全能够满足。

但是有个很明显的问题,用户跳跃突然问:「刚才说的那个方案是什么?」这种时候,关键词搜不准,得按意思找之前聊过的内容,消息就得带向量去查找。

双库的做法就是:业务库管消息,向量库管 embedding,各存各的。可查一条相关历史,得拿着会话 ID 去另一头找向量,跨库拼一遍。

而当使用了 PostgreSQL + pgvector 之后,操作就更方便了,消息内容和 vector 放同一张表,过滤和相似度检索一条 SQL 搞定。

下面使用 NestJS + Prisma + PG 走一遍大致的实现流程。

项目初始化

@nestjs/cli 脚手架起一个空项目:

bash 复制代码
nest new nestjs-pg --package-manager pnpm --skip-git

nest-cli 会带好 NestJS 运行的基础依赖(@nestjs/common@nestjs/core@nestjs/platform-express 等),还需要安装:

bash 复制代码
pnpm add @nestjs/config @prisma/client class-validator class-transformer
pnpm add -D prisma
pnpm prisma init
  • @nestjs/config :读 .env 里的 DATABASE_URL
  • prisma | @prisma/client: 定义三表模型、生成 Client、执行 migrate 同步表结构
  • class-validator | class-transformer: 校验请求参数(后面写接口时会用到)

.env 里先配好数据库连接,和 docker-compose 账号一致:

env 复制代码
DATABASE_URL=postgresql://copyer:123456@localhost:5432/copyer-test

聊天记录该怎么存

针对表的设计,就从上面三个问题出发,落到数据库里就是三张表,各管一件事:

  • users:谁在用。存用户基本信息,一个用户可以开多场对话。
  • conversations:哪一场。某用户下的一次聊天,左侧会话列表里的每一项,对应这里一行。
  • messages :说了什么。具体的消息内容,role 区分 user / assistant / system;vector 存这条消息的向量,供后面按意思检索。

关系是典型的一对多:usersconversationsmessages,删用户时会话跟着删,删会话时消息跟着删。

表结构用 Prisma 定义,写在 prisma/schema.prisma。这里需要注意的是 Message.vector 是 pgvector 类型,要用 Unsupported("vector(1024)") 标记------建表归 Prisma 管,向量的写入和检索后面用 raw SQL 单独处理

prisma 复制代码
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["postgresqlExtensions"]
}

datasource db {
  provider   = "postgresql"
  url        = env("DATABASE_URL")
  extensions = [vector(schema: "public")]
}

model User {
  id            Int            @id @default(autoincrement())
  name          String
  createdAt     DateTime       @default(now()) @map("created_at")
  conversations Conversation[]

  @@map("users")
}

model Conversation {
  id        Int       @id @default(autoincrement())
  userId    Int       @map("user_id")
  title     String?
  createdAt DateTime  @default(now()) @map("created_at")
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  messages  Message[]

  @@map("conversations")
}

model Message {
  id             Int          @id @default(autoincrement())
  conversationId Int          @map("conversation_id")
  role           String
  content        String
  vector         Unsupported("vector(1024)")?
  createdAt      DateTime     @default(now()) @map("created_at")
  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)

  @@map("messages")
}

定义好后执行 migrate,表结构和关系由 Prisma 同步到 PG,不用手写 SQL 维护:

bash 复制代码
pnpm prisma migrate dev

NestJS 里注入 Prisma

NestJS 侧要把 Prisma 接进来:Service 继承 PrismaClient,Module 注册成全局模块,各处注入同一个实例。

PrismaService --- 继承 PrismaClient,在 Nest 生命周期里连接和断开:

typescript 复制代码
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

PrismaModule --- 用 @Global() 标记全局,导出 PrismaService,后面任何 Module 都能直接注入,不用再逐个 import:

typescript 复制代码
// src/prisma/prisma.module.ts
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

AppModule --- 根模块里引入一次即可:

typescript 复制代码
// src/app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    // ...其他 Module
  ],
})
export class AppModule {}

之后可以在任意 Service 里注入使用

typescript 复制代码
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { CreateUserDto } from "./dto/create-user.dto";

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  // 新增用户
  async create(dto: CreateUserDto) {
    const res = await this.prisma.user.create({ data: { name: dto.name } });
    return res;
  }

  // 查询用户
  async findOne(id: number) {
    const user = await this.prisma.user.findUnique({ where: { id } });
    if (!user) {
      throw new NotFoundException(`User ${id} not found`);
    }
    return user;
  }
}

到这一步,表结构、数据库连接、Prisma 注入都就绪了。接下来看消息进来之后,vector 怎么写、怎么查。

消息入库:文本和向量一起落下

用户发了一条消息,要先落库 rolecontent,再调 embedding 接口把文本变成向量。vectorUnsupported 类型,不能写在 create 的 data 里 ------得先插入业务字段,再用 raw SQL 补 vector 列。

MessagesService 中的 createWithVector 方法实现如下:

typescript 复制代码
// src/messages/messages.service.ts
async createWithVector(conversationId: number, role: MessageRole, content: string) {
  await this.ensureConversationExists(conversationId);
  const normalized = this.normalizeContent(content);
  this.assertRole(role);

  // 1. 先取向量;embedding 失败则不会创建消息行
  const embedding = await this.embeddingService.embed(normalized);
  const vectorLiteral = this.embeddingService.toVectorLiteral(embedding);

  // 2. create + update vector 包在同一事务,任一步失败整笔回滚
  return this.prisma.$transaction(async (tx) => {
    const message = await tx.message.create({
      data: { conversationId, role, content: normalized },
      select: { id: true, conversationId: true, role: true, content: true, createdAt: true },
    });

    await tx.$executeRaw`
      UPDATE messages
      SET vector = ${vectorLiteral}::vector
      WHERE id = ${message.id}
    `;

    return message;
  });
}

新增的时候多了一步(prisma 方法不支持 vector 类型),写的原始 SQL 语句,但消息和 vector 始终在同一张表、同一个 ID 下------不用维护跨库映射

记忆召回:在当前会话里按意思找

先模拟几条消息,用来验证「按意思找」能不能把相关的挑出来。

text 复制代码
user:      我们团队在选型 Node 后端,NestJS 的 Module 和依赖注入适合拆微服务吗?
assistant: NestJS 基于 TypeScript,内置 Module、Controller、Provider 分层,配合Prisma 连 PostgreSQL 很常见。
user:      另一条线想走 Python:FastAPI 写 AI 接口、Celery 做异步任务,和 NestJS 之间 HTTP 调用可行吗?
user:      对了,前端这边 React 18 的 Server Components 和 Next.js App Router 要不要上?
assistant: Vue 3 组合式 API 上手快,Vite + Pinia 搭管理台很顺,小团队可以优先考虑。

如果用户问「NestJS 和 Python 后端怎么配合」,得靠语义。看看 MessagesService 中的方法 searchSimilarInConversation 实现------只在当前会话里,取向量距离最近的几条:

typescript 复制代码
// src/messages/messages.service.ts
async searchSimilarInConversation(
  conversationId: number,
  query: string,
  topK = 3,
  excludeMessageId?: number,
) {
  await this.ensureConversationExists(conversationId);

  // 1. 问题文本 → 向量(和入库时用同一套 embedding)
  const embedding = await this.embeddingService.embed(query.trim());
  const vectorLiteral = this.embeddingService.toVectorLiteral(embedding);

  // 2. 业务过滤 + 向量排序,一条 SQL 完成
  const rows = await this.prisma.$queryRaw(Prisma.sql`
    SELECT id, role, content,
           1 - (vector <=> ${vectorLiteral}::vector) AS score
    FROM messages
    WHERE conversation_id = ${conversationId}   -- 只搜当前会话
      AND vector IS NOT NULL
      ${excludeMessageId != null ? Prisma.sql`AND id <> ${excludeMessageId}` : Prisma.empty}
    ORDER BY vector <=> ${vectorLiteral}::vector  -- 余弦距离越小越相似
    LIMIT ${topK}
  `);

  return rows.map((row) => ({ ...row, score: Number(row.score) }));
}

整体流程:

  • 用「NestJS 和 Python 怎么搭后端」去搜,前三条后端相关消息排在前面;React / Vue 两条因为语义偏离,score 更低。
  • WHERE conversation_id 做范围限定。
  • ORDER BY vector <=> 做相似度排序。

该靠前的靠前,该靠后的靠后,顺序和对话里的主线对得上。

总结

PostgreSQL 搭配 pgvector,能让向量与业务数据同表存储:写入保证强事务,检索时过滤与排序一条 SQL 搞定,彻底告别应用层的双库 ID 映射。

诚然,专用向量库(Milvus)在超大规模和高 QPS 场景下依然更强;但在那之前,大多数 Agent 项目完全能在 PostgreSQL 里跑通向量检索,既满足需求,又免去了双写和跨库拼接的额外成本。

相关推荐
Access开发易登软件1 小时前
Access 怎么做前后端分离?用 Web API 读写 SQL Server
前端·数据库·人工智能·microsoft·excel·access
Juicedata1 小时前
腾讯云 x JuiceFS:基于 FoundationDB 的企业级统一存储实践
数据库·人工智能·科技·云计算·腾讯云
捧 花1 小时前
从同步生成到异步任务:YoudaoNoteLM 的生成队列模块设计
go·agent·worker·eino
小罗水1 小时前
第13章 Redis 缓存、幂等锁与任务状态
数据库·redis·缓存
SelectDB技术团队2 小时前
当 PostgreSQL 面临性能瓶颈:80TB 电商业务迁移至 Apache Doris 的实践思考
数据库·postgresql·apache
程序员萤火2 小时前
LLM Agent 底层揭秘:大模型如何通过 JSON-RPC 2.0 协议跨进程调工具?
agent
心念枕惊2 小时前
新写了个直播录制工具,可录制抖音快手斗鱼直播
运维·服务器·数据库
Revolution612 小时前
Nest.js 是什么:怎样用它写出第一个后端接口
后端·node.js·nestjs
逐米时代2 小时前
向量数据库选型——Chroma、Qdrant、Milvus到底怎么选
数据库·milvus