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

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

warning 适用边界与维护说明

本文适合已有 PostgreSQL、异步 Python 与 RAG 基础的读者参考关系型知识库 Schema、SQL RRF 融合和多框架接入设计,不是可直接串接执行的生产部署包。本次仅作全文结构审阅,未运行代码或进行外部事实核验。当前 RLS 策略组合、授权表与写入权限、事务内身份设置仍有缺口;LangChain 自定义检索器实际只做租户元数据过滤,不能提供用户级数据库授权。独立流水线的向量编解码、JSON 序列化及检索结果解码尚未闭环;文档更新已明确是非原子示例,失败后不能恢复已删除的旧块。Supabase 写入用户归属、Edge 检索身份及分区建表仍有具体缺陷。第二节 Schema、框架自建存储与 Supabase Schema 应分别使用;Text-to-SQL 缺销售业务表、数据及执行安全限制,框架接口也需按实际环境适配。中文全文检索须使用实际建立并完成词典映射的搜索配置。已标明的召回率、提速、容量与迁移分档仅是假设,不能作为实测保证;场景选型与托管套餐、备份及扩展支持建议仍需按业务负载和当前服务条件核对。 已取消无法确定目标的可点击引用「通用知识/04-RAG系统架构设计模式」,保留原提及;Missing link target: 通用知识/04-RAG系统架构设计模式 已取消无法确定目标的可点击引用「通用知识/01-向量搜索原理与ANN算法」,保留原提及;Missing link target: 通用知识/01-向量搜索原理与ANN算法 已取消无法确定目标的可点击引用「通用知识/03-混合搜索工程实践」,保留原提及;Missing link target: 通用知识/03-混合搜索工程实践 已取消无法确定目标的可点击引用「Milvus/10-RAG集成实战」,保留原提及;Missing link target: Milvus/10-RAG集成实战 已取消无法确定目标的可点击引用「Qdrant/10-RAG集成实战」,保留原提及;Missing link target: Qdrant/10-RAG集成实战 已取消无法确定目标的可点击引用「通用知识/02-Embedding模型选型指南」,保留原提及;Missing link target: 通用知识/02-Embedding模型选型指南

pgvector RAG 集成实战:用 PostgreSQL 构建生产级知识库

abstract 本文目标

  • 理解 pgvector RAG 的独特价值:为什么"少一套系统"有时比"多一点性能"更重要
  • 设计 pgvector 专属 RAG Schema:充分利用关系型数据库的 JOIN、事务、RLS 能力
  • 实现真正的混合搜索:pgvector 语义搜索 + tsvector 全文搜索 + RRF 融合排序,全用 SQL 实现
  • 集成 LangChain 和 LlamaIndex:展示框架接入与元数据过滤示例;生产使用还需完成接口适配、数据库权限策略及身份上下文衔接
  • 掌握 Supabase 托管方案:利用托管基础设施减少实例管理工作;仍需配置权限、评估成本并维护性能与恢复能力
  • 给出迁移决策建议:明确什么时候应该从 pgvector 换到 Milvus

pgvector 做 RAG,最大的优势不是速度,而是简单。你的文档元数据、用户信息、权限控制——全在同一个数据库里,没有数据同步的烦恼,没有跨系统一致性的问题。这就是 pgvector 派的 RAG 哲学:宁愿少一点性能,也不要多一套系统。

想象一下专用向量数据库的典型架构:PostgreSQL 存业务数据,Milvus/Qdrant 存向量,Redis 做缓存,三套系统需要保持同步。文档删除了要同时删 PG 和 Milvus;用户权限变了要重新过滤 payload;出了 bug 要跨三个系统排查日志。而 pgvector 的架构只有一个数据库,所有这些问题天然消失。

当然,pgvector 不是银弹。本文最后会给出明确的迁移阈值——超过这个规模,你就应该认真考虑换专用向量库了。

阅读前置:RAG 架构设计模式 向量搜索原理 混合搜索工程实践

对比参考:Milvus RAG 集成实战 Qdrant RAG 集成实战


目录


一、pgvector RAG 的独特价值体系

1.1 架构对比:一个数据库 vs 三套系统

先用一张图说清楚 pgvector RAG 和专用向量库方案在架构上的本质差异。

%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart LR
    subgraph "专用向量库方案"
        direction TB
        A1["PostgreSQL<br/>业务数据 / 用户权限"]
        A2["Milvus / Qdrant<br/>向量数据"]
        A3["应用层<br/>手动同步"]
        A1 <-->|"需要保持同步"| A3
        A2 <-->|"需要保持同步"| A3
    end

    subgraph "pgvector 方案"
        direction TB
        B1["PostgreSQL + pgvector<br/>业务数据 + 向量数据<br/>统一存储 / 统一事务 / 统一权限"]
        B2["应用层<br/>单一数据源"]
        B1 <-->|"直接访问"| B2
    end

    style A1 fill:#fee2e2,stroke:#ef4444
    style A2 fill:#fee2e2,stroke:#ef4444
    style A3 fill:#fef9c3,stroke:#ca8a04
    style B1 fill:#dcfce7,stroke:#16a34a
    style B2 fill:#dbeafe,stroke:#3b82f6

这不是在说专用向量库不好。专用向量库在大规模场景下性能碾压 pgvector。问题是:你真的到了那个规模吗?

1.2 pgvector RAG 的四大结构性优势

优势一:向量数据和业务数据 JOIN 随意写

这是 pgvector 最被低估的能力。假设你要实现"只返回用户有权限访问的文档中的相关内容",在专用向量库方案里:

① 查向量库,获取 Top-50 候选
② 查 PostgreSQL,过滤出用户有权限的文档 ID
③ 在应用层做交集
④ 如果交集不足 Top-5,重复步骤①但扩大召回范围

在 pgvector 里只需要一条 SQL:

SELECT c.content, c.embedding <=> $1 AS distance
FROM chunks c
JOIN documents d ON c.document_id = d.id
JOIN document_permissions dp ON d.id = dp.document_id
WHERE dp.user_id = $2
  AND d.tenant_id = $3
ORDER BY distance
LIMIT 5;

优势二:事务保证向量和元数据的原子一致性

文档更新时,你不可能同时更新两个系统并保证原子性。在 pgvector 里:

BEGIN;
  UPDATE documents SET title = '新标题', updated_at = NOW() WHERE id = $1;
  UPDATE chunks SET content = $2, embedding = $3 WHERE document_id = $1;
COMMIT;
-- 要么全成功,要么全回滚,不存在"PG 更新了但向量库没更新"的中间状态

优势三:Row Level Security 实现多租户隔离

这是 pgvector 比 Milvus/Qdrant 的 payload filter 更优雅的地方。RLS 是数据库级别的安全保证,不依赖应用层代码。即使开发者写了个 bug 忘记加 tenant_id 过滤,RLS 也能兜底。

优势四:复用已有的 PostgreSQL 运维体系

你的 DBA 已经有 PG 的监控、备份、告警体系。pgvector 不需要额外学习成本,pg_stat_statements、EXPLAIN ANALYZE、pgBadger 这些工具全都能用。

1.3 pgvector RAG 完整数据流

%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart TB
    subgraph "离线阶段(文档索引)"
        direction LR
        D1["原始文档<br/>PDF / Word / MD / HTML"]
        D2["文档解析<br/>提取文本 + 元数据"]
        D3["语义分块<br/>按段落 / 滑动窗口"]
        D4["生成 Embedding<br/>OpenAI / BGE / Qwen"]
        D5["单事务写入 PostgreSQL<br/>documents + chunks 表"]
        D1 --> D2 --> D3 --> D4 --> D5
    end

    subgraph "在线阶段(检索生成)"
        direction LR
        Q1["用户提问"]
        Q2["Query 向量化<br/>同模型生成"]
        Q3["pgvector 混合搜索<br/>向量搜索 + 全文搜索<br/>RLS 权限过滤"]
        Q4["RRF 融合排序<br/>SQL CTE 实现"]
        Q5["Reranker 精排<br/>可选"]
        Q6["LLM 生成答案<br/>GPT-4 / Claude / Qwen"]
        Q7["含引用来源的回答"]
        Q1 --> Q2 --> Q3 --> Q4 --> Q5 --> Q6 --> Q7
    end

    D5 -.->|"PostgreSQL 统一存储"| Q3

    style D1 fill:#dbeafe,stroke:#3b82f6
    style D5 fill:#dcfce7,stroke:#16a34a
    style Q3 fill:#fef9c3,stroke:#ca8a04
    style Q6 fill:#fce7f3,stroke:#db2777
    style Q7 fill:#f0fdf4,stroke:#16a34a

二、RAG 专用 Schema 设计

2.1 核心表设计思路

pgvector RAG 的 Schema 设计要充分利用关系型数据库的优势。不要像设计 NoSQL 那样把所有信息塞进一张宽表,要充分利用规范化设计和外键约束。

四张核心表的关系:

