未记录事实核验 · 状态:维护中

记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。

warning 适用边界与维护说明

本文适合已有 PostgreSQL、Python ORM 与数据库运维经验的读者作跨生态集成和选型参考,不是可直接照抄的完整部署教程。本次仅作全文结构审阅,未运行示例或作外部事实核验。当前 SQLAlchemy 同步、异步查询仍调用未定义的 cosine_distance,异步示例还保留同步 Embedding 请求;pgai 部分缺少接口版本范围、镜像扩展条件、可衔接的凭据配置和 Worker 启动条件,部署前须补齐。StreamingDiskANN 与 HNSW 的性能比较、时间分区收益及异步吞吐优势未给出比较依据,数据量阈值、性能倍数和压缩率也不能作为生产保证。Ollama 查询应遵守正文已说明的模型、维度与服务地址边界;逻辑复制、按表备份及恢复应满足正文已列出的环境前置。Django 索引与迁移、变更跟踪、复制监控字段和备份续行的原缺陷已有正文修正,不再作为当前风险。 已取消无法确定目标的可点击引用「07-混合搜索与过滤优化」,保留原提及;Missing link target: 07-混合搜索与过滤优化 已取消无法确定目标的可点击引用「06-索引原理与调优」,保留原提及;Missing link target: 06-索引原理与调优 已取消无法确定目标的可点击引用「#九、备份与恢复:pg_dump 的零配置支持」,保留原提及;Missing anchor: #九、备份与恢复:pg_dump 的零配置支持 已取消无法确定目标的可点击引用「09-生产运维与性能调优」,保留原提及;Ambiguous link target: 09-生产运维与性能调优

pgvector 与 PostgreSQL 生态集成

abstract 本文目标

  • 掌握 ORM 集成:用 SQLAlchemy 和 Django 优雅地操作向量数据,告别裸写 SQL
  • 理解生态协同:学会 pgvector + PostGIS、pgvector + TimescaleDB 的组合拳
  • 掌握 pgai 新特性:直接在 SQL 里调用 AI 模型生成 Embedding(2024 年重大新特性)
  • 了解基础设施透明:弄清楚备份、连接池、逻辑复制对向量列的处理方式

阅读前置:混合搜索与过滤优化 索引原理与调优 pgvector 概述


目录


一、pgvector 的真正护城河:生态,不是性能

先讲一个对比故事。

你手头有两个方案:方案 A 是某个专用向量数据库,QPS 比 pgvector 高 3 倍;方案 B 是 pgvector。但方案 A 要求你:

  • 单独学习它的 SDK
  • 单独部署、运维一套新系统
  • 写数据同步管道,把业务数据库的更新同步过去
  • 单独配备备份方案、监控方案、告警方案
  • 重新处理事务一致性——你更新了业务数据,向量数据什么时候更新?

方案 B 是 pgvector。你的 SQLAlchemy model 加一个字段类型,你的 Django ORM 加一行导入,你的 pg_dump 脚本不用改,你的 Prometheus 不用改,你的连接池不用改,你的备份策略不用改。

索引调优可能缩小性能差距,但能否弥补取决于负载、召回要求与硬件;复用已有 PostgreSQL 运维体系则可能降低集成成本。 这才是 pgvector 对于已有 PostgreSQL 技术栈的团队来说最大的护城河。

本文的目标,就是把这个"生态优势"从理念落实到具体代码。


二、SQLAlchemy 集成:Python 后端的主战场

SQLAlchemy 是 Python 生态里最主流的 ORM 框架,FastAPI、Flask、Celery 等框架都与它深度集成。pgvector 官方维护的 pgvector-python 库对 SQLAlchemy 提供了一等公民级别的支持。

2.1 安装依赖

pip install pgvector sqlalchemy psycopg2-binary
# 如果使用异步,还需要:
pip install asyncpg sqlalchemy[asyncio]

2.2 Model 定义:从"加一个字段"开始

从外部看,在 SQLAlchemy 模型里使用向量,就像加一个普通字段一样简单:

from sqlalchemy import Column, Integer, String, Text, create_engine
from sqlalchemy.orm import declarative_base, Session
from pgvector.sqlalchemy import Vector

Base = declarative_base()

class Document(Base):
    """文档表:存储原始内容 + 对应的向量 Embedding"""
    __tablename__ = "documents"

    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(500), nullable=False)
    content = Column(Text, nullable=False)
    source = Column(String(200))                    # 来源:URL、文件名等
    # 核心字段:1536 维向量(OpenAI text-embedding-3-small 的输出维度)
    embedding = Column(Vector(1536))

    def __repr__(self):
        return f"<Document id={self.id} title='{self.title}'>"

note 维度要和你的 Embedding 模型匹配

Vector(1536) 中的 1536 是 OpenAI text-embedding-3-small 和 text-embedding-ada-002 的输出维度。 如果用 text-embedding-3-large,维度是 3072;用 nomic-embed-text(本地 Ollama),维度是 768。 更改向量维度需要进行数据与索引迁移,不能直接混用不同维度的向量;可根据现有数据采用新增列、转换列类型等方案,不一定要重建整张表。

2.3 建表与索引:用 DDL 事件注册 HNSW 索引

推荐在 model 里同时声明向量索引,这样 Base.metadata.create_all() 时会自动创建:

from sqlalchemy import Column, Integer, String, Text, Index, event
from sqlalchemy.orm import declarative_base
from pgvector.sqlalchemy import Vector

Base = declarative_base()

class Document(Base):
    __tablename__ = "documents"

    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(500), nullable=False)
    content = Column(Text, nullable=False)
    source = Column(String(200))
    category = Column(String(100))                  # 业务分类,用于过滤
    embedding = Column(Vector(1536))

    # 声明 HNSW 索引——注意 postgresql_using 和 postgresql_with 的写法
    __table_args__ = (
        Index(
            "idx_documents_embedding_hnsw",
            "embedding",
            postgresql_using="hnsw",
            postgresql_with={"m": 16, "ef_construction": 64},
            postgresql_ops={"embedding": "vector_cosine_ops"},
        ),
    )

