数据库基础体系 · 第 61/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

pgvector 实战:PostgreSQL 向量类型、HNSW、IVFFlat 与混合查询

在 PostgreSQL 中加入向量检索能力,通常不是把数据库替换成另一套系统,而是安装 pgvector 扩展,让普通表同时保存:

  • 业务字段:标题、正文、租户、权限、时间等;
  • 向量字段:文本、图片或其他对象的 embedding;
  • 结构化索引:B-tree、分区、JSONB、GIN;
  • 全文索引:tsvector 与 GIN;
  • 向量索引:HNSW 或 IVFFlat。

因此,pgvector 的核心价值并不只是“存一个向量”,而是让向量召回和 PostgreSQL 原有的事务、过滤、全文检索、权限条件处于同一个数据模型中。

本文使用 PostgreSQL 与 pgvector 的公开稳定语义。具体参数是否可用,应以实际安装版本为准:

SELECT version();

SELECT extname, extversion
FROM pg_extension
WHERE extname = 'vector';

示例默认运行在 PostgreSQL 数据库中,SQL 通过一个普通客户端执行;没有假设连接池会自动提交,也没有假设应用与数据库部署在同一台机器上。


一、向量检索到底在解决什么问题

设每个对象被编码成一个 dd 维向量:

x=(x1,x2,,xd)x = (x_1, x_2, \ldots, x_d)

查询也被编码为同维度向量:

q=(q1,q2,,qd)q = (q_1, q_2, \ldots, q_d)

向量检索的目标不是判断两个对象是否完全相等,而是在候选集合中找到“距离最近”或“相似度最高”的对象。

这带来三个基础问题:

  1. 如何保存向量?
  2. 如何定义距离或相似度?
  3. 如何避免每次查询都扫描整张表?

pgvector 的 vector 类型解决第一个问题;<-><#><=> 等运算符解决第二个问题;HNSW 和 IVFFlat 索引解决第三个问题。


二、安装扩展与建立最小数据模型

2.1 安装扩展

pgvector 通常由操作系统包、容器镜像或源码安装,安装完成后仍需要在每个要使用它的数据库中执行:

CREATE EXTENSION IF NOT EXISTS vector;

这里有一个容易忽略的边界:

  • 扩展安装文件可能是服务器级别的;
  • CREATE EXTENSION 是数据库级别的;
  • 一个数据库启用了 pgvector,不代表同一 PostgreSQL 实例中的其他数据库也启用了它。

应用连接的数据库必须执行过 CREATE EXTENSION,否则表定义中的 vector 类型不可见。

2.2 创建文档表

下面建立一个同时支持结构化过滤、全文检索和向量检索的表:

CREATE TABLE documents (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id   bigint NOT NULL,
    title       text NOT NULL,
    body        text NOT NULL,
    metadata    jsonb NOT NULL DEFAULT '{}'::jsonb,

    -- 例如由同一个 embedding 模型产生的 1536 维向量
    embedding   vector(1536) NOT NULL,

    created_at  timestamptz NOT NULL DEFAULT now()
);

vector(1536) 中的 1536 是维度约束。插入其他维度的向量会失败:

INSERT INTO documents (tenant_id, title, body, embedding)
VALUES (1, '示例', '内容', '[0.1, 0.2]'::vector);

如果该列声明为 vector(1536),上面的插入会因为维度不匹配而失败。这个失败是有益的:embedding 模型切换后,应用不能悄悄把新旧模型的向量混在同一列中。

实际项目中通常还要保存模型信息:

ALTER TABLE documents
ADD COLUMN embedding_model text NOT NULL DEFAULT 'embedding-model-v1';

如果同一张表需要存储多种模型产生的向量,常见做法是:

  • 为不同模型建立不同列;
  • 为不同模型建立不同表;
  • 或者使用同一列但把模型作为严格的分区、过滤和数据迁移边界。

仅仅保存一个 embedding_model 文本字段而不在查询中约束它,不能防止召回语义混杂。


三、pgvector 的向量类型和距离运算符

3.1 vector 不是 JSON 数组

vector 是 pgvector 提供的专用类型,不是 float[],也不是 JSON 数组。它具有:

  • 固定维度或不固定维度的类型声明;
  • 专用距离运算符;
  • 专用索引操作类;
  • 适配向量检索的存储和计算实现。

最常用的形式是:

embedding vector(1536)

pgvector 还提供其他向量表示,例如半精度向量、二进制向量和稀疏向量。它们适合不同的数据规模和计算场景,但索引操作类和距离语义也不同。不能因为它们都叫“向量”就直接互换。

