Chapter 01

数据模型与排序契约

起点页给了一句话本质和概念地图。这一章把地图最上面两格——「向量列」和「排序索引契约」——填实:向量怎么存成一列,距离怎么变成运算符,运算符怎么绑到索引,以及那条决定后面一切的契约:向量索引只服务 ORDER BY 距离 LIMIT k。

本章你将建立的 schema

  • 向量是关系表里的一列,类型有 4 种,能不能建索引取决于维度撞不撞 8KB page 上限。
  • 4 个距离运算符各对应一种度量;<#> 返回的是负内积,这是索引"只能升序"逼出来的。
  • opclass 是建索引时写进 DDL 的"用哪种距离"声明,query 的运算符必须和它匹配。
  • 向量索引是 amcanorderbyop 排序索引——它只认 ORDER BY 距离 ... LIMIT k,不认 WHERE。

贯穿全教程的例子是一个多租户 RAG 系统的文档块表。每段被切碎的文档(chunk)存一行,连同它的 embedding 和几个元数据字段:

schema.sql SQL
CREATE EXTENSION IF NOT EXISTS vector;   -- 0.5.0+ 自带 HNSW

CREATE TABLE doc_chunks (
    id          bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    doc_id      bigint      NOT NULL,
    tenant_id   int         NOT NULL,          -- 多租户:每个客户只能搜自己的
    lang        text        NOT NULL,          -- 'zh' / 'en' ...
    content     text        NOT NULL,
    embedding   vector(1536) NOT NULL,         -- text-embedding-3-small
    created_at  timestamptz NOT NULL DEFAULT now()
);

整本教程的查询都长这一个样子——它把"向量检索"和"关系过滤"焊在同一条 SQL 里,而这恰恰是 pgvector 全部难点的来源:

query.sql SQL
SELECT id, content
FROM   doc_chunks
WHERE  tenant_id = 42 AND lang = 'zh'      -- 关系过滤
ORDER  BY embedding <=> $1                  -- 向量排序($1 是查询向量)
LIMIT  10;                                 -- 取最近 10 段

记住这条 SQL 的形状。这一章解释它的每一个零件;后面四章解释当数据量、并发写入和过滤选择性变化时,这条 SQL 的行为如何随之改变。

1.1向量列:把 embedding 存成一列

向量是一个定长 float 数组,作为关系表里的一个原子列存储,PG 把它当成一个有距离运算的类型。

为什么需要它

没有 vector 类型,你只能把 1536 个浮点拆成 1536 列(荒谬),或塞进 float8[] / JSON。数组和 JSON 都能"存",但 PG 不知道两个数组之间有"距离",更不能在上面建 ANN 索引。vector 类型做的事,是让 Postgres 把这一列当成几何对象,而不是一堆数字。

pgvector 提供 4 种向量类型,差别在于"一个分量用几个字节",这直接决定了能存多少维、能不能建索引:

表 1.1 · 四种向量类型(pgvector 0.7.0+)
类型每分量最大维度可建索引维度典型用途
vector4B (float32)16,000≤ 2,000默认。1536 维 embedding 的主力
halfvec2B (float16)16,000≤ 4,0003072 维、或想把索引体积砍半
bit1 bit64,000≤ 64,000二值量化后的指纹,Hamming 距离
sparsevec仅存非零16,000 非零≤ 1,000 非零稀疏向量(SPLADE 等)
洞察 · 2000 维上限不是拍脑袋

vector 的可建索引维度卡在 2,000,是 8KB page 的推论:一个 HNSW 索引项要把向量原值连同图指针塞进单页,4 × 2000 + 开销 已逼近 8KB。halfvec 每分量只占 2 字节,同样的页能塞下 4,000 维——所以给 3072 维的 text-embedding-3-large 建索引时,标准做法是把列声明成 vector(3072),建索引时转成 halfvec:USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops)。这不是 hack,是 page 物理限制下的标准动作。

类比 · 带边界声明

vector 像 numeric:都是 PG 的一种标量类型,能参与运算、能进表达式。类比失效处:numeric 有全序,你能 ORDER BY price;vector 没有全序,你不能 ORDER BY embedding——只能 ORDER BY 距离表达式。这个区别是 §1.4 整节的伏笔。

1.2距离运算符:相似度变成一个标量表达式

pgvector 用 4 个中缀运算符表达"两个向量有多远",每个对应一种度量。

为什么需要它

SQL 的 ORDER BY 需要一个能比大小的标量。"相似度"本身是个概念,运算符把它落成 embedding <=> $1 这样一个能算出数字、能排序的表达式。没有运算符,相似度排序就无从写起。

