# 可复现的向量检索

LLMS 索引： [llms.txt](/llms.txt)

---

向量列并不自带“语义”。它只是一种有维度的数值，配合一个距离/相似度函数
产生次序。语义来自向量生成过程；查询性能来自精确或近似执行路径；质量来自
外部标注。

把三者分开，才能复现，也才能退出。

## 15.4.1 维度、距离度量与归一化 {#item-15-4-1}

### 一列必须有模型身份

最小表不是：

```sql
embedding vector(1536)
```

而是至少：

```sql
embedding       vector(1536) NOT NULL,
embedding_model text         NOT NULL,
embedded_at     timestamptz  NOT NULL
```

真实系统还可能需要：

```text
input_template_version
source_text_hash
normalization
generation_status / error
provider/model revision
dimension
```

同一维度不代表同一空间。两个 1536 维模型生成的向量不能因为类型相容就放进
同一个近邻索引比较。一次模型升级应视为数据迁移，而不是悄悄覆盖列。

本章用：

```sql
embedding shop_ch14.vector(4) NOT NULL,
embedding_model text NOT NULL
  CHECK (
    embedding_model =
    'pg36-handcrafted-topic-4d-v1'
  )
```

四维分别是 audio、kitchen、outdoor、books 的人工主题坐标。它的价值是每个
距离可手算、没有 API 漂移；它不证明真实 embedding 的语义能力。

### 四个常见距离/相似合同

pgvector 0.8.4 对 `vector` 提供：

| 运算符 | 含义 | HNSW opclass |
|---|---|---|
| `<->` | L2 / Euclidean distance | `vector_l2_ops` |
| `<#>` | negative inner product | `vector_ip_ops` |
| `<=>` | cosine distance | `vector_cosine_ops` |
| `<+>` | L1 / taxicab distance | `vector_l1_ops` |

还有 binary vector 的 Hamming/Jaccard，以及 `halfvec`、`sparsevec` 的相应
能力；本章不展开。

pgvector 让“越小越近”符合 PostgreSQL ascending index scan：

```sql
-- L2：小者优先
ORDER BY embedding <-> :query

-- inner product：返回负内积，所以仍按 ASC
ORDER BY embedding <#> :query

-- cosine similarity 若要展示
1 - (embedding <=> :query)
```

不要把 `<#>` 的负数直接叫“相似度”。用于展示内积时要乘 `-1`，用于索引
排序则保留升序负内积。