本文主体使用 vector 类型。

3.2 欧氏距离:<->

两个向量 xxyy 的欧氏距离为:

dL2(x,y)=i=1d(xiyi)2d_{L2}(x,y)=\sqrt{\sum_{i=1}^{d}(x_i-y_i)^2}

pgvector 中:

embedding <-> query_vector

返回 L2 距离,值越小越相似。

查询最近的 5 条记录:

SELECT
    id,
    title,
    embedding <-> '[0.12,0.24,...]'::vector AS distance
FROM documents
ORDER BY embedding <-> '[0.12,0.24,...]'::vector
LIMIT 5;

这里的向量必须实际包含声明的全部维度。示例中的省略号只是文章展示记号,不是可执行 SQL。

3.3 余弦距离:<=>

余弦相似度定义为:

cos_sim(x,y)=xyxy\operatorname{cos\_sim}(x,y) = \frac{x\cdot y}{\|x\|\|y\|}

其中:

xy=i=1dxiyix\cdot y=\sum_{i=1}^{d}x_i y_i

pgvector 的 <=> 返回余弦距离,而不是余弦相似度:

dcos(x,y)=1cos_sim(x,y)d_{\text{cos}}(x,y)=1-\operatorname{cos\_sim}(x,y)

因此:

  • 余弦相似度越高,余弦距离越小;
  • SQL 中仍然使用升序 ORDER BY
  • 若要展示相似度,可以写成:
SELECT
    id,
    title,
    1 - (embedding <=> $1::vector) AS cosine_similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;

$1 是客户端绑定的向量参数。参数化比字符串拼接更安全,也避免了 SQL 注入和转义错误。

3.4 内积:<#>

pgvector 的 <#> 返回负内积

dip(x,y)=(xy)d_{\text{ip}}(x,y)=-(x\cdot y)

之所以返回负值,是为了让“距离越小越好”的索引排序形式继续成立。原始内积越大,负内积越小。

例如:

SELECT
    id,
    title,
    embedding <#> $1::vector AS negative_inner_product
FROM documents
ORDER BY embedding <#> $1::vector
LIMIT 10;

如果需要显示正的内积:

SELECT
    id,
    title,
    -(embedding <#> $1::vector) AS inner_product
FROM documents
ORDER BY embedding <#> $1::vector
LIMIT 10;

3.5 归一化向量时,内积与余弦相似度的关系

如果所有向量都做了 L2 归一化:

x=1,y=1\|x\|=1,\quad \|y\|=1

那么:

cos_sim(x,y)=xy1×1=xy\operatorname{cos\_sim}(x,y) = \frac{x\cdot y}{1\times 1} = x\cdot y

此时最大化余弦相似度等价于最大化内积,也等价于最小化负内积。

但这个等价关系有前提:查询向量和库中向量都必须使用相同的归一化规则。如果只有一边归一化,或者模型本身依赖向量模长表达信息,就不能随意把余弦距离替换成内积。


四、精确搜索与近似搜索

4.1 精确搜索是什么

没有向量索引,下面的查询通常需要对满足条件的行计算距离,再取最小值:

SELECT id, title
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;

从逻辑上说,它计算的是:

TopK({d(xi,q)xiD})\operatorname{TopK} \left( \{d(x_i,q)\mid x_i\in D\} \right)

其中 DD 是候选数据集。

这种方式的优点是结果准确;缺点是每次查询都可能处理大量向量。数据量、维度和并发上升后,CPU、内存带宽和 I/O 成本会变得明显。

4.2 近似搜索是什么

近似最近邻搜索不保证返回数学意义上的绝对最近邻。它通过索引只探索一部分候选,从而降低查询代价。

因此需要区分:

  • 距离函数:定义“什么叫接近”;
  • 索引算法:决定“搜索哪些对象”;
  • 搜索参数:控制探索范围和查询成本;
  • 召回率:近似结果中包含精确 Top-K 结果的比例。

提高搜索参数通常可以提高召回率,但可能增加延迟和 CPU 消耗。它不是“索引越大,结果必然越准确”的单变量问题。


五、HNSW:分层小世界图索引

5.1 HNSW 的基本结构

HNSW 是 Hierarchical Navigable Small World 的缩写,即分层可导航小世界图。

它不是把向量简单排序,而是为向量建立图结构:

  • 每个向量是一个节点;
  • 相近向量之间建立边;
  • 图包含多个层级;
  • 高层节点较少,用于快速跳跃;
  • 底层包含更多节点,用于细致搜索。

查询过程可以抽象为:

  1. 从高层的入口节点开始;
  2. 查看当前节点的邻居;
  3. 如果某个邻居比当前节点更接近查询向量,就移动到该邻居;
  4. 在当前层找不到更近节点后,下降到下一层;
  5. 在底层扩大候选集,得到近似最近邻。

这和从头到尾扫描所有向量不同。HNSW 利用图的局部连接,把搜索集中在查询向量附近的区域。

5.2 HNSW 的两个重要构建参数

创建索引:

CREATE INDEX documents_embedding_hnsw_cos_idx
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (
    m = 16,
    ef_construction = 64
);

这里有三个必须对应的概念:

  • USING hnsw:选择 HNSW 索引方法;
  • vector_cosine_ops:指定该索引使用余弦距离;
  • mef_construction:控制图的构建过程。

m 表示图中每个节点连接的邻居规模。它越大,图通常越稠密:

  • 构建和存储成本增加;
  • 查询可能有更多可走的路径;
  • 召回率可能改善,但不是无条件改善。

ef_construction 控制构建索引时搜索候选邻居的规模。它越大,通常能构建出更高质量的图,但建索引更慢、更耗内存。

这些参数是索引定义的一部分。修改它们通常需要重建索引,而不是通过一次查询参数动态改变。

5.3 HNSW 的查询参数

查询时可以使用:

SET hnsw.ef_search = 100;

或者只影响当前事务:

BEGIN;

SET LOCAL hnsw.ef_search = 100;

SELECT id, title
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;

COMMIT;

ef_search 控制查询阶段探索的候选规模。直觉上:

  • 较小值:更快,但更容易漏掉真实近邻;
  • 较大值:更可能找到高质量结果,但延迟和资源消耗上升。

对于连接池应用,优先使用 SET LOCAL 或通过连接级参数明确管理作用域。直接执行 SET hnsw.ef_search = 100 会影响该数据库会话后续的查询;如果连接池复用连接,设置可能泄漏到其他请求。

5.4 HNSW 的增量维护与构建成本

HNSW 不需要像 IVFFlat 那样先对已有数据聚类,因此通常适合数据持续写入的场景。但这不意味着写入没有成本:

  • 插入向量时需要为新节点寻找邻居并修改图;
  • 大批量导入期间会增加 CPU 和索引写入压力;
  • 初始构建通常需要较多内存;
  • 高并发写入和高并发向量查询会竞争资源。

如果批量装载是主要场景,常见流程是:

  1. 创建表;
  2. 批量导入数据;
  3. 建立 HNSW 索引;
  4. 开始在线查询和增量写入。

如果必须在线创建,可以使用 PostgreSQL 的并发建索引方式:

CREATE INDEX CONCURRENTLY documents_embedding_hnsw_cos_idx
ON documents
USING hnsw (embedding vector_cosine_ops);

CREATE INDEX CONCURRENTLY 不能放在显式事务块中,并且构建失败时可能留下需要人工处理的无效索引对象。部署工具不能把所有 DDL 都默认包在一个事务里。


六、IVFFlat:基于倒排文件的聚类索引

6.1 IVFFlat 的基本结构

IVFFlat 是 Inverted File with Flat Compression 的缩写。它的核心过程是:

  1. 使用已有向量训练若干个中心点;
  2. 将每个向量分配到距离最近的中心点;
  3. 每个中心点对应一个倒排列表;
  4. 查询时先找到最接近查询向量的若干中心点;
  5. 只扫描这些列表中的原始向量,再计算精确距离。

假设有 4 个聚类中心:

c1,c2,c3,c4c_1,c_2,c_3,c_4

每个数据向量 xx 被分配到:

cluster(x)=argminjd(x,cj)\operatorname{cluster}(x) = \arg\min_j d(x,c_j)

查询向量 qq 先计算到各中心点的距离。如果只探测最接近的一个中心点,那么属于其他中心点但实际可能很接近 qq 的向量就不会被访问。

这就是 IVFFlat 的核心取舍:

  • lists 决定聚类列表数量;
  • probes 决定一次查询探测多少列表;
  • probes 越大,扫描范围越大,召回率通常越高,但查询更慢。

6.2 创建 IVFFlat 索引

CREATE INDEX documents_embedding_ivfflat_cos_idx
ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (
    lists = 100
);

IVFFlat 建索引时需要从表中已有的数据学习聚类结构。因此,空表或极少数据上创建索引,聚类质量通常没有意义。更稳妥的流程是先导入具有代表性的数据,再创建索引。

lists 没有适用于所有数据集的固定值。它受以下因素影响:

  • 行数;
  • 向量分布;
  • 维度;
  • 查询过滤条件;
  • 期望的召回率和延迟。

官方文档给出的经验公式可以作为起点,但不能当作性能保证。最终应使用真实数据和真实查询集比较精确搜索结果。

6.3 设置 IVFFlat 查询范围

BEGIN;

SET LOCAL ivfflat.probes = 10;

SELECT id, title
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;

COMMIT;

如果 lists = 100probes = 10 表示查询大致探测其中一部分列表,而不是扫描全部 100 个列表。它不等价于“返回 10 条结果”,返回条数仍由 LIMIT 决定。

当:

probes = lists

IVFFlat 会探测全部列表,结果在距离计算意义上接近精确扫描,但索引带来的剪枝优势基本消失。

6.4 IVFFlat 的训练数据边界

IVFFlat 的聚类中心来自建索引时的数据。以下情况可能导致索引质量下降:

  • 建索引时表中只有早期数据;
  • 后续写入的数据分布发生明显变化;
  • 不同租户的数据分布差异很大,却共用一套聚类;
  • embedding 模型切换后仍沿用旧索引;
  • 查询条件只命中某个很小的子集,但索引聚类面向全表训练。

这时即使把 probes 调大,也未必能完全修复分布不匹配;可能需要重新建索引,或者按租户、模型、时间等边界拆分数据。


七、HNSW 与 IVFFlat 的机制差异

维度 HNSW IVFFlat
核心结构 多层近邻图 聚类中心与倒排列表
是否依赖建索引时训练数据 不需要聚类训练 需要已有数据学习列表
查询控制参数 hnsw.ef_search ivfflat.probes
主要查询权衡 图探索范围 探测列表数量
增量写入适应性 通常较自然 受初始聚类质量影响
构建特点 构建可能较慢、占内存 需要聚类,通常也需要较多构建资源
典型调优方向 mef_constructionef_search listsprobes

不能只根据“哪个算法更先进”做选择。应建立精确结果基线,然后在同一批查询上比较:

  • 召回率;
  • p50、p95、p99 延迟;
  • CPU;
  • 内存;
  • 索引大小;
  • 导入和更新成本。

八、索引操作类必须与查询距离匹配

pgvector 的索引不是“创建一个向量索引后支持所有距离”。距离类型要和操作类一致。

例如余弦索引:

CREATE INDEX documents_embedding_hnsw_cos_idx
ON documents
USING hnsw (embedding vector_cosine_ops);

内积索引:

CREATE INDEX documents_embedding_hnsw_ip_idx
ON documents
USING hnsw (embedding vector_ip_ops);

L2 索引:

CREATE INDEX documents_embedding_hnsw_l2_idx
ON documents
USING hnsw (embedding vector_l2_ops);

如果查询使用:

ORDER BY embedding <=> $1::vector

它需要余弦距离语义;如果只有 vector_l2_ops,该索引不能正确匹配这条排序。

反过来,为每种距离建立索引会增加存储和写入成本。通常应先确定应用的相似度定义,再创建对应索引,而不是把所有操作类都创建一遍。


九、为什么有索引却没有使用

向量索引尤其依赖查询形状。下面这种写法最容易匹配近似向量索引:

SELECT id, title
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20;

可以使用:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20;

诊断时重点看:

  • 是否出现 Index Scan 或对应的向量索引扫描;
  • 是否退化为 Seq Scan
  • 实际扫描行数是否远大于返回行数;
  • 是否存在额外排序;
  • 过滤条件是否在向量索引扫描之后执行。

常见原因包括:

  1. 没有 LIMIT,扫描全部排序的成本可能不适合索引;
  2. 运算符与操作类不匹配;
  3. 查询表达式包裹了列,导致无法匹配索引排序;
  4. 统计信息或表规模让优化器选择顺序扫描;
  5. 过滤条件使候选集很小,顺序扫描更便宜;
  6. 使用了和索引不同的距离度量;
  7. 参数类型没有明确转换为 vector

不要把“执行计划使用索引”当作“结果一定高召回”。近似索引被使用,只能说明查询走了近似路径。


十、带过滤条件的向量查询

最直接的写法是:

SELECT id, title
FROM documents
WHERE tenant_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 10;

这里有一个重要的算法边界:向量索引首先根据向量邻近关系探索候选,再处理普通过滤条件。过滤条件不是天然融入 HNSW 图或 IVFFlat 聚类结构的。

假设全表有 100 万条记录,但某个租户只有 1000 条:

  • 索引搜索可能先找到全表中最接近的 10 条;
  • 其中很多行属于其他租户;
  • 过滤后只剩 1 条甚至 0 条;
  • 最终结果不足 10 条。

这不是 SQL 语义错误,而是近似向量扫描与后置过滤共同产生的结果。

10.1 提高过滤场景的可用性

可以从数据组织和扫描参数两方面处理。

首先,为结构化条件建立索引:

CREATE INDEX documents_tenant_id_idx
ON documents (tenant_id);

其次,使用更大的候选探索范围。较新的 pgvector 版本提供迭代扫描相关配置时,可以检查实际版本是否支持,并按版本文档配置。例如 HNSW 的相关参数可能包括:

SET LOCAL hnsw.ef_search = 200;

对于 HNSW,某些版本还提供 hnsw.iterative_scanhnsw.max_scan_tuples 等控制项;IVFFlat 也有对应的迭代扫描能力。它们的具体行为和参数名属于版本敏感语义,应以服务器安装版本的 pgvector 文档和 SHOW 结果为准:

SHOW hnsw.ef_search;
SHOW ivfflat.probes;

如果租户边界非常强,另一种方案是建立部分索引或按租户、时间等维度分区。但部分索引需要稳定、可枚举的谓词,不能为数量巨大且频繁变化的租户无限创建索引。

例如,对于固定业务状态可以建立部分索引:

CREATE INDEX documents_active_embedding_hnsw_idx
ON documents
USING hnsw (embedding vector_cosine_ops)
WHERE metadata @> '{"status":"active"}';

查询必须包含能够证明该谓词成立的条件,否则优化器不能安全使用该部分索引。


十一、全文检索与向量检索:两种不同的相关性

向量检索回答的是:

这段内容在语义空间中是否接近查询?

全文检索回答的是:

文档中是否出现了与查询词法相关的词项,并且出现位置、频率等因素如何?

例如查询“数据库连接池”:

  • 一篇使用“连接池耗尽”原词的故障文档,全文检索可能排名很高;
  • 一篇使用“数据库会话复用”但没有出现“连接池”的文档,向量检索可能认为它语义接近;
  • 精确的产品名、错误码、版本号通常更适合全文或结构化过滤;
  • 概念改写、同义表达通常更适合向量检索。

两者不是互相替代的索引,而是不同的召回信号。

11.1 建立 tsvector 列和 GIN 索引

ALTER TABLE documents
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
    to_tsvector(
        'simple',
        coalesce(title, '') || ' ' || coalesce(body, '')
    )
) STORED;