表 1.2 · 4 个距离运算符
运算符度量值的含义opclass(cosine 举例)
<->L2(欧氏)越小越近vector_l2_ops
<=>cosine 距离越小越近(0=同向)vector_cosine_ops
<#>负内积越小越近(值是负的)vector_ip_ops
<+>L1(曼哈顿,0.7.0+)越小越近vector_l1_ops

底层机制(比文档深一层):为什么 <#> 返回的是负内积,而不是内积本身?因为内积是"越大越相似",要把最相似的排前面就得降序;可是 Postgres 的索引扫描只能升序吐结果(最小值先出)。pgvector 的解法很直接:把内积取负。于是"内积最大 / 最相似"变成"负内积最小",升序扫描自然把最相似的放最前面。代价是你查出来的"相似度"是 -0.83 这种负数——要展示给人看就乘 -1,但排名本身已经是对的。这个"为了迁就升序索引而取负"的小动作,是 §1.4 那条排序契约的第一个具体痕迹。

想一想

如果你的 embedding 已经全部归一化到单位长度(‖v‖ = 1,OpenAI 的 embedding 就是),那么用 <=>(cosine)和用 <#>(负内积)排序,结果排名会一样吗?性能上谁更划算?

展开答案(先停 10 秒)

排名完全一样。单位向量下 cosine_similarity = dot,而 cosine 距离 = 1 − dot,负内积 = −dot——两者都是 dot 的单调变换,排序结果一致。

性能上 <#> 更划算:cosine 距离要在内积之外再算两个向量的模并做除法;单位向量下这步是浪费。所以"向量已归一化"时,业界惯例是建 vector_ip_ops 索引、查询用 <#>。这也是后面选型时一个常见的小优化。

1.3opclass:把运算符绑到索引上

opclass(operator class)是建索引时写进 DDL 的一句声明,告诉索引"该用哪种距离"。

为什么需要它

同一个向量列,A 项目想用 cosine、B 项目想用 L2。索引内部的图必须按某一种距离来建。opclass 就是那个"按哪种距离建图"的开关。它还有第二个作用:让 planner 能把 query 里的运算符和索引对上号。

底层机制(比文档深一层):opclass 在运算符(<=>)和索引内部的距离函数之间建立绑定。建索引时把 opclass 写进 DDL,索引就按这种距离建图;查询时,ORDER BY 用的运算符必须和索引的 opclass 同属一类,planner 才会考虑用这个索引。对不上号——比如索引建的是 vector_cosine_ops,查询却写 <->(L2)——planner 直接放弃索引,退化成全表暴力计算。在百万行上这是 30 秒 vs 5 毫秒 的差距,而且不报任何错,只是慢。这是 §1.2 表里"运算符那一列必须和 opclass 那一列配套"的实际后果。

查询写 建索引写 查询运算符 ORDER BY emb <=> $1 必须匹配 opclass vector_cosine_ops 写进 CREATE INDEX USING hnsw (...) 得到 能走索引的查询 否则退化 seq scan
图 1.1运算符 → opclass → 索引的绑定链。注意:朱红的双向箭头是关键——查询运算符和建索引的 opclass 必须同类。对不上,索引就像不存在,查询静默退化成全表暴力计算,不报错、只慢。
index.sql SQL
-- 建 cosine 索引:opclass 决定按余弦建图
CREATE INDEX ON doc_chunks
    USING hnsw (embedding vector_cosine_ops);

-- 查询用 <=>(cosine)—— 和 opclass 同类,planner 认它
SELECT id FROM doc_chunks ORDER BY embedding <=> $1 LIMIT 10;

-- 查询误用 <->(L2)—— 和 opclass 不同类,索引用不上,全表扫描
SELECT id FROM doc_chunks ORDER BY embedding <-> $1 LIMIT 10;  -- 慢,但不报错

1.4排序契约:amcanorderbyop——这一章的钥匙

HNSW / IVFFlat 是"按距离排序并取前 k"的索引(amcanorderbyop),不是过滤索引——它只认 ORDER BY 距离 ... LIMIT k。

为什么需要它

这一节是理解 pgvector 一切"奇怪行为"的总开关。把它吃透,后面三章的事故你都能从这里反推:为什么过滤后只剩 4 条、为什么加了 DESC 索引就不走、为什么删了一批行召回会崩。它们都是这一条契约的推论。

底层机制(比文档深一层):Postgres 的索引访问方法(access method)有一个标志位 amcanorderbyop。普通 B-tree 索引用它回答"等值和范围"(WHERE x = 5、WHERE x < 10)。HNSW 和 IVFFlat 不一样——它们把 amcanorderbyop 设成 true,意思是"它能按一个运算符返回有序的结果流"。于是 planner 只在看到 ORDER BY <距离运算符> ... LIMIT k 时才考虑用它:索引的工作就是"流式吐出离查询向量最近的那几个,吐够 k 个就停"。