参见
[pgvector 0.8.4 Querying](https://github.com/pgvector/pgvector/blob/v0.8.4/README.md#querying)。

### 选距离要回到模型合同

L2：

\[
d_{L2}(x,y)=\sqrt{\sum_i(x_i-y_i)^2}
\]

它同时受方向与向量长度影响。

cosine similarity：

\[
\cos(x,y)=\frac{x\cdot y}{\|x\|\|y\|}
\]

更强调方向，cosine distance 通常为 \(1-\cos(x,y)\)。

inner product：

\[
x\cdot y=\sum_i x_i y_i
\]

同时受方向与模长影响。某些模型明确训练为用 dot product 排序。

若所有向量都被归一化为单位长度：

\[
\|x-y\|_2^2 = 2 - 2(x\cdot y)
\]

此时 L2、cosine 与 inner product 的次序存在紧密关系；但仍应按模型文档
和性能目标选 opclass，不能因为数学关系就混用未归一化数据。

模型 ADR 要回答：

```text
does the producer normalize?
does the database verify normalization tolerance?
which operator is used by every query?
which matching opclass indexes that operator?
how are zero vectors handled?
```

一个常见错误：

```sql
CREATE INDEX ... (embedding vector_cosine_ops);
SELECT ... ORDER BY embedding <-> :q LIMIT 10;
```

索引是 cosine，查询却用 L2；二者 operator family 不匹配，不能期待该索引
支持查询。目录与执行计划必须同时复核。

### 维度是存储和索引合同

本章使用 `vector(4)`，数据库拒绝不同维度的值。pgvector 0.8.4 的 HNSW
`vector` 索引支持最多 2000 维；`halfvec`、`bit`、`sparsevec` 有各自上限。
这些是当前版本事实，升级前重查官方文档。

“维度越高语义越好”不是规律。维度会影响：

- 每行存储；
- index tuple 与图内存；
- 构建和查询计算；
- WAL、备份和网络；
- 模型能力与压缩损失。

先以模型要求为输入，再用真实规模测成本。不要为了迎合数据库索引上限随意
截断向量；降维、量化或子向量都需要新的质量基线。

## 15.4.2 精确近邻与近似索引 {#item-15-4-2}

### 默认是精确搜索

没有近似索引时：

```sql
SELECT product_id, title
FROM shop_ch15.product_search
WHERE active
  AND category = 'outdoor'
ORDER BY
  embedding
    OPERATOR(shop_ch14.<->)
    '[0,0,0.9,0]'::shop_ch14.vector(4),
  product_id
LIMIT 3;
```

PostgreSQL 对所有合格行算距离、排序并取前三。这是 exact nearest neighbor：
在当前快照和距离合同下，召回是完备的。它的成本随参与比较的行数、维度与
并行度增长。

本章质量视图不是直接执行一个可能被 HNSW 接管的 `ORDER BY ... LIMIT`。
它先产生所有 pair 的 distance，再用 window function 排名：

```sql
row_number() OVER (
  PARTITION BY query_id
  ORDER BY distance, product_id
)
```

这使质量 golden 明确来自 exact 全集，与线上 planner 是否选择 ANN 无关。

强制 exact plan：

```sql
SET enable_indexscan = off;
SET enable_bitmapscan = off;
```

得到：

```text
Seq Scan on product_search
Sort Key: ((embedding <-> ...)), product_id
```

这是本章的 reference path。

### ANN 用召回换速度

pgvector 支持 HNSW 与 IVFFlat：

```text
exact search:
  computes all eligible distances
  perfect recall under the metric

approximate search:
  visits a selected part of an index
  lower work, potentially different results
```

官方 README 明确指出，增加 approximate index 后，同一查询可能得到不同
结果；必须把这种差异视为算法合同，而不是数据库 bug。

本章建：

```sql
CREATE INDEX product_search_embedding_hnsw_idx
ON shop_ch15.product_search
USING hnsw (
  embedding shop_ch14.vector_l2_ops
)
WITH (
  m = 8,
  ef_construction = 32
);
```

默认值在 pgvector 0.8.4 是 `m=16`、`ef_construction=64`；本章用较小值
只是让夹具声明显式，并非生产建议。

HNSW 建多层图：

- 通常有较好的 speed/recall trade-off；
- 构建更慢、内存更多；
- 不需要像 IVFFlat 那样先训练 lists，所以空表也能先建；
- 持续写入仍要维护图和 WAL。

IVFFlat 把向量分到 lists，查询探测部分 lists：

- 构建更快、内存较少；
- 通常查询 speed/recall trade-off 低于 HNSW；
- 建索引前应有代表性数据；
- lists/probes 选择直接影响 recall。

这不是永久排名。数据规模、更新率、过滤、内存和 SLO 会改变选择。

### 索引查询形状必须匹配

让 ANN index 生效，典型查询要：

```sql
ORDER BY embedding <-> :query
LIMIT K
```

只写距离范围：

```sql
WHERE embedding <-> :query < :radius
```

不一定形成同样的 ordered index path。pgvector 官方建议把范围条件与
`ORDER BY`、`LIMIT` 结合。

还要避免把 indexed expression 包进不等价表达式：

```sql
-- 可能破坏路径匹配
ORDER BY 1 - (embedding <=> :query) DESC

-- 直接按 index operator 升序
ORDER BY embedding <=> :query
```

展示 similarity 可以在外层计算；候选扫描保持与 opclass/operator 一致。

### ANN recall 必须相对 exact 定义

对于同一 query 与 filters：

\[
Recall@K_{ANN} =
\frac{|ANN_K \cap Exact_K|}{K}
\]

本章 [`ann-compare.sql`](/labs/ch15/ann-compare.sql) 在同一事务中：

1. 从 exact 质量视图取 q06 前三；
2. 强制 HNSW path 并取同样过滤后的前三；
3. 求集合交集。

结果：

```text
exact_ids = 7,8,9
ann_ids   = 7,8,9
recall@3  = 1.000000
```

一次查询、17 行、四维向量上的 1.0 不是生产结论。正式测量至少按：

```text
query segment
filter selectivity
K
ef_search/probes
concurrency
data freshness
model version
```

报告分布，并把 exact 抽样任务长期保留。pgvector 官方 monitoring 建议同样
是关闭 index scan 取得 exact 结果，再与近似结果比较。

## 15.4.3 索引参数、过滤条件与召回代价 {#item-15-4-3}

### HNSW 有构建参数和查询参数

构建参数：

| 参数 | 作用 | 增大通常带来的影响 |
|---|---|---|
| `m` | 每层最大连接数 | 图更密、潜在召回更好、空间/构建更贵 |
| `ef_construction` | 构图候选列表大小 | 潜在召回更好、构建/写入更慢 |

查询参数：

| 参数 | 作用 | 增大通常带来的影响 |
|---|---|---|
| `hnsw.ef_search` | 查询动态候选列表 | recall 上升机会、延迟与工作量上升 |

pgvector 0.8.4 默认 `ef_search=40`。单次实验应使用事务局部设置：

```sql
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT ... ORDER BY embedding <-> :query LIMIT 10;
COMMIT;
```

不要把 session pool 中遗留的 GUC 当作服务配置；也不要只测一个值。需要
绘制：

```text
ef_search -> recall distribution
          -> P50/P95/P99 latency
          -> buffers/CPU
```

参数发布要与 index build identity、模型、数据快照和查询集一起版本化。

### 过滤为什么会“吃掉”结果

pgvector 0.8.4 官方说明，approximate index scan 的普通过滤是在扫描后应用。
假设只有 10% 行满足过滤，初次探索 40 个候选，平均可能只留下约 4 个；需要
K=10 时就会短缺。

这不是 SQL 三值逻辑错，而是 ANN 没继续探索足够候选。

可选策略：

1. **过滤列 B-tree + exact search**
   当过滤后集合很小，先用普通索引缩小到少量行，再 exact 排序，常常更快且
   recall 完整。
2. **iterative index scan**
   过滤后不足时继续探索 ANN。
3. **partial HNSW index**
   少数稳定类别各有索引，例如 `WHERE category_id=123`。
4. **partitioning**
   过滤维度离散且能形成真实数据生命周期/裁剪边界时分区。
5. **扩大 candidate/ef_search**
   简单但增加每次查询成本，仍要测。

本章同时创建：

```sql
CREATE INDEX product_search_filter_idx
ON shop_ch15.product_search(category, product_id)
WHERE active;
```

它提醒读者：向量检索仍然是 PostgreSQL 查询，普通关系索引和选择性统计并未
失效。

### iterative scan 的 strict 与 relaxed

pgvector 从 0.8.0 起支持 iterative index scan：

```sql
SET LOCAL hnsw.iterative_scan = 'strict_order';
-- or
SET LOCAL hnsw.iterative_scan = 'relaxed_order';
```

strict 保持距离严格次序；relaxed 允许轻微乱序，可能获得更好的 recall。
relaxed 结果若要重新严格排序，官方示例使用 materialized CTE 后在外层排序。

迭代不会无限进行，还受：

```text
hnsw.max_scan_tuples
ivfflat.max_probes
memory limits
```

等边界影响。返回 K 行、返回顺序与 recall 都要分别断言。

本章用 strict：

```sql
SET hnsw.iterative_scan = 'strict_order';
```

只为让证据顺序稳定。它不能让 approximate graph 等价于 exact scan。

### partial index 不是“每个租户建一个”

官方建议在过滤值很少时考虑 partial HNSW：

```sql
CREATE INDEX ...
USING hnsw (embedding vector_l2_ops)
WHERE category_id = 123;
```

若有成千上万租户或高基数 ACL，给每个值建索引会造成：

- index 数量爆炸；
- DDL/catalog/autovacuum 管理困难；
- 写放大；
- planner 规划开销；
- 备份恢复和升级时间增长。

高基数过滤更可能需要：

- exact on a selective conventional index；
- 合理分区；
- 分片/路由层；
- 两阶段候选与业务过滤；
- 或重新选择搜索架构。

任何方案都必须验证权限过滤不会因“为了 recall”被放宽。

### 构建与维护的安全线

HNSW 构建图若能放入 `maintenance_work_mem` 会更快；官方也警告不要把该参数
设到耗尽 server 内存。生产建索引：

```sql
CREATE INDEX CONCURRENTLY ...
```

可以减少阻塞写入，但会更慢、产生更长资源占用，失败还可能留下 invalid
index。要监控：

```sql
SELECT
  phase,
  blocks_done,
  blocks_total
FROM pg_stat_progress_create_index;
```

并检查：

```sql
SELECT indisvalid, indisready, indislive
FROM pg_index
WHERE indexrelid = 'schema.index_name'::regclass;
```

本章 setup 是离线教学重建，所以用普通 `CREATE INDEX`；不得把这个选择直接
搬到在线主表。

### 何时退回 exact

以下场景 exact 可能更好：

- 过滤后只剩少量行；
- K 较大，ANN 需要探索大部分集合；
- 质量要求不允许近似漏召回；
- 数据规模尚小；
- 向量更新频繁，ANN 维护成本超过收益；
- exact 可以在可接受延迟内并行完成。

先测 crossover，再引入 ANN。拥有 HNSW 扩展能力不构成使用理由。

## 15.4.4 冻结文本、模型标识、许可证、向量文件与校验和 {#item-15-4-4}

### “同一个模型”仍可能生成不同向量

可复现输入至少包括：

```text
exact source text bytes
field concatenation/template
Unicode and whitespace normalization
truncation/chunking
model/provider/revision
tokenizer revision
output dimension
post-normalization
generation parameters
```

只记录营销名，例如 `embedding-v3`，不够。云服务可能滚动更新后端；本地模型
可能因权重、tokenizer、运行库或量化不同而变化。

### 本章如何消除外部变量

[`fixture-manifest.json`](/labs/ch15/fixture-manifest.json) 明确：

```json
{
  "model_id": "pg36-handcrafted-topic-4d-v1",
  "dimensions": 4,
  "method": "manually assigned deterministic topic coordinates",
  "external_model": false,
  "external_api": false,
  "distance": "L2"
}
```

商品与 query 的向量都保存在冻结 CSV 中。setup 载入后，数据库导出：

```sql
COPY (
  SELECT
    product_id,
    sku,
    category,
    active::text,
    title,
    description,
    embedding::text
  FROM shop_ch15.product_search
  ORDER BY product_id
) TO STDOUT WITH (FORMAT csv, HEADER true);
```

再与源文件逐字节 `cmp`。这比“查询结果差不多”严格得多：向量文本一个数字
变化就失败。

### 文件 hash 与数据库 checksum 各负责什么

fixture manifest 固定：

```text
frozen-corpus.csv      SHA-256
frozen-queries.csv     SHA-256
frozen-judgments.csv   SHA-256
fixture.sql            SHA-256
```

最终数据库 `business_checksum` 则覆盖：

```text
all products and vectors
all queries and vectors
all judgments
all top-3 ranks and rounded scores
all quality summaries
```

二者互补：

- file hash 证明输入资产没变；
- database checksum 证明装载、计算和最终行为没变。

运行时间、OID、绝对路径、索引物理页大小不进入 business checksum，因为它们
不是跨重建稳定的业务事实。

### 许可证和数据边界不能等上线后再补

向量可能泄露源数据特征，也可能受模型服务条款约束。ADR 至少记录：

```text
source text ownership and lawful basis
whether text leaves the trust boundary
provider retention/training policy
region and transfer mechanism
model/output usage license
secrets and service account scope
deletion propagation
backup retention
incident and audit trail
```

“只传向量，不传原文”也不是自动匿名。是否能从向量推断敏感信息要按威胁
模型评估。

本章文本和数字都是项目自有合成 fixture，不调用外部服务：

```text
text_license   = project-owned synthetic fixture
vector_license = handcrafted numeric fixture
```

这使仓库可重复，不替真实项目完成法务评审。

### 模型升级要双版本迁移

不要：

```sql
UPDATE product
SET embedding = new_model(text);
```

在同一列原地混写。更稳健的流程：

```text
1. create new vector column/table with model_id v2
2. backfill from frozen source-text hash
3. build matching v2 opclass/index
4. evaluate v1 vs v2 on frozen + fresh sets
5. shadow query / guarded traffic
6. switch read version explicitly
7. retain rollback window
8. export or retire v1 under data-retention rules
```

若维度改变，类型和索引自然需要新对象；即使维度相同，也应保持逻辑隔离。

### 退出路径

pgvector 列可转文本导出：

```sql
COPY (
  SELECT
    product_id,
    embedding_model,
    embedding::text
  FROM shop_ch15.product_search
  ORDER BY product_id
) TO STDOUT WITH (FORMAT csv, HEADER true);
```

但“能导出字符串”只是技术出口第一步。还要证明目标系统：

- 能按同一精度解析；
- 保留 document/model identity；
- 使用同一距离与归一化；
- 重建索引后质量不变；
- 在切换窗口双读校验；
- 能回放迁移期间更新。

本章精确 reset 删除 `shop_ch15`，却保留第 14 章持有的扩展；它演示了对象
边界，不是生产数据迁移演练。

---

[上一节：模糊匹配与拼写容错](../03/) · [返回本章目录](../) · [下一节：混合检索与排序验证](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
