Skip to content

课 4 · pgvector 向量存储迁移

本课目标

把基础课的 sqlite-vec 向量存储迁移到 Postgres + pgvector,让向量检索和业务数据共用同一个数据库,并完成混合检索架构的最后一块拼图。

关键理解:pgvector 把向量能力嵌入 Postgres——你已经熟悉的 SQL 查询可以直接加上向量相似度排序。

为什么要迁移

基础课用 sqlite-vec 已经能做向量检索,进阶课迁移的原因:

问题sqlite-vecpgvector
多进程写入写锁竞争(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 migrate

drizzle-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 的余弦距离算子,用 sqldrizzle-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 虚拟表,只存向量,rowidchunks.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 迁移脚本(可选)

课堂实作

  1. 确认 CREATE EXTENSION vector 已执行
  2. schema.ts 追加 chunks 表,运行 pnpm drizzle-kit generate && pnpm drizzle-kit migrate
  3. 手动把普通索引替换为 HNSW 索引
  4. 把基础课的一个文档重新导入,验证向量写入 Postgres
  5. 调用 vectorSearch,确认返回结果带 score 字段

面试追问

pgvector 和专用向量数据库(Milvus、Qdrant)有什么区别?

pgvector 把向量能力嵌入 Postgres,最大优势是和业务数据在同一个库,SQL 关联查询天然支持,运维成本低。专用向量数据库(Milvus、Qdrant)在超大规模(数亿向量)下性能更好,并提供更多 ANN 算法和过滤组合。进阶项目规模(万级向量)用 pgvector 完全够用,也是大多数 AI 应用的合理起点。

HNSW 索引构建后,写入新向量会不会破坏索引?

不会。pgvector 的 HNSW 索引支持增量插入,新向量写入后索引自动更新。代价是索引维护有额外开销,适合写少读多的场景。如果批量导入大量向量,可以先写数据、再建索引,速度会快很多。

向量维度换了(比如从 1536 改成 3072)怎么办?

需要重建表和索引,已有数据不能直接复用——因为余弦相似度在不同维度空间里没有可比性。实践中固定一个 Embedding 模型版本,不要中途换模型。

面向前端工程师和独立开发者的 AI 应用工程课程