未记录事实核验 · 状态:维护中
记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。
warning 适用边界与维护说明
本文适合用作向量类型、距离度量、操作符类配对及ANN索引参数权衡的概念参考,不是完整可执行练习或生产配置承诺;代码中的占位向量、查询参数、表和连接对象需自行准备。当前第2.2节类型上限表仍有sparsevec限制混淆,第8.3节将HNSW候选集内存直接关联work_mem的解释及估算、第8.5节将work_mem称为每个查询工作内存的注释仍可能误导字段设计和并发预算,不宜直接据此配置。第6.2节索引内存公式、第7.2节索引体积倍数及部分经验阈值未提供可核查依据;已明确标为示意假设的召回率区间、构建时间和内存比例也不能作为性能或容量保证。索引选型、缓存和并行建议需结合版本、数据分布、过滤条件、并发及实际执行计划评估;第10.3节评估须按正文要求确认近似查询实际采用匹配的HNSW索引。安装与部署仅为非必需延伸阅读名称,不影响概念学习。本次仅作结构与正文一致性审阅,未进行外部事实核验或代码运行验证。 已取消无法确定目标的可点击引用「04-安装与部署」,保留原提及;Ambiguous link target: 04-安装与部署
pgvector 核心概念详解
tip 文档定位
这是 pgvector 的概念字典。它不是 API 文档的复制,而是用生活化的语言解释每个概念背后的设计意图。理解了"为什么这样设计",你在面对各种参数选择时就有了判断依据,而不是靠记忆或猜测。
目录
- 一、vector 类型:你的"个人特征档案"
- 二、halfvec 和 sparsevec:节省内存的姊妹类型
- 三、距离操作符:相似度的三种量法
- 四、什么时候用哪个距离?
- 五、IVFFlat 索引:图书馆的分区管理员
- 六、HNSW 索引:预先绘制好的捷径地图
- 七、索引参数的直觉理解
- 八、关键 PostgreSQL 配置对向量查询的影响
- 九、操作符类(Operator Class):索引与距离函数的配对
- 十、精确搜索 vs 近似搜索:召回率的权衡
- 十一、常用函数速查
- 十二、参数调优决策树
一、vector 类型:你的"个人特征档案"
1.1 什么是向量
在使用 pgvector 之前,必须先理解"向量"在这里是什么意思。
类比:多维度的个人评分表
想象你是一个招聘顾问,需要给每个候选人建立档案。你不只看学历,而是从 10 个维度给每人打分:
候选人 A:
编程能力: 9.2
沟通能力: 7.5
数据分析: 8.8
英语水平: 6.3
项目管理: 5.1
...(共 10 个维度)
这就是一个 10 维向量:[9.2, 7.5, 8.8, 6.3, 5.1, ...]
大语言模型做的事情类似,但维度要高得多——它用 1536 个(或 3072 个)数值来表达一段文字的"语义特征":
# OpenAI text-embedding-3-small 的输出
embedding = openai.embeddings.create(
model="text-embedding-3-small",
input="pgvector 是什么?"
)
# 得到一个长度为 1536 的浮点数列表
# [0.0234, -0.1823, 0.0091, ..., 0.0572]
# ↑↑ 这 1536 个数字共同编码了"pgvector 是什么?"的语义
每一个维度不直接对应"某种含义"——这是 AI 模型自己学到的抽象特征表示,人类无法直接解读。但神奇之处在于:语义相近的文本,它们的向量在高维空间中距离很近。
1.2 vector(N) 的含义
-- 创建一个包含向量字段的表
CREATE TABLE documents (
id bigserial PRIMARY KEY,
content text,
embedding vector(1536) -- 存储 1536 维的向量
);
vector(1536) 的括号里的数字表示维度数——这个向量由多少个浮点数组成。
| 模型 | 维度 | 每个向量大小 |
|---|---|---|
| OpenAI text-embedding-3-small | 1536 | ≈ 6 KB |
| OpenAI text-embedding-3-large | 3072 | ≈ 12 KB |
| OpenAI text-embedding-ada-002 | 1536 | ≈ 6 KB |
| Nomic embed-text | 768 | ≈ 3 KB |
| BGE-M3 | 1024 | ≈ 4 KB |
| Jina embeddings v3 | 1024 | ≈ 4 KB |
warning 维度必须统一
同一个字段里,所有向量的维度必须相同。你不能在一列 vector(1536) 里混存 1536 维和 768 维的向量。这和你用 int 类型只能存整数是一个道理——类型约束维度一致性。
1.3 向量的写入与查询
-- 插入一条向量数据(字符串格式自动转换)
INSERT INTO documents (content, embedding)
VALUES (
'pgvector 是 PostgreSQL 的向量扩展',
'[0.0234, -0.1823, 0.0091, ...]'::vector
);
-- 或者在 Python 中用参数化查询(推荐,避免 SQL 注入)
cursor.execute(
"INSERT INTO documents (content, embedding) VALUES (%s, %s)",
("pgvector 是 PostgreSQL 的向量扩展", embedding_list)
)
-- 查询最相似的 5 条文档
SELECT content, embedding <=> query_vec AS similarity
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 5;
二、halfvec 和 sparsevec:节省内存的姊妹类型
2.1 halfvec:用"半精度"换空间
vector 类型每个维度用 4 字节(32位浮点数,float32)存储。halfvec 把每个维度压缩到 2 字节(16位浮点数,float16)。
类比:JPG 和 RAW 照片
RAW 格式(float32)保留了最完整的数据,但文件很大。JPG(float16)用有损压缩换取更小的体积,绝大多数场景下肉眼看不出差别,但放大到像素级别会发现细微失真。
halfvec 也是这样的权衡:
- 内存节省 50%:1536 维向量从 6KB 降到 3KB
- 精度损失需评估:float16可能适用于部分ANN搜索场景,但需比较转换前后的近邻召回率和下游任务质量,不能保证精度已经足够
- 支持更高维度:halfvec 可以建索引的最大维度是 4000(而 vector 只有 2000)
-- 创建 halfvec 列(适合高维向量或内存敏感场景)
CREATE TABLE documents (
id bigserial PRIMARY KEY,
embedding halfvec(3072) -- 3072维,每个向量只需 6KB 而非 12KB
);
-- 建索引
CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops);
-- 查询语法与 vector 完全相同
SELECT * FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::halfvec
LIMIT 10;
note 何时选择 halfvec
- 向量维度超过 2000(vector 索引的上限)
- 数据量大,内存紧张(节省 50% 的存储和内存)
- Embedding 模型本身用 float16 输出(避免无意义的精度升级)
2.2 sparsevec:稀疏向量的专用格式
类比:只记非零分的成绩表
假设有一个 10 万个词汇的词典,用 TF-IDF 表示一篇文章时,这篇文章只会用到其中几百个词,其余 99%+ 的维度都是 0。如果用 vector(100000) 存储,大量空间被 0 占据。
sparsevec 只存储非零元素:
完整向量(vector 格式,10万维):
[0, 0, 0, ..., 0.23, 0, 0, ..., 0.87, 0, 0, ..., 0.15, ...]
↑ 99%以上都是 0,浪费大量空间
稀疏向量(sparsevec 格式):
{42: 0.23, 8834: 0.87, 63219: 0.15}
↑ 只记录"第42个维度是0.23,第8834个是0.87,第63219个是0.15"
-- 创建 sparsevec 列
CREATE TABLE tfidf_docs (
id bigserial PRIMARY KEY,
embedding sparsevec(100000) -- 10万维,但实际非零元素只有几百个
);
-- 插入时使用稀疏格式({index:value, ...}/dimensions)
INSERT INTO tfidf_docs (embedding)
VALUES ('{42:0.23, 8834:0.87, 63219:0.15}/100000');
-- 或者用类型转换从密集向量转换
SELECT '[0, 0.23, 0, 0.87, 0, 0.15]'::vector::sparsevec;
| 类型 | 适用场景 | 最大维度(存储) | 最大维度(索引) |
|---|---|---|---|
| vector | 通用密集向量(LLM embeddings) | 16,000 | 2,000 |
| halfvec | 高维密集向量,内存敏感 | 16,000 | 4,000 |
| bit | 二值向量(二进制哈希) | 64,000 | 64,000 |
| sparsevec | TF-IDF、BM25 等稀疏向量 | 105,535 | 1,000(非零元素数) |
三、距离操作符:相似度的三种量法
pgvector 提供了四个距离操作符。理解它们之前,先明确一点:相似度越高,距离越小。pgvector 的操作符返回的是距离,所以用 ORDER BY distance ASC 来找最相似的结果。
3.1 <-> L2 欧氏距离(Euclidean Distance)
类比:两点之间的直线距离
在二维平面上,两点 A(1, 2) 和 B(4, 6) 之间的距离,就是用尺子量出来的那段线段的长度:
distance = sqrt((4-1)² + (6-2)²) = sqrt(9 + 16) = 5
推广到 N 维:
L2(a, b) = sqrt(Σ(aᵢ - bᵢ)²)
感性理解:L2 距离关心的是两个向量在"绝对位置"上的差异。就像测量两座山峰的直线距离,不只考虑方向,也考虑高度(大小)的差异。
-- 语法:<->
SELECT *, embedding <-> '[0.1, 0.2, 0.3]'::vector AS l2_distance
FROM documents
ORDER BY embedding <-> '[0.1, 0.2, 0.3]'::vector
LIMIT 10;
对应索引操作符类:vector_l2_ops
3.2 <#> 负内积(Negative Inner Product)
类比:两个方向向量"同向"的程度
内积(点积)的计算:
IP(a, b) = Σ(aᵢ × bᵢ)
如果两个向量长度都是 1(单位向量),内积就等于余弦相似度。内积越大,两个向量越"同向"(相似)。
pgvector 用负内积 <#> 的原因:SQL 的 ORDER BY ... ASC 升序排列,而内积越大越相似,取负号后,负内积越小越相似,恰好与 ORDER BY ASC 的排序逻辑一致。
-- 语法:<#>(注意:返回的是负内积,返回负内积,排序值越小表示内积越大;不能仅凭正负号判断业务上的相似性)
SELECT *, (embedding <#> '[0.1, 0.2, 0.3]'::vector) * -1 AS inner_product
FROM documents
ORDER BY embedding <#> '[0.1, 0.2, 0.3]'::vector
LIMIT 10;
对应索引操作符类:vector_ip_ops
warning 内积的使用前提
内积的最大语义价值在于向量是单位向量(L2 范数为 1)的情况下。如果向量未归一化,内积同时受方向和大小影响,可能不符合你的语义意图。使用内积前,请确认你的 embedding 模型是否输出了归一化向量。OpenAI 的大多数模型输出的是归一化向量。
3.3 <=> 余弦距离(Cosine Distance)
类比:忽略音量,只比较旋律
两首歌,一首钢琴演奏(音量正常),一首耳机里的微弱版本(音量很小)。如果旋律相同,余弦相似度就是 1,尽管"音量"(向量的模长)差很多。
余弦相似度 = IP(a,b) / (|a| × |b|) → 范围 [-1, 1]
余弦距离 = 1 - 余弦相似度 → 范围 [0, 2]
余弦距离把两个向量归一化后再算内积,消除了向量大小(模长)的影响,只关注方向的一致性。
-- 语法:<=>
SELECT *, embedding <=> '[0.1, 0.2, 0.3]'::vector AS cosine_distance
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, 0.3]'::vector
LIMIT 10;
对应索引操作符类:vector_cosine_ops
3.4 <+> L1 曼哈顿距离(Manhattan Distance)
类比:城市街区里步行的路程
在曼哈顿这样的方格城市里,从 A 点走到 B 点,只能沿街道行走(不能穿越建筑),总路程就是曼哈顿距离:
L1(a, b) = Σ|aᵢ - bᵢ|
相较L2,L1距离对单个维度的大差值放大较少,对异常值(outlier)的鲁棒性比 L2 更强(L2 对大差值的平方放大了影响)。
-- 语法:<+>(pgvector 0.7+ 支持)
SELECT *, embedding <+> '[0.1, 0.2, 0.3]'::vector AS l1_distance
FROM documents
ORDER BY embedding <+> '[0.1, 0.2, 0.3]'::vector
LIMIT 10;
对应索引操作符类:vector_l1_ops
四、什么时候用哪个距离?
4.1 决策框架
这是实际工程中最常见的困惑。下面给一个清晰的决策框架:
%%{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
classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
START["选择距离度量"] --> Q1{"你的 Embedding 向量<br/>是否已归一化<br/>(L2 范数 = 1)?"}
Q1 -->|"是"| Q2{"你的应用场景<br/>关心什么?"}
Q1 -->|"否"| Q3{"向量的模长<br/>是否有语义含义?"}
Q2 -->|"只关心语义方向<br/>(大多数 NLP 场景)"| COS["余弦距离 <=><br/>余弦 = 内积(归一化后)<br/>两者等价,但内积更快"]
Q2 -->|"对性能要求极高<br/>归一化向量"| IP["负内积 <#><br/>计算最快<br/>归一化向量下与余弦等价"]
Q3 -->|"有(比如文档长度<br/>影响相似度判断)"| L2["L2 欧氏距离 <-><br/>综合考虑方向和大小"]
Q3 -->|"无(只关心方向)"| COS2["余弦距离 <=><br/>消除大小影响"]
COS --> FINAL1["RAG / 文档检索<br/>推荐系统 / 语义搜索"]
IP --> FINAL2["高性能推荐系统<br/>归一化后与余弦相同"]
L2 --> FINAL3["图像相似搜索<br/>异常检测<br/>向量聚类"]
COS2 --> FINAL4["长文本 vs 短文本比较<br/>跨语言语义检索"]
class Q1,Q2,Q3 decision
class START,COS,IP,L2,COS2,FINAL1,FINAL2,FINAL3,FINAL4 default
4.2 RAG 场景实战:选余弦还是内积?
以最常见的 RAG(Retrieval-Augmented Generation)场景为例:
# 场景:用户问"pgvector 怎么安装?"
# 你需要从知识库中找最相关的文档片段
# OpenAI 的 embedding 输出是归一化向量(|v| = 1)
# 在这种情况下,余弦距离和负内积等价
# 但负内积的计算略快(省去了计算模长的步骤)
# 方案 A:余弦距离(更语义化,不关心向量大小)
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);
SELECT content FROM docs
ORDER BY embedding <=> query_embedding LIMIT 5;
# 方案 B:负内积(性能略优,仅适用于归一化向量)
CREATE INDEX ON docs USING hnsw (embedding vector_ip_ops);
SELECT content FROM docs
ORDER BY embedding <#> query_embedding LIMIT 5;
实践建议:
| 场景 | 推荐距离 | 理由 |
|---|---|---|
| RAG / 语义搜索(OpenAI embeddings) | 余弦 <=> 或内积 <#> |
OpenAI 输出归一化向量,两者等价,内积稍快 |
| RAG / 语义搜索(Hugging Face 模型) | 余弦 <=> |
非归一化模型,余弦更稳定 |
| 图像特征相似搜索 | L2 <-> |
图像特征向量的大小有语义含义 |
| 用户行为向量(推荐系统) | 内积 <#> |
行为频次高的用户向量更长,内积天然赋予更高权重 |
| TF-IDF / BM25 稀疏向量 | 内积 <#> |
稀疏向量的内积就是词项匹配的得分 |
| 异常检测 | L2 <-> |
检测绝对位置的偏离 |
4.3 一个让人豁然开朗的例子
假设你在做简历推荐系统,用向量表示候选人的技能:
候选人 A:[8.0, 7.0, 9.0] # Python强, SQL中, 机器学习强
候选人 B:[4.0, 3.5, 4.5] # Python中, SQL中, 机器学习中(整体能力一般)
目标岗位:[0.8, 0.7, 0.9] # A 的等比例缩放版本,并非单位归一化向量
- 余弦距离:A 和 B 与目标岗位的余弦距离相同(方向完全一致)——余弦认为两人同样合适
- L2 距离:按上述目标向量,A 的距离约为13.93,B的距离约为5.79,因此B更近;这说明L2衡量与目标绝对数值的接近程度,并不会自动偏好技能数值更高的人。
哪个更对? 取决于你的业务逻辑:
- 如果你认为"技能方向匹配比绝对水平更重要"(初级岗位也行),选余弦
- 如果你认为"技能水平高低也很重要",可考虑L2,但目标向量必须采用与候选人一致的尺度,并表达岗位所需的绝对技能水平。
五、IVFFlat 索引:图书馆的分区管理员
5.1 核心思路
类比:图书馆的分区管理
设想一个有 100 万本书的大图书馆。要找一本书,有两个策略:
- 暴力搜索:从第一本翻到最后一本(相当于 PostgreSQL 全表扫描)
- IVFFlat:先把书按主题分成 1000 个区,每个区有一个"区域中心书目"。找书时,先看查询词最接近哪几个区域,只在那几个区里翻。
-- 创建 IVFFlat 索引
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- lists = 100 意味着:把所有向量聚成 100 个簇(100个"分区")
5.2 lists 参数:分多少个区
lists 决定了 K-means 聚类的数量,也就是"图书馆分多少个区"。
太少(lists 太小):
- 每个区里的书太多,每次搜索要翻的书太多
- 索引加速效果有限,类似于按大类分("文学"、"科技"),范围太大
太多(lists 太大):
- 每个区里的书很少,每次搜索又快又准
- 但构建索引慢,占用更多空间
PostgreSQL 官方建议:
数据量 < 100万: lists = rows / 1000
数据量 >= 100万: lists = sqrt(rows)
例子:
- 10万条数据: lists = 10万/1000 = 100
- 100万条数据: lists = sqrt(100万) = 1000
- 1000万条数据: lists = sqrt(1000万) ≈ 3162
tip 经验规则
每个 list 平均 1000-5000 条向量时,IVFFlat 通常有最好的性价比。如果某个 list 里有几十万条向量,搜索性能就和全表扫描差不多了。
5.3 probes 参数:搜索时探测多少个区
构建索引时用 lists,查询时用 probes 控制探测多少个聚类:
-- 设置 probes(每次查询前设置,或在配置文件中设置)
SET ivfflat.probes = 10;
-- 事务级别设置(不影响其他会话)
BEGIN;
SET LOCAL ivfflat.probes = 20;
SELECT * FROM documents ORDER BY embedding <=> query_vec LIMIT 10;
COMMIT;
probes 的权衡:以下速度排序与召回率区间仅为示意假设,未提供实验依据;实际结果取决于数据分布、过滤条件、参数和执行计划,不保证达到表中数值。
probes = 1(默认):
只搜索最近的 1 个聚类,最快,但召回率最低
probes = sqrt(lists)(推荐起点):
搜索 sqrt(lists) 个聚类,平衡速度和召回率
probes = lists:
覆盖所有聚类时不再通过减少探测聚类实现近似加速;规划器可能不使用IVFFlat索引,实际路径和耗时需查看执行计划,不能保证它总是最慢
类比:probes 就是你派出去"找书"的探员数量。派 1 个人只在最可能的区域找,快但可能漏掉。派所有人去每个区找,完整但慢。
| probes | 速度 | 召回率 |
|---|---|---|
| 1 | 极快 | 60-80%(粗略搜索) |
| sqrt(lists) | 快 | 90-95% |
| lists/2 | 中等 | 97-99% |
| lists | 最慢 | 100% |
5.4 IVFFlat 的重要限制
warning 建索引的时机
IVFFlat 必须在数据基本稳定后建索引。原因:
- 建索引时,K-means 聚类是基于当时的数据分布计算的
- 之后插入的新数据会被分配到现有聚类中,但聚类中心不会更新
- 如果新数据的分布与历史数据差异很大(比如换了 embedding 模型),原来的聚类会失效,召回率下降
建议:数据量变化超过 10-20% 时,考虑重建索引:
REINDEX INDEX CONCURRENTLY idx_documents_embedding;
六、HNSW 索引:预先绘制好的捷径地图
6.1 核心思路
类比:出行导航的层级路网
古代没有地图,找路只能靠打听,一步步问到目的地(相当于暴力搜索)。有了地图和高速公路网络后:
- 先沿高速公路(HNSW 高层)快速到达目的地所在的大区域
- 再走省道(HNSW 中层)缩小到镇级范围
- 最后走小路(HNSW 底层)精确到达目的地
HNSW 就是预先为你画好的"导航图",图中包含了各种层级的"快捷路径"。
-- 创建 HNSW 索引(推荐作为默认选择)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
6.2 m 参数:每个节点画几条高速公路
m 是每个向量节点在图中最多能有多少个邻居连接(在底层是 2m)。
类比:每座城市最多有几条高速公路出口
m = 4: 每座城市只有 4 个高速出口,找路时选项少,可能走弯路
m = 16: 每座城市有 16 个出口,找路更灵活,精度更高
m = 64: 每座城市有 64 个出口,找路极其精准,但建路(建图)成本很高
m 的影响:
| m 值 | 索引内存 | 构建速度 | 查询精度 | 适用场景 |
|---|---|---|---|---|
| 4-8 | 小 | 快 | 较低 | 内存极度紧张 |
| 16(默认) | 中 | 中 | 良好 | 大多数场景 |
| 32-64 | 大 | 慢 | 很高 | 对精度要求极高 |
note 内存估算
HNSW 索引的内存占用约等于:
索引内存 ≈ 向量数量 × (向量大小 + m × 8字节)
100万条 × (6144字节 + 16 × 8字节) = 6.27 GB(m=16)
100万条 × (6144字节 + 64 × 8字节) = 6.65 GB(m=64)
m 对内存的影响没有想象中大,因为向量本身的大小占主导。
6.3 ef_construction 参数:建图时的搜索精度
构建 HNSW 索引时,每插入一个新节点,需要为它在图中找到最近的邻居(用于建立连接)。ef_construction 控制这个"寻邻居"过程的候选集大小。
类比:建公路时的"勘探队规模"
建一条高速公路,要先勘探可能的路线。
- 勘探队只有 10 人:勘探范围小,可能错过更好的路线(但速度快)
- 勘探队有 200 人:覆盖更广,找到的路线质量更好(但耗时更长)
ef_construction = 64(默认):
构建速度快,索引质量良好
ef_construction = 128:
构建稍慢,索引质量更好,适合对召回率要求高的场景
ef_construction = 200-400:
构建很慢,索引质量最高,适合一次性建好后长期不变的场景
warning 注意区分两个 ef 参数
ef_construction:建索引时用,一旦索引建好就固定了,无法动态调整hnsw.ef_search:查询时用,可以随时动态调整,影响每次查询的精度和速度
6.4 hnsw.ef_search 参数:查询时的搜索精度
查询时,HNSW 维护一个候选集来追踪"目前找到的最好结果"。ef_search 控制这个候选集的大小。
-- 全局设置(影响该连接的所有查询)
SET hnsw.ef_search = 100;
-- 事务级别设置(仅影响当前事务)
BEGIN;
SET LOCAL hnsw.ef_search = 200;
SELECT * FROM documents ORDER BY embedding <=> query LIMIT 10;
COMMIT;
ef_search 的权衡:
ef_search = 40(默认):
查询快,但如果你 LIMIT 10,候选集只有 40 个,不一定能找到全局最优的 10 个
ef_search = 100:
候选集 100 个,从中选 LIMIT 10,精度提升,速度略下降
ef_search = 400:
非常高精度,接近暴力搜索的召回率,速度明显下降
tip 实践建议
ef_search 必须 ≥ LIMIT 值。如果你 LIMIT 50,ef_search = 40 是没有意义的。
通常 ef_search = LIMIT × 3 到 LIMIT × 10 是合理范围。
七、索引参数的直觉理解
7.1 参数关系全图
%%{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; }"}}%%
graph TD
classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
subgraph "IVFFlat 索引参数"
LISTS["lists(构建时)<br/>决定聚类数量<br/>影响索引精度和大小"]
PROBES["ivfflat.probes(查询时)<br/>决定搜索多少个聚类<br/>越大越准但越慢"]
LISTS -->|"建好后固定"| PROBES
end
subgraph "HNSW 索引参数"
M["m(构建时)<br/>每节点最大邻居数<br/>影响内存和精度"]
EF_C["ef_construction(构建时)<br/>构建时候选集大小<br/>越大索引质量越好"]
EF_S["hnsw.ef_search(查询时)<br/>查询时候选集大小<br/>越大越准但越慢"]
M -->|"建好后固定"| EF_S
EF_C -->|"建好后固定"| EF_S
end
subgraph "召回率-性能权衡"
RECALL["召回率<br/>(找到真实最近邻的比例)"]
PERF["查询性能<br/>(QPS / 延迟)"]
RECALL <-->|"此消彼长"| PERF
end
PROBES --> RECALL
EF_S --> RECALL
class LISTS,PROBES,M,EF_C,EF_S,RECALL,PERF default
7.2 HNSW vs IVFFlat:一张表看清差异
| 对比维度 | HNSW | IVFFlat |
|---|---|---|
| 构建时间 | 慢(逐点插入) | 中等(K-means + 批量分配) |
| 构建内存 | 中等 | 小 |
| 索引大小 | 大(约 2x 数据) | 中(约 1.5x 数据) |
| 查询性能(QPS) | 高 | 中等 |
| 查询召回率 | 高 | 中等 |
| 实时写入 | 支持(增量更新) | 弱(需定期重建) |
| 参数调优难度 | 简单(ef_search) | 中等(probes) |
| 适用场景 | 生产首选 | 数据稳定的批量场景 |
一句话总结:
- HNSW = 高精度、高性能、支持实时写入、但占内存大
- IVFFlat = 构建快、占内存小、但数据更新后需要重建
八、关键 PostgreSQL 配置对向量查询的影响
8.1 shared_buffers:HNSW 索引的"热缓存"
类比:工作台上放多少资料
工人从仓库(磁盘)拿资料放到工作台(内存),工作台越大,常用资料都在手边,效率越高。shared_buffers 就是这个工作台的大小。
HNSW 搜索是从图的顶层开始,沿着边"走"到底层。如果这些节点(索引页)都在 shared_buffers 中,每次访问是纯内存操作(微秒级);如果需要从磁盘读取(页 Cache Miss),延迟上升到毫秒级。
-- 查看当前配置
SHOW shared_buffers;
-- 建议:设置为可用 RAM 的 25%
-- 在 postgresql.conf 中修改:
-- shared_buffers = 8GB (假设机器有 32GB RAM)
向量场景的估算:
如果你的 HNSW 索引是 10 GB,
建议 shared_buffers ≥ 2.5 GB(能缓存 25% 的索引页)
理想情况是整个索引都在内存中,此时每次查询只需内存读取
8.2 maintenance_work_mem:索引构建的"施工预算"
这是 CREATE INDEX 操作可用的内存。建 HNSW 索引时,这个内存用于:
- 存放待处理的向量数据
- 维护临时的图结构
- 排序和候选集操作
这个参数对索引构建速度影响极大:
-- 建索引前临时调大(不影响全局)
SET maintenance_work_mem = '4GB';
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- SET为会话级设置,建完不会自动恢复;应显式恢复原值,或在事务中使用SET LOCAL
构建时间示意假设(100万条,1536维,m=16;下表保留原有数值,但未提供实验来源、硬件及测量条件,不代表已实测结果):
| maintenance_work_mem | 构建时间 |
|---|---|
| 64 MB(默认) | 约 45 分钟 |
| 512 MB | 约 18 分钟 |
| 2 GB | 约 8 分钟 |
| 8 GB | 约 5 分钟 |
warning 不要永久设置太大
maintenance_work_mem 是每个维护操作独占的内存。如果你设为 8GB,同时运行 5 个 autovacuum 进程,可能消耗 40GB 内存。只在需要建大索引时临时调大,建完恢复默认值。
8.3 work_mem:向量查询的"计算预算"
work_mem 是每个排序、哈希操作可用的内存。向量查询(ORDER BY embedding <=> ... LIMIT N)本质上是一个"在候选集中维护 top-K 的排序操作",受 work_mem 影响。
-- 查看当前配置
SHOW work_mem;
-- 为向量查询调大(会话级)
SET work_mem = '128MB';
影响场景:
- HNSW 候选集:ef_search 越大,候选集越大,需要更多 work_mem
- 结果排序:如果查询需要先 Seq Scan 再排序(没有索引的情况),需要更多 work_mem
- JOIN 操作:向量结果与其他表做 JOIN 时,哈希操作需要 work_mem
估算:
每个向量查询的 work_mem 需求
≈ ef_search × 向量大小 × 2(安全系数)
= 100 × 6150字节 × 2
≈ 1.2 MB
对于大多数场景,work_mem = 64MB 足够
并发查询很多时(>50),需要控制 work_mem,防止 OOM
8.4 max_parallel_workers_per_gather:并行扫描
PostgreSQL 支持并行查询。对于没有索引的向量全表扫描(Seq Scan),并行化能显著提升速度:
-- 允许 4 个并行 worker
SET max_parallel_workers_per_gather = 4;
-- 没有索引时,规划器可能选择并行全表扫描;是否并行及实际worker数需查看执行计划
-- 4个 worker 各扫描 1/4 的数据,最后合并结果
EXPLAIN SELECT * FROM documents ORDER BY embedding <=> query_vec LIMIT 10;
-- → Gather Merge → Parallel Seq Scan(4 workers)
note 索引扫描不需要并行
如果已经有 HNSW 索引,查询会走 Index Scan,Index Scan 通常是单线程的(pgvector 的 HNSW 实现目前不支持并行索引扫描)。并行查询对 Seq Scan 更有帮助。
8.5 配置速查表
-- 推荐配置(32GB RAM,向量数据库为主要用途)
-- postgresql.conf
shared_buffers = 8GB -- 25% 内存给 PG 缓存
effective_cache_size = 24GB -- 帮助规划器估算可用缓存
maintenance_work_mem = 2GB -- 建索引时使用,按需调大
work_mem = 128MB -- 每个查询的工作内存
max_worker_processes = 8 -- 并行 worker 数
max_parallel_workers = 8
max_parallel_workers_per_gather = 4 -- 每次 Gather 的并行度
-- 实时查看 HNSW 索引使用的缓存命中率
SELECT
schemaname, relname AS tablename, indexrelname AS indexname,
idx_blks_read, idx_blks_hit,
round(idx_blks_hit::numeric / nullif(idx_blks_hit + idx_blks_read, 0) * 100, 2) AS cache_hit_ratio
FROM pg_statio_user_indexes
WHERE indexrelname LIKE '%embedding%';
九、操作符类(Operator Class):索引与距离函数的配对
9.1 为什么需要操作符类
一个 HNSW 索引只能服务于一种距离函数。创建索引时必须指定操作符类,告诉 PostgreSQL"这个索引用于哪种距离的查询"。
-- 错误示例:创建了余弦索引,却用 L2 距离查询
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops); -- 余弦索引
-- 这个查询不会走 HNSW 索引(距离函数不匹配)
SELECT * FROM docs ORDER BY embedding <-> query_vec LIMIT 10; -- L2 距离
-- → PostgreSQL 会退化为 Seq Scan
9.2 操作符类速查表
| 数据类型 | 距离函数 | 操作符 | 操作符类 |
|---|---|---|---|
| vector | L2 欧氏距离 | <-> |
vector_l2_ops |
| vector | 内积 | <#> |
vector_ip_ops |
| vector | 余弦距离 | <=> |
vector_cosine_ops |
| vector | L1 曼哈顿距离 | <+> |
vector_l1_ops |
| halfvec | L2 欧氏距离 | <-> |
halfvec_l2_ops |
| halfvec | 内积 | <#> |
halfvec_ip_ops |
| halfvec | 余弦距离 | <=> |
halfvec_cosine_ops |
| halfvec | L1 曼哈顿距离 | <+> |
halfvec_l1_ops |
| sparsevec | L2 欧氏距离 | <-> |
sparsevec_l2_ops |
| sparsevec | 内积 | <#> |
sparsevec_ip_ops |
| sparsevec | 余弦距离 | <=> |
sparsevec_cosine_ops |
| bit | Hamming 距离 | <~> |
bit_hamming_ops |
| bit | Jaccard 距离 | <%> |
bit_jaccard_ops |
9.3 多距离函数场景
如果你的应用同时用多种距离函数,需要为每种函数单独建索引:
-- 场景:同时需要余弦搜索和 L2 搜索
CREATE INDEX idx_cosine ON documents USING hnsw (embedding vector_cosine_ops);
CREATE INDEX idx_l2 ON documents USING hnsw (embedding vector_l2_ops);
-- 余弦查询 → 走 idx_cosine
SELECT * FROM documents ORDER BY embedding <=> query LIMIT 10;
-- L2 查询 → 走 idx_l2
SELECT * FROM documents ORDER BY embedding <-> query LIMIT 10;
warning 两个索引 = 两倍存储和维护成本
每个索引都需要存储和更新。如果你不确定需要哪种距离,先从余弦距离开始——它对大多数 NLP 场景都表现良好,且对向量是否归一化不敏感。
十、精确搜索 vs 近似搜索:召回率的权衡
10.1 什么是召回率
"召回率"在向量搜索中的含义:在真实最近邻中,你找到了多少。
例子:数据库里有 100 万个向量
真实意义上最近的 10 个向量是 [A, B, C, D, E, F, G, H, I, J]
HNSW 搜索(ef_search=40)找到的 10 个是 [A, B, C, D, E, F, G, H, I, K]
召回率 = 找到的真实近邻数 / K = 9/10 = 90%
10.2 何时需要精确搜索(100% 召回率)
部分RAG和语义搜索场景可将90-95%的近邻召回率作为评估起点,但是否足够取决于关键文档遗漏的后果、下游回答质量和业务指标,不能保证漏掉一两个结果不影响效果。
但有些场景需要精确搜索:
-- 精确 KNN 搜索(不使用索引,暴力计算所有距离)
-- 适用于:数据量小(<10万),或对准确性要求极高
-- 方法1:禁用向量索引,强制全表扫描
SET enable_indexscan = off;
SELECT * FROM documents ORDER BY embedding <-> query LIMIT 10;
SET enable_indexscan = on; -- 查询完记得恢复
-- 方法2:明确全表扫描(不推荐,可能影响规划器其他决策)
SELECT * FROM documents
ORDER BY embedding <-> query
LIMIT 10;
-- 如果没有索引,PostgreSQL 自动做精确 KNN
10.3 如何验证你的索引召回率
-- 评估 HNSW 召回率的方法(以余弦距离为例)
-- Step 1: 用 Seq Scan 找到精确最近邻(真实答案)
SET enable_indexscan = off;
CREATE TEMP TABLE exact_results AS
SELECT id, embedding <=> query_vec AS dist
FROM documents
ORDER BY dist LIMIT 100;
SET enable_indexscan = on;
-- Step 2: 查询近似最近邻;须先通过 EXPLAIN 确认实际使用匹配的 HNSW 索引
-- enable_indexscan = on 仅允许索引扫描,不保证本查询采用 HNSW
CREATE TEMP TABLE approx_results AS
SELECT id, embedding <=> query_vec AS dist
FROM documents
ORDER BY embedding <=> query_vec LIMIT 100;
-- Step 3: 计算交集(召回率)
SELECT
COUNT(*) FILTER (WHERE a.id IN (SELECT id FROM exact_results)) AS matched,
COUNT(*) AS total,
round(COUNT(*) FILTER (WHERE a.id IN (SELECT id FROM exact_results))::numeric / NULLIF((SELECT COUNT(*) FROM exact_results), 0) * 100, 2) AS recall_rate
FROM approx_results a;
十一、常用函数速查
11.1 向量操作函数
-- 向量维度
SELECT vector_dims('[1, 2, 3]'::vector); -- → 3
-- 向量的 L2 范数(模长)
SELECT vector_norm('[1, 2, 3]'::vector); -- → sqrt(1+4+9) ≈ 3.742
-- 归一化(除以模长,使模长为1)
SELECT l2_normalize('[1, 2, 3]'::vector); -- → [0.267, 0.535, 0.802]
-- 子向量(取向量的一部分)
SELECT subvector('[1, 2, 3, 4, 5]'::vector, 2, 3); -- → [2, 3, 4](从第2位开始取3个)
-- 向量转换
SELECT embedding::halfvec FROM documents LIMIT 1; -- vector 转 halfvec
SELECT embedding::vector FROM docs_halfvec LIMIT 1; -- halfvec 转 vector
11.2 聚合函数
-- 向量平均值(聚合多个向量)
SELECT avg(embedding) FROM documents WHERE category = 'tech';
-- 向量求和
SELECT sum(embedding) FROM documents WHERE category = 'tech';
-- 典型用途:用户历史兴趣向量的平均,作为用户偏好向量
SELECT avg(item_embedding) AS user_preference_vector
FROM user_clicks
WHERE user_id = 12345
GROUP BY user_id;
11.3 距离函数(函数调用形式)
-- 除了操作符,也可以用函数形式(结果相同)
SELECT l2_distance('[1,2,3]'::vector, '[4,5,6]'::vector); -- → 5.196
SELECT inner_product('[1,2,3]'::vector, '[4,5,6]'::vector); -- → 32
SELECT cosine_distance('[1,2,3]'::vector, '[4,5,6]'::vector); -- → 0.025
-- 注意:函数形式不会走索引,只能用于计算
-- 索引加速必须用操作符形式(<->, <#>, <=>)
11.4 索引维护
-- 查看索引状态
SELECT indexrelname AS indexname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'documents';
-- 查看索引大小
SELECT pg_size_pretty(pg_relation_size('idx_documents_embedding'));
-- 查看索引构建进度(PG 12+)
SELECT phase, blocks_done, blocks_total,
round(blocks_done::numeric / nullif(blocks_total, 0) * 100, 2) AS progress_pct
FROM pg_stat_progress_create_index
WHERE relid = 'documents'::regclass;
-- 重建索引(不锁表)
REINDEX INDEX CONCURRENTLY idx_documents_embedding;
十二、参数调优决策树
12.1 选择索引类型
%%{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
classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
START["开始:选择 pgvector 索引"] --> Q1{"数据规模?"}
Q1 -->|"< 10万条"| SMALL["可以不建索引<br/>PostgreSQL Seq Scan<br/>已经很快"]
Q1 -->|"10万 - 500万条"| Q2{"数据是否频繁更新?"}
Q1 -->|"> 500万条"| HNSW2["优先评估 HNSW<br/>(不能仅凭超过500万条断定IVFFlat召回率不够)"]
Q2 -->|"频繁写入<br/>(每天新增>1%)"| HNSW1["HNSW<br/>(增量更新,无需重建)"]
Q2 -->|"数据基本稳定<br/>(批量导入后很少变化)"| Q3{"内存是否紧张?"}
Q3 -->|"内存紧张"| IVF["评估 IVFFlat<br/>(原60%比例仅作示意假设,实际内存消耗需测量)"]
Q3 -->|"内存充足"| HNSW1
HNSW1 --> P1["参数建议:<br/>m=16, ef_construction=64<br/>ef_search=40-100"]
IVF --> P2["参数建议:<br/>lists=sqrt(rows)<br/>probes=sqrt(lists)"]
HNSW2 --> P3["参数建议:<br/>m=16-32, ef_construction=64-128<br/>ef_search=100-200"]
class Q1,Q2,Q3 decision
class START,SMALL,HNSW2,HNSW1,IVF,P1,P2,P3 default
12.2 召回率不够怎么办
症状:向量搜索返回的结果质量不高,用户反馈"找不到相关内容"
排查步骤:
Step 1: 验证是真的召回率低,还是 embedding 模型不好
→ 用精确搜索(禁用索引)找到的结果,质量是否更好?
→ 如果精确搜索也不好:问题在 embedding 模型或距离函数选择
→ 如果精确搜索明显更好:问题在索引参数
Step 2: 调整索引参数(不用重建索引)
SET hnsw.ef_search = 200; -- 从默认 40 调大
-- 或者
SET ivfflat.probes = 20; -- 从默认 1 调大
Step 3: 如果调参后仍然不够,考虑重建索引
-- 使用更大的 ef_construction
DROP INDEX idx_embedding;
SET maintenance_work_mem = '4GB';
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops)
WITH (m=32, ef_construction=128);
12.3 查询速度太慢怎么办
症状:向量查询 p99 延迟超过 100ms,不能接受
排查步骤:
Step 1: EXPLAIN ANALYZE 查看执行计划
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM documents ORDER BY embedding <=> query LIMIT 10;
→ 如果是 Seq Scan:说明没走索引,检查操作符类是否匹配
→ 如果是 Index Scan:看 Buffers 信息,是否有大量磁盘读取
Step 2: 检查索引缓存命中率
→ 如果命中率低(<90%),考虑增大 shared_buffers
Step 3: 降低 ef_search(牺牲一些召回率换速度)
SET hnsw.ef_search = 20; -- 从 40 降到 20
Step 4: 检查是否有标量过滤导致的退化
→ 带 WHERE 过滤的查询,pgvector 可能退化到 Seq Scan
→ 考虑增大 ef_search 或在应用层做二次过滤
note 延伸阅读
- pgvector 架构深度解析:理解参数背后的内部机制
- pgvector 安装与部署:从零开始搭建环境
- 向量搜索原理与 ANN 算法:HNSW 和 IVFFlat 的数学原理
- 向量数据库选型指南:pgvector vs Milvus vs Qdrant 的完整对比