Appearance
课 4 · pgvector 向量存储迁移
本课目标
把基础课的 sqlite-vec 向量存储迁移到 Postgres + pgvector,让向量检索和业务数据共用同一个数据库,并完成混合检索架构的最后一块拼图。
关键理解:pgvector 把向量能力嵌入 Postgres——你已经熟悉的 SQL 查询可以直接加上向量相似度排序。
为什么要迁移
基础课用 sqlite-vec 已经能做向量检索,进阶课迁移的原因:
| 问题 | sqlite-vec | pgvector |
|---|---|---|
| 多进程写入 | 写锁竞争(Worker + API 并发) | 支持多连接并发写 |
| 向量 + 元数据关联查询 | 跨文件 JOIN 不便 | 同库一条 SQL 即可 |
| 事务安全 | 单文件,Worker 崩溃可能损坏 | ACID 事务保障 |
| 混合检索融合 | 需要跨进程传结果 | 和 ES 结果在应用层合并,架构清晰 |
| 生产化 | 不支持连接池 | 标准 Postgres,可接 pgBouncer 等 |
安装与启用
pgvector/pgvector:pg16 镜像已预装扩展,启动后只需在数据库内激活一次:
sql
CREATE EXTENSION IF NOT EXISTS vector;drizzle-orm 0.30+ 内置 vector 列类型,不需要额外安装 pgvector npm 包:
bash
# 模块 2 已安装 drizzle-orm,这里无需新增依赖表结构设计
在 packages/shared/src/db/schema.ts 追加 chunks 表(复用模块 2 建好的 Drizzle 基础设施):
typescript
// packages/shared/src/db/schema.ts(追加)
import { pgTable, text, timestamp, jsonb, index, vector } from 'drizzle-orm/pg-core'
export const chunks = pgTable(
'chunks',
{
id: text('id').primaryKey(),
docId: text('doc_id').notNull(),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }), // text-embedding-3-small
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
embeddingIdx: index('chunks_embedding_idx').on(table.embedding),
}),
)运行迁移:
bash
pnpm drizzle-kit generate
pnpm drizzle-kit migratedrizzle-kit 暂不直接生成 HNSW 语法,在普通索引创建后手动替换一次:
sql
-- 运行一次即可
DROP INDEX IF EXISTS chunks_embedding_idx;
CREATE INDEX chunks_embedding_idx
ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);为什么选 HNSW?
HNSW(Hierarchical Navigable Small World)是当前最主流的 ANN 索引算法:
- 构建速度比 IVFFlat 慢,但查询时召回率更高
- 不需要预先指定
nlist(聚类数),省去调参麻烦 - pgvector 0.6+ 默认推荐 HNSW
IVFFlat 适合数据量特别大(数百万向量)且内存受限的场景。进阶项目规模用 HNSW 即可。
写入向量
typescript
// apps/worker/src/indexer.ts
import { db } from '../db/client'
import { chunks } from '@knowledgeops/shared/db/schema'
export async function insertChunks(chunkList: Array<{
id: string
docId: string
content: string
embedding: number[]
metadata?: Record<string, unknown>
}>) {
if (chunkList.length === 0) return
// 批量写入,Drizzle 自动处理 vector 序列化
await db
.insert(chunks)
.values(
chunkList.map((c) => ({
id: c.id,
docId: c.docId,
content: c.content,
embedding: c.embedding,
metadata: c.metadata ?? {},
})),
)
.onConflictDoNothing()
}向量检索
typescript
// packages/rag-core/src/retriever/vector.ts
import { sql } from 'drizzle-orm'
import { db } from '../../db/client'
import { chunks } from '@knowledgeops/shared/db/schema'
export async function vectorSearch(queryEmbedding: number[], topK = 10) {
// pgvector 接受 '[x1,x2,...]' 格式的字符串 literal
const vec = `[${queryEmbedding.join(',')}]`
return db
.select({
id: chunks.id,
content: chunks.content,
metadata: chunks.metadata,
score: sql<number>`1 - (${chunks.embedding} <=> ${vec}::vector)`,
})
.from(chunks)
.orderBy(sql`${chunks.embedding} <=> ${vec}::vector`)
.limit(topK)
}<=> 是 pgvector 的余弦距离算子,用 sql 从 drizzle-orm 内嵌——它是 Drizzle 处理自定义数据库算子的标准方式,不影响其余查询的类型安全。
与混合检索对接
现在向量检索和 ES 关键词检索都在手,RRF 融合不需要改任何逻辑,只需把 vectorSearch 的返回格式对齐即可:
typescript
// packages/rag-core/src/retriever/hybrid.ts
import { vectorSearch } from './vector'
import { keywordSearch } from './keyword' // 模块 4 课 2 的 ES 实现
export async function hybridSearch(query: string, queryEmbedding: number[], topK = 5) {
const [vectorResults, keywordResults] = await Promise.all([
vectorSearch(queryEmbedding, 20),
keywordSearch(query, 20),
])
return rrfFusion(vectorResults, keywordResults, topK)
}sqlite-vec 迁移步骤
基础课最终项目的 sqlite-vec 数据采用拆表结构:
chunks存分块正文和元数据vec_chunks是 sqlite-vec 虚拟表,只存向量,rowid与chunks.id对齐documents存文档标题和来源
默认知识库文件位于 ~/.kb/kb-<id>.sqlite。可以从基础课项目的 ~/.kb/config.json 找到当前知识库路径,或通过环境变量传入。
迁移脚本如下:
typescript
// scripts/migrate-vectors.ts
import Database from 'better-sqlite3'
import { db } from '../apps/api/src/db/client'
import { chunks } from '../packages/shared/src/db/schema'
const defaultKbPath = `${process.env.HOME ?? process.env.USERPROFILE}/.kb/kb-1.sqlite`
const sqlitePath = process.env.SQLITE_KB_PATH ?? defaultKbPath
const sqlite = new Database(sqlitePath)
const dimensions = sqlite
.prepare(`SELECT value FROM kb_meta WHERE key = 'embedding_dimensions'`)
.get() as { value: string } | undefined
console.log(`源知识库:${sqlitePath}`)
console.log(`向量维度:${dimensions?.value ?? '未知,请确认 pgvector 列维度一致'}`)
const rows = sqlite
.prepare(
`
SELECT
c.id,
c.doc_id,
c.content,
c.chunk_index,
c.metadata,
v.embedding,
d.title,
d.source
FROM chunks c
JOIN vec_chunks v ON v.rowid = c.id
JOIN documents d ON d.id = c.doc_id
`,
)
.all() as Array<{
id: number
doc_id: number
content: string
chunk_index: number
metadata: string | null
embedding: Buffer
title: string
source: string | null
}>
console.log(`迁移 ${rows.length} 条向量数据...`)
await db
.insert(chunks)
.values(
rows.map((row) => {
const embeddingBuffer = row.embedding.buffer.slice(
row.embedding.byteOffset,
row.embedding.byteOffset + row.embedding.byteLength,
)
return {
id: String(row.id),
docId: String(row.doc_id),
content: row.content,
embedding: Array.from(new Float32Array(embeddingBuffer)), // sqlite-vec 存 BLOB
metadata: {
...(row.metadata ? JSON.parse(row.metadata) : {}),
chunkIndex: row.chunk_index,
title: row.title,
source: row.source,
},
}
}),
)
.onConflictDoNothing()
console.log('迁移完成')
sqlite.close()如果不想依赖默认路径,可以直接指定知识库文件:
powershell
$env:SQLITE_KB_PATH="C:\Users\你的用户名\.kb\kb-1.sqlite"; pnpm tsx scripts/migrate-vectors.ts迁移前还要确认 pgvector 表的 vector(..., { dimensions }) 和基础课知识库里的 embedding_dimensions 一致。维度不一致时不要强行写入,应该用同一个 Embedding 模型重新导入,或按正确维度重建 pgvector 表和 HNSW 索引。
本节产物
packages/shared/src/db/
schema.ts # 追加 chunks 表定义(vector 列)
drizzle/migrations/ # drizzle-kit 生成的迁移文件
apps/worker/src/
indexer.ts # 切块 + embedding + 写入 pgvector(Drizzle)
packages/rag-core/src/retriever/
vector.ts # pgvector 向量检索(Drizzle + sql 算子)
hybrid.ts # 混合检索(向量 + ES)
scripts/
migrate-vectors.ts # sqlite-vec → pgvector 迁移脚本(可选)课堂实作
- 确认
CREATE EXTENSION vector已执行 - 在
schema.ts追加chunks表,运行pnpm drizzle-kit generate && pnpm drizzle-kit migrate - 手动把普通索引替换为 HNSW 索引
- 把基础课的一个文档重新导入,验证向量写入 Postgres
- 调用
vectorSearch,确认返回结果带score字段
面试追问
pgvector 和专用向量数据库(Milvus、Qdrant)有什么区别?
pgvector 把向量能力嵌入 Postgres,最大优势是和业务数据在同一个库,SQL 关联查询天然支持,运维成本低。专用向量数据库(Milvus、Qdrant)在超大规模(数亿向量)下性能更好,并提供更多 ANN 算法和过滤组合。进阶项目规模(万级向量)用 pgvector 完全够用,也是大多数 AI 应用的合理起点。
HNSW 索引构建后,写入新向量会不会破坏索引?
不会。pgvector 的 HNSW 索引支持增量插入,新向量写入后索引自动更新。代价是索引维护有额外开销,适合写少读多的场景。如果批量导入大量向量,可以先写数据、再建索引,速度会快很多。
向量维度换了(比如从 1536 改成 3072)怎么办?
需要重建表和索引,已有数据不能直接复用——因为余弦相似度在不同维度空间里没有可比性。实践中固定一个 Embedding 模型版本,不要中途换模型。