CREATE INDEX documents_search_vector_gin_idx
ON documents
USING gin (search_vector);

这里使用 simple 配置只是为了让示例行为稳定。中文文本的分词效果不能简单等同于英文;生产环境应根据语言、分词扩展和业务词典选择合适的文本搜索方案。

查询全文:

SELECT
    id,
    title,
    ts_rank(search_vector, websearch_to_tsquery('simple', $1)) AS text_rank
FROM documents
WHERE search_vector @@ websearch_to_tsquery('simple', $1)
ORDER BY text_rank DESC
LIMIT 20;

各部分的含义是:

  • websearch_to_tsquery:把用户搜索语法转换为查询对象;
  • @@:判断 tsvector 是否匹配;
  • ts_rank:根据词项匹配情况计算一个文本相关性分数;
  • GIN:加速匹配候选的查找。

ts_rank 不是概率,也不天然与向量距离处于同一数值尺度。不能直接做:

ts_rank(...) + (1 - embedding <=> query)

然后把结果解释成有严格意义的统一相关性分数。两种分数需要校准,或者使用不依赖原始分数尺度的融合方法。


十二、混合查询:用 RRF 融合全文和向量排名

一种稳健的混合检索方法是 Reciprocal Rank Fusion,简称 RRF,倒数排名融合。