这句话的每个部分都是硬约束:

  • 必须有 ORDER BY 距离:没有排序表达式,索引无事可做。WHERE embedding <=> $1 < 0.3(按距离阈值过滤、不排序)——用不上索引。
  • 必须有 LIMIT:没有 LIMIT,等于要求全部有序输出,planner 算下来还不如全表排序划算,于是放弃索引。
  • 不能加 DESC:索引只能升序吐。ORDER BY ... DESC 要降序,索引服务不了。
WHERE tenant_id = 42 ORDER BY embedding <=> $1 LIMIT 10 过滤 向量索引服务不了 → B-tree 索引 / 扫描后过滤 排序 + 截断 HNSW 排序索引 amcanorderbyop:只认这一块
图 1.2同一条 SQL 被拆成三段:向量索引只认右边(ORDER BY 距离 + LIMIT),左边的 WHERE 它碰都不碰。注意:这张图是第 4 章"过滤欠返"的总根源——既然索引只按距离排序吐固定条数,那 WHERE 只能在它之后再筛,筛完自然未必还够 10 条。
类比 · 带边界声明

把 HNSW 索引想成一条已经按"离你多近"排好队的传送带:你只能从最近的一端,一个一个取,取够 k 个就喊停。类比失效处:你不能对传送带喊"只挑 tenant=42 的"——过滤不是传送带的职责。要么取下来一个个看 tenant(post-filter,未必取够),要么提前为 tenant=42 单独搭一条传送带(partial index)。这两条路正是第 4 章的主线。

想一想

下面这条查询,建了 vector_ip_ops 的 HNSW 索引后,能走索引吗?
SELECT id FROM doc_chunks ORDER BY embedding <#> $1 DESC LIMIT 10;

展开答案(先停 10 秒)

不能。两个原因叠在一起,都来自这一节的契约:

① 索引只能升序吐结果,DESC 要降序,直接出局。② 更深一层:<#> 已经是负内积(§1.2),它本身就把"最相似"变成了"最小值"。所以正确写法是去掉 DESC、用 <#> 升序——负内积最小 = 内积最大 = 最相似在最前。很多人凭"内积越大越相似"的直觉加了 DESC,反而把索引废了。这是 §1.2 的负号和 §1.4 的升序约束合起来挖的一个坑。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里,再点开对照。直接展开等于把这一节当再读一遍。

  1. 为 3072 维的 text-embedding-3-large 建 HNSW 索引,直接 vector(3072) 会失败。说出失败的根因,并写出能成功的那行 DDL。
  2. <#> 查出来的相似度是 -0.91。这个负号是怎么来的?为什么排名仍然是对的?
  3. 索引建的是 vector_l2_ops,查询写 ORDER BY embedding <=> $1 LIMIT 10。会发生什么?会报错吗?
  4. 一句话说清:为什么向量索引帮不上 WHERE tenant_id = 42 这个条件?
答案(先做完再展开)
  1. 根因:vector 可建索引维度上限 2,000,受 8KB page 限制;3072 > 2000。解法:建索引时转 halfvec——CREATE INDEX ON doc_chunks USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);(halfvec 每分量 2 字节,上限 4,000)。
  2. 内积越大越相似,要降序排;但 PG 索引只能升序吐结果。pgvector 把内积取负,让"最相似"变成"最小的负数",升序扫描就把最相似的排最前。排名是 dot 的单调变换,所以正确——只是数值带了负号,展示时乘 -1 即可。
  3. 不报错,但索引用不上:查询运算符 <=> 和索引 opclass vector_l2_ops 不同类,planner 放弃索引,退化成全表暴力计算。表现为"有索引却很慢"。
  4. 因为向量索引是 amcanorderbyop 排序索引,只服务 ORDER BY 距离 ... LIMIT k,它的职责是"按距离排序取前 k",不包含按字段值过滤。tenant_id = 42 是等值过滤,得靠 B-tree 或排序后再筛。
进阶挑战 · 刚好够不着

同一个向量列,能不能既支持 cosine 检索又支持 L2 检索,且两者都走索引?

你的 RAG 系统大部分查询用 cosine,但有个新功能需要按 L2 距离去重。一个 vector_cosine_ops 索引服务不了 L2 查询(§1.3)。在不改列、不重存向量的前提下,怎么让两种查询都能走索引?各自的代价是什么?

提示(卡住再展开)

opclass 是索引的属性,不是列的属性——一个列上可以建多个索引。想想在同一个 embedding 列上建两个 HNSW 索引、用不同 opclass 会怎样:存储翻倍、写入要维护两张图(回到第 3 章的 churn 成本),但两种查询各有索引可走。再想:如果向量已归一化,L2 和 cosine 的排名其实等价(§1.2 的想一想),那第二个索引是不是根本不必建?把"逻辑上需不需要"和"物理上建几个索引"分开回答。