层级 表名 说明
顶层 tenants 租户(多租户隔离)
子层 users 用户,归属于某租户
子层 documents 文档,归属于某租户
最底层 chunks 文本块(含向量),归属于某文档
%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
erDiagram
    tenants {
        uuid id PK
        varchar name
        jsonb settings
        timestamp created_at
    }
    users {
        uuid id PK
        uuid tenant_id FK
        varchar email
        varchar role
        timestamp created_at
    }
    documents {
        uuid id PK
        uuid tenant_id FK
        uuid created_by FK
        varchar title
        text source_url
        varchar mime_type
        integer total_chunks
        jsonb metadata
        varchar status
        timestamp created_at
        timestamp updated_at
    }
    chunks {
        uuid id PK
        uuid document_id FK
        integer chunk_index
        text content
        vector embedding
        tsvector content_tsv
        integer token_count
        jsonb metadata
        timestamp created_at
    }
    document_permissions {
        uuid id PK
        uuid document_id FK
        uuid user_id FK
        varchar permission_level
    }

    tenants ||--o{ users : "has"
    tenants ||--o{ documents : "owns"
    users ||--o{ documents : "creates"
    documents ||--o{ chunks : "splits_into"
    documents ||--o{ document_permissions : "has"
    users ||--o{ document_permissions : "granted"

2.2 完整建表 SQL

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

-- ============================================================
-- 租户表
-- ============================================================
CREATE TABLE tenants (
    id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name        VARCHAR(255) NOT NULL,
    settings    JSONB DEFAULT '{}',
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- ============================================================
-- 用户表
-- ============================================================
CREATE TABLE users (
    id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    email       VARCHAR(255) UNIQUE NOT NULL,
    role        VARCHAR(50) NOT NULL DEFAULT 'viewer',  -- admin, editor, viewer
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- ============================================================
-- 文档表
-- ============================================================
CREATE TABLE documents (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    created_by      UUID NOT NULL REFERENCES users(id),
    title           VARCHAR(500) NOT NULL,
    source_url      TEXT,
    mime_type       VARCHAR(100) DEFAULT 'text/plain',
    total_chunks    INTEGER DEFAULT 0,
    metadata        JSONB DEFAULT '{}',
    -- metadata 示例:
    -- {"category": "技术文档", "tags": ["Python", "数据库"], "version": "2.0"}
    status          VARCHAR(50) DEFAULT 'processing',  -- processing, indexed, failed
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_documents_tenant ON documents(tenant_id);
CREATE INDEX idx_documents_status ON documents(status);
CREATE INDEX idx_documents_metadata ON documents USING GIN(metadata);

-- ============================================================
-- 文本块表(核心表,含向量)
-- ============================================================
CREATE TABLE chunks (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    chunk_index     INTEGER NOT NULL,           -- 在文档中的顺序
    content         TEXT NOT NULL,              -- 原始文本
    embedding       VECTOR(1536),               -- OpenAI text-embedding-3-small
    content_tsv     TSVECTOR,                   -- 全文搜索索引(自动维护)
    token_count     INTEGER,                    -- token 数量
    metadata        JSONB DEFAULT '{}',
    -- metadata 示例:
    -- {"page": 3, "section": "第二章", "heading": "安装配置"}
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(document_id, chunk_index)
);

-- 向量索引(HNSW,生产推荐)
CREATE INDEX idx_chunks_embedding ON chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- 全文搜索索引
CREATE INDEX idx_chunks_tsv ON chunks USING GIN(content_tsv);

-- 普通索引
CREATE INDEX idx_chunks_document ON chunks(document_id);
CREATE INDEX idx_chunks_metadata ON chunks USING GIN(metadata);

-- 自动维护 tsvector(中文需要 zhparser 扩展,英文用默认)
CREATE OR REPLACE FUNCTION update_chunks_tsv()
RETURNS TRIGGER AS $$
BEGIN
    NEW.content_tsv := to_tsvector('english', NEW.content);
    -- 中文环境改为:to_tsvector('zhparser', NEW.content)
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_chunks_tsv
    BEFORE INSERT OR UPDATE OF content
    ON chunks
    FOR EACH ROW
    EXECUTE FUNCTION update_chunks_tsv();

-- ============================================================
-- 文档权限表(细粒度访问控制)
-- ============================================================
CREATE TABLE document_permissions (
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    document_id         UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    user_id             UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    permission_level    VARCHAR(50) NOT NULL DEFAULT 'read',  -- read, write, admin
    granted_at          TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(document_id, user_id)
);

CREATE INDEX idx_permissions_user ON document_permissions(user_id);
CREATE INDEX idx_permissions_document ON document_permissions(document_id);

-- ============================================================
-- 自动更新 updated_at
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_documents_updated_at
    BEFORE UPDATE ON documents
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at();

2.3 Row Level Security 多租户隔离

RLS 是 pgvector 多租户方案中最强大的武器。一旦启用,即使应用层代码忘记加 tenant_id 过滤,数据库也会自动隔离数据。

-- ============================================================
-- 启用 RLS
-- ============================================================
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE document_permissions ENABLE ROW LEVEL SECURITY;

-- ============================================================
-- 设置当前会话的用户上下文(应用层在每次连接时调用)
-- ============================================================
-- 在连接池中,每次从池里取连接后执行:
-- SET LOCAL app.current_user_id = '用户UUID';
-- SET LOCAL app.current_tenant_id = '租户UUID';

-- ============================================================
-- documents 表的 RLS 策略
-- ============================================================

-- 策略1:用户可以看到自己租户的所有文档(基础隔离)
CREATE POLICY tenant_isolation ON documents
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- 策略2:细粒度权限(叠加在租户隔离之上)
-- 用户只能看到:自己创建的 OR 被显式授权的文档
CREATE POLICY user_document_access ON documents
    FOR SELECT
    USING (
        created_by = current_setting('app.current_user_id')::UUID
        OR EXISTS (
            SELECT 1 FROM document_permissions dp
            WHERE dp.document_id = documents.id
              AND dp.user_id = current_setting('app.current_user_id')::UUID
        )
    );

-- ============================================================
-- chunks 表的 RLS 策略(通过 JOIN documents 继承权限)
-- ============================================================
CREATE POLICY chunks_via_documents ON chunks
    FOR SELECT
    USING (
        EXISTS (
            SELECT 1 FROM documents d
            WHERE d.id = chunks.document_id
              AND d.tenant_id = current_setting('app.current_tenant_id')::UUID
              AND (
                  d.created_by = current_setting('app.current_user_id')::UUID
                  OR EXISTS (
                      SELECT 1 FROM document_permissions dp
                      WHERE dp.document_id = d.id
                        AND dp.user_id = current_setting('app.current_user_id')::UUID
                  )
              )
        )
    );

-- ============================================================
-- 应用层调用方式(Python 示例)
-- ============================================================
-- async with pool.acquire() as conn:
--     await conn.execute(
--         "SET LOCAL app.current_user_id = $1; "
--         "SET LOCAL app.current_tenant_id = $2",
--         str(user_id), str(tenant_id)
--     )
--     # 之后所有查询自动应用 RLS,无需手动加 WHERE 条件

tip RLS vs Payload Filter 的关键区别

Milvus/Qdrant 的 payload filter 是应用层控制:如果开发者忘记加过滤条件,数据就会泄露。 pgvector 的 RLS 是数据库层控制:在正确配置策略、使用受 RLS 约束的运行角色,并由可信服务设置身份上下文时,可降低漏写应用过滤条件的风险;超级用户、BYPASSRLS 角色以及通常情况下的表所有者不受同等约束。 在金融、医疗等合规要求严格的场景,这个差异非常重要。

2.4 权限分配辅助函数

-- 批量授权:将文档访问权限授予用户
CREATE OR REPLACE FUNCTION grant_document_access(
    p_document_id   UUID,
    p_user_id       UUID,
    p_level         VARCHAR DEFAULT 'read'
)
RETURNS VOID AS $$
BEGIN
    INSERT INTO document_permissions (document_id, user_id, permission_level)
    VALUES (p_document_id, p_user_id, p_level)
    ON CONFLICT (document_id, user_id)
    DO UPDATE SET permission_level = p_level;
END;
$$ LANGUAGE plpgsql;

-- 批量授权给整个团队
CREATE OR REPLACE FUNCTION grant_tenant_document_access(
    p_document_id   UUID,
    p_tenant_id     UUID,
    p_level         VARCHAR DEFAULT 'read'
)
RETURNS INTEGER AS $$
DECLARE
    affected_count INTEGER;
BEGIN
    INSERT INTO document_permissions (document_id, user_id, permission_level)
    SELECT p_document_id, u.id, p_level
    FROM users u
    WHERE u.tenant_id = p_tenant_id
    ON CONFLICT (document_id, user_id) DO NOTHING;

    GET DIAGNOSTICS affected_count = ROW_COUNT;
    RETURN affected_count;
END;
$$ LANGUAGE plpgsql;

三、文档处理流水线

3.1 流水线整体设计

文档处理流水线的核心原则:解析、分块、向量化、入库这四个步骤必须在同一个事务中完成(或者有幂等的补偿机制)。任何中途失败都应该让文档回到 processing 状态,而不是留下"半入库"的脏数据。

%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart TD
    START["上传文档"]
    P1["创建 documents 记录<br/>status = processing"]
    P2["解析文档内容<br/>提取纯文本 + 元数据"]
    P3["文本分块<br/>RecursiveCharacterTextSplitter"]
    P4["批量生成 Embedding<br/>并发调用 Embedding API"]
    P5["单事务批量写入 chunks<br/>含 content + embedding"]
    P6["更新 documents.status = indexed<br/>更新 total_chunks"]
    END["索引完成"]
    ERR["更新 status = failed<br/>记录错误信息"]

    START --> P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> END
    P2 -->|"解析失败"| ERR
    P4 -->|"API 调用失败"| ERR
    P5 -->|"写入失败"| ERR

    style START fill:#dbeafe,stroke:#3b82f6
    style END fill:#dcfce7,stroke:#16a34a
    style ERR fill:#fee2e2,stroke:#ef4444
    style P5 fill:#fef9c3,stroke:#ca8a04

3.2 完整流水线代码

"""
pgvector RAG 文档处理流水线
依赖:pip install asyncpg openai langchain-text-splitters pypdf python-docx
"""

import asyncio
import uuid
from typing import Optional
from dataclasses import dataclass

import asyncpg
from openai import AsyncOpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter


# ============================================================
# 配置
# ============================================================
DATABASE_URL = "postgresql://user:password@localhost:5432/rag_db"
OPENAI_API_KEY = "sk-..."
EMBEDDING_MODEL = "text-embedding-3-small"
EMBEDDING_DIM = 1536
CHUNK_SIZE = 512        # tokens
CHUNK_OVERLAP = 50
BATCH_SIZE = 100        # 每批插入的 chunk 数量
EMBED_BATCH_SIZE = 20   # 每次调用 Embedding API 的文本数量


@dataclass
class DocumentInfo:
    title: str
    content: str
    source_url: Optional[str] = None
    mime_type: str = "text/plain"
    metadata: dict = None


# ============================================================
# Embedding 服务
# ============================================================
class EmbeddingService:
    def __init__(self):
        self.client = AsyncOpenAI(api_key=OPENAI_API_KEY)

    async def embed_batch(self, texts: list[str]) -> list[list[float]]:
        """批量生成 Embedding,自动分批处理"""
        all_embeddings = []

        for i in range(0, len(texts), EMBED_BATCH_SIZE):
            batch = texts[i: i + EMBED_BATCH_SIZE]
            # 清理文本(API 不接受空字符串)
            batch = [t.replace("\n", " ").strip() or " " for t in batch]

            response = await self.client.embeddings.create(
                model=EMBEDDING_MODEL,
                input=batch
            )
            batch_embeddings = [item.embedding for item in response.data]
            all_embeddings.extend(batch_embeddings)

        return all_embeddings


# ============================================================
# 文档处理流水线
# ============================================================
class DocumentPipeline:
    def __init__(self, db_pool: asyncpg.Pool):
        self.pool = db_pool
        self.embedder = EmbeddingService()
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=CHUNK_SIZE,
            chunk_overlap=CHUNK_OVERLAP,
            length_function=len,  # 生产环境改为 tiktoken 计数
            separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", " ", ""]
        )

    async def process_document(
        self,
        doc: DocumentInfo,
        tenant_id: str,
        user_id: str
    ) -> str:
        """
        处理单个文档:解析 → 分块 → 向量化 → 写入
        返回 document_id
        使用单事务保证原子性
        """
        document_id = str(uuid.uuid4())

        async with self.pool.acquire() as conn:
            # ── 步骤 1:创建文档记录 ──────────────────────────
            await conn.execute("""
                INSERT INTO documents
                    (id, tenant_id, created_by, title, source_url, mime_type, metadata, status)
                VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'processing')
            """,
                document_id,
                tenant_id,
                user_id,
                doc.title,
                doc.source_url,
                doc.mime_type,
                str(doc.metadata or {}).replace("'", '"')
            )

            try:
                # ── 步骤 2:文本分块 ──────────────────────────
                chunks_text = self.splitter.split_text(doc.content)
                if not chunks_text:
                    raise ValueError(f"文档 '{doc.title}' 分块结果为空")

                print(f"文档 '{doc.title}' 分块完成,共 {len(chunks_text)} 个 chunk")

                # ── 步骤 3:生成 Embedding ────────────────────
                embeddings = await self.embedder.embed_batch(chunks_text)

                print(f"Embedding 生成完成,维度 {len(embeddings[0])}")

                # ── 步骤 4:单事务批量写入 ────────────────────
                async with conn.transaction():
                    # 批量插入 chunks
                    chunk_records = [
                        (
                            str(uuid.uuid4()),   # id
                            document_id,          # document_id
                            idx,                  # chunk_index
                            text,                 # content
                            embeddings[idx],      # embedding(list[float])
                            len(text.split()),    # token_count 估算
                        )
                        for idx, text in enumerate(chunks_text)
                    ]

                    await conn.executemany("""
                        INSERT INTO chunks
                            (id, document_id, chunk_index, content, embedding, token_count)
                        VALUES ($1, $2, $3, $4, $5::vector, $6)
                    """, chunk_records)

                    # 更新文档状态
                    await conn.execute("""
                        UPDATE documents
                        SET status = 'indexed',
                            total_chunks = $1,
                            updated_at = NOW()
                        WHERE id = $2
                    """, len(chunks_text), document_id)

                print(f"文档 '{doc.title}' 索引完成,document_id = {document_id}")
                return document_id

            except Exception as e:
                # 任何步骤失败,更新状态为 failed
                await conn.execute("""
                    UPDATE documents
                    SET status = 'failed',
                        metadata = metadata || $1::jsonb,
                        updated_at = NOW()
                    WHERE id = $2
                """, f'{{"error": "{str(e)[:200]}"}}', document_id)
                raise

    async def process_documents_batch(
        self,
        docs: list[DocumentInfo],
        tenant_id: str,
        user_id: str,
        concurrency: int = 3
    ) -> list[str]:
        """
        并发处理多个文档
        concurrency 控制并发数,避免 Embedding API 限速
        """
        semaphore = asyncio.Semaphore(concurrency)

        async def process_with_semaphore(doc):
            async with semaphore:
                return await self.process_document(doc, tenant_id, user_id)

        results = await asyncio.gather(
            *[process_with_semaphore(doc) for doc in docs],
            return_exceptions=True
        )

        successful = [r for r in results if isinstance(r, str)]
        failed = [r for r in results if isinstance(r, Exception)]

        print(f"批量处理完成:{len(successful)} 成功,{len(failed)} 失败")
        return successful


# ============================================================
# 文档解析器(支持多格式)
# ============================================================
class DocumentParser:

    @staticmethod
    def parse_pdf(file_path: str) -> DocumentInfo:
        """解析 PDF 文件"""
        import pypdf

        reader = pypdf.PdfReader(file_path)
        text_parts = []

        for page_num, page in enumerate(reader.pages):
            page_text = page.extract_text()
            if page_text.strip():
                text_parts.append(page_text)

        full_text = "\n\n".join(text_parts)

        return DocumentInfo(
            title=file_path.split("/")[-1].replace(".pdf", ""),
            content=full_text,
            source_url=file_path,
            mime_type="application/pdf",
            metadata={"total_pages": len(reader.pages)}
        )

    @staticmethod
    def parse_docx(file_path: str) -> DocumentInfo:
        """解析 Word 文档"""
        import docx

        doc = docx.Document(file_path)
        paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
        full_text = "\n\n".join(paragraphs)

        return DocumentInfo(
            title=file_path.split("/")[-1].replace(".docx", ""),
            content=full_text,
            source_url=file_path,
            mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document"
        )

    @staticmethod
    def parse_markdown(file_path: str) -> DocumentInfo:
        """解析 Markdown 文件"""
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read()

        # 提取标题
        lines = content.split("\n")
        title = next(
            (line.lstrip("#").strip() for line in lines if line.startswith("#")),
            file_path.split("/")[-1].replace(".md", "")
        )

        return DocumentInfo(
            title=title,
            content=content,
            source_url=file_path,
            mime_type="text/markdown"
        )


# ============================================================
# 使用示例
# ============================================================
async def main():
    pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)

    pipeline = DocumentPipeline(pool)
    parser = DocumentParser()

    # 解析文档
    docs = [
        parser.parse_pdf("/docs/技术手册.pdf"),
        parser.parse_markdown("/docs/API文档.md"),
        DocumentInfo(
            title="产品介绍",
            content="这是一段产品介绍文本...",
            metadata={"category": "产品", "version": "1.0"}
        )
    ]

    # 批量处理,最多 3 并发
    doc_ids = await pipeline.process_documents_batch(
        docs=docs,
        tenant_id="tenant-uuid-xxx",
        user_id="user-uuid-xxx",
        concurrency=3
    )

    print(f"成功索引文档 IDs:{doc_ids}")
    await pool.close()


if __name__ == "__main__":
    asyncio.run(main())

3.3 增量更新与文档删除

class DocumentManager:
    def __init__(self, db_pool: asyncpg.Pool):
        self.pool = db_pool
        self.pipeline = DocumentPipeline(db_pool)

    async def update_document(
        self,
        document_id: str,
        new_doc: DocumentInfo,
        tenant_id: str,
        user_id: str
    ):
        """
        更新文档:删除旧 chunks,重新索引
        当前示例使用两个事务,不保证新旧版本切换的原子性;新索引失败时旧 chunks 已被删除
        """
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                # 删除旧 chunks(CASCADE 自动处理)
                await conn.execute(
                    "DELETE FROM chunks WHERE document_id = $1",
                    document_id
                )

                # 重置文档状态
                await conn.execute("""
                    UPDATE documents
                    SET title = $1, status = 'processing',
                        updated_at = NOW(), total_chunks = 0
                    WHERE id = $2
                """, new_doc.title, document_id)

        # 事务提交后重新索引(避免长事务持有锁)
        # 注意:这里有短暂的"已删除旧数据但新数据未就绪"窗口
        # 生产环境可用 Blue-Green 策略(先写新版本再删旧版本)
        chunks_text = self.pipeline.splitter.split_text(new_doc.content)
        embeddings = await self.pipeline.embedder.embed_batch(chunks_text)

        async with self.pool.acquire() as conn:
            async with conn.transaction():
                chunk_records = [
                    (str(uuid.uuid4()), document_id, idx, text, embeddings[idx], len(text.split()))
                    for idx, text in enumerate(chunks_text)
                ]
                await conn.executemany("""
                    INSERT INTO chunks (id, document_id, chunk_index, content, embedding, token_count)
                    VALUES ($1, $2, $3, $4, $5::vector, $6)
                """, chunk_records)

                await conn.execute("""
                    UPDATE documents
                    SET status = 'indexed', total_chunks = $1, updated_at = NOW()
                    WHERE id = $2
                """, len(chunks_text), document_id)

    async def delete_document(self, document_id: str):
        """
        删除文档(chunks 通过 CASCADE 自动删除)
        """
        async with self.pool.acquire() as conn:
            result = await conn.execute(
                "DELETE FROM documents WHERE id = $1",
                document_id
            )
            print(f"已删除文档 {document_id},影响行数:{result}")

四、混合搜索实现(SQL 版)

4.1 为什么要混合搜索

假设某评测中纯向量搜索召回率为 62%,混合搜索为 84%,则相对提升约为 35%,绝对提升为 22 个百分点;这里的数值仅为说明计算方式的假设,不代表本文提供了实测结果。原因很直观:

  • 向量搜索擅长语义相似,能找到意思相近但措辞不同的内容
  • 全文搜索擅长精确关键词匹配,能找到包含特定术语、型号、代码的内容

两者互补,覆盖了不同类型的用户查询。

4.2 RRF(倒数排名融合)原理

RRF 的公式极其简洁:

RRF_score(d) = Σ (1 / (k + rank_i(d)))

其中 k = 60(默认值),rank_i(d) 是文档 d 在第 i 个检索结果列表中的排名。

示例:文档 A 在向量搜索中排名第 2,在全文搜索中排名第 5
RRF(A) = 1/(60+2) + 1/(60+5) = 0.01613 + 0.01538 = 0.03151

文档 B 在向量搜索中排名第 1,未出现在全文搜索结果中
RRF(B) = 1/(60+1) + 0 = 0.01639

最终排序:B(0.01639) > A(0.03151)?
→ 不对!A > B,因为 A 在两个列表都出现了

关键洞察:同时出现在两个列表中的文档可以累加两项 RRF 得分,但是否超过单列表第一名仍取决于各列表排名与 k 值。上例中 A 的累加得分高于 B,不能推广为所有双列表命中文档都必然排在前面。

4.3 混合搜索 SQL 实现

-- ============================================================
-- 混合搜索:向量搜索 + 全文搜索 + RRF 融合
-- 参数:
--   $1: 查询向量(float4[])
--   $2: 查询文本(text)
--   $3: 返回结果数(integer)
--   $4: 候选集大小(integer,建议 50-100)
--   $5: RRF k 值(integer,默认 60)
-- ============================================================

WITH
-- ── CTE 1:向量搜索 ────────────────────────────────────────
vector_search AS (
    SELECT
        c.id          AS chunk_id,
        c.document_id,
        c.content,
        c.metadata    AS chunk_metadata,
        d.title       AS doc_title,
        d.metadata    AS doc_metadata,
        c.embedding <=> $1::vector   AS distance,
        ROW_NUMBER() OVER (ORDER BY c.embedding <=> $1::vector) AS vector_rank
    FROM chunks c
    JOIN documents d ON c.document_id = d.id
    WHERE d.status = 'indexed'
    -- RLS 已在会话级别启用,这里不需要手动加 tenant_id 过滤
    ORDER BY distance
    LIMIT $4  -- 候选集大小(建议 50-100)
),

-- ── CTE 2:全文搜索 ────────────────────────────────────────
fulltext_search AS (
    SELECT
        c.id          AS chunk_id,
        c.document_id,
        c.content,
        c.metadata    AS chunk_metadata,
        d.title       AS doc_title,
        d.metadata    AS doc_metadata,
        ts_rank(c.content_tsv, query) AS ts_score,
        ROW_NUMBER() OVER (ORDER BY ts_rank(c.content_tsv, query) DESC) AS text_rank
    FROM chunks c
    JOIN documents d ON c.document_id = d.id,
    plainto_tsquery('english', $2) query   -- 中文改为 plainto_tsquery('zhparser', $2)
    WHERE d.status = 'indexed'
      AND c.content_tsv @@ query
    ORDER BY ts_score DESC
    LIMIT $4
),

-- ── CTE 3:RRF 融合排序 ───────────────────────────────────
rrf_fusion AS (
    SELECT
        COALESCE(vs.chunk_id, fs.chunk_id)                          AS chunk_id,
        COALESCE(vs.document_id, fs.document_id)                    AS document_id,
        COALESCE(vs.content, fs.content)                            AS content,
        COALESCE(vs.chunk_metadata, fs.chunk_metadata)              AS chunk_metadata,
        COALESCE(vs.doc_title, fs.doc_title)                        AS doc_title,
        COALESCE(vs.doc_metadata, fs.doc_metadata)                  AS doc_metadata,
        -- RRF 得分计算(k=$5,默认60)
        COALESCE(1.0 / ($5::float + vs.vector_rank), 0)
        + COALESCE(1.0 / ($5::float + fs.text_rank), 0)             AS rrf_score,
        vs.distance                                                  AS vector_distance,
        fs.ts_score                                                  AS text_score
    FROM vector_search vs
    FULL OUTER JOIN fulltext_search fs ON vs.chunk_id = fs.chunk_id
)

SELECT
    chunk_id,
    document_id,
    content,
    chunk_metadata,
    doc_title,
    doc_metadata,
    rrf_score,
    vector_distance,
    text_score
FROM rrf_fusion
ORDER BY rrf_score DESC
LIMIT $3;

4.4 封装成 Python 函数

from pgvector.asyncpg import register_vector
import json


class HybridSearcher:
    def __init__(self, db_pool: asyncpg.Pool):
        self.pool = db_pool
        self.embedder = EmbeddingService()

    async def search(
        self,
        query: str,
        tenant_id: str,
        user_id: str,
        top_k: int = 5,
        candidate_size: int = 50,
        rrf_k: int = 60,
        filter_metadata: dict = None
    ) -> list[dict]:
        """
        混合搜索:向量 + 全文 + RRF
        自动应用 RLS 权限过滤
        """
        # 生成查询向量
        query_embedding = (await self.embedder.embed_batch([query]))[0]

        async with self.pool.acquire() as conn:
            # 注册 pgvector 类型
            await register_vector(conn)

            # 设置用户上下文(触发 RLS)
            await conn.execute(
                "SELECT set_config('app.current_user_id', $1, true), "
                "       set_config('app.current_tenant_id', $2, true)",
                str(user_id), str(tenant_id)
            )

            rows = await conn.fetch("""
                WITH
                vector_search AS (
                    SELECT
                        c.id AS chunk_id, c.document_id, c.content,
                        c.metadata AS chunk_metadata,
                        d.title AS doc_title, d.metadata AS doc_metadata,
                        c.embedding <=> $1::vector AS distance,
                        ROW_NUMBER() OVER (ORDER BY c.embedding <=> $1::vector) AS vector_rank
                    FROM chunks c
                    JOIN documents d ON c.document_id = d.id
                    WHERE d.status = 'indexed'
                    ORDER BY distance
                    LIMIT $4
                ),
                fulltext_search AS (
                    SELECT
                        c.id AS chunk_id, c.document_id, c.content,
                        c.metadata AS chunk_metadata,
                        d.title AS doc_title, d.metadata AS doc_metadata,
                        ts_rank(c.content_tsv, query) AS ts_score,
                        ROW_NUMBER() OVER (ORDER BY ts_rank(c.content_tsv, query) DESC) AS text_rank
                    FROM chunks c
                    JOIN documents d ON c.document_id = d.id,
                    plainto_tsquery('english', $2) query
                    WHERE d.status = 'indexed' AND c.content_tsv @@ query
                    ORDER BY ts_score DESC
                    LIMIT $4
                ),
                rrf_fusion AS (
                    SELECT
                        COALESCE(vs.chunk_id, fs.chunk_id) AS chunk_id,
                        COALESCE(vs.document_id, fs.document_id) AS document_id,
                        COALESCE(vs.content, fs.content) AS content,
                        COALESCE(vs.chunk_metadata, fs.chunk_metadata) AS chunk_metadata,
                        COALESCE(vs.doc_title, fs.doc_title) AS doc_title,
                        COALESCE(vs.doc_metadata, fs.doc_metadata) AS doc_metadata,
                        COALESCE(1.0 / ($5::float + vs.vector_rank), 0)
                        + COALESCE(1.0 / ($5::float + fs.text_rank), 0) AS rrf_score,
                        vs.distance AS vector_distance,
                        fs.ts_score AS text_score
                    FROM vector_search vs
                    FULL OUTER JOIN fulltext_search fs ON vs.chunk_id = fs.chunk_id
                )
                SELECT * FROM rrf_fusion
                ORDER BY rrf_score DESC
                LIMIT $3
            """, query_embedding, query, top_k, candidate_size, rrf_k)

        return [
            {
                "chunk_id": str(row["chunk_id"]),
                "document_id": str(row["document_id"]),
                "content": row["content"],
                "doc_title": row["doc_title"],
                "rrf_score": float(row["rrf_score"]),
                "vector_distance": float(row["vector_distance"]) if row["vector_distance"] else None,
                "text_score": float(row["text_score"]) if row["text_score"] else None,
                "metadata": dict(row["chunk_metadata"] or {}),
            }
            for row in rows
        ]

    async def vector_only_search(
        self,
        query: str,
        tenant_id: str,
        user_id: str,
        top_k: int = 5
    ) -> list[dict]:
        """纯向量搜索(速度更快,适合实时场景)"""
        query_embedding = (await self.embedder.embed_batch([query]))[0]

        async with self.pool.acquire() as conn:
            await register_vector(conn)
            await conn.execute(
                "SELECT set_config('app.current_user_id', $1, true), "
                "       set_config('app.current_tenant_id', $2, true)",
                str(user_id), str(tenant_id)
            )

            rows = await conn.fetch("""
                SELECT
                    c.id AS chunk_id,
                    c.document_id,
                    c.content,
                    d.title AS doc_title,
                    c.embedding <=> $1::vector AS distance,
                    c.metadata AS chunk_metadata
                FROM chunks c
                JOIN documents d ON c.document_id = d.id
                WHERE d.status = 'indexed'
                ORDER BY distance
                LIMIT $2
            """, query_embedding, top_k)

        return [dict(row) for row in rows]

4.5 存储过程版本(性能更优)

对于高频查询,可以将混合搜索封装成 PostgreSQL 函数,减少网络往返:

-- 在数据库中创建混合搜索函数
CREATE OR REPLACE FUNCTION hybrid_search(
    query_embedding VECTOR(1536),
    query_text      TEXT,
    match_count     INTEGER DEFAULT 5,
    candidate_count INTEGER DEFAULT 50,
    rrf_k           INTEGER DEFAULT 60
)
RETURNS TABLE (
    chunk_id        UUID,
    document_id     UUID,
    content         TEXT,
    doc_title       VARCHAR,
    rrf_score       FLOAT,
    vector_distance FLOAT,
    text_score      FLOAT,
    metadata        JSONB
)
LANGUAGE SQL STABLE
AS $$
    WITH
    vector_search AS (
        SELECT
            c.id, c.document_id, c.content, d.title,
            c.metadata, c.embedding <=> query_embedding AS distance,
            ROW_NUMBER() OVER (ORDER BY c.embedding <=> query_embedding) AS vrank
        FROM chunks c
        JOIN documents d ON c.document_id = d.id
        WHERE d.status = 'indexed'
        ORDER BY distance LIMIT candidate_count
    ),
    fulltext_search AS (
        SELECT
            c.id, c.document_id, c.content, d.title,
            c.metadata, ts_rank(c.content_tsv, q) AS ts_score,
            ROW_NUMBER() OVER (ORDER BY ts_rank(c.content_tsv, q) DESC) AS frank
        FROM chunks c
        JOIN documents d ON c.document_id = d.id,
        plainto_tsquery('english', query_text) q
        WHERE d.status = 'indexed' AND c.content_tsv @@ q
        ORDER BY ts_score DESC LIMIT candidate_count
    ),
    fused AS (
        SELECT
            COALESCE(vs.id, fs.id)                               AS chunk_id,
            COALESCE(vs.document_id, fs.document_id)             AS document_id,
            COALESCE(vs.content, fs.content)                     AS content,
            COALESCE(vs.title, fs.title)                         AS doc_title,
            COALESCE(1.0/(rrf_k + vs.vrank), 0)
            + COALESCE(1.0/(rrf_k + fs.frank), 0)               AS rrf_score,
            vs.distance, fs.ts_score,
            COALESCE(vs.metadata, fs.metadata)                   AS metadata
        FROM vector_search vs
        FULL OUTER JOIN fulltext_search fs ON vs.id = fs.id
    )
    SELECT chunk_id, document_id, content, doc_title,
           rrf_score, distance, ts_score, metadata
    FROM fused
    ORDER BY rrf_score DESC
    LIMIT match_count;
$$;

-- 调用方式(假设特定测试出现 20-30% 提速,也不能外推为通用结论;本文未提供对应测试,函数封装本身不保证加速)
SELECT * FROM hybrid_search(
    '[0.1, 0.2, ...]'::vector,  -- 查询向量
    'pgvector 安装配置',          -- 查询文本
    5,                            -- 返回 5 条
    50,                           -- 候选集 50 条
    60                            -- RRF k=60
);

五、LangChain 集成

5.1 基础配置

"""
LangChain + pgvector 集成
依赖:pip install langchain-postgres langchain-openai psycopg2-binary
"""

from langchain_postgres import PGVector
from langchain_postgres.vectorstores import PGVector
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain.schema import Document
from langchain.prompts import ChatPromptTemplate
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema.output_parser import StrOutputParser


# ============================================================
# 数据库连接字符串
# ============================================================
CONNECTION_STRING = "postgresql+psycopg2://user:password@localhost:5432/rag_db"


# ============================================================
# 初始化 PGVector Vectorstore
# ============================================================
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

vectorstore = PGVector(
    embeddings=embeddings,
    collection_name="rag_documents",   # 对应 PostgreSQL 中的集合名称
    connection=CONNECTION_STRING,
    use_jsonb=True                      # 使用 JSONB 存储元数据,支持过滤
)


# ============================================================
# 批量写入文档
# ============================================================
def index_documents(documents: list[Document]) -> list[str]:
    """
    将 LangChain Document 对象批量写入 pgvector
    Document 格式:Document(page_content="...", metadata={"source": "...", "tenant_id": "..."})
    """
    ids = vectorstore.add_documents(documents)
    print(f"已写入 {len(ids)} 个文档块")
    return ids


# ============================================================
# 基础检索
# ============================================================
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 5}
)

# 带分数的检索
docs_with_scores = vectorstore.similarity_search_with_score(
    query="pgvector 如何安装",
    k=5
)

for doc, score in docs_with_scores:
    print(f"距离:{score:.4f} | 来源:{doc.metadata.get('source', 'unknown')}")
    print(f"内容:{doc.page_content[:100]}...")
    print()

5.2 带权限过滤的 Retriever

这是 pgvector + LangChain 集成中最有价值的部分:利用 metadata filter 实现租户隔离。

from langchain_core.vectorstores import VectorStoreRetriever
from langchain_core.callbacks import CallbackManagerForRetrieverRun
from typing import List
import asyncpg


class RLSAwareRetriever(VectorStoreRetriever):
    """
    感知 RLS 的 Retriever
    通过在每次检索前设置数据库会话变量来激活 Row Level Security
    """

    user_id: str
    tenant_id: str
    db_pool: asyncpg.Pool = None

    class Config:
        arbitrary_types_allowed = True

    def _get_relevant_documents(
        self,
        query: str,
        *,
        run_manager: CallbackManagerForRetrieverRun
    ) -> List[Document]:
        raise NotImplementedError("请使用 aget_relevant_documents 异步方法")

    async def aget_relevant_documents(
        self,
        query: str,
        *,
        run_manager: CallbackManagerForRetrieverRun = None
    ) -> List[Document]:
        """
        异步检索,在查询前设置 RLS 上下文
        """
        # 方案1:通过 metadata filter 实现软隔离(不需要 RLS)
        # 适合多数场景,简单可靠
        docs = await self.vectorstore.asimilarity_search(
            query=query,
            k=self.search_kwargs.get("k", 5),
            filter={
                "tenant_id": self.tenant_id,
                # 可以加更多过滤条件
                # "category": "技术文档"
            }
        )
        return docs

    @classmethod
    def from_vectorstore(
        cls,
        vectorstore: PGVector,
        user_id: str,
        tenant_id: str,
        k: int = 5
    ) -> "RLSAwareRetriever":
        return cls(
            vectorstore=vectorstore,
            user_id=user_id,
            tenant_id=tenant_id,
            search_kwargs={"k": k}
        )


# ============================================================
# 使用权限感知 Retriever 构建 RAG Chain
# ============================================================
def build_rag_chain(user_id: str, tenant_id: str):

    # 带权限过滤的检索器
    retriever = RLSAwareRetriever.from_vectorstore(
        vectorstore=vectorstore,
        user_id=user_id,
        tenant_id=tenant_id,
        k=5
    )

    # Prompt 模板
    prompt = ChatPromptTemplate.from_template("""
你是一个知识库问答助手。请根据以下检索到的上下文回答用户问题。
如果上下文中没有相关信息,请直接说"我在知识库中没有找到相关信息",不要编造答案。

上下文:
{context}

用户问题:{question}

回答:""")

    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

    def format_docs(docs: list[Document]) -> str:
        return "\n\n---\n\n".join([
            f"来源:{doc.metadata.get('source', '未知')}\n{doc.page_content}"
            for doc in docs
        ])

    # LCEL 链
    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )

    return chain


# ============================================================
# 完整使用示例
# ============================================================
async def demo_langchain_rag():
    # 1. 写入文档
    docs = [
        Document(
            page_content="pgvector 是 PostgreSQL 的向量扩展,支持精确和近似向量搜索。",
            metadata={"source": "pgvector文档", "tenant_id": "tenant-001", "category": "技术"}
        ),
        Document(
            page_content="安装 pgvector:CREATE EXTENSION vector; 即可启用。",
            metadata={"source": "安装指南", "tenant_id": "tenant-001", "category": "教程"}
        ),
        Document(
            page_content="pgvector 支持 L2 距离、余弦相似度和内积三种距离度量方式。",
            metadata={"source": "pgvector文档", "tenant_id": "tenant-001", "category": "技术"}
        ),
    ]
    index_documents(docs)

    # 2. 构建 RAG 链(指定用户和租户)
    chain = build_rag_chain(
        user_id="user-123",
        tenant_id="tenant-001"
    )

    # 3. 提问
    answer = await chain.ainvoke("pgvector 支持哪些距离度量方式?")
    print(f"回答:{answer}")

5.3 流式输出 RAG

from langchain.callbacks import StreamingStdOutCallbackHandler


async def streaming_rag_demo():
    """流式输出 RAG 响应"""

    retriever = vectorstore.as_retriever(
        search_kwargs={"k": 5, "filter": {"tenant_id": "tenant-001"}}
    )

    llm_streaming = ChatOpenAI(
        model="gpt-4o-mini",
        temperature=0,
        streaming=True
    )

    prompt = ChatPromptTemplate.from_template("""
基于以下上下文回答问题:
{context}

问题:{question}
""")

    chain = (
        {"context": retriever | (lambda docs: "\n\n".join(d.page_content for d in docs)),
         "question": RunnablePassthrough()}
        | prompt
        | llm_streaming
        | StrOutputParser()
    )

    print("回答(流式):", end="", flush=True)
    async for chunk in chain.astream("pgvector 的安装方式是什么?"):
        print(chunk, end="", flush=True)
    print()


# ============================================================
# 带引用来源的 RAG
# ============================================================
async def rag_with_citations():
    """返回答案的同时返回引用来源"""

    retriever = vectorstore.as_retriever(
        search_kwargs={"k": 5, "filter": {"tenant_id": "tenant-001"}}
    )

    query = "pgvector 支持哪些索引类型?"

    # 同时获取文档和生成答案
    retrieved_docs = await retriever.aget_relevant_documents(query)

    context = "\n\n".join([
        f"[文档{i+1}] {doc.page_content}"
        for i, doc in enumerate(retrieved_docs)
    ])

    prompt = ChatPromptTemplate.from_template("""
基于以下上下文回答问题,回答时请标注引用的文档编号(如[文档1]):
{context}

问题:{question}
""")

    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    chain = prompt | llm | StrOutputParser()

    answer = await chain.ainvoke({"context": context, "question": query})

    print(f"回答:{answer}")
    print("\n引用来源:")
    for i, doc in enumerate(retrieved_docs):
        print(f"  [文档{i+1}] {doc.metadata.get('source', '未知')}")

六、LlamaIndex 集成

6.1 PGVectorStore 基础配置

"""
LlamaIndex + pgvector 集成
依赖:pip install llama-index llama-index-vector-stores-postgres llama-index-embeddings-openai
"""

from llama_index.core import VectorStoreIndex, StorageContext, Settings
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.schema import TextNode, Document
from llama_index.vector_stores.postgres import PGVectorStore
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
import psycopg2


# ============================================================
# 全局配置
# ============================================================
Settings.embed_model = OpenAIEmbedding(
    model="text-embedding-3-small",
    embed_batch_size=20
)
Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.chunk_size = 512
Settings.chunk_overlap = 50


# ============================================================
# 初始化 PGVectorStore
# ============================================================
def create_vector_store(table_name: str = "llamaindex_chunks") -> PGVectorStore:
    """
    创建 LlamaIndex 的 pgvector 存储
    """
    vector_store = PGVectorStore.from_params(
        database="rag_db",
        host="localhost",
        password="password",
        port=5432,
        user="postgres",
        table_name=table_name,
        embed_dim=1536,
        # HNSW 索引配置
        hnsw_kwargs={
            "hnsw_m": 16,
            "hnsw_ef_construction": 64,
            "hnsw_ef_search": 40,
            "hnsw_dist_method": "vector_cosine_ops",
        },
        # 混合搜索配置
        hybrid_search=True,
        text_search_config="english",  # 中文改为 "zhparser"
    )
    return vector_store


# ============================================================
# 构建索引并写入文档
# ============================================================
def build_index(documents: list[Document], table_name: str = "llamaindex_chunks") -> VectorStoreIndex:
    """
    从文档列表构建 pgvector 索引
    """
    vector_store = create_vector_store(table_name)
    storage_context = StorageContext.from_defaults(vector_store=vector_store)

    # 直接从文档构建索引(LlamaIndex 自动分块和向量化)
    index = VectorStoreIndex.from_documents(
        documents,
        storage_context=storage_context,
        show_progress=True
    )

    print(f"索引构建完成,表名:{table_name}")
    return index


# ============================================================
# 从已有数据库加载索引
# ============================================================
def load_existing_index(table_name: str = "llamaindex_chunks") -> VectorStoreIndex:
    """
    从已有的 pgvector 表加载索引(不重新索引,节省费用)
    """
    vector_store = create_vector_store(table_name)
    storage_context = StorageContext.from_defaults(vector_store=vector_store)

    index = VectorStoreIndex.from_vector_store(
        vector_store=vector_store,
        storage_context=storage_context
    )
    return index

6.2 查询引擎配置

from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.core.response_synthesizers import get_response_synthesizer


def build_query_engine(
    index: VectorStoreIndex,
    top_k: int = 5,
    similarity_cutoff: float = 0.7,
    use_hybrid: bool = True
) -> RetrieverQueryEngine:
    """
    构建带过滤和后处理的查询引擎
    """
    # 配置检索器
    retriever = VectorIndexRetriever(
        index=index,
        similarity_top_k=top_k,
        vector_store_query_mode="hybrid" if use_hybrid else "default",
        alpha=0.5,           # hybrid 模式下向量搜索的权重(0.5 表示均等)
    )

    # 相似度过滤(过滤相关性太低的结果)
    postprocessor = SimilarityPostprocessor(similarity_cutoff=similarity_cutoff)

    # 响应合成器
    response_synthesizer = get_response_synthesizer(
        response_mode="compact",  # compact / tree_summarize / simple_summarize
        verbose=True
    )

    query_engine = RetrieverQueryEngine(
        retriever=retriever,
        node_postprocessors=[postprocessor],
        response_synthesizer=response_synthesizer
    )

    return query_engine


# ============================================================
# 带元数据过滤的查询
# ============================================================
from llama_index.core.vector_stores.types import MetadataFilter, MetadataFilters, FilterOperator


def query_with_filter(
    index: VectorStoreIndex,
    query: str,
    tenant_id: str,
    category: str = None,
    top_k: int = 5
):
    """
    带元数据过滤的查询(实现租户隔离和类别过滤)
    """
    filters = [
        MetadataFilter(
            key="tenant_id",
            value=tenant_id,
            operator=FilterOperator.EQ
        )
    ]

    if category:
        filters.append(MetadataFilter(
            key="category",
            value=category,
            operator=FilterOperator.EQ
        ))

    retriever = VectorIndexRetriever(
        index=index,
        similarity_top_k=top_k,
        filters=MetadataFilters(filters=filters),
        vector_store_query_mode="hybrid"
    )

    query_engine = RetrieverQueryEngine.from_args(
        retriever=retriever,
        response_mode="compact"
    )

    response = query_engine.query(query)

    print(f"问题:{query}")
    print(f"回答:{response.response}")
    print("\n引用节点:")
    for node in response.source_nodes:
        print(f"  - 相似度:{node.score:.4f} | {node.metadata.get('source', 'unknown')}")

    return response


# ============================================================
# 完整使用示例
# ============================================================
async def demo_llamaindex_rag():
    from llama_index.core import SimpleDirectoryReader

    # 1. 加载文档
    documents = SimpleDirectoryReader("/path/to/docs").load_data()

    # 添加租户元数据
    for doc in documents:
        doc.metadata["tenant_id"] = "tenant-001"
        doc.metadata["category"] = "技术文档"

    # 2. 构建索引
    index = build_index(documents)

    # 3. 查询(带过滤)
    response = query_with_filter(
        index=index,
        query="pgvector 的 HNSW 索引如何配置?",
        tenant_id="tenant-001",
        category="技术文档",
        top_k=5
    )

6.3 高级:Text-to-SQL + 向量搜索联合查询

LlamaIndex 的一个独特能力:在 pgvector 上同时做 Text-to-SQL(结构化查询)和向量搜索,非常适合"半结构化"知识库场景。

from llama_index.core.indices.struct_store import PGVectorSQLQueryEngine
from sqlalchemy import create_engine, MetaData


def build_sql_vector_engine():
    """
    Text-to-SQL + Vector Search 联合查询引擎
    适合场景:知识库中既有结构化数据(表格、统计)又有非结构化文本
    """
    engine = create_engine(
        "postgresql+psycopg2://user:password@localhost:5432/rag_db"
    )

    # 让 LlamaIndex 感知数据库 schema
    metadata = MetaData()
    metadata.reflect(bind=engine)

    vector_store = create_vector_store()

    # 联合查询引擎
    query_engine = PGVectorSQLQueryEngine(
        engine=engine,
        schema=metadata,
        vector_store=vector_store,
        embed_model=Settings.embed_model,
        llm=Settings.llm
    )

    # 示例查询:自动判断用 SQL 还是向量搜索
    response = query_engine.query(
        "2024 年第四季度销售额最高的产品类别是哪个,相关的产品介绍文档有什么内容?"
        # 这个查询会被拆分为:
        # ① SQL 查询:SELECT category, SUM(sales) FROM ... WHERE quarter = 'Q4 2024' GROUP BY ...
        # ② 向量查询:搜索相关产品介绍
        # ③ 综合两个结果回答
    )

    return response

七、Supabase 托管方案

7.1 为什么选 Supabase

Supabase 是目前最成熟的 pgvector 托管方案。它内置 pgvector,提供了:

  • 托管基础设施:减少 PostgreSQL 实例管理工作,但仍需关注套餐限制、权限、性能、备份覆盖与恢复演练
  • 内置 API:自动生成 REST API 和实时订阅
  • Auth 集成:用户认证和 RLS 深度集成
  • Edge Functions:可以在数据库旁边运行 Serverless 函数
  • 免费套餐:足够个人项目和 POC 使用

可将存储超过 160 万个向量 Embedding 作为容量评估的假设场景;本文未提供对应用户案例或测试条件,不能据此认定性能表现良好。

7.2 Supabase RAG 快速上手

-- 在 Supabase SQL Editor 中执行

-- 1. 启用 pgvector
CREATE EXTENSION IF NOT EXISTS vector;

-- 2. 创建文档表
CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    content     TEXT NOT NULL,
    embedding   VECTOR(1536),
    metadata    JSONB DEFAULT '{}',
    user_id     UUID REFERENCES auth.users(id),   -- 关联 Supabase Auth
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 3. 创建 HNSW 索引
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);

-- 4. 启用 RLS(关联 Supabase Auth 用户)
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can only access their own documents"
    ON documents FOR ALL
    USING (auth.uid() = user_id);

-- 5. 创建向量搜索函数(供 Python/JS 客户端调用;此处不含全文检索或 RRF 融合)
CREATE OR REPLACE FUNCTION match_documents (
    query_embedding VECTOR(1536),
    match_count     INT DEFAULT 5,
    filter          JSONB DEFAULT '{}'
)
RETURNS TABLE (
    id          BIGINT,
    content     TEXT,
    metadata    JSONB,
    similarity  FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN QUERY
    SELECT
        d.id,
        d.content,
        d.metadata,
        1 - (d.embedding <=> query_embedding) AS similarity
    FROM documents d
    WHERE d.metadata @> filter
    ORDER BY d.embedding <=> query_embedding
    LIMIT match_count;
END;
$$;

7.3 Python 客户端集成

"""
Supabase + pgvector Python 集成
依赖:pip install supabase openai
"""

import os
from openai import OpenAI
from supabase import create_client, Client


SUPABASE_URL = os.getenv("SUPABASE_URL")           # https://xxx.supabase.co
SUPABASE_KEY = os.getenv("SUPABASE_ANON_KEY")       # anon public key
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
openai_client = OpenAI(api_key=OPENAI_API_KEY)


def get_embedding(text: str) -> list[float]:
    """生成文本 Embedding"""
    response = openai_client.embeddings.create(
        model="text-embedding-3-small",
        input=text.replace("\n", " ")
    )
    return response.data[0].embedding


def insert_document(content: str, metadata: dict = None) -> dict:
    """
    插入文档到 Supabase(RLS 自动应用当前登录用户)
    需要先调用 supabase.auth.sign_in_with_password() 登录
    """
    embedding = get_embedding(content)

    result = supabase.table("documents").insert({
        "content": content,
        "embedding": embedding,
        "metadata": metadata or {}
    }).execute()

    return result.data[0] if result.data else None


def search_documents(
    query: str,
    match_count: int = 5,
    filter_metadata: dict = None
) -> list[dict]:
    """
    语义搜索(调用 Supabase RPC 函数)
    RLS 自动过滤,只返回当前用户的文档
    """
    query_embedding = get_embedding(query)

    result = supabase.rpc(
        "match_documents",
        {
            "query_embedding": query_embedding,
            "match_count": match_count,
            "filter": filter_metadata or {}
        }
    ).execute()

    return result.data


def rag_query(question: str, match_count: int = 5) -> str:
    """
    完整 RAG 流程:检索 + 生成
    """
    # 1. 语义检索
    relevant_docs = search_documents(question, match_count=match_count)

    if not relevant_docs:
        return "我在知识库中没有找到相关信息。"

    # 2. 构建上下文
    context = "\n\n".join([
        f"相关度:{doc['similarity']:.2%}\n{doc['content']}"
        for doc in relevant_docs
    ])

    # 3. LLM 生成答案
    response = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "你是一个知识库问答助手。只根据提供的上下文回答问题,如果上下文中没有答案,请直接说明。"
            },
            {
                "role": "user",
                "content": f"上下文:\n{context}\n\n问题:{question}"
            }
        ],
        temperature=0
    )

    return response.choices[0].message.content


# ============================================================
# 使用示例
# ============================================================
if __name__ == "__main__":
    # 登录(Supabase Auth)
    supabase.auth.sign_in_with_password({
        "email": "user@example.com",
        "password": "password"
    })

    # 写入文档
    insert_document(
        content="pgvector 支持三种距离度量:L2 距离(<->)、余弦相似度(<=>)、内积(<#>)。",
        metadata={"category": "技术文档", "source": "pgvector官方文档"}
    )

    # RAG 问答
    answer = rag_query("pgvector 支持哪些距离计算方式?")
    print(f"回答:{answer}")

7.4 JavaScript/TypeScript 集成

// 依赖:npm install @supabase/supabase-js openai

import { createClient } from '@supabase/supabase-js'
import OpenAI from 'openai'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
)

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })


// 生成 Embedding
async function getEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text.replace(/\n/g, ' ')
  })
  return response.data[0].embedding
}


// 语义搜索
async function searchDocuments(
  query: string,
  matchCount: number = 5
): Promise<Array<{ id: number; content: string; similarity: number }>> {
  const embedding = await getEmbedding(query)

  const { data, error } = await supabase.rpc('match_documents', {
    query_embedding: embedding,
    match_count: matchCount,
    filter: {}
  })

  if (error) throw error
  return data
}


// RAG 问答(流式)
async function streamingRag(question: string): Promise<void> {
  const docs = await searchDocuments(question)

  const context = docs
    .map(d => d.content)
    .join('\n\n---\n\n')

  const stream = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    stream: true,
    messages: [
      { role: 'system', content: '你是知识库助手,只根据提供的上下文回答。' },
      { role: 'user', content: `上下文:\n${context}\n\n问题:${question}` }
    ]
  })

  process.stdout.write('回答:')
  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content ?? ''
    process.stdout.write(content)
  }
  console.log()
}


// Edge Function 示例(部署到 Supabase Edge)
// supabase/functions/rag-query/index.ts
export default async function handler(req: Request): Promise<Response> {
  const { question, userId } = await req.json()

  // 验证用户身份(使用 JWT)
  const authHeader = req.headers.get('Authorization')!
  const supabaseUser = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!,
    { global: { headers: { Authorization: authHeader } } }
  )

  const { data: { user } } = await supabaseUser.auth.getUser()
  if (!user) return new Response('Unauthorized', { status: 401 })

  const docs = await searchDocuments(question)
  const context = docs.map(d => d.content).join('\n\n')

  // 生成答案...
  const answer = `基于检索到的 ${docs.length} 个文档块的回答...`

  return new Response(JSON.stringify({ answer, sources: docs }), {
    headers: { 'Content-Type': 'application/json' }
  })
}