对一个结果在某个召回列表中的排名 rr,其贡献为:

RRF(r)=1k+r\operatorname{RRF}(r)=\frac{1}{k+r}

其中:

  • r=1,2,r=1,2,\ldots 是名次;
  • kk 是平滑常数,常见取 60;
  • 一个文档如果同时出现在多个列表中,就累加各列表贡献。

RRF 的优点是它不要求全文分数和向量分数处在同一数值范围。

12.1 完整 SQL 示例

下面分别召回 100 条,再按名次融合:

WITH semantic AS MATERIALIZED (
    SELECT
        id,
        row_number() OVER (
            ORDER BY embedding <=> $1::vector
        ) AS semantic_rank
    FROM documents
    WHERE tenant_id = $2
    ORDER BY embedding <=> $1::vector
    LIMIT 100
),
lexical AS MATERIALIZED (
    SELECT
        id,
        row_number() OVER (
            ORDER BY ts_rank(
                search_vector,
                websearch_to_tsquery('simple', $3)
            ) DESC
        ) AS lexical_rank
    FROM documents
    WHERE tenant_id = $2
      AND search_vector @@ websearch_to_tsquery('simple', $3)
    ORDER BY ts_rank(
        search_vector,
        websearch_to_tsquery('simple', $3)
    ) DESC
    LIMIT 100
)
SELECT
    d.id,
    d.title,
    d.body,
    COALESCE(1.0 / (60 + s.semantic_rank), 0.0)
      + COALESCE(1.0 / (60 + l.lexical_rank), 0.0) AS fused_score,
    s.semantic_rank,
    l.lexical_rank
