数据库基础体系 · 第 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 通过一个普通客户端执行;没有假设连接池会自动提交,也没有假设应用与数据库部署在同一台机器上。
一、向量检索到底在解决什么问题
设每个对象被编码成一个 维向量:
查询也被编码为同维度向量:
向量检索的目标不是判断两个对象是否完全相等,而是在候选集合中找到“距离最近”或“相似度最高”的对象。
这带来三个基础问题:
- 如何保存向量?
- 如何定义距离或相似度?
- 如何避免每次查询都扫描整张表?
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 欧氏距离:<->
两个向量 和 的欧氏距离为:
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 余弦距离:<=>
余弦相似度定义为:
其中:
pgvector 的 <=> 返回余弦距离,而不是余弦相似度:
因此:
- 余弦相似度越高,余弦距离越小;
- 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 的 <#> 返回负内积:
之所以返回负值,是为了让“距离越小越好”的索引排序形式继续成立。原始内积越大,负内积越小。
例如:
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 归一化:
那么:
此时最大化余弦相似度等价于最大化内积,也等价于最小化负内积。
但这个等价关系有前提:查询向量和库中向量都必须使用相同的归一化规则。如果只有一边归一化,或者模型本身依赖向量模长表达信息,就不能随意把余弦距离替换成内积。
四、精确搜索与近似搜索
4.1 精确搜索是什么
没有向量索引,下面的查询通常需要对满足条件的行计算距离,再取最小值:
SELECT id, title
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;
从逻辑上说,它计算的是:
其中 是候选数据集。
这种方式的优点是结果准确;缺点是每次查询都可能处理大量向量。数据量、维度和并发上升后,CPU、内存带宽和 I/O 成本会变得明显。
4.2 近似搜索是什么
近似最近邻搜索不保证返回数学意义上的绝对最近邻。它通过索引只探索一部分候选,从而降低查询代价。
因此需要区分:
- 距离函数:定义“什么叫接近”;
- 索引算法:决定“搜索哪些对象”;
- 搜索参数:控制探索范围和查询成本;
- 召回率:近似结果中包含精确 Top-K 结果的比例。
提高搜索参数通常可以提高召回率,但可能增加延迟和 CPU 消耗。它不是“索引越大,结果必然越准确”的单变量问题。
五、HNSW:分层小世界图索引
5.1 HNSW 的基本结构
HNSW 是 Hierarchical Navigable Small World 的缩写,即分层可导航小世界图。
它不是把向量简单排序,而是为向量建立图结构:
- 每个向量是一个节点;
- 相近向量之间建立边;
- 图包含多个层级;
- 高层节点较少,用于快速跳跃;
- 底层包含更多节点,用于细致搜索。
查询过程可以抽象为:
- 从高层的入口节点开始;
- 查看当前节点的邻居;
- 如果某个邻居比当前节点更接近查询向量,就移动到该邻居;
- 在当前层找不到更近节点后,下降到下一层;
- 在底层扩大候选集,得到近似最近邻。
这和从头到尾扫描所有向量不同。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:指定该索引使用余弦距离;m和ef_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 和索引写入压力;
- 初始构建通常需要较多内存;
- 高并发写入和高并发向量查询会竞争资源。
如果批量装载是主要场景,常见流程是:
- 创建表;
- 批量导入数据;
- 建立 HNSW 索引;
- 开始在线查询和增量写入。
如果必须在线创建,可以使用 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 的缩写。它的核心过程是:
- 使用已有向量训练若干个中心点;
- 将每个向量分配到距离最近的中心点;
- 每个中心点对应一个倒排列表;
- 查询时先找到最接近查询向量的若干中心点;
- 只扫描这些列表中的原始向量,再计算精确距离。
假设有 4 个聚类中心:
每个数据向量 被分配到:
查询向量 先计算到各中心点的距离。如果只探测最接近的一个中心点,那么属于其他中心点但实际可能很接近 的向量就不会被访问。
这就是 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 = 100,probes = 10 表示查询大致探测其中一部分列表,而不是扫描全部 100 个列表。它不等价于“返回 10 条结果”,返回条数仍由 LIMIT 决定。
当:
probes = lists
IVFFlat 会探测全部列表,结果在距离计算意义上接近精确扫描,但索引带来的剪枝优势基本消失。
6.4 IVFFlat 的训练数据边界
IVFFlat 的聚类中心来自建索引时的数据。以下情况可能导致索引质量下降:
- 建索引时表中只有早期数据;
- 后续写入的数据分布发生明显变化;
- 不同租户的数据分布差异很大,却共用一套聚类;
- embedding 模型切换后仍沿用旧索引;
- 查询条件只命中某个很小的子集,但索引聚类面向全表训练。
这时即使把 probes 调大,也未必能完全修复分布不匹配;可能需要重新建索引,或者按租户、模型、时间等边界拆分数据。
七、HNSW 与 IVFFlat 的机制差异
| 维度 | HNSW | IVFFlat |
|---|---|---|
| 核心结构 | 多层近邻图 | 聚类中心与倒排列表 |
| 是否依赖建索引时训练数据 | 不需要聚类训练 | 需要已有数据学习列表 |
| 查询控制参数 | hnsw.ef_search |
ivfflat.probes |
| 主要查询权衡 | 图探索范围 | 探测列表数量 |
| 增量写入适应性 | 通常较自然 | 受初始聚类质量影响 |
| 构建特点 | 构建可能较慢、占内存 | 需要聚类,通常也需要较多构建资源 |
| 典型调优方向 | m、ef_construction、ef_search |
lists、probes |
不能只根据“哪个算法更先进”做选择。应建立精确结果基线,然后在同一批查询上比较:
- 召回率;
- 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; - 实际扫描行数是否远大于返回行数;
- 是否存在额外排序;
- 过滤条件是否在向量索引扫描之后执行。
常见原因包括:
- 没有
LIMIT,扫描全部排序的成本可能不适合索引; - 运算符与操作类不匹配;
- 查询表达式包裹了列,导致无法匹配索引排序;
- 统计信息或表规模让优化器选择顺序扫描;
- 过滤条件使候选集很小,顺序扫描更便宜;
- 使用了和索引不同的距离度量;
- 参数类型没有明确转换为
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_scan、hnsw.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,倒数排名融合。
对一个结果在某个召回列表中的排名 ,其贡献为:
其中:
- 是名次;
- 是平滑常数,常见取 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 条而不是全文和向量结果直接全表合并?
因为混合检索通常采用“两阶段”:
- 召回阶段:各检索器快速提供较大的候选集;
- 融合或重排阶段:对候选集计算融合分数、业务分数或 cross-encoder 分数。
如果每个检索器只取最终需要的 20 条,可能出现候选互补不足;如果每个检索器都取全表,又失去检索的意义。100 只是示例值,应通过离线评测调整。
12.2 直接融合原始分数为什么危险
余弦距离通常在某个有限范围内变化,ts_rank 的范围和分布则受词频、文档长度、配置和查询词影响。即使都转换成“越大越好”,也不能假设:
中的两个分数已经可比。
如果业务确实需要分数融合,可以:
- 在标注数据上做分数归一化;
- 使用分位数或 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;
向量只负责相似性召回,不能替代来源、片段位置和版本信息。生成答案时,应用应根据 id、source_uri、chunk_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;
再将近似查询结果与精确结果比较。召回率可以定义为:
其中:
- :近似索引返回的 Top-K 集合;
- :精确搜索返回的 Top-K 集合;
- :比较的结果数量。
然后分别调节:
- 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);
然后:
- 后台生成
embedding_v2; - 对新写入同时写入新旧向量,或切换写入路径;
- 建立新向量索引;
- 用离线和在线查询验证结果;
- 切换查询;
- 最后清理旧列和旧索引。
这个过程同时涉及应用发布、回填任务、索引构建、回滚策略和存储容量,不能把它当成一次普通字段类型修改。
十六、与专用向量数据库的边界
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 方案至少要同时验证四件事:
-
距离语义正确
模型输出是否需要余弦、内积或 L2?查询参数和索引操作类是否一致?向量是否统一归一化? -
索引结果可接受
HNSW 或 IVFFlat 的近似结果,与精确搜索的Recall@K是否满足业务要求? -
过滤后仍有足够候选
租户、权限、状态和 JSONB 条件是否导致候选耗尽?是否需要更大的探索范围、部分索引或分区? -
混合排序可解释
全文和向量召回是否分别覆盖了精确词项与语义表达?融合后是否通过标注查询集验证,而不是凭少量示例调整权重?
pgvector 的基本用法并不复杂:用 vector 保存 embedding,用距离运算符排序,用 HNSW 或 IVFFlat 缩小搜索范围,再与 PostgreSQL 的全文和结构化查询合并。但真正决定系统质量的,是距离定义、索引操作类、近似参数、过滤顺序、模型版本和结果评估是否保持一致。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:向量索引原理:Flat、HNSW、IVF、PQ 的精度、内存和延迟
- 下一篇:Milvus 完整基础:Collection、Segment、索引、查询和集群部署
- 延伸:PostgreSQL JSONB 与扩展生态:全文检索、分区、FDW 和扩展边界
- 延伸:混合检索与 RAG 数据层:全文、向量、融合、重排和引用
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论