# 初始化数据库连接
engine = create_engine(
    "postgresql+psycopg2://user:password@localhost:5432/mydb",
    pool_size=10,
    max_overflow=20,
)

# 启用 pgvector 扩展(首次运行需要)
with engine.connect() as conn:
    conn.exec_driver_sql("CREATE EXTENSION IF NOT EXISTS vector")
    conn.commit()

# 建表(幂等操作)
Base.metadata.create_all(engine)

2.4 写入数据:插入向量记录

import openai
from sqlalchemy.orm import Session

openai_client = openai.OpenAI(api_key="your-api-key")

def get_embedding(text: str, model: str = "text-embedding-3-small") -> list[float]:
    """调用 OpenAI API 获取文本的 Embedding 向量"""
    response = openai_client.embeddings.create(input=text, model=model)
    return response.data[0].embedding

def insert_document(title: str, content: str, source: str, category: str) -> Document:
    """插入一条文档记录,自动生成 Embedding"""
    embedding = get_embedding(content)

    with Session(engine) as session:
        doc = Document(
            title=title,
            content=content,
            source=source,
            category=category,
            embedding=embedding,    # 直接传 Python list,pgvector-python 自动转换
        )
        session.add(doc)
        session.commit()
        session.refresh(doc)
        return doc

# 使用示例
doc = insert_document(
    title="pgvector 入门指南",
    content="pgvector 是 PostgreSQL 的向量扩展,支持精确和近似最近邻搜索...",
    source="internal_docs",
    category="技术文档",
)
print(f"插入成功,ID: {doc.id}")

2.5 相似度查询:四种距离函数

pgvector-python 提供了四个距离函数,直接作为 SQLAlchemy 的 order_by 参数使用:

from pgvector.sqlalchemy import Vector
from sqlalchemy import select, text
from sqlalchemy.orm import Session

# 距离通过向量列方法调用,无需导入模块级距离函数:
# Document.embedding.cosine_distance(query_embedding)
# Document.embedding.l2_distance(query_embedding)
# Document.embedding.max_inner_product(query_embedding)  # 负内积
# Document.embedding.l1_distance(query_embedding)

def semantic_search(
    query_text: str,
    top_k: int = 5,
    category: str = None,
) -> list[dict]:
    """
    语义搜索:找到与 query_text 最相似的文档
    支持按 category 过滤
    """
    query_embedding = get_embedding(query_text)

    with Session(engine) as session:
        stmt = (
            select(
                Document.id,
                Document.title,
                Document.content,
                Document.source,
                Document.category,
                # 计算距离并作为列返回,用于显示相似度分数
                cosine_distance(Document.embedding, query_embedding).label("distance"),
            )
            .order_by(cosine_distance(Document.embedding, query_embedding))
            .limit(top_k)
        )

        # 可选:按 category 过滤;是否使用业务或向量索引取决于查询计划,近似搜索还需评估过滤后的召回率
        if category:
            stmt = stmt.where(Document.category == category)

        results = session.execute(stmt).mappings().all()

    return [
        {
            "id": r["id"],
            "title": r["title"],
            "content": r["content"][:200] + "...",
            "source": r["source"],
            "category": r["category"],
            "similarity_score": round(1 - r["distance"], 4),  # 转换为余弦相似度,理论范围为 -1~1
        }
        for r in results
    ]

# 使用示例
results = semantic_search("如何优化 PostgreSQL 向量搜索性能", top_k=5)
for r in results:
    print(f"[{r['similarity_score']:.4f}] {r['title']}")

2.6 批量插入:高效处理大量文档

当需要批量写入数千条文档时,逐条插入效率极低。推荐分批获取 Embedding + 批量 insert:

import time
from typing import Generator

def batch_get_embeddings(
    texts: list[str],
    model: str = "text-embedding-3-small",
    batch_size: int = 100,
) -> list[list[float]]:
    """
    批量获取 Embedding,按批次调用 API
    OpenAI API 单次最多支持 2048 条,建议 100 条一批
    """
    all_embeddings = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i : i + batch_size]
        response = openai_client.embeddings.create(input=batch, model=model)
        batch_embeddings = [item.embedding for item in response.data]
        all_embeddings.extend(batch_embeddings)
        print(f"已处理 {min(i + batch_size, len(texts))}/{len(texts)} 条")
        time.sleep(0.1)  # 避免触发 API 速率限制
    return all_embeddings


def bulk_insert_documents(documents: list[dict]) -> int:
    """
    批量插入文档,使用 SQLAlchemy bulk_insert_mappings 提升性能
    documents: [{"title": ..., "content": ..., "source": ..., "category": ...}]
    """
    # 1. 批量获取所有 Embedding
    contents = [doc["content"] for doc in documents]
    embeddings = batch_get_embeddings(contents)

    # 2. 组装插入数据
    insert_data = [
        {
            "title": doc["title"],
            "content": doc["content"],
            "source": doc["source"],
            "category": doc.get("category", "未分类"),
            "embedding": emb,
        }
        for doc, emb in zip(documents, embeddings)
    ]

    # 3. 批量写入(假设在特定测试条件下可比逐条 session.add 快 5-10 倍;本文未提供测试,实际收益需测量)
    with Session(engine) as session:
        session.bulk_insert_mappings(Document, insert_data)
        session.commit()

    return len(insert_data)


# 使用示例:批量导入 1000 条文档
sample_docs = [
    {"title": f"文档 {i}", "content": f"这是第 {i} 篇文档的内容...", "source": "batch_import"}
    for i in range(1000)
]
count = bulk_insert_documents(sample_docs)
print(f"成功插入 {count} 条文档")

2.7 异步 SQLAlchemy:FastAPI 的最佳搭档

现代 Python Web 应用越来越多地采用异步框架。pgvector-python 完整支持 asyncpg 驱动和 SQLAlchemy 的异步接口:

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
# 余弦距离通过 Document.embedding.cosine_distance(...) 调用,无需导入模块级函数

# 异步引擎:注意驱动是 asyncpg
async_engine = create_async_engine(
    "postgresql+asyncpg://user:password@localhost:5432/mydb",
    pool_size=10,
    max_overflow=20,
)

