一聚教程网:一个值得你收藏的教程网站

热门教程

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

时间:2026-08-03 09:22:50 编辑:袖梨 来源:一聚教程网

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

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

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

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

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

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

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

PostgreSQL + pgvector

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

MySQL + Milvus

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

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

先把 PostgreSQL 跑起来

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

 复制代码services:# PostgreSQL with pgvector (AI 向量数据库)postgres:# 官方 pgvector 镜像,基于 PostgreSQL 16,内置向量扩展image: pgvector/pgvector:pg16container_name: pg_vector_dbrestart: always# 环境变量environment:POSTGRES_USER: copyerPOSTGRES_PASSWORD: 123456POSTGRES_DB: copyer-testports:- "5432:5432"volumes:- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/postgres:/var/lib/postgresql/data# 初始化脚本(默认会执行 init-scripts sql 文件,多数用于创建初始表)- ./init-scripts:/docker-entrypoint-initdb.dhealthcheck:test: ["CMD-SHELL", "pg_isready -U copyer -d copyer-test"]interval: 5stimeout: 5sretries: 5# PostgreSQL GUI (pgAdmin)pgAdmin:container_name: pgAdminimage: dpage/pgadmin4:latest# web 登录的账号和密码environment:PGADMIN_DEFAULT_EMAIL: [email protected]PGADMIN_DEFAULT_PASSWORD: copyervolumes:- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/pgadmin:/var/lib/pgadminhealthcheck:test: ["CMD", "curl", "-f", "http://localhost:80/login"]interval: 30stimeout: 20sretries: 3ports:- "8088:80"depends_on:- postgresnetworks:default:name: common-network

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

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

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

 复制代码-- 启用 pgvector 扩展CREATE EXTENSION IF NOTEXISTS vector;-- 小说 chunk 向量表(对应 Milvus collection: story)CREATE TABLE IF NOTEXISTS story (    id VARCHAR(100) PRIMARY KEY,      -- 主键    vector vector(1024) NOTNULL,     -- 向量    book_id VARCHAR(100) NOTNULL,    -- 小说 id    book_name VARCHAR(200) NOTNULL,  -- 小说名称    chapter_num INTEGER NOTNULL,     -- 章节编号    "index" INTEGER NOTNULL,         -- 片段    content TEXT NOT NULL-- 内容);-- 按书籍过滤CREATE INDEX IF NOTEXISTS idx_story_book_id ON story (book_id);-- 向量相似度检索(余弦距离,对应 Milvus MetricType.COSINE)CREATE INDEX IF NOTEXISTS idx_story_vector_cosineON story USING hnsw (vector vector_cosine_ops);

启动容器

 复制代码docker-compose up -d

就可以在 Docker Desktop 上看到

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

最小验证:写入与检索

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

安装依赖

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

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

 复制代码// src/insert.mjsimport { 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 = newOpenAIEmbeddings({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 搞定:

 复制代码// src/query.mjsconst 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,每次回来,聊天记录都还在。左侧是历史会话,中间是往来消息——这种「接着聊」的体验,靠的就是把聊天留下来

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

  1. 谁在用
  2. 聊的是哪一个会话
  3. 具体说了什么。

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

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

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

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

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

项目初始化

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

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

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

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

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

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

聊天记录该怎么存

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

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

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

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

 复制代码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 维护:

 复制代码pnpm prisma migrate dev

NestJS 里注入 Prisma

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

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

 复制代码// src/prisma/prisma.service.tsimport { Injectable, OnModuleInit, OnModuleDestroy } from"@nestjs/common";import { PrismaClient } from"@prisma/client";@Injectable()export classPrismaServiceextends PrismaClientimplements OnModuleInit, OnModuleDestroy{  async onModuleInit() {await this.$connect();  }  async onModuleDestroy() {await this.$disconnect();  }}

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

 复制代码// src/prisma/prisma.module.tsimport { Global, Module } from"@nestjs/common";import { PrismaService } from"./prisma.service";@Global()@Module({providers: [PrismaService],exports: [PrismaService],})export classPrismaModule {}

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

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

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

 复制代码import { Injectable, NotFoundException } from"@nestjs/common";import { PrismaService } from"../prisma/prisma.service";import { CreateUserDto } from"./dto/create-user.dto";@Injectable()export classUsersService {constructor(privatereadonly prisma: PrismaService) {}// 新增用户async create(dto: CreateUserDto) {const res = awaitthis.prisma.user.create({ data: { name: dto.name } });return res;  }  // 查询用户async findOne(id: number) {const user = awaitthis.prisma.user.findUnique({ where: { id } });if (!user) {throw newNotFoundException(`User ${id} not found`);    }    return user;  }}

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

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

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

MessagesService 中的 createWithVector 方法实现如下:

 复制代码// src/messages/messages.service.tsasync createWithVector(conversationId: number, role: MessageRole, content: string) {await this.ensureConversationExists(conversationId);const normalized = this.normalizeContent(content);this.assertRole(role);// 1. 先取向量;embedding 失败则不会创建消息行const embedding = awaitthis.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 下——不用维护跨库映射

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

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

 复制代码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 实现——只在当前会话里,取向量距离最近的几条:

 复制代码// src/messages/messages.service.tsasync searchSimilarInConversation(  conversationId: number,  query: string,  topK = 3,  excludeMessageId?: number,) {  await this.ensureConversationExists(conversationId);// 1. 问题文本 → 向量(和入库时用同一套 embedding)const embedding = awaitthis.embeddingService.embed(query.trim());const vectorLiteral = this.embeddingService.toVectorLiteral(embedding);// 2. 业务过滤 + 向量排序,一条 SQL 完成const rows = awaitthis.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) }));}

整体流程:

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

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

总结

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

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

热门栏目