FROM documents AS d
LEFT JOIN semantic AS s ON s.id = d.id
LEFT JOIN lexical AS l ON l.id = d.id
WHERE s.id IS NOT NULL
   OR l.id IS NOT NULL
ORDER BY fused_score DESC, d.id
LIMIT 20;

参数含义:

  • $1:查询 embedding;
  • $2:租户 ID;
  • $3:用户输入的全文查询;
  • semantic:语义召回列表;
  • lexical:全文召回列表;
  • MATERIALIZED:明确先形成候选结果,再用于后续连接,便于表达候选集边界;
  • COALESCE:只出现在某一个召回列表中的文档,其另一项贡献按 0 处理。

为什么要先取 100 条而不是全文和向量结果直接全表合并?

因为混合检索通常采用“两阶段”:

  1. 召回阶段:各检索器快速提供较大的候选集;
  2. 融合或重排阶段:对候选集计算融合分数、业务分数或 cross-encoder 分数。

如果每个检索器只取最终需要的 20 条,可能出现候选互补不足;如果每个检索器都取全表,又失去检索的意义。100 只是示例值,应通过离线评测调整。

12.2 直接融合原始分数为什么危险

余弦距离通常在某个有限范围内变化,ts_rank 的范围和分布则受词频、文档长度、配置和查询词影响。即使都转换成“越大越好”,也不能假设:

αvector_score+(1α)text_score\alpha \cdot \text{vector\_score} + (1-\alpha)\cdot\text{text\_score}

中的两个分数已经可比。

如果业务确实需要分数融合,可以:

  • 在标注数据上做分数归一化;
  • 使用分位数或 z-score 等统计变换;
  • 对不同查询类型单独校准;
  • 或采用 RRF 这类基于排名的融合。

十三、混合查询中的结构化条件、权限和 RAG 边界

在 RAG 数据层中,检索结果通常还要满足:

  • 租户隔离;
  • 文档状态;
  • 数据可见性;
  • 时间窗口;
  • 产品或知识库范围;
  • 访问权限。

这些条件必须在候选召回和最终返回两个层面都认真处理。

例如:

WHERE tenant_id = $2
  AND metadata @> $4::jsonb

JSONB 的作用是表达灵活属性,但不能把所有高频过滤字段都塞进 JSONB 后再依赖向量索引解决。对于稳定、高选择性的条件,通常应该使用普通列和 B-tree;对于 JSONB 中的查询,则根据谓词建立 GIN 或表达式索引。

更重要的是,不能先召回跨租户结果,再在应用层过滤租户。这样会同时带来:

  • 数据越权风险;
  • 候选被无关租户占满;
  • 过滤后结果不足;
  • 审计和故障排查困难。

RAG 还需要保存引用信息,例如:

ALTER TABLE documents
ADD COLUMN source_uri text,
ADD COLUMN chunk_no integer,
ADD COLUMN content_hash text;

向量只负责相似性召回,不能替代来源、片段位置和版本信息。生成答案时,应用应根据 idsource_urichunk_no 等字段回填引用。


十四、典型失败表现与诊断路径

14.1 查询结果为空或数量不足

可能原因:

  • 向量维度错误;
  • WHERE 条件过滤掉了大部分近邻;
  • IVFFlat 的 probes 太小;
  • HNSW 的探索范围太小;
  • 全文查询没有匹配词项;
  • 租户、状态或权限条件不一致。

诊断方法是先拆开查询:

-- 只验证向量召回
SELECT id
FROM documents
WHERE tenant_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 20;