AsyncSessionLocal = sessionmaker(
    async_engine, class_=AsyncSession, expire_on_commit=False
)


async def async_semantic_search(query_text: str, top_k: int = 5) -> list[dict]:
    """异步语义搜索,适合 FastAPI 等 async 框架"""
    query_embedding = get_embedding(query_text)  # 实际应改为 async 版本

    async with AsyncSessionLocal() as session:
        stmt = (
            select(Document, cosine_distance(Document.embedding, query_embedding).label("distance"))
            .order_by(cosine_distance(Document.embedding, query_embedding))
            .limit(top_k)
        )
        result = await session.execute(stmt)
        rows = result.all()

    return [
        {
            "id": doc.id,
            "title": doc.title,
            "similarity": round(1 - distance, 4),
        }
        for doc, distance in rows
    ]


# FastAPI 集成示例
from fastapi import FastAPI, Depends
from contextlib import asynccontextmanager

app = FastAPI()

@app.get("/search")
async def search_endpoint(q: str, k: int = 5):
    results = await async_semantic_search(q, top_k=k)
    return {"query": q, "results": results}

tip 异步 vs 同步的选择

  • FastAPI / Starlette:用异步 SQLAlchemy + asyncpg,吞吐量提升显著
  • Flask / Django(传统同步):用同步 SQLAlchemy + psycopg2,更简单
  • Celery 任务:批量 Embedding 更新通常在后台任务中,用同步接口即可

三、Django ORM 集成:django-pgvector 全解析

Django 是 Python 生态里另一大 Web 框架,拥有完整的 ORM、Admin、认证等全套工具。django-pgvector 库让 Django 模型直接支持向量字段。

3.1 安装与配置

pip install pgvector django

在 settings.py 中添加应用:

# settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    # ... 其他 apps
    "pgvector.django",      # 添加这一行
    "myapp",
]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "mydb",
        "USER": "myuser",
        "PASSWORD": "mypassword",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

3.2 Model 定义:四种向量字段类型

django-pgvector 提供了与 pgvector 数据类型一一对应的 Django 字段:

# models.py
from django.db import models
from pgvector.django import HnswIndex
from pgvector.django import (
    VectorField,        # 标准浮点向量(float32),最常用
    HalfVectorField,    # 半精度向量(float16),内存减半,精度略降
    BitField,           # 二值向量,用于哈希指纹
    SparseVectorField,  # 稀疏向量,适合 BM25 等稀疏表示
)


class Article(models.Model):
    """文章模型:包含向量 Embedding 字段"""

    title = models.CharField(max_length=500)
    content = models.TextField()
    author = models.CharField(max_length=100)
    published_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    category = models.CharField(max_length=100, db_index=True)

    # 标准向量字段(float32,1536 维)
    embedding = VectorField(dimensions=1536)

    # 可选:半精度版本,用于节省存储(精度损失很小)
    # embedding_half = HalfVectorField(dimensions=1536)

    class Meta:
        verbose_name = "文章"
        verbose_name_plural = "文章"
        # 在 Meta 里声明向量索引
        indexes = [
            # HNSW 索引——推荐生产环境使用
            HnswIndex(
                fields=["embedding"],
                name="idx_article_embedding_hnsw",
                m=16,
                ef_construction=64,
                opclasses=["vector_cosine_ops"],
            ),
        ]

    def __str__(self):
        return f"{self.title} ({self.category})"

warning Django 向量索引的注意事项

Django 原生 models.Index 不会仅因设置 vector_cosine_ops 就变成 HNSW 索引。 可在 Meta.indexes 中使用 pgvector.django.HnswIndex 声明 HNSW 及其参数(见下文), 或者在迁移中显式编写对应的 RunSQL。

3.3 使用专用索引类

django-pgvector 提供了 HnswIndex 和 IvfflatIndex,支持完整的参数配置:

# models.py(推荐写法)
from pgvector.django import HnswIndex, IvfflatIndex

class Article(models.Model):
    # ... 字段定义同上

    class Meta:
        indexes = [
            # HNSW 索引:高查询性能,推荐生产使用
            HnswIndex(
                name="idx_article_hnsw_cosine",
                fields=["embedding"],
                m=16,                       # 每个节点的最大连接数
                ef_construction=64,         # 构建时搜索范围(越大越精准但构建越慢)
                opclasses=["vector_cosine_ops"],   # 余弦距离
            ),
            # 或者 IVFFlat 索引:适合超大数据集,构建更快
            # IvfflatIndex(
            #     name="idx_article_ivfflat",
            #     fields=["embedding"],
            #     lists=100,
            #     opclasses=["vector_cosine_ops"],
            # ),
        ]

3.4 生成迁移文件并应用

# 生成迁移文件
python manage.py makemigrations

# 查看生成的迁移(确认向量字段和索引都正确)
python manage.py sqlmigrate myapp 0001_initial

# 应用迁移
python manage.py migrate

向量字段不会自动生成启用扩展的迁移操作。需在创建向量字段之前的迁移中加入 from pgvector.django import VectorExtension,并将 VectorExtension() 放在相关 CreateModel 或 AddField 操作之前;执行账号还需具备创建扩展的权限。完成这些操作并使用上文的 HnswIndex 后,sqlmigrate 可预览类似以下 SQL(这里只展示部分字段):

-- 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS "vector";

-- 建表
CREATE TABLE "myapp_article" (
    "id" bigserial NOT NULL PRIMARY KEY,
    "title" varchar(500) NOT NULL,
    "content" text NOT NULL,
    "embedding" vector(1536)
);

