做 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 存这条消息的向量,供后面按意思检索。
关系是典型的一对多:users → conversations → messages,删用户时会话跟着删,删会话时消息跟着删。

表结构用 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 怎么写、怎么查。
消息入库:文本和向量一起落下
用户发了一条消息,要先落库 role、content,再调 embedding 接口把文本变成向量。vector 是 Unsupported 类型,不能写在 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 里跑通向量检索,既满足需求,又免去了双写和跨库拼接的额外成本。