再验证全文召回:

SELECT id
FROM documents
WHERE tenant_id = $2
  AND search_vector @@ websearch_to_tsquery('simple', $3)
LIMIT 20;

最后再执行融合查询。不要一开始就只观察最终的 fused_score

14.2 召回率低

先建立精确基线:

BEGIN;

-- 不设置近似索引参数,或者在测试环境中强制比较顺序扫描
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM documents
WHERE tenant_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 100;

COMMIT;

再将近似查询结果与精确结果比较。召回率可以定义为:

Recall@K=AKEKK\operatorname{Recall@K} = \frac{|A_K\cap E_K|}{K}

其中:

  • AKA_K:近似索引返回的 Top-K 集合;
  • EKE_K:精确搜索返回的 Top-K 集合;
  • KK:比较的结果数量。

然后分别调节:

  • HNSW 的 ef_search
  • IVFFlat 的 probes
  • IVFFlat 的 lists
  • 过滤条件和分区设计;
  • 查询向量与库中向量的模型、维度、归一化方式。

如果 IVFFlat 数据分布已经变化,单纯增加 probes 可能不如重建索引有效。

14.3 执行计划没有使用向量索引

检查操作符、操作类和查询形状:

SELECT
    indexname,
    indexdef
FROM pg_indexes
WHERE tablename = 'documents';

例如索引是:

embedding vector_cosine_ops

查询就应使用:

ORDER BY embedding <=> $1::vector

而不是用 <-><#> 代替。

同时检查是否写成了复杂表达式:

ORDER BY normalize(embedding) <=> $1::vector

如果索引建在原始 embedding 列上,这种表达式通常不能直接匹配原索引。若应用要求对归一化后的值检索,应在写入阶段保存归一化向量,或者为相应表达式建立符合语义的索引,而不是在查询中临时改变距离空间。

14.4 建索引失败、变慢或占用大量资源

排查:

  • 建索引前是否已经导入代表性数据;
  • maintenance_work_mem 是否足够;
  • 是否有并发导入或长事务;
  • 磁盘空间是否足够;
  • 是否误把并发建索引放入事务;
  • 是否在生产高峰期重建大型 HNSW。

HNSW 构建期间,pgvector 可能通过服务器消息提示图构建已经超出内存工作区。提高内存可以减少构建过程的临时开销,但也要考虑同一实例中其他连接、排序、哈希聚合和 PostgreSQL 自身内存的竞争。


十五、事务、部署和数据迁移边界

15.1 扩展、表和索引属于数据库对象

下面这些操作通常应作为数据库迁移的一部分管理:

CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE documents
ADD COLUMN embedding vector(1536);

迁移系统需要明确:

  • 当前数据库是否允许创建扩展;
  • 扩展版本是否满足应用所需语义;
  • DDL 是否在事务中执行;
  • 是否需要停机窗口;
  • 是否需要并发建索引;
  • 失败后如何清理半成品对象。

15.2 CREATE INDEX CONCURRENTLY 的事务限制

普通建索引可以在显式事务中执行:

BEGIN;
CREATE INDEX ...;
COMMIT;

但并发建索引不能这样执行。迁移工具如果默认“每个迁移文件包在一个事务中”,会导致:

ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block

正确做法不是删掉 CONCURRENTLY,而是让迁移框架对该迁移关闭事务包装,并准备处理失败后的残留索引。

15.3 模型切换不能只改列注释

embedding 模型变化通常意味着:

  • 维度可能变化;
  • 向量分布变化;
  • 相似度尺度变化;
  • 旧向量和新向量不再可直接比较。

安全迁移可以采用双列或双表:

ALTER TABLE documents
ADD COLUMN embedding_v2 vector(3072);

然后:

  1. 后台生成 embedding_v2
  2. 对新写入同时写入新旧向量,或切换写入路径;
  3. 建立新向量索引;
  4. 用离线和在线查询验证结果;
  5. 切换查询;
  6. 最后清理旧列和旧索引。

这个过程同时涉及应用发布、回填任务、索引构建、回滚策略和存储容量,不能把它当成一次普通字段类型修改。


十六、与专用向量数据库的边界

Milvus 等专用向量数据库把向量集合、索引、分片、加载和检索作为核心能力;pgvector 则把向量能力放进 PostgreSQL 的表、事务和 SQL 执行模型中。

选择 pgvector 通常意味着:

  • 业务数据和向量数据需要强关联;
  • 权限、租户、时间和 JSONB 条件需要一起过滤;
  • 需要 PostgreSQL 的事务和已有生态;
  • 数据规模和访问模式适合单一关系数据库的运维边界。

选择专用向量数据库可能更适合:

  • 向量检索是系统的绝对核心;
  • 需要专门的向量分布式扩展能力;
  • 数据规模、查询吞吐或分片模型超出 PostgreSQL 的合适范围;
  • 可以接受业务数据库与向量检索系统之间的数据同步。

两者也可以组合:PostgreSQL 保存权威业务记录和权限信息,专用向量系统保存检索副本。但这会引入同步延迟、删除传播、版本一致性、失败重试和引用回查等问题。若系统采用这种架构,向量检索返回的对象 ID 仍应经过 PostgreSQL 的权限和状态校验。


十七、一个可执行的最小端到端流程

下面给出从建表到向量查询、全文查询和混合查询的核心顺序。

第一步:创建扩展和表

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id   bigint NOT NULL,
    title       text NOT NULL,
    body        text NOT NULL,
    embedding   vector(3) NOT NULL,
    search_vector tsvector GENERATED ALWAYS AS (
        to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(body, ''))
    ) STORED
);

这里使用 3 维只是为了展示。真实系统中,维度必须与 embedding 模型严格一致。

第二步:插入示例数据

INSERT INTO documents (tenant_id, title, body, embedding)
VALUES
    (1, '连接池耗尽排查', '数据库连接池耗尽时需要检查连接泄漏和超时配置',
     '[0.90, 0.10, 0.00]'),
    (1, '数据库会话复用', '会话复用可以降低频繁建立连接的成本',
     '[0.85, 0.15, 0.00]'),
    (1, 'PostgreSQL 索引', 'B-tree 适合等值和范围查询',
     '[0.10, 0.80, 0.10]');

第三步:建立索引

CREATE INDEX documents_embedding_hnsw_cos_idx
ON documents
USING hnsw (embedding vector_cosine_ops);

CREATE INDEX documents_search_vector_gin_idx
ON documents
USING gin (search_vector);

CREATE INDEX documents_tenant_id_idx
ON documents (tenant_id);

第四步:执行向量搜索

BEGIN;

SET LOCAL hnsw.ef_search = 100;

SELECT
    id,
    title,
    1 - (embedding <=> '[0.88,0.12,0.00]'::vector)
        AS cosine_similarity
FROM documents
WHERE tenant_id = 1
ORDER BY embedding <=> '[0.88,0.12,0.00]'::vector
LIMIT 2;

COMMIT;

预期前两条文档更接近查询向量,因为它们在第一维、第二维上的方向相似。

第五步:执行全文搜索

SELECT
    id,
    title,
    ts_rank(
        search_vector,
        websearch_to_tsquery('simple', '连接池')
    ) AS text_rank
FROM documents
WHERE tenant_id = 1
  AND search_vector @@ websearch_to_tsquery('simple', '连接池')
ORDER BY text_rank DESC
LIMIT 10;

这一步依赖词项匹配,而不是向量距离。若文本分词配置不适合输入语言,结果可能为空或质量较低。

第六步:融合两类结果

实际应用中,把前面的两个查询放进 RRF 融合 SQL,并在返回前补充:

  • 文档来源;
  • 片段编号;
  • 内容版本;
  • 权限校验;
  • 需要传给生成模型的上下文文本。

这样,向量召回、全文召回、结构化过滤和引用信息才能形成完整的数据流。


十八、最终应如何评估一个 pgvector 方案

一个可用的 pgvector 方案至少要同时验证四件事:

  1. 距离语义正确
    模型输出是否需要余弦、内积或 L2?查询参数和索引操作类是否一致?向量是否统一归一化?

  2. 索引结果可接受
    HNSW 或 IVFFlat 的近似结果,与精确搜索的 Recall@K 是否满足业务要求?

  3. 过滤后仍有足够候选
    租户、权限、状态和 JSONB 条件是否导致候选耗尽?是否需要更大的探索范围、部分索引或分区?

  4. 混合排序可解释
    全文和向量召回是否分别覆盖了精确词项与语义表达?融合后是否通过标注查询集验证,而不是凭少量示例调整权重?

pgvector 的基本用法并不复杂:用 vector 保存 embedding,用距离运算符排序,用 HNSW 或 IVFFlat 缩小搜索范围,再与 PostgreSQL 的全文和结构化查询合并。但真正决定系统质量的,是距离定义、索引操作类、近似参数、过滤顺序、模型版本和结果评估是否保持一致。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。