-- 建索引
CREATE INDEX "idx_article_hnsw_cosine"
ON "myapp_article"
USING hnsw ("embedding" vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

3.5 QuerySet 查询:距离函数和注解

django-pgvector 提供了对应的 Django ORM 注解函数:

# views.py 或 services.py
from django.db.models import F
from pgvector.django import (
    CosineDistance,     # 余弦距离
    L2Distance,         # 欧氏距离
    MaxInnerProduct,    # 负内积(越小越相似)
    L1Distance,         # 曼哈顿距离
)
from .models import Article


def search_articles(query_text: str, top_k: int = 10, category: str = None):
    """
    语义搜索文章
    返回最相似的 top_k 篇文章,附带相似度分数
    """
    query_embedding = get_embedding(query_text)

    queryset = Article.objects.annotate(
        distance=CosineDistance("embedding", query_embedding)
    ).order_by("distance")

    # 链式过滤:向量搜索 + 业务条件——这正是 pgvector 的优势
    if category:
        queryset = queryset.filter(category=category)

    # 过滤掉相似度太低的结果(distance > 0.5 表示余弦相似度 < 0.5)
    queryset = queryset.filter(distance__lt=0.5)

    results = queryset[:top_k].values(
        "id", "title", "author", "category", "published_at", "distance"
    )

    return [
        {
            **r,
            "similarity": round(1 - r["distance"], 4),
        }
        for r in results
    ]


def find_similar_articles(article_id: int, top_k: int = 5):
    """
    找出与指定文章最相似的其他文章——"猜你喜欢"功能的核心
    """
    source_article = Article.objects.get(id=article_id)

    similar = (
        Article.objects.exclude(id=article_id)  # 排除自身
        .annotate(distance=CosineDistance("embedding", source_article.embedding))
        .order_by("distance")[:top_k]
        .values("id", "title", "category", "distance")
    )

    return list(similar)

3.6 Django Admin 集成

pgvector 的向量字段在 Django Admin 中默认显示为文本框(不实用)。推荐在 Admin 中隐藏向量字段,展示业务字段:

# admin.py
from django.contrib import admin
from .models import Article


@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["id", "title", "author", "category", "published_at"]
    list_filter = ["category", "published_at"]
    search_fields = ["title", "content", "author"]

    # 在编辑表单中排除向量字段(向量由后台自动生成,不手动输入)
    exclude = ["embedding"]

    # 添加只读字段和自定义操作
    readonly_fields = ["published_at", "updated_at"]
    actions = ["regenerate_embeddings"]

    def regenerate_embeddings(self, request, queryset):
        """管理操作:重新生成选中文章的 Embedding"""
        count = 0
        for article in queryset:
            article.embedding = get_embedding(article.content)
            article.save(update_fields=["embedding"])
            count += 1
        self.message_user(request, f"成功重新生成了 {count} 篇文章的 Embedding")

    regenerate_embeddings.short_description = "重新生成 Embedding"

3.7 Django Signals:自动维护 Embedding

向量数据最怕的问题是"过期"——业务数据更新了,Embedding 还是旧的。Django Signals 可以优雅地解决这个问题:

# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.db import transaction
from .models import Article
import logging

logger = logging.getLogger(__name__)


@receiver(post_save, sender=Article)
def update_article_embedding(sender, instance, created, **kwargs):
    """
    文章保存后自动更新 Embedding
    使用 transaction.on_commit 确保在事务提交后才执行,避免脏读
    """
    # 当前模型未配置内容变更跟踪,因此每次触发 post_save 都重新生成向量。
    # 如需仅在 content 变化时更新,应另行实现变更比较或配置相应跟踪器。

    def _update():
        try:
            new_embedding = get_embedding(instance.content)
            Article.objects.filter(id=instance.id).update(embedding=new_embedding)
            logger.info(f"文章 {instance.id} Embedding 已更新")
        except Exception as e:
            logger.error(f"文章 {instance.id} Embedding 更新失败: {e}")

    # 在事务提交后执行回调;on_commit 本身不会异步执行,生产异步处理可使用 Celery 任务
    transaction.on_commit(_update)

tip 生产建议

Signals 里直接调用 Embedding API 会阻塞主线程。生产环境推荐:

  1. Signal 触发时,把任务放入 Celery 队列
  2. Celery Worker 异步调用 Embedding API
  3. 更新向量字段 这样主业务流程不受影响,Embedding 更新异步完成。

四、pgvector + PostGIS:地理位置 × 语义相似

这是 pgvector 生态最有趣的组合之一。想象这样一个场景:找附近 5 公里内,与你描述最匹配的餐厅。这需要同时满足两个条件:

  1. 地理位置在范围内(空间过滤)
  2. 语义描述最相似(向量搜索)

两个条件都发生在 PostgreSQL 里,一次查询搞定,不需要任何跨系统通信。

4.1 安装与启用 PostGIS

# Ubuntu/Debian
sudo apt install postgresql-15-postgis-3

# 或使用 Docker(推荐)
docker pull postgis/postgis:15-3.4
-- 在数据库中启用两个扩展
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS postgis;

4.2 表设计:同时存储地理坐标和向量

-- 创建一个同时包含地理位置和向量的表
CREATE TABLE restaurants (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(200)    NOT NULL,
    description TEXT            NOT NULL,
    address     TEXT,
    -- PostGIS 地理位置类型:经纬度坐标,使用 WGS84 坐标系(SRID=4326)
    location    GEOGRAPHY(POINT, 4326),
    -- pgvector 向量字段:餐厅描述的 Embedding
    embedding   VECTOR(1536),
    -- 业务字段
    cuisine_type    VARCHAR(50),
    price_level     INT,            -- 1=便宜 2=中等 3=贵
    rating          DECIMAL(3, 1),
    created_at  TIMESTAMP DEFAULT NOW()
);

-- 地理空间索引(PostGIS)
CREATE INDEX idx_restaurants_location ON restaurants USING GIST(location);

-- 向量相似度索引(pgvector)
CREATE INDEX idx_restaurants_embedding ON restaurants
USING HNSW(embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 业务字段索引
CREATE INDEX idx_restaurants_cuisine ON restaurants(cuisine_type);
CREATE INDEX idx_restaurants_price ON restaurants(price_level);

4.3 插入数据

-- 插入几条示例数据(向量用占位符,实际由应用层生成)
INSERT INTO restaurants (name, description, address, location, cuisine_type, price_level, rating, embedding)
VALUES
(
    '川味人家',
    '正宗四川火锅,麻辣鲜香,锅底纯正,配菜新鲜,适合朋友聚餐',
    '北京市朝阳区三里屯路 15 号',
    ST_SetSRID(ST_MakePoint(116.455, 39.937), 4326),  -- 经度, 纬度
    '川菜',
    2,
    4.7,
    '[0.1, 0.2, ...]'::vector  -- 实际 Embedding,这里省略
),
(
    '江南小馆',
    '清淡江浙菜,精致细腻,适合商务宴请,环境优雅',
    '北京市朝阳区工体北路 8 号',
    ST_SetSRID(ST_MakePoint(116.451, 39.934), 4326),
    '浙菜',
    3,
    4.5,
    '[0.3, 0.4, ...]'::vector
);

4.4 核心查询:附近 + 语义搜索

-- 场景:用户在三里屯(116.454, 39.936),想找"辛辣刺激的聚会场所"
-- 范围:5 公里内,相似度最高的前 10 家

WITH query_params AS (
    SELECT
        ST_SetSRID(ST_MakePoint(116.454, 39.936), 4326) AS user_location,
        '[用户描述的 Embedding 向量]'::vector             AS query_vector
)
SELECT
    r.id,
    r.name,
    r.description,
    r.cuisine_type,
    r.price_level,
    r.rating,
    -- 计算距离(PostGIS,单位:米)
    ST_Distance(r.location, q.user_location) AS distance_meters,
    -- 计算语义相似度(pgvector)
    1 - (r.embedding <=> q.query_vector)     AS semantic_similarity
FROM
    restaurants r,
    query_params q
WHERE
    -- 空间过滤:5 公里范围(ST_DWithin 会用到空间索引,非常高效)
    ST_DWithin(r.location::geography, q.user_location, 5000)
    -- 可选:价格过滤
    AND r.price_level <= 2
ORDER BY
    -- 综合排序:语义相似度(权重 0.7)+ 距离衰减(权重 0.3)
    (1 - (r.embedding <=> q.query_vector)) * 0.7
    - (ST_Distance(r.location, q.user_location) / 5000) * 0.3
    DESC
LIMIT 10;

4.5 Python 封装

from sqlalchemy import text

def find_nearby_restaurants(
    user_lat: float,
    user_lng: float,
    query_text: str,
    radius_km: float = 5.0,
    max_price: int = 3,
    top_k: int = 10,
) -> list[dict]:
    """
    附近语义搜索餐厅
    结合 PostGIS 地理过滤 + pgvector 向量相似度
    """
    query_embedding = get_embedding(query_text)

    sql = text("""
        WITH query_params AS (
            SELECT
                ST_SetSRID(ST_MakePoint(:lng, :lat), 4326) AS user_location,
                CAST(:query_vector AS vector) AS query_vector
        )
        SELECT
            r.id, r.name, r.description, r.cuisine_type,
            r.price_level, r.rating,
            ST_Distance(r.location::geography, q.user_location) AS distance_m,
            1 - (r.embedding <=> q.query_vector) AS similarity
        FROM restaurants r, query_params q
        WHERE
            ST_DWithin(r.location::geography, q.user_location, :radius_m)
            AND r.price_level <= :max_price
        ORDER BY
            (1 - (r.embedding <=> q.query_vector)) * 0.7
            - (ST_Distance(r.location::geography, q.user_location) / :radius_m) * 0.3
            DESC
        LIMIT :top_k
    """)

    with engine.connect() as conn:
        result = conn.execute(
            sql,
            {
                "lat": user_lat,
                "lng": user_lng,
                "query_vector": str(query_embedding),
                "radius_m": radius_km * 1000,
                "max_price": max_price,
                "top_k": top_k,
            },
        )
        return [dict(row._mapping) for row in result]

note 为什么这个组合很有价值

PostGIS 提供空间索引和精确地理计算能力,pgvector 提供向量距离与近邻检索能力。 本节展示两者在 PostgreSQL 内组合空间过滤与语义排序的方式。 专用向量数据库是否具备所需空间功能,应按具体产品和版本比较,不能由本示例一概判定。


五、pgvector + TimescaleDB/pgvectorscale:时序 × 向量

另一个强大组合:把向量搜索和时序数据结合起来。典型场景:

  • 日志 Embedding 搜索:找出过去一周内,语义上最相似的错误日志
  • 监控异常检测:找出当前指标状态与历史哪个时间段最相似
  • 用户行为分析:找出某时间段内,行为模式最相似的用户群体

5.1 TimescaleDB 与 pgvectorscale 简介

这两个扩展都来自 Timescale 公司:

扩展 功能 核心价值
TimescaleDB 时序数据管理 自动分区(hypertable)、时间维度索引、压缩
pgvectorscale 增强向量搜索 StreamingDiskANN 索引,比 HNSW 在大规模数据下更高效
pgai AI 模型调用 在 SQL 里直接调用 OpenAI、Ollama 等模型

三者配合使用,构成 Timescale 称为"AI Ready PostgreSQL"的完整方案。

5.2 启用扩展

-- 启用三个扩展(需先安装对应软件包)
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;  -- 依赖 vector

5.3 表设计:hypertable + 向量字段

-- 创建日志表:带时间戳的向量数据
CREATE TABLE log_embeddings (
    time        TIMESTAMPTZ     NOT NULL,           -- 时间戳(TimescaleDB 必须有)
    log_id      TEXT            NOT NULL,
    service     VARCHAR(100)    NOT NULL,
    level       VARCHAR(20)     NOT NULL,           -- ERROR, WARN, INFO
    message     TEXT            NOT NULL,
    embedding   VECTOR(768),                        -- 日志文本的 Embedding
    extra       JSONB                               -- 额外结构化字段
);

-- 将普通表转换为 hypertable(按时间自动分区)
-- chunk_time_interval = 1 day:每天一个分区
SELECT create_hypertable('log_embeddings', 'time',
    chunk_time_interval => INTERVAL '1 day');

-- pgvectorscale 的 StreamingDiskANN 索引
-- 比 HNSW 在磁盘上的性能更好,适合超大规模数据
CREATE INDEX idx_log_diskann ON log_embeddings
USING diskann (embedding);

-- TimescaleDB 时序索引(自动创建 time 的索引,但也可以手动指定)
CREATE INDEX idx_log_time_service ON log_embeddings(time DESC, service);

5.4 时序向量查询:过去 N 天内最相似的日志

-- 场景:找出过去 7 天内,与当前错误日志最相似的历史记录
-- 用于快速定位"我见过这种错误吗?上次怎么解决的?"

WITH current_error AS (
    -- 假设这是当前出现的新错误
    SELECT '[当前错误日志的 Embedding]'::vector AS query_vec
)
SELECT
    l.time,
    l.service,
    l.level,
    l.message,
    l.embedding <=> c.query_vec AS distance,
    1 - (l.embedding <=> c.query_vec) AS similarity
FROM
    log_embeddings l,
    current_error c
WHERE
    -- 时间过滤:最近 7 天(TimescaleDB 会做时间分区裁剪,非常高效)
    l.time >= NOW() - INTERVAL '7 days'
    -- 服务过滤
    AND l.service = 'payment-service'
    -- 只看错误级别
    AND l.level IN ('ERROR', 'CRITICAL')
ORDER BY
    l.embedding <=> c.query_vec
LIMIT 20;

note TimescaleDB 的时间裁剪优势

当你加上 WHERE time >= NOW() - INTERVAL '7 days' 这样的条件时, TimescaleDB 会自动跳过不相关的时间分区(chunk exclusion), 只扫描最近 7 天的数据块,性能远好于没有时间分区的普通表。 向量搜索 + 时间裁剪 = 范围大幅缩小,索引效率大幅提升。

5.5 pgvectorscale vs HNSW 的选择

待压测的场景假设(本文未提供这些阈值的测试依据):

数据量 < 500 万:           可先评估 pgvector 原生 HNSW,不能仅据条数判断是否足够
数据量 500 万 ~ 5000 万:   可将 pgvectorscale StreamingDiskANN 纳入比较
数据量 > 5000 万:          可同时评估 Milvus 或 Citus 分片方案,而非必然迁移

内存充裕(索引能全部放内存):  可优先测试 HNSW
内存紧张(磁盘数据多):        可测试面向磁盘场景的 StreamingDiskANN

最终选择需结合向量维度、过滤条件、召回率、并发、硬件和维护成本,不能把上述条数视为通用选型界线。

六、pgai 扩展:让 SQL 直接调用 AI 模型

pgai 是 Timescale 在 2024 年发布的重要扩展。它的核心思路很简单,但非常有冲击力:

把 Embedding 生成这件事从应用层移到数据库层。

6.1 传统方式 vs pgai 方式

传统方式的数据流:

应用代码 → 调用 Embedding API → 拿到向量 → 写入 PostgreSQL

pgai 方式:

SQL 语句 → PostgreSQL 调用 Embedding API → 向量直接存入表

这个改变看起来只是少了几行代码,但带来的好处很实际:

  • 批量处理时,不需要把数据从数据库拉到应用层再推回去
  • 直接 SQL 调用可将向量写入纳入当前数据库事务,但外部模型请求不受数据库回滚控制;Vectorizer 的后台异步更新不保证提交时向量已同步
  • 定时任务可以直接写 SQL,不需要维护 Python 脚本

6.2 安装与配置

# Docker 方式(最简单)
docker run -d \
  --name pgai-demo \
  -e POSTGRES_PASSWORD=pgvector \
  -e OPENAI_API_KEY=your-api-key \
  -p 5432:5432 \
  timescale/timescaledb-ha:pg16-latest
-- 启用 pgai 扩展
CREATE EXTENSION IF NOT EXISTS ai CASCADE;

-- 配置 OpenAI API Key(也可以通过环境变量设置)
SELECT ai.set_secret('OPENAI_API_KEY', 'your-openai-api-key');

6.3 在 SQL 里调用 OpenAI 生成 Embedding

-- 直接在 SQL 里生成 Embedding
SELECT ai.openai_embed(
    'text-embedding-3-small',       -- 模型名
    '这是一段需要生成向量的文本',     -- 输入文本
    api_key => current_setting('ai.openai_api_key')
);
-- 返回:vector(1536) 类型的值

-- 批量给表里的数据生成 Embedding
UPDATE documents
SET embedding = ai.openai_embed(
    'text-embedding-3-small',
    content,
    api_key => current_setting('ai.openai_api_key')
)
WHERE embedding IS NULL;

-- 插入时自动生成 Embedding(一条 INSERT 搞定)
INSERT INTO documents (title, content, embedding)
VALUES (
    'pgai 使用指南',
    'pgai 让你在 SQL 里直接调用 OpenAI API...',
    ai.openai_embed(
        'text-embedding-3-small',
        'pgai 让你在 SQL 里直接调用 OpenAI API...',
        api_key => current_setting('ai.openai_api_key')
    )
);

6.4 调用本地 Ollama 模型

以下代码展示 Ollama 调用和查询的结构,不可直接接在前文的 1536 维 documents.embedding 上执行:nomic-embed-text 示例输出为 768 维,需要另建匹配维度的列或表,并用同一模型重新生成全部待检索向量。localhost:11434 只有在执行模型请求的进程能够通过该地址访问 Ollama 时才适用;数据库容器中的 localhost 通常不是宿主机。pgai 接口与运行条件仍受下一节所述维护边界限制。

-- 前提:本地运行着 Ollama,并已拉取 nomic-embed-text 模型
-- ollama pull nomic-embed-text

-- 调用本地 Ollama 生成 Embedding(完全离线,数据不出本地)
SELECT ai.ollama_embed(
    'nomic-embed-text',             -- Ollama 模型名
    '这是需要生成向量的本地文本',
    host => 'http://localhost:11434'  -- Ollama 服务地址
);
-- 返回:vector(768) 类型

-- 使用 Ollama Embedding 做语义搜索
SELECT
    title,
    content,
    1 - (embedding <=> ai.ollama_embed(
        'nomic-embed-text',
        '查询文本',
        host => 'http://localhost:11434'
    )) AS similarity
FROM documents
ORDER BY embedding <=> ai.ollama_embed(
    'nomic-embed-text',
    '查询文本',
    host => 'http://localhost:11434'
)
LIMIT 5;

6.5 pgai Vectorizer:自动化 Embedding 同步

pgai Vectorizer 是 pgai 最强大的功能——它可以监听表的变化,自动保持 Embedding 与原始数据同步:

-- 为 documents 表创建 Vectorizer
-- 它会:1. 自动生成 embeddings 表  2. 监听 documents 变化  3. 自动更新 Embedding
SELECT ai.create_vectorizer(
    'documents'::regclass,              -- 源表
    embedding => ai.embedding_openai(   -- 使用 OpenAI 模型
        'text-embedding-3-small',
        1536
    ),
    chunking => ai.chunking_recursive_character_text_splitter(
        'content',                      -- 对 content 列进行分块
        chunk_size => 500,
        chunk_overlap => 50
    ),
    formatting => ai.formatting_python_template('标题:$title 内容:$chunk')
);

-- Vectorizer 会自动创建一个视图 documents_embedding_store
-- 可以直接查询
SELECT
    chunk,
    embedding <=> '[查询向量]'::vector AS distance
FROM documents_embedding_store
ORDER BY distance
LIMIT 5;

note pgai Vectorizer 的工作原理

  1. 在源表上创建触发器,监听 INSERT/UPDATE/DELETE
  2. 把待处理的变更放入工作队列
  3. 后台 Worker 异步调用 Embedding API
  4. 将结果写入专用的 embedding 存储表
  5. 暴露一个视图,方便查询

整个过程对应用代码透明——你只需要维护业务表,Embedding 自动跟上。


七、连接池:pgBouncer 的注意事项

pgBouncer 是 PostgreSQL 最流行的连接池方案。好消息是:pgvector 的向量查询和普通 SQL 查询一样,对连接池没有特殊要求。但有几个细节需要注意。

7.1 pgBouncer 连接模式选择

pgBouncer 有三种连接模式,对 pgvector 的影响不同:

Session Mode(会话模式)
  连接绑定到会话生命周期,客户端连接 = 后端连接
  优点:完全兼容所有 PG 特性,包括 SET 语句
  缺点:连接复用效果最差
  pgvector 影响:无影响,推荐初期使用

Transaction Mode(事务模式)
  连接在事务期间保持,事务结束后归还连接池
  优点:最高的连接复用效率
  缺点:不支持 SET 语句跨事务保留
  pgvector 影响:需要注意 SET LOCAL 的使用方式

Statement Mode(语句模式)
  每条语句执行完即归还连接
  基本不兼容多语句事务
  pgvector 影响:不推荐使用

7.2 Transaction Mode 下的注意事项

pgvector 有一些查询参数通过 SET 语句配置,在 Transaction Mode 下需要特别处理:

-- 这些 SET 语句在 Transaction Mode 下不会跨事务保留
SET hnsw.ef_search = 100;       -- HNSW 查询时的搜索范围
SET ivfflat.probes = 10;        -- IVFFlat 的探针数量

-- 解决方案 1:在每个事务开头重新 SET
BEGIN;
SET LOCAL hnsw.ef_search = 100;  -- SET LOCAL 只在当前事务有效,符合 Transaction Mode
SELECT * FROM documents ORDER BY embedding <=> '[...]'::vector LIMIT 5;
COMMIT;

-- 事务池优先在每个事务内使用 SET LOCAL。
-- server_reset_query 默认不在 transaction 模式执行,不能直接作为此处的默认参数方案。

7.3 pgBouncer 配置参考

# pgbouncer.ini 配置示例
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = hba

# 推荐向量应用使用 transaction 模式
pool_mode = transaction

# 连接池大小配置
# max_client_conn 限制客户端连接数,default_pool_size 控制各数据库/用户池的服务端连接规模;后端总连接预算还需结合池数量及 PostgreSQL max_connections 计算
max_client_conn = 1000          # 客户端最大连接数
default_pool_size = 25          # 每个数据库的连接池大小

# 向量查询通常耗时较长,适当调大超时
query_timeout = 30              # 单条查询超时(秒)
client_idle_timeout = 60        # 客户端空闲超时

# 关键:确保 pgvector 扩展在连接初始化时已加载
# server_reset_query 是否在归还连接时执行取决于池模式及相关配置;transaction 模式默认不执行
server_reset_query = DISCARD ALL

warning 向量查询的超时设置

向量的 HNSW 近似搜索通常在毫秒级完成,但如果发生 seq scan(全表向量扫描), 对于百万行级别的数据可能需要几十秒。 生产环境务必设置合理的 statement_timeout,避免慢查询占满连接池。

SET statement_timeout = '10s';  -- 超过 10 秒自动终止

八、逻辑复制:向量列如何被复制

pgvector 的向量列在逻辑复制中的行为与其他列完全一致。这个"理所当然"的特性,放在专用向量数据库里其实是一个很难解决的问题。

8.1 向量列与逻辑复制的兼容性

-- 前置:发布端需配置 wal_level=logical、复制权限、连接访问规则和足够的复制资源。
-- 订阅端需预建兼容的表结构,并安装、启用 vector 及涉及的其他扩展;DDL 不会随逻辑复制自动同步。
-- UPDATE/DELETE 还需为发布表配置适用的副本标识。
-- 在发布端创建发布;向量列可随表数据参与复制
CREATE PUBLICATION pgvector_pub
FOR TABLE documents, restaurants, articles
WITH (publish = 'insert, update, delete');

-- 在从库上:创建订阅(Subscription)
-- 以下仅展示订阅语句,需先满足上文复制与目标端环境前置
CREATE SUBSCRIPTION pgvector_sub
CONNECTION 'host=primary port=5432 dbname=mydb user=replicator password=xxx'
PUBLICATION pgvector_pub;

8.2 复制注意事项

-- 检查复制状态
SELECT
    subname,
    pid,
    received_lsn,
    latest_end_lsn,
    last_msg_receipt_time AS last_message_received_at
FROM pg_stat_subscription;

-- 检查复制延迟(向量列数据量大,可能影响复制延迟)
SELECT
    client_addr,
    state,
    sent_lsn - replay_lsn AS replication_lag_bytes,
    EXTRACT(EPOCH FROM replay_lag) AS reported_replay_lag_seconds
FROM pg_stat_replication;

note 向量列对复制带宽的影响

1536 维 float32 向量每行占 6 KB。如果批量插入大量向量数据(如每天导入 10 万行), 相当于每天要复制约 600 MB 的向量数据。 这对复制延迟有直接影响。建议:

  • 批量导入时关注从库复制延迟
  • 读写分离场景下,向量搜索读请求路由到从库时,注意复制延迟导致的"数据短暂不一致"

九、备份与恢复:pg_dump 的零配置支持

这是最让人放心的部分。pgvector 的向量数据备份完全透明,不需要任何特殊处理。

9.1 pg_dump 直接备份

# 备份整个数据库(包含向量数据)
pg_dump -h localhost -U myuser -d mydb -F c -f backup_$(date +%Y%m%d).dump

# 只备份包含向量的表
pg_dump -h localhost -U myuser -d mydb \
  -t documents -t restaurants \
  -F c -f vectors_backup_$(date +%Y%m%d).dump

# 恢复
pg_restore -h localhost -U myuser -d mydb backup_20260311.dump

# 或使用 pg_dumpall 备份所有数据库(包含扩展信息)
pg_dumpall -h localhost -U postgres -f full_backup.sql

tip pg_dump 备份的内容

完整数据库备份通常包含:

  • CREATE EXTENSION vector 语句;恢复端仍需预先安装兼容的扩展软件,并具备创建扩展的权限
  • 向量数据(custom 归档格式不等同于 COPY 二进制数据格式)
  • 向量索引的定义(恢复时重建索引) 使用 -t 按表备份时,不保证包含所选表依赖的扩展等对象,恢复前需准备相应依赖。备份不会打包扩展二进制软件。

9.2 增量备份:WAL 归档

# 启用 WAL 归档(postgresql.conf)
# wal_level = replica
# archive_mode = on
# archive_command = 'cp %p /backup/wal/%f'

# pgBackRest 是生产环境推荐的备份工具,支持 WAL 增量备份
# 向量数据对 pgBackRest 完全透明,无需特殊配置
pgbackrest --stanza=mydb backup --type=incr

9.3 备份大向量表的性能考虑

# 对于包含大量向量数据的表,备份时需考虑:
# 1. 压缩:此处使用 pg_dump -Z 9;压缩率约 40% 仅作为未测试的示例假设,实际结果取决于数据、压缩方式和客户端版本
pg_dump -h localhost -U myuser -d mydb -Z 9 -F c -f backup_compressed.dump

# 2. 并行备份:使用目录格式和 4 个 worker;并行目录备份并非 PostgreSQL 14 才开始支持
pg_dump -h localhost -U myuser -d mydb \
  -F d \
  -j 4 \
  -f backup_dir/

# 3. 只备份向量数据(不包含索引,恢复后重建)
# pre-data 包含表结构等前置定义,data 包含数据;此处省略 post-data 中的索引等定义
pg_dump -h localhost -U myuser -d mydb \
  --section=pre-data \
  --section=data \
  -F c -f backup_no_index.dump
# 此备份省略索引定义,恢复后需另行创建索引;pg_dump 原本也只保存索引定义,常规恢复时同样会重建索引

十、集成方案选型参考

用一张表格总结各种集成场景的选择建议:

场景 推荐方案 关键理由
Python FastAPI + 向量搜索 SQLAlchemy async + asyncpg 适合 FastAPI 异步调用链,吞吐收益取决于负载与实现
Python Flask/Django + 向量搜索 django-pgvector / SQLAlchemy sync 简单直接,与现有项目无缝集成
需要地理位置 + 语义搜索 pgvector + PostGIS 可在 PostgreSQL 单库内组合空间过滤与语义搜索
日志/时序数据 + 向量搜索 pgvector + TimescaleDB 时间分区裁剪大幅提升性能
大规模向量(>1000 万) pgvector + pgvectorscale StreamingDiskANN 磁盘优化
Embedding 自动同步 pgai Vectorizer 减少应用层同步代码,以后台任务异步更新向量
本地私有化部署 pgai + Ollama 数据不出境,完全离线
高并发连接管理 pgBouncer Transaction Mode 连接复用效率最高
数据同步/读副本 PostgreSQL 逻辑复制 可复制向量列;需配置复制前置,不能单凭订阅语句构成完整高可用方案
数据备份 pg_dump 可备份向量数据;按表备份依赖及恢复端扩展环境需单独准备

小结

pgvector 的生态优势体现在每一个细节里:

  1. ORM 层:SQLAlchemy 和 Django 都有成熟的官方支持,向量字段就是一个普通字段,不破坏现有代码结构
  2. 空间与语义组合查询:PostGIS 与 pgvector 可在 PostgreSQL 内组合地理过滤和语义排序,是否可由其他系统替代需按具体功能需求比较
  3. 时序扩展:TimescaleDB 的分区裁剪 + pgvectorscale 的磁盘优化,把单节点能力推向更高上限
  4. pgai 自动化:把 Embedding 生成这件繁琐的事交给数据库,应用代码更干净
  5. 基础设施透明:连接池、复制、备份——许多现有工具可继续使用,但连接池参数、复制前置和恢复端扩展环境仍需按部署条件配置

这套生态的价值,在你第一次用 pg_dump 一键备份了向量数据,或者第一次用 Django QuerySet 链式过滤了向量搜索结果的时候,会有深刻的体会。

下一步:生产运维与性能调优 — 把 pgvector 调到生产最优状态。


summary 本文核心要点

  • SQLAlchemy:pgvector-python 提供 Vector 类型和四个距离函数,支持同步/异步
  • Django:django-pgvector 提供 VectorField、HnswIndex、CosineDistance 等完整工具链
  • PostGIS 组合:ST_DWithin(空间过滤)+ <=> 运算符(向量相似),一次查询完成地理+语义搜索
  • TimescaleDB 组合:hypertable 时间分区裁剪 + diskann 索引,应对时序向量场景
  • pgai:ai.openai_embed() 和 ai.ollama_embed() 直接在 SQL 里生成 Embedding;Vectorizer 自动同步
  • pgBouncer:Transaction Mode 下需注意 SET LOCAL 而非 SET
  • 备份/复制:可复用 PostgreSQL 工具,但逻辑复制前置、按表备份依赖和恢复端扩展环境仍需配置