7.5 Supabase 生产优化建议

-- 1. 为高频过滤字段创建 GIN 索引
CREATE INDEX ON documents USING GIN(metadata);

-- 2. 分区表(超过 100 万行时考虑)
-- 对 user_id 做哈希分区以组织数据;多个用户可能落入同一分区,分区本身不提供访问隔离,仍需独立配置权限与 RLS
CREATE TABLE documents_partitioned (
    LIKE documents INCLUDING ALL
) PARTITION BY HASH (user_id);

CREATE TABLE documents_p0 PARTITION OF documents_partitioned
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE documents_p1 PARTITION OF documents_partitioned
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- ...

-- 3. 使用 pg_cron 定期更新统计信息
SELECT cron.schedule('0 2 * * *', 'VACUUM ANALYZE documents');

八、pgvector vs 专用向量库的选择建议

8.1 核心选择框架

这是本文最务实的部分。把话说清楚:pgvector 不适合所有人。知道什么时候应该换工具,比知道怎么用工具更重要。

%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart TD
    S1{"数据规模<br/>向量数量"}
    S2{"并发查询<br/>QPS"}
    S3{"是否有<br/>现有 PG 基础设施"}
    S4{"团队是否有<br/>PG 运维经验"}
    S5{"是否需要<br/>多模态向量"}

    S1 -->|"< 100 万"| S2
    S1 -->|"> 500 万"| R_MILVUS["考虑迁移到<br/>Milvus / Qdrant"]
    S1 -->|"100-500 万"| S3

    S2 -->|"< 100 QPS"| S3
    S2 -->|"> 500 QPS"| R_MILVUS
    S2 -->|"100-500 QPS"| S4

    S3 -->|"是"| R_PGVECTOR["推荐 pgvector<br/>复用现有基础设施"]
    S3 -->|"否"| S4

    S4 -->|"是"| R_PGVECTOR
    S4 -->|"否"| S5

    S5 -->|"是"| R_MILVUS
    S5 -->|"否"| R_SUPABASE["推荐 Supabase<br/>托管 pgvector"]

    style R_PGVECTOR fill:#dcfce7,stroke:#16a34a
    style R_MILVUS fill:#fee2e2,stroke:#ef4444
    style R_SUPABASE fill:#dbeafe,stroke:#3b82f6

8.2 量化的迁移阈值

以下数值作为容量与迁移讨论的假设分档,不代表本文提供了社区实测或生产案例证据;实际边界取决于硬件、向量维度、索引、过滤条件、负载及业务目标:

指标 pgvector 舒适区 警戒线 建议迁移到专用向量库
向量数量 < 100 万 100-500 万 > 500 万
向量维度 768 / 1536 3072 自定义超大维度
单次查询延迟(P99) < 100ms 100-500ms > 500ms(且业务敏感)
并发 QPS < 100 100-500 > 500
每日新增向量 < 10 万 10-100 万 > 100 万
索引构建时间 < 1 小时 1-8 小时 > 8 小时(影响业务)

warning 警戒线不是绝对红线

500 万向量的 pgvector 集群通过合理的硬件配置(NVMe SSD + 大内存)可以跑得很好。 真正该触发迁移讨论的,是当你开始把大量精力花在优化 pgvector 性能而不是业务功能上的时候。

8.3 详细场景对比

场景 pgvector Milvus Qdrant 推荐
企业内部知识库(< 50 万文档) 优秀 过度 合适 pgvector
多租户 SaaS 产品(RLS 需求强) 优秀 需额外实现 需额外实现 pgvector
电商商品向量搜索(> 1000 万 SKU) 勉强 优秀 优秀 Milvus/Qdrant
实时推荐系统(< 50ms 延迟要求) 有风险 优秀 优秀 Milvus/Qdrant
个人项目 / POC 优秀 复杂 合适 pgvector (Supabase)
多模态(图像 + 文本向量) 有限 原生支持 原生支持 Milvus
已有 PostgreSQL 技术栈 天然优势 额外学习 额外学习 pgvector
Serverless 架构 受限 不适合 适合 Qdrant Cloud

8.4 渐进式迁移策略

如果你从 pgvector 出发,未来可能需要迁移,建议从一开始就保留迁移的灵活性:

"""
抽象层设计:隐藏底层向量库的差异
该抽象可减少调用层改动;实际迁移仍需实现后端方法,并迁移数据、重建索引、适配过滤与授权语义及评估检索结果
"""

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional


@dataclass
class SearchResult:
    id: str
    content: str
    score: float
    metadata: dict


class VectorSearchBackend(ABC):
    """向量搜索后端的抽象接口"""

    @abstractmethod
    async def insert(self, texts: list[str], metadata: list[dict]) -> list[str]:
        pass

    @abstractmethod
    async def search(
        self,
        query: str,
        top_k: int = 5,
        filter_metadata: Optional[dict] = None
    ) -> list[SearchResult]:
        pass

    @abstractmethod
    async def delete(self, ids: list[str]) -> None:
        pass


class PGVectorBackend(VectorSearchBackend):
    """pgvector 实现"""

    def __init__(self, db_pool, embedder):
        self.pool = db_pool
        self.embedder = embedder

    async def insert(self, texts, metadata):
        # pgvector 实现...
        pass

    async def search(self, query, top_k=5, filter_metadata=None):
        # pgvector 混合搜索实现...
        pass

    async def delete(self, ids):
        pass


class MilvusBackend(VectorSearchBackend):
    """Milvus 实现(迁移时替换)"""

    def __init__(self, collection_name: str, embedder):
        self.collection_name = collection_name
        self.embedder = embedder

    async def insert(self, texts, metadata):
        # Milvus 实现...
        pass

    async def search(self, query, top_k=5, filter_metadata=None):
        # Milvus 实现...
        pass

    async def delete(self, ids):
        pass


class RAGService:
    """RAG 服务(与底层向量库解耦)"""

    def __init__(self, backend: VectorSearchBackend, llm_client):
        self.backend = backend
        self.llm = llm_client

    async def query(self, question: str, tenant_id: str) -> str:
        # 检索
        results = await self.backend.search(
            query=question,
            top_k=5,
            filter_metadata={"tenant_id": tenant_id}
        )

        # 组装上下文
        context = "\n\n".join([r.content for r in results])

        # 生成答案(与向量库无关)
        # ...
        return "答案"


# 使用示意:完成后端实现、数据迁移及授权与检索验证后,可在此切换后端实例
# backend = PGVectorBackend(pool, embedder)       # 当前方案
# backend = MilvusBackend("rag_docs", embedder)   # 迁移后方案
# rag_service = RAGService(backend, llm_client)   # 代码不变

8.5 pgvector 性能调优参考

在决定迁移之前,先确认你已经做了这些优化:

-- ============================================================
-- 1. HNSW 索引调优
-- ============================================================
-- 默认参数(m=16, ef_construction=64)适合大多数场景
-- 如果召回率不够,增大 m 和 ef_construction
-- 如果构建速度太慢,减小 ef_construction
DROP INDEX IF EXISTS idx_chunks_embedding;
CREATE INDEX idx_chunks_embedding ON chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- 查询时的 ef_search(越大越精确,越慢)
SET hnsw.ef_search = 100;  -- 默认 40

-- ============================================================
-- 2. 内存配置(关键!)
-- ============================================================
-- postgresql.conf 优化
-- shared_buffers = 内存的 25%(例如 32GB 内存 → 8GB)
-- effective_cache_size = 内存的 75%(例如 → 24GB)
-- work_mem = 256MB  (排序、哈希等查询操作的内存基准,并非每连接固定预算;同一查询、并行工作进程及并发查询可能累计多份内存)
-- maintenance_work_mem = 4GB  (索引构建内存)

-- ============================================================
-- 3. 并行查询
-- ============================================================
-- 允许并行向量扫描
ALTER TABLE chunks SET (parallel_workers = 4);
SET max_parallel_workers_per_gather = 4;

-- ============================================================
-- 4. 分区(超过 100 万行时)
-- ============================================================
-- 按 document_id 的哈希值分区,实现并行检索
-- 参考上文 Supabase 分区示例

-- ============================================================
-- 5. 监控慢查询
-- ============================================================
-- 开启 pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 查看最慢的向量查询
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE query LIKE '%<=>%'
ORDER BY mean_exec_time DESC
LIMIT 10;

九、生产运维要点

9.1 连接池管理

pgvector 的向量运算是 CPU 密集型操作。连接池配置直接影响性能。

"""
生产级连接池配置
"""
import asyncpg
from contextlib import asynccontextmanager


async def create_optimized_pool(database_url: str) -> asyncpg.Pool:
    """
    创建针对 pgvector 优化的连接池
    """
    async def init_connection(conn):
        """每个连接初始化时执行"""
        # 注册 pgvector 类型
        from pgvector.asyncpg import register_vector
        await register_vector(conn)

        # 设置每个连接的向量搜索参数
        await conn.execute("SET hnsw.ef_search = 100")
        await conn.execute("SET work_mem = '256MB'")

    pool = await asyncpg.create_pool(
        dsn=database_url,
        min_size=5,           # 最小保持 5 个连接
        max_size=20,          # 最大 20 个连接(根据机器 CPU 核心数调整)
        max_queries=50000,    # 每个连接最多执行 5 万次查询后自动重建
        max_inactive_connection_lifetime=300,  # 5 分钟不用自动关闭
        init=init_connection  # 连接初始化回调
    )

    return pool


class RAGDatabaseManager:
    def __init__(self):
        self.pool: asyncpg.Pool = None

    async def initialize(self, database_url: str):
        self.pool = await create_optimized_pool(database_url)

    async def close(self):
        if self.pool:
            await self.pool.close()

    @asynccontextmanager
    async def get_user_connection(self, user_id: str, tenant_id: str):
        """
        获取带用户上下文的连接(自动设置 RLS 变量)
        使用 async with 确保连接归还连接池
        """
        async with self.pool.acquire() as conn:
            # 设置会话级别的用户上下文(仅对当前事务有效)
            await conn.execute("""
                SELECT
                    set_config('app.current_user_id', $1, true),
                    set_config('app.current_tenant_id', $2, true)
            """, str(user_id), str(tenant_id))
            yield conn

9.2 监控指标

-- ============================================================
-- 关键监控查询
-- ============================================================

-- 1. 查看向量索引状态
SELECT
    schemaname,
    tablename,
    indexname,
    indexdef
FROM pg_indexes
WHERE indexdef LIKE '%hnsw%' OR indexdef LIKE '%ivfflat%';

-- 2. 查看各表大小(向量占用空间)
SELECT
    relname AS table_name,
    pg_size_pretty(pg_total_relation_size(oid)) AS total_size,
    pg_size_pretty(pg_relation_size(oid)) AS data_size,
    pg_size_pretty(pg_total_relation_size(oid) - pg_relation_size(oid)) AS index_size
FROM pg_class
WHERE relname IN ('chunks', 'documents')
ORDER BY pg_total_relation_size(oid) DESC;

-- 3. 查看 chunks 表的向量数量和文档覆盖
SELECT
    COUNT(*) AS total_chunks,
    COUNT(DISTINCT document_id) AS total_documents,
    AVG(token_count) AS avg_token_count,
    MAX(created_at) AS latest_chunk
FROM chunks;

-- 4. 慢查询监控(需要 pg_stat_statements)
SELECT
    LEFT(query, 100) AS query_preview,
    round(mean_exec_time::numeric, 2) AS avg_ms,
    calls,
    round(total_exec_time::numeric, 2) AS total_ms
FROM pg_stat_statements
WHERE query LIKE '%chunks%'
ORDER BY mean_exec_time DESC
LIMIT 10;

-- 5. 索引使用率(确认向量查询走了 HNSW 索引)
SELECT
    indexrelname AS index_name,
    idx_scan AS index_scans,
    idx_tup_read AS tuples_read
FROM pg_stat_user_indexes
WHERE relname = 'chunks'
ORDER BY idx_scan DESC;

9.3 数据备份与恢复

# pgvector 数据和普通 PG 数据备份方式完全相同,这是 pgvector 的一大优势

# 完整备份(含向量数据)
pg_dump -h localhost -U postgres -d rag_db \
  --format=custom \
  --file=/backup/rag_db_$(date +%Y%m%d).dump

# 只备份 chunks 表(向量数据较大,可单独备份)
pg_dump -h localhost -U postgres -d rag_db \
  --table=chunks \
  --format=custom \
  --file=/backup/chunks_$(date +%Y%m%d).dump

# 恢复
pg_restore -h localhost -U postgres -d rag_db_restore \
  --format=custom \
  /backup/rag_db_20260101.dump

# Supabase 自动备份(无需手动操作)
# Pro 计划每天自动备份,保留 7 天;团队计划保留 30 天

9.4 常见问题排查

-- ============================================================
-- 问题1:向量搜索很慢(> 1 秒)
-- ============================================================

-- 检查是否走了索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content, embedding <=> '[0.1, 0.2, ...]'::vector AS distance
FROM chunks
ORDER BY distance LIMIT 5;

-- 如果看到 Seq Scan,说明没走 HNSW 索引,检查:
-- ① 索引是否存在
-- ② 数据量是否太小(PG 优化器认为全表扫描更快)
-- ③ ef_search 是否设置合理

-- 强制使用索引(测试用)
SET enable_seqscan = OFF;
EXPLAIN (ANALYZE) SELECT ...;

-- ============================================================
-- 问题2:全文搜索没有结果
-- ============================================================

-- 检查 tsvector 是否正确更新
SELECT content, content_tsv FROM chunks LIMIT 3;

-- 手动更新(如果 trigger 没触发)
UPDATE chunks SET content_tsv = to_tsvector('english', content);

-- 测试查询
SELECT * FROM chunks
WHERE content_tsv @@ plainto_tsquery('english', '你的搜索词');

-- ============================================================
-- 问题3:RLS 导致查询返回空结果
-- ============================================================

-- 检查当前会话的 RLS 设置
SELECT current_setting('app.current_user_id', true),
       current_setting('app.current_tenant_id', true);

-- 检查 RLS 是否会影响查询(row_security = OFF 不会授予普通角色绕过权限;若查询会受策略过滤,可能报错)
SET row_security = OFF;
SELECT COUNT(*) FROM chunks;  -- 看总数
SET row_security = ON;

-- 检查特定用户的权限
SELECT dp.* FROM document_permissions dp
JOIN documents d ON dp.document_id = d.id
WHERE dp.user_id = '你的用户UUID';

-- ============================================================
-- 问题4:Embedding 维度不匹配
-- ============================================================

-- 查看当前 chunks 表的向量维度定义
SELECT pg_typeof(embedding),
       vector_dims(embedding) AS actual_dim
FROM chunks LIMIT 1;

-- 如果需要修改维度(不会自动删除现有向量;现有值维度不兼容时类型修改会失败,需重新生成向量并迁移)
-- ALTER TABLE chunks ALTER COLUMN embedding TYPE VECTOR(3072);
-- 生产环境应该创建新表,迁移数据,再切换

总结与最佳实践

核心要点回顾

pgvector RAG 的价值不在于"它是最快的向量数据库",而在于它让你的系统从三层架构变成一层架构。当你把所有复杂性降低之后,才有余力去打磨真正重要的东西:提示词质量、分块策略、Embedding 模型选择。

Schema 设计原则:充分利用关系型数据库的规范化、外键、事务特性,不要把 pgvector 当成 MongoDB 用。

RLS 多租户:这是 pgvector 相比专用向量库最独特的优势。在合规场景下,数据库层面的安全远比应用层过滤可靠。

混合搜索:向量搜索 + tsvector 全文搜索 + RRF 融合,可用 SQL CTE 实现。62% 到 84% 仅作为假设评测数值;实际召回率与性能需在业务数据、权限过滤及目标负载下评估。

框架集成:LangChain 的 PGVector 和 LlamaIndex 的 PGVectorStore 都有成熟的 pgvector 支持。如果你已经在用这些框架,接入成本极低。

迁移决策:向量数量超过 500 万,或并发 QPS 超过 500,且 pgvector 调优已经达到瓶颈,这时候认真考虑专用向量库。在此之前,pgvector 够用。

进阶学习路径

  • RAG 架构设计模式 - 了解 Naive RAG、Advanced RAG、Agentic RAG 的架构演进
  • 混合搜索工程实践 - 深入学习 BM25、稀疏向量、RRF 等混合搜索技术
  • Embedding 模型选型指南 - 选择最适合你场景的 Embedding 模型
  • Milvus RAG 集成实战 - 了解专用向量库的 RAG 方案,做对比参考
  • 向量数据库选型指南 - 全面的向量数据库选型决策框架

note 关于中文支持

本文全文搜索部分使用 'english' 配置。若中文项目选择 zhparser 路径,需要安装扩展,并另行创建采用该解析器、配置好词典映射的文本搜索配置;随后让 to_tsvector、plainto_tsquery 与框架 text_search_config 使用同一实际配置名。扩展名称不代表同名文本搜索配置已经存在。Supabase 目前不支持 zhparser,纯中文项目建议只用向量搜索,不用全文搜索,或改用 pg_bigm 扩展做中文二元语法检索。

tip 生产部署推荐配置

  • 小型项目(< 50 万向量,作为评估分档):可评估 Supabase 免费/Pro 套餐;托管可减少实例管理工作,但仍需承担权限、容量、性能和恢复维护
  • 中型项目(50-500 万向量):自建 PostgreSQL 16 + pgvector 0.8+,HNSW 索引,32GB+ 内存
  • 大型项目(> 500 万向量):评估迁移到 Milvus,或 Supabase 企业版 + 分区表