跳转到主要内容

15 见微知著:全文、模糊与向量检索

搜索不是“给某一列加个索引”。它至少包含四个问题:

哪些对象有资格出现?        filtering
从多少对象中找候选?        candidate generation
候选之间怎样比较先后?      ranking
怎样证明结果真的更好?      evaluation

全文检索、三元组模糊匹配和向量近邻解决的是不同子问题:

方法 最擅长捕捉 主要盲区
PostgreSQL FTS 词形归一、多词布尔/短语、字段权重 拼写错误、词表之外的语义表达
pg_trgm 字符串局部相似、错拼、包含与相似候选 业务语义、长文档相关性
向量检索 由模型编码的相似性 精确词优势、模型偏差、近似召回
混合检索 多路召回互补 参数、成本、解释与验证复杂度

它们可以组合,却不能因为“混合”两个字就自动更好。本章把检索从演示查询 变成一个可反驳的工程实验:

冻结语料、查询、相关性标注和模型身份;每一路独立产生候选;质量黄金值 使用精确计算;近似索引另测召回;最后才讨论延迟、部署与上线。

本章完成后

你应当能够:

  • 区分精确过滤、词法相关、字符串相似与模型相似;
  • 把查询解析、过滤、候选生成、排序、融合和重排拆成独立阶段;
  • 为检索实验建立带版本的语料、查询集与分级相关性标注;
  • 解释 parser、dictionary、configuration、tsvectortsquery
  • 显式固定文本搜索配置,构造带 A/B 权重的存储生成列;
  • 正确选择 websearch_to_tsqueryplainto_tsqueryphraseto_tsqueryto_tsquery,而不把原始用户输入直接交给严格语法;
  • ts_rank_cd 排序,并知道 0–1 归一化不等于跨检索器可比概率;
  • 解释 similarityword_similarity% 门槛与 GIN/GiST 的边界;
  • 选择 L2、余弦或内积时,把模型训练与归一化合同写清楚;
  • 区分精确近邻与 HNSW/IVFFlat 近似近邻,不用 ANN 结果定义质量黄金值;
  • 解释过滤为何可能降低 ANN 返回数,以及 iterative scan 能补什么、不能 保证什么;
  • 用 RRF 合并不同分数尺度的排名,并知道何时需要校准或重排;
  • 计算 Precision@K、Recall@K、MRR@K 与 NDCG@K;
  • 在 Pigsty 中把扩展装包、数据库启用、版本核对和节点一致性分开验收;
  • 把模型费用、数据出境、向量回填、索引维护、WAL 与副本延迟纳入 ADR;
  • 交付一个可运行、可审校、可精确复位的检索 PoC。

一条有意不完美的实验

贯穿实验位于 shop_ch15,固定:

17 products (16 active + 1 inactive guard)
8 queries
24 graded relevance judgments
4-dimensional handcrafted vectors
3 candidate generators
RRF k=60, source depth=4, result depth=3

四维向量的模型标识为:

pg36-handcrafted-topic-4d-v1

它不是 embedding 模型,也没有调用任何外部 API。它只是可人工核算、每次 重建完全相同的主题坐标,用来隔离数据库机制与模型随机性。真实模型质量必须 用真实文本重新测,不能继承本章数字。

固定评估集得到:

策略 Precision@3 Recall@3 MRR@3 mean NDCG@3 min NDCG@3
全文 0.291667 0.291667 0.750000 0.613043 0.000000
模糊 0.916667 0.916667 1.000000 0.942881 0.842828
精确向量 1.000000 1.000000 1.000000 0.817314 0.631039
RRF 混合 1.000000 1.000000 1.000000 0.962929 0.759192

结果故意保留三个反例:

  1. wireles hedphonespostgre databse tuning 的全文结果为空,说明 词形归一不是拼写纠正;
  2. 精确向量覆盖全部相关对象,但次序不总正确,Recall 高不等于 NDCG 高;
  3. q02 上混合 NDCG 为 0.759192,低于纯模糊的 0.842828,说明 融合可以把弱信号带进来。

因此本章的结论不是“向量最好”或“混合必胜”,而是:

the measured winner on this frozen set
is a release candidate for this frozen set

精确质量与近似服务路径分开

质量视图枚举过滤后的全部候选并计算精确 L2 距离,不经过 HNSW。HNSW 只在 独立 probe 中运行:

query = q06 / trail hydration
exact top-3 = 7,8,9
HNSW top-3  = 7,8,9
recall@3    = 1.000000

这一个点证明“能够查询并比较”,不证明生产召回为 1。pgvector 官方说明, 默认无 ANN 索引时是精确搜索;HNSW/IVFFlat 以部分召回换取速度,生产应持续 拿近似结果与精确结果对照。参见 pgvector 0.8.4 README

强制执行计划分别验证:

FTS      -> GIN bitmap index scan
trigram  -> GIN bitmap index scan
exact    -> sequential scan + sort
HNSW     -> HNSW index scan
filtered -> HNSW index scan + active/category filter

这里关闭部分 planner 路径,只为证明索引可用;不是 17 行数据上的性能比较。

实验资产

规范与决策:

输入与实现:

三份 CSV 不只是仓库附件。自动化会从数据库重新导出并执行逐字节比较, fixture-manifest.json 还固定各文件 SHA-256、行数、模型身份、生成方法与 许可证边界。

快速运行

本章复用第 14 章已经安装和认证的扩展,不自行接管扩展生命周期:

export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin

PG36_EVIDENCE_DIR="$PWD/evidence/ch15" \
  ./static/labs/ch15/task.sh all

all 会:

  1. 验证目标数据库、角色、ch04-v1 模型和第 14 章扩展身份;
  2. 以 owner/marker/对象白名单保护 shop_ch15
  3. 创建带权重的存储 tsvector、四张表、七个排名/质量视图;
  4. 创建全文 GIN、标题 trigram GIN、向量 HNSW 与过滤 B-tree;
  5. 从数据库导出三份 CSV 并与冻结输入逐字节比较;
  6. 采集文档、索引、体积、权限、查询解析、排名和质量证据;
  7. 采集精确计划与三类索引计划;
  8. 以精确集合为真值测一次 HNSW Recall@3;
  9. 证明 pg36_app 可读、更新返回 SQLSTATE 42501
  10. 运行关系、生成列、过滤、质量、checksum 和权限的全量断言;
  11. 证明错误 token、错误 target、活跃 worker 时 reset 分别被拒绝;
  12. 不用 CASCADE 精确复位,确认第 14 章扩展保留,再完整重建复验。

正式 Homebrew PostgreSQL 18.6 证据两轮均得到:

status=ok
fixture=frozen-byte-identical
quality=precision+recall+mrr+ndcg
ranking=fts+trigram+exact-vector+rrf
ann=q06-exact-vs-hnsw-recall-1.000000
guards=P3660+P3661+P3663
extensions=ch14-preserved
pigsty_l1=not-run
release_candidate_checksum=bf92a6ad0f60dc3e125b39dbf67bf4d6c5e50275192bd01a7ca4c50d142f822e

安全边界

task.sh all 会删除并重建带本章精确 marker 的 shop_ch15,只适合 本书本地/开发夹具。它不会删除扩展。生产上应以在线建索引、双写/回填、 灰度读流量和可回退发布替代“删后重建”。

学习路径

15.1 先定义检索任务与评估集

先固定“什么叫好”。没有评估集,后面的任何好看查询都只是故事。

15.2 PostgreSQL 全文检索

从 PostgreSQL 原生词法管线开始,理解它为何强,也理解错拼为何仍会漏。

15.3 模糊匹配与拼写容错

用字符三元组补足错拼,但不给零分 Top-K 贴上“相关”标签。

15.4 可复现的向量检索

把向量看作有来源、有版本、有度量合同的数据,而不是神秘的“AI 列”。

15.5 混合检索与排序验证

学会合并名次、计算指标、解释反例,而不是拿一个查询挑选截图。

15.6 扩展部署与运行代价

把 PostgreSQL 对象映射到 Pigsty 交付和长期运行,不把安装成功当作上线。

15.7 实战:pg36_shop 商品混合检索 PoC

最后运行双周期实验,拿出可以评审、可以拒绝、也可以退出的 proposal。

版本与证据边界

本章原理覆盖 PostgreSQL 14–18;可执行 baseline 固定 pg_trgm 1.6、 vector 0.8.4,并在 PostgreSQL 18.6 上验证。实验使用英语配置,因为冻结 语料是英语。中文、多语言与混合字段不能照抄 english,应重新选择 tokenizer/ dictionary、语料和指标。

Pigsty 映射按 4.4 文档在 2026-07-29 核验。本地证据路径是直接 PostgreSQL, 没有冒充 Pigsty L1。当前 Pigsty 文档把 pgvector 列为默认安装包,把 pg_trgm 列为默认启用扩展;实际环境仍要检查目标版本、镜像/仓库和每个 主备节点。

权威入口:


上一章:博采众长:内核分支与扩展生态 · 返回上卷导读 · 下一章:经天纬地:时序、空间与时空查询 · 查看全书目录 · 查看索引中心

15.1 先定义检索任务与评估集

如果没有“哪些结果算相关”的外部判断,搜索系统只能证明自己能够执行。 返回三行、命中一个熟悉商品、甚至计划使用了索引,都不等于检索质量好。

本节先冻结问题。第 15.2–15.5 节才允许比较实现。

15.1.1 精确筛选、词法相关与语义相似

先问是在判断资格,还是比较相关

下面两个 SQL 看起来都在“找商品”,合同完全不同:

-- 布尔资格:结果要么符合,要么不符合
SELECT product_id, title
FROM product
WHERE active
  AND category = 'outdoor'
  AND price < 500;

-- 排名:每个候选都有先后
SELECT product_id, title
FROM product
WHERE active
  AND category = 'outdoor'
ORDER BY relevance_score DESC, product_id
LIMIT 10;

前者是 filtering。正确性来自布尔谓词、约束、权限和事务快照;B-tree、 hash、BRIN 或分区裁剪可能让它更快,但不会改变逻辑答案。

后者是 retrieval/ranking。正确性至少包含:

  • 相关对象是否进入候选集;
  • 不相关对象是否被抑制;
  • 最相关对象是否排得足够靠前;
  • 同分时结果是否稳定;
  • 过滤是否在正确阶段生效;
  • 结果是否符合当前用户的权限与业务状态。

不要把两个问题压成一个“不透明相关性 SQL”。先用可验证谓词确定资格,再在 合格集合中比较相关性,最容易推理。

四种相似不是一回事

以商品检索为例:

输入与目标 更接近的能力 例子
SKU、状态、类别完全相等 精确过滤 sku = 'AUD-001'
词形、布尔词、短语与字段权重 全文词法检索 headphones 匹配词位
字符局部重合与错拼 trigram 模糊匹配 wireles hedphones
模型编码的意图相近 向量相似 music on the go

词法相关不等于字符串包含。PostgreSQL FTS 会把文本解析为 token,再经 词典归一为 lexeme;英语配置能把 rats 归一成 rat,也可能去掉 stop word。它支持 AND、OR、NOT、短语和位置,适合“文档含有哪些有意义的词”。

字符串相似不等于语言含义。pg_trgm 按三个连续字符的集合衡量重合, 所以对局部拼写错误很有效;databasedatabse 很近,但字符很近并不 保证商品意图相同。

向量相似也不是数据库自动理解语义。数据库只看一串数和一个距离函数; “这串数表达什么”来自模型、输入模板、截断、版本和归一化。手工坐标也能 做向量近邻,但不能称为训练模型的语义质量证据。

因此“语义检索”至少应展开为:

model identity
+ input construction
+ preprocessing/tokenization
+ vector dimension
+ normalization
+ distance/operator
+ relevance evaluation

缺任意一项,未来都很难解释同一文本为何得到不同结果。

把任务写成合同

一份可执行任务定义至少回答:

document_unit: one product row
searchable_fields: [title, description]
eligible_if:
  active: true
  category: query.category_filter
query_language: English
top_k: 3
tie_breaker: product_id ascending
relevance_scale: 0..3
latency_scope: not established by this fixture

本章没有把价格、库存、租户或行级权限塞进夹具,但生产合同不能省略。尤其是 租户与 ACL:一个相关性极高却无权读取的对象不是“排名较低”,而是根本不能 进入候选集合。

先写反例

本章八个查询不是八种同义表达,而是有意覆盖失败模式:

查询 想观察什么
wireless headphones 精确商品词
wireles hedphones 两处错拼
music on the go 描述性意图
coffee bean grinder 标题与描述共同提供词
make espresso at home 任务表达
trail hydration 类别过滤与多候选
postgre databse tuning 技术词错拼
semantic nearest neighbor 长尾精确术语

如果评估集只有实现者已经看过的成功查询,它只会确认实现者的直觉。先把必然 失败的查询写进去,后续比较才有信息量。

15.1.2 查询、候选、排序和过滤的分层

一条检索链有六个阶段

raw request
  -> query parsing / normalization
  -> hard filters and authorization
  -> candidate generation
  -> per-source ranking
  -> fusion / re-ranking
  -> top-K response and evidence

每一层有不同的失败:

阶段 典型失败
查询解析 非法语法、空 query、极长输入、语言选错
硬过滤 跨租户泄漏、停用对象复活、过滤放到 ANN 之后导致不足 K
候选生成 FTS 零召回、模糊门槛太低、ANN 漏召回
单路排序 字段权重错、距离函数错、同分漂移
融合/重排 分数尺度乱加、弱源挤掉强源、重排器超时
响应 无稳定游标、证据不可解释、缓存越权

分层不是为了制造更多组件,而是为了让每个结论可以单独测。

查询解析必须显式

生产接口通常接收 raw text。不要让客户端偷偷决定它是:

  • 所有词必须出现;
  • 任一词出现;
  • 完整短语;
  • 支持引号与排除词;
  • 前缀查询;
  • 还是严格 tsquery 表达式。

本章显式使用:

websearch_to_tsquery(
  'pg_catalog.english'::regconfig,
  raw_query
)

它接受普通 web 风格文本、引号、OR 和减号排除,并且官方文档说明不会因 输入语法抛错。这降低了语法层事故,但不代表可以无限接受输入:长度、token 数、超时、并发和滥用仍要在接口层设限。

先过滤还是先 ANN,要写出物理含义

逻辑目标是:

WHERE active
  AND category = 'outdoor'
ORDER BY embedding <-> :query_vector
LIMIT 3;

精确执行可以枚举所有合格行再排序。近似索引通常先沿图或倒排结构取候选, 然后应用 PostgreSQL 过滤;当过滤选择性高时,扫描出的近邻大多被过滤掉, 最终可能不足三行。

所以“SQL 把 WHERE 写在 ORDER BY 前面”不证明物理上先过滤。要看:

  • 执行计划;
  • 过滤选择性;
  • 返回行数;
  • ANN 与 exact 的集合差异;
  • ef_search、iterative scan、partial index 或 partition 策略。

本章的 HNSW filtered plan 明确显示:

Index Scan using product_search_embedding_hnsw_idx
  Filter: (active AND category = 'outdoor')

它证明过滤发生在 HNSW 扫描结果上。hnsw.iterative_scan = 'strict_order' 能在过滤后不足时继续扫描,仍受最大扫描限制和数据分布影响, 不是“保证召回”的开关。

候选深度与返回深度不是一个 K

若最终返回 3 个结果,每路只取 3 个候选,融合器没有纠错空间。常见设计是:

lexical top  N_l
fuzzy   top  N_f
vector  top  N_v
        -> union/deduplicate
        -> fusion or re-rank
        -> final top K

本章每路取前 4,最终取 3,只为保持 SQL 可读。生产候选深度应通过质量/延迟 曲线决定,不能照抄 4

候选源还必须带上:

query_id
document_id
source
source_rank
source_score_or_distance
filter/version/model identity

否则融合以后只剩一个总分,无法追问“它为何出现”。

排名必须确定

浮点分数可以相同,近似索引也可能在近似等距对象间选择不同结果。所有实验 查询都追加稳定 tie-breaker:

ORDER BY score DESC, product_id;

这不会让近似算法变成精确算法,却能消除同分的随机输出,让回归测试和分页 至少有明确顺序。线上游标还要把排序键全部编码进去,不能只按一个不唯一分数 翻页。

过滤正确性优先于相关性

本章商品 17:

Legacy Wireless Earbuds
active = false
embedding = [0.95,0,0,0]

它对两个 audio 查询非常接近,是专门布置的 canary。verify.sql 断言它 不能出现在全文、模糊、精确向量或混合的任何排名中。若出现,实验立即失败, 即使平均 NDCG 更高也不能发布。

这条原则可推广为:

authorization / tenant / lifecycle correctness
beats relevance score

15.1.3 建立带人工相关性标签的查询集合

三份输入,三个不同身份

本章把输入拆成:

数据库中对应:

product_search(product_id, ..., embedding, search_document)
eval_query(query_id, raw_query, category_filter, embedding, intent)
relevance_judgment(query_id, product_id, grade, rationale)

把查询与标注分开很重要:查询是实际输入分布,标注是人对业务意图的判断。 模型和检索器都不能改写标注来让自己得分更高。

先定义标注等级

本章使用 1–3 级正相关:

grade 含义
3 直接满足意图,是理想结果
2 明确相关,但不是最佳
1 可接受的邻近结果
0/无行 未标注为相关

每条标注都有 rationale,例如:

q06,7,3,water bottle exactly serves trail hydration
q06,8,2,backpack includes a hydration sleeve
q06,9,1,water filter is related to outdoor water needs

理由不是装饰。两位标注者发生分歧时,没有理由就无法判断是规范模糊、文档 信息不足,还是标注错误。

真实项目应进一步规定:

  • 标注者是否知道检索器输出;
  • 一个查询由几人独立标注;
  • 分歧怎样仲裁;
  • 未展示对象是“不相关”还是“未判断”;
  • 如何抽样候选池,避免只标注现有系统召回到的对象;
  • 是否按用户群、语言、地区、设备与时间分层;
  • PII、敏感类别与数据保留规则。

如果只标注旧检索器的候选,新方法召回的新对象会被误当成零相关,这叫 pooling bias。

四个指标回答四个问题

令前 KK 个结果为 RKR_K,相关集合为 GG

Precision@K:

P@K=RKGK P@K = \frac{|R_K \cap G|}{K}

它问“展示位有多少是相关的”。本章即使某策略只返回一行,也仍除以 3; 空位会损失质量,避免系统靠少返回来虚增 precision。

Recall@K:

R@K=RKGG R@K = \frac{|R_K \cap G|}{|G|}

它问“已知相关对象覆盖了多少”。本章每个查询恰好有 3 个相关对象,因此 Precision@3 与 Recall@3 数值相同;这是夹具结构的巧合,不是两个指标等价。

MRR@K:

MRR@K=1QqQ{1/rankq,rankqK0,otherwise MRR@K = \frac{1}{|Q|} \sum_{q \in Q} \begin{cases} 1 / rank_q, & rank_q \le K \\ 0, & \text{otherwise} \end{cases}

它只关心第一个相关结果多靠前,适合“用户通常点第一个可用答案”的任务。 多个高质量结果的次序差异不会充分反映在 MRR 中。

NDCG@K 使用分级相关性:

DCG@K=i=1K2gradei1log2(i+1) DCG@K = \sum_{i=1}^{K} \frac{2^{grade_i}-1}{\log_2(i+1)} NDCG@K=DCG@KIDCG@K NDCG@K = \frac{DCG@K}{IDCG@K}

它奖励高等级对象靠前,并以理想次序归一。本章用 NDCG 暴露了“向量找全, 次序却不理想”以及“混合在某个错拼查询上变差”。

指标不是越多越科学。先从用户行为选择指标:

任务 更重要的信号
唯一答案/导航 MRR、Success@K
商品列表前三屏 NDCG、Precision@K、业务转化
法务/安全材料发现 Recall@K、漏召回审计
ANN 服务路径 exact-relative recall + latency

冻结不等于永久

fixture-manifest.json 固定:

corpus SHA-256
query SHA-256
judgment SHA-256
loader SHA-256
row counts
model id + dimension + method
text/vector license boundary

自动化执行:

cmp frozen-corpus.csv evidence/corpus.csv
cmp frozen-queries.csv evidence/queries.csv
cmp frozen-judgments.csv evidence/judgments.csv

数据库多一个空格、少一条标注、换一个向量都会被发现。

但冻结集只是一个版本。遇到以下变化应生成新版本,而不是覆盖旧 golden:

  • 真实查询分布改变;
  • 商品/文档类型扩展;
  • 模型、模板或预处理改变;
  • 语言配置与词典改变;
  • 人工标注规则改变;
  • 融合目标或业务约束改变。

发布报告要同时写:

quality delta on frozen regression set
+ quality on fresh holdout set
+ online behavior under guarded experiment

只在一个反复调参的集合上提高,会逐渐把参数拟合到测试集。

本章验收

运行:

PG36_EVIDENCE_DIR="$PWD/evidence/ch15" \
  ./static/labs/ch15/task.sh evaluate

审校器要求:

17 corpus rows
8 query rows
24 judgment rows
all three exports byte-identical
every query has exactly three positive judgments
product 17 inactive and absent from every ranking

到这里还没有决定用哪种检索器。我们只是让后续每个决定都有同一把尺。


返回本章目录 · 下一节:PostgreSQL 全文检索 · 查看全书目录 · 查看索引中心

15.2 PostgreSQL 全文检索

PostgreSQL 全文检索不是 LIKE 的加速版。它把原文处理成带词位、权重的 lexeme 向量,把查询处理成逻辑表达式,再用 @@ 匹配、用 ranking function 排序。

本节先只讨论词法证据。拼写容错与向量语义分别留给后两节。

15.2.1 文档、词典、配置与 tsvector

文档是检索单位,不一定是一列

全文检索中的 document 是“一次返回和排序的单位”。它可以是:

  • 一篇文章;
  • 一个商品;
  • 一封邮件;
  • 多列拼接后的一个业务对象;
  • 甚至跨表构造的投影。

本章把一个商品行视为 document,并只索引标题与描述。不要先把所有字符串 列都拼进去:SKU、类目、品牌、权限标签往往需要精确过滤或独立权重,盲目 拼接会同时损害相关性和索引体积。

从原文到 tsvector 的管线是:

text
  -> parser: split and classify tokens
  -> dictionary chain: recognize / normalize / discard
  -> lexemes + positions + optional weights
  -> tsvector

四类 PostgreSQL 对象各有职责:

对象 职责
parser 切 token 并判定 token type
dictionary 把 token 归一为 lexeme,或认定 stop word
template 提供 dictionary 的底层行为
configuration 把 parser token type 映射到 dictionary 链

官方文档把 token 解释为原始片段,把 lexeme 解释为用于索引的归一化词。 例如英语配置会去掉常见 stop word,并把一些复数/词形归到词干。参见 Full Text Search Introduction

配置必须是数据合同的一部分

下面写法依赖会话/数据库/集群默认值:

to_tsvector(title || ' ' || description)

同一数据迁到另一个集群,default_text_search_config 不同,就可能生成不同 lexeme。更危险的是建索引和查询端使用了不同配置:SQL 都能执行,却永远 错过部分匹配。

本章始终显式写:

'pg_catalog.english'::pg_catalog.regconfig

并用全限定名称避免受 search_path 影响。生产 ADR 至少记录:

configuration OID/name
dictionary files and versions
custom synonym/thesaurus source
language routing rule
reindex/rebuild procedure

english 适合本章英语夹具,不适合中文照抄。语言不是一个装饰参数:中文 如何分词、多语言文档如何路由、专有名词是否要保留原形,都会改变 document 和 query 的共同词表。

检查当前配置:

SHOW default_text_search_config;

\dF

SELECT
  cfgname,
  pg_get_userbyid(cfgowner) AS owner,
  cfgnamespace::regnamespace
FROM pg_ts_config
ORDER BY cfgnamespace::regnamespace::text, cfgname;

调试某段文本:

SELECT *
FROM ts_debug(
  'pg_catalog.english'::regconfig,
  'Making espresso at home'
);

ts_debug 能显示 token type、dictionary、lexeme 与是否被丢弃,是处理 “为什么没命中”的第一证据,而不是先调相关性权重。

多字段用权重表达结构

本章定义:

search_document tsvector
GENERATED ALWAYS AS (
  pg_catalog.setweight(
    pg_catalog.to_tsvector(
      'pg_catalog.english'::pg_catalog.regconfig,
      coalesce(title, '')
    ),
    'A'
  )
  ||
  pg_catalog.setweight(
    pg_catalog.to_tsvector(
      'pg_catalog.english'::pg_catalog.regconfig,
      coalesce(description, '')
    ),
    'B'
  )
) STORED

三个细节都不能省:

  1. coalesce 防止一列 NULL 让整个 document 变成 NULL
  2. title 用 A、description 用 B,保留字段结构给 ranking;
  3. configuration 显式固定,生成列和查询端一致。

默认 ts_rank/ts_rank_cd 权重按 D、C、B、A 分别为 0.1, 0.2, 0.4, 1.0。这只是默认,不是商品搜索的普遍最优值。

为什么用存储生成列

存储生成列在行写入/更新时计算,占用真实存储;读取时不必重新解析全文。 PostgreSQL 还保证它随基列变化自动更新,应用不能绕过表达式写入另一份 不一致的 tsvector

官方限制生成表达式只能使用 immutable function,不能含子查询,也不能 引用当前行之外的数据。参见 Generated Columns

因此它适合:

  • 稳定、行内、确定性的文本预处理;
  • 明确的 parser/dictionary 配置;
  • 由数据库维护的一致索引列。

它不适合直接调用:

  • 外部 embedding API;
  • 会变化的跨表同义词;
  • 当前时间;
  • 随机或非确定函数。

外部模型输出应是普通列加模型身份与作业状态,由受控异步流程写入。

用目录证明生成关系

看值:

SELECT
  product_id,
  title,
  search_document
FROM shop_ch15.product_search
ORDER BY product_id;

看生成属性与表达式:

SELECT
  a.attname,
  a.attgenerated,
  pg_get_expr(d.adbin, d.adrelid) AS expression
FROM pg_attribute AS a
JOIN pg_attrdef AS d
  ON d.adrelid = a.attrelid
 AND d.adnum = a.attnum
WHERE a.attrelid = 'shop_ch15.product_search'::regclass
  AND a.attname = 'search_document';

attgenerated = 's' 表示 stored。verify.sql 还会为 17 行重新计算表达式, 逐行与存储值比较,避免只有目录声明正确、数据却来自旧表达式。

15.2.2 查询语法、权重与相关性排序

tsquery 不是原始字符串

@@ 比较的是 document 与 query:

search_document @@ parsed_query

常用构造函数:

函数 输入合同 典型用途
plainto_tsquery 普通文本,存活词之间加 AND 简单所有词查询
phraseto_tsquery 普通文本,保留词位顺序/stop word 距离 短语
websearch_to_tsquery web 风格文本、引号、OR、减号 面向原始用户输入
to_tsquery 严格运算符表达式 受控高级查询语言

to_tsquery 能表达 &|!<->、权重和前缀,但它要求输入已经 符合语法。把 raw user text 直接传入,标点或漏写运算符就可能报错。

本章选择:

websearch_to_tsquery('pg_catalog.english', query.raw_query)

官方文档说明它不会抛出 syntax error,并支持:

unquoted words -> AND
"quoted phrase" -> FOLLOWED BY
OR              -> OR
-term           -> NOT

参见 Controlling Text Search

“不会语法报错”并不等于“没有风险”。要处理:

SELECT
  websearch_to_tsquery('pg_catalog.english', :raw) AS parsed,
  numnode(websearch_to_tsquery('pg_catalog.english', :raw)) AS nodes;

空 query、全部是 stop word、节点过多、负向条件过多或输入过长,都应有接口 级策略与 statement_timeout

先用 @@ 缩小,再计算 rank

本章词法排名:

WITH scored AS (
  SELECT
    q.query_id,
    p.product_id,
    ts_rank_cd(
      p.search_document,
      websearch_to_tsquery(
        'pg_catalog.english',
        q.raw_query
      ),
      32
    )::double precision AS score
  FROM shop_ch15.eval_query AS q
  JOIN shop_ch15.product_search AS p
    ON p.active
   AND p.category = q.category_filter
  WHERE p.search_document @@
        websearch_to_tsquery(
          'pg_catalog.english',
          q.raw_query
        )
)
SELECT ...;

@@ 是候选条件;ts_rank_cd 只对命中 document 排序。若对所有行先算 rank 再筛选,会放大 CPU 与 I/O。

ts_rank 主要基于匹配 lexeme 频率;ts_rank_cd 还考虑 cover density, 也就是匹配词在文档中的接近程度,并依赖词位信息。对被 strip 掉词位的 tsvector,cover density 信息会丢失。

normalization 不是概率校准

本章给 ts_rank_cd 的第三个参数为 32:

rank / (rank + 1)

它把正分数压进 0–1 范围,但官方文档特别说明,这只是 cosmetic scaling, 不会改变排序,也不能产生全局百分比。

所以这些写法没有理论依据:

-- 错:看到都在 0..1 就当同一量纲
0.5 * ts_rank_normalized
+ 0.5 * cosine_similarity

全文 rank 的 0.7、余弦相似的 0.7、trigram similarity 的 0.7 来自不同 生成机制,含义不相同。要么用训练/标注数据做分数校准,要么像本章一样用 只依赖名次的 RRF。

权重是可验证假设

setweight(..., 'A') 只给 lexeme 标标签;最终权重由 ranking function 的 weight array 决定:

ts_rank_cd(
  ARRAY[0.1, 0.2, 0.4, 1.0]::real[],
  search_document,
  parsed_query,
  32
)

数组顺序是 D、C、B、A,不是 A、B、C、D。调权重时应记录:

which fields map to which labels
weight vector
query set version
metric deltas per query segment

不要因为一个标题命中的样例“看起来更好”就把 A 权重调到很大;它可能把描述 中的强意图全部压下去。

高亮不是匹配证据本身

ts_headline 可以生成带命中词的摘要,但它会重新处理原始 document,且 输出是展示材料,不应替代 @@ 与 rank 证据。生产还要:

  • 对输出做 HTML escaping;
  • 控制片段长度和调用成本;
  • 不把未经授权的原文片段泄露给用户;
  • 缓存时把语言、query 与权限身份纳入 key。

先返回稳定 document id 和 rank,再在安全边界内生成展示片段。

15.2.3 GIN/GiST 索引、更新与语言边界

GIN 是通常的全文首选

PostgreSQL 支持用 GIN 或 GiST 加速全文检索。官方文档把 GIN 称为 preferred text search index type:

CREATE INDEX product_search_fts_idx
ON shop_ch15.product_search
USING gin (search_document);

GIN 为每个 lexeme 保存匹配位置列表,适合多词交集。它不在索引里保存 tsvector 的权重标签,所以带权查询需要回表 recheck。

GiST 用固定长度 signature 表示 document,是 lossy 的,可能产生 false match,由 PostgreSQL 自动回表排除。GiST 可以 INCLUDE 其他列,而 GIN/ GiST 在写入、体积、构建和查询上的权衡应由目标数据测量。

参见 Preferred Index Types for Text Search

本章不是 GIN/GiST benchmark。它选择 GIN 是因为普通 document FTS 的候选 生成合同明确,并用计划证明 opclass:

Bitmap Heap Scan on product_search
  Recheck Cond: (search_document @@ ...)
  -> Bitmap Index Scan on product_search_fts_idx

由于只有 17 行,正常 planner 很可能觉得顺序扫描更便宜。实验临时:

SET enable_seqscan = off;

只证明路径存在。真正上线要恢复默认 planner 设置,在代表性规模下看 EXPLAIN (ANALYZE, BUFFERS, WAL) 和延迟分布。

存储列、表达式索引与触发器

三种常见维护方式:

方式 优点 代价
tsvector 存储生成列 + GIN 自动一致、值可见、查询清晰 行与 WAL 增大,表达式受 immutable 限制
GIN(to_tsvector(config, text)) 表达式索引 不存单独列 查询表达式必须匹配,rank 仍需计算 document
普通 tsvector + trigger/job 可跨更复杂来源 有漂移/失败状态,必须治理回填

如果 document 只来自当前行且表达式稳定,本章偏好存储生成列。若 document 跨表、依赖外部词典版本或异步 enrichment,就把刷新状态建成显式数据模型, 不要伪装成同步生成列。

每次内容更新都可能更新多个索引

一次 title/description UPDATE 可能同时:

  1. 生成新 tsvector
  2. 更新 FTS GIN;
  3. 更新标题 trigram GIN;
  4. 若重新生成 embedding,更新 HNSW;
  5. 产生 heap/index WAL;
  6. 在副本重放;
  7. 留下旧 tuple/index entry 等待 vacuum。

“搜索读得快”可能把成本转给写入、WAL、autovacuum 和副本延迟。生产实验要 分别测:

bulk initial build
steady inserts
document updates
embedding-only updates
concurrent index build
vacuum/reindex
standby replay

不能用 pg_relation_size 的一个时间点代替整个生命周期。

GIN pending list 与维护预算

GIN 默认可用 fast update,把新条目先放入 pending list,之后批量合并。 这通常降低前台写入成本,却可能让后续查询或 cleanup 承担尖峰。检查:

SELECT *
FROM gin_metapage_info(
  get_raw_page(
    'shop_ch15.product_search_fts_idx',
    0
  )
);

这需要 pageinspect 和相应权限,只应由运维角色执行。更通用的观测包括:

SELECT
  pg_relation_size('shop_ch15.product_search_fts_idx') AS index_bytes,
  last_autovacuum,
  n_dead_tup
FROM pg_stat_user_tables
WHERE relid = 'shop_ch15.product_search'::regclass;

参数选择应结合更新率、gin_pending_list_limit、autovacuum 与延迟尖峰,而 不是全局照抄一组值。

语言边界是功能边界

本章的英语结果:

wireless headphones       -> 1 lexical match
wireles hedphones         -> 0
music on the go           -> 1
trail hydration           -> 2
postgre databse tuning    -> 0

这证明:

  • stemming/stop word 能处理词法变体与普通短语;
  • 它不会自动修正 wireleshedphonesdatabse
  • AND 语义可能把只命中部分词的 document 排除。

对中文或混合语言,先回答:

how language is detected
how text is segmented
which dictionaries normalize which token types
how names/SKUs/code are preserved
how query and document choose the same config
how config changes trigger rebuild and re-evaluation

如果需要外部分词扩展,回到第 14 章的扩展 ADR:软件包、权限、升级、备份、 主备节点和退出都要重新评审。

何时停止扩展 FTS

PostgreSQL FTS 很适合与事务数据同库、查询边界明确、规模和 ranking 需求 可控的场景。以下信号值得重新评估架构,而不是继续堆 SQL:

  • 多语言/自定义分析链远超当前 parser/dictionary 能力;
  • 需要复杂学习排序、拼写纠正、聚合/facet 与实时个性化;
  • 搜索写入和主交易表索引维护互相伤害;
  • 索引规模、构建窗口或副本恢复不满足 SLO;
  • 需要独立扩缩容、故障域或跨多源索引。

重新评估不等于立刻外置。先用本章评估集和成本证据比较 PostgreSQL、扩展与 外部搜索系统,保持双写、回放、校验和退出路径。


上一节:先定义检索任务与评估集 · 返回本章目录 · 下一节:模糊匹配与拼写容错 · 查看全书目录 · 查看索引中心

15.3 模糊匹配与拼写容错

全文检索把不同词形归一到 lexeme,却不会自动把错拼词改成正确词。 pg_trgm 从另一个角度补召回:不先理解语言,而是比较字符三元组。

这使它很适合人名、标题、标识符和拼写容错,也意味着它可能找到“长得像” 但业务上完全不相关的字符串。

15.3.1 pg_trgm 相似度与距离

三元组如何形成

trigram 是三个连续字符。pg_trgm 忽略非单词字符;对每个词,左侧补两个 空格,右侧补一个空格。官方例子中 cat 产生:

"  c", " ca", "cat", "at "

两个字符串共享的 trigram 越多,similarity(a, b) 越高。它的取值在 0–1,距离运算符则是:

a <-> b = 1 - similarity(a, b)

这不是 Levenshtein edit distance。字符插入、删除或换位会同时影响周围 多个 trigram;短字符串可用 trigram 更少,一个字符的影响会更大。

查看:

SELECT shop_ch14.show_trgm('wireless');

SELECT
  shop_ch14.similarity(
    'wireless headphones',
    'wireles hedphones'
  );

本章扩展装在 shop_ch14,所以函数和 operator class 都全限定。普通安装 可能在 public;不要假设 search_path

similarityword_similarity 与 strict 版本

三个函数回答不同问题:

函数 比较范围
similarity(a,b) 两个完整字符串的 trigram 集合
word_similarity(a,b) ab 中任一连续 trigram extent
strict_word_similarity(a,b) extent 还必须落在词边界

例如 query 是一个短词,title 是多个词时,完整字符串 similarity 会被 title 额外字符稀释;word_similarity(query, title) 更像是在 title 中寻找最相似 局部。strict 版本适合不希望跨词边界拼接的场景。

参数顺序很重要。官方把 word_similarity(first, second) 描述为 first 的 trigram 集与 second 某一连续 extent 的最大相似。对称函数 similarity 可以交换,word_similarity 的语义不要想当然地交换。

本章用:

greatest(
  shop_ch14.similarity(lower(p.title), lower(q.raw_query)),
  shop_ch14.word_similarity(
    lower(q.raw_query),
    lower(p.title)
  )
) AS score

这样同时保留整串与局部词候选。lower 使表达式与索引表达式一致;官方文档 还说明默认构建中的 trigram similarity 本身不区分大小写,但显式规范化能 让应用合同与表达式索引更清楚。

参见 pg_trgm Functions and Operators

函数分数、布尔门槛与距离排序

pg_trgm 提供三组布尔操作符:

%      uses pg_trgm.similarity_threshold
<%     uses pg_trgm.word_similarity_threshold
<<%    uses pg_trgm.strict_word_similarity_threshold

默认门槛在本章 PostgreSQL 18 文档中分别为 0.3、0.6、0.5。它们是 session GUC,可以 SET LOCAL,不应靠某个连接之前遗留的值:

BEGIN;
SET LOCAL pg_trgm.similarity_threshold = 0.35;

SELECT product_id, title
FROM shop_ch15.product_search
WHERE lower(title)
      OPERATOR(shop_ch14.%)
      lower('wireles hedphones')
ORDER BY
  shop_ch14.similarity(
    lower(title),
    lower('wireles hedphones')
  ) DESC,
  product_id;
COMMIT;

门槛控制 candidate set,函数分数控制 set 内次序。若只写:

ORDER BY similarity(...) DESC
LIMIT 10

就会永远返回十行,即使后几行分数是 0;也不一定能利用 GIN 做候选过滤。 这是“Top-K 必有结果”最常见的误判。

本章 fuzzy_ranking 故意对过滤后的全部小集合排名,不设阈值。它让评估 框架看到低分尾部,也展示为何生产必须选择门槛、最小结果可信度或 no-result 行为。

索引操作类决定可用路径

本章:

CREATE INDEX product_search_title_trgm_idx
ON shop_ch15.product_search
USING gin (
  lower(title) shop_ch14.gin_trgm_ops
);

强制 probe:

SET enable_seqscan = off;

EXPLAIN (COSTS OFF)
SELECT product_id
FROM shop_ch15.product_search
WHERE lower(title)
      OPERATOR(shop_ch14.%)
      lower('wireles hedphones');

得到:

Bitmap Index Scan on product_search_title_trgm_idx

目录同时验证:

access_method = gin
operator_class = shop_ch14.gin_trgm_ops
valid/ready/live = true

PostgreSQL 官方 pg_trgm 提供 GiST 与 GIN opclass。两者都支持相似操作符, 也支持 trigram 可提取的 LIKEILIKE、正则与等号查询;普通等值查询 通常还是 B-tree 更合适。

若需求是:

ORDER BY title <-> :query
LIMIT K

GiST 能提供 KNN distance 路径;官方文档明确指出某些 distance Top-N 形式 能高效使用 GiST,而不能用 GIN 直接完成同样的有序扫描。选择前先写清楚是 “按门槛过滤大量候选”还是“按距离取最近 K 个”。

15.3.2 前缀、包含、拼写错误与候选召回

先按意图选算子

用户意图 首选起点
SKU 完全相等 B-tree =
规范化前缀补全 B-tree/pattern opclass 或专门前缀设计
任意位置包含 trigram LIKE '%term%'
错拼容忍 % / word similarity + threshold
词形、布尔词、短语 FTS
模型表达的语义 vector

能被 trigram 索引支持,不等于它是所有文本谓词的首选。用 trigram 做每一次 等值查询,通常比普通唯一/B-tree 索引更大、更贵、语义也更弱。

短输入是天然边界

trigram 需要可提取的三字符片段。官方文档提醒:LIKE 或正则模式若没有 可提取 trigram,会退化为 full-index scan。对一两个字符的自动补全:

  • trigram 区分力低;
  • 候选可能巨大;
  • 门槛对短词异常敏感;
  • 用户每敲一个字符就查询会放大负载。

常见策略:

length < 3       -> no fuzzy query / curated prefix path
length 3..N      -> prefix or stricter threshold
longer typo text -> trigram candidate generation

长度规则要按语言与规范化后的 token 定义,不要只按 UTF-8 byte 数。

规范化必须与索引表达式相同

本章索引的是:

lower(title)

所以 predicate 也写 lower(title)。生产可能还要处理:

  • Unicode normalization;
  • accent folding;
  • 标点与空白;
  • 全角/半角;
  • SKU 中应保留的连字符;
  • locale/collation;
  • 同义缩写。

若规范化函数不 immutable,或查询表达式和索引表达式不同,表达式索引可能 无法使用。更重要的是:过度规范化会把本来不同的业务标识合并。先把规则做成 测试向量,再决定存储列或表达式索引。

门槛不是一次拍脑袋

对每个 query,按 score 取候选并与标注比较:

SELECT
  q.query_id,
  p.product_id,
  greatest(
    shop_ch14.similarity(lower(p.title), lower(q.raw_query)),
    shop_ch14.word_similarity(lower(q.raw_query), lower(p.title))
  ) AS score,
  j.grade
FROM shop_ch15.eval_query AS q
JOIN shop_ch15.product_search AS p
  ON p.active
 AND p.category = q.category_filter
LEFT JOIN shop_ch15.relevance_judgment AS j
  USING (query_id, product_id)
ORDER BY q.query_id, score DESC, p.product_id;

再画出或列出不同 threshold 下:

candidate count
precision/recall
latency
index/heap buffers
no-result rate

门槛可能按字段、查询长度或语言分层,但每多一个规则就多一个需要版本化和 回归的参数。不要在应用各处散落 0.20.30.45

召回池与展示结果要分开

模糊匹配很适合 召回候选,不一定适合最终排序:

typo query
  -> trigram top-N candidate ids
  -> exact filters
  -> FTS/vector/business features
  -> fusion or re-rank
  -> final K

如果 title 很短、候选同质,trigram 分数可以直接做主要排序;如果 document 很长、字段复杂或业务相关性与字面差别大,它更适合作为一项 feature。

本章 q04 coffee bean grinder 的模糊前三名包含商品 14(coffee scale), 却漏掉标注相关的 espresso machine,说明“字符串看起来像”会压过工作流上 相关但字面不同的对象。

15.3.3 与全文检索的互补和重复

用失败矩阵判断互补

本章观察:

query FTS matches fuzzy top-3 relevant 解释
q01 exact words 1 3 FTS 精确,fuzzy 扩展同类
q02 two typos 0 3 trigram 补错拼
q03 descriptive phrase 1 3 描述命中与标题相似都可工作
q04 grinder 1 2 fuzzy 被字面相似干扰
q05 espresso task 1 3 两路互补
q06 trail hydration 2 2 两路都漏一个字面较远对象
q07 technical typos 0 3 trigram 补技术词错拼
q08 exact long-tail 1 3 FTS 精确,fuzzy 给邻近书籍

这比“FTS + trigram 效果更好”更有用。它指出:

  • 哪些查询需要 fallback;
  • 哪些查询适合 union;
  • 哪些弱候选可能污染最终结果;
  • 下一批标注应该补什么失败类型。

不要做脆弱的二选一 fallback

一种常见实现:

if FTS has results:
    use FTS only
else:
    use trigram

它简单,但“FTS 有一条结果”不代表召回充分。q06 的 FTS 有两条,却漏掉 第三条相关对象;fallback 不会启动。

更稳健的做法是并行产生有限候选:

lexical top N
UNION ALL
fuzzy top N

然后保留 source/rank,去重并融合。这样能看到一条对象由几路支持,也能在 延迟预算不足时按证据关闭某一路。

也不要无条件扩大候选

多一路候选会增加:

  • index/heap 读取;
  • SQL 排序与去重;
  • 重排器输入;
  • 解释复杂度;
  • 弱信号把强结果挤下去的机会。

本章 RRF 的 q02 就是反例:纯 fuzzy 把最理想商品排第一;vector 与 lexical 信息加入后,混合把商品 3 排到第一,NDCG 下降。

候选源的采用条件应写成:

incremental recall gain
vs latency/resource cost
vs ranking degradation
on a frozen and a fresh evaluation set

可解释证据

给每个最终结果至少保留:

{
  "product_id": 3,
  "sources": {
    "fuzzy": {"rank": 1, "score": 0.7},
    "vector": {"rank": 2, "distance": 0.06}
  },
  "fusion": {"method": "rrf", "k": 60, "rank": 1}
}

不必把内部数值全部暴露给最终用户,但调试与审核必须能重建。

一条可操作的路由原则

先不要设计“智能路由器”。以简单证据开始:

exact identifier detected -> exact path first
normal natural-language query -> FTS + bounded vector
likely typo / sparse FTS -> add trigram candidates
very short query -> avoid broad fuzzy scan
authorization filters -> always mandatory

每条规则都需要 query segment 指标,最终也可能被统一并行候选替代。路由器 本身是一个模型;如果没有独立评估,它只是在隐藏失败。

本节验收

本章同时固定两类证据:

behavior:
  fuzzy quality and per-query ranks

mechanics:
  GIN operator class and forced bitmap index plan

行为证据回答“结果如何”;机制证据回答“数据库能否走这条路径”。二者不能 相互替代。


上一节:PostgreSQL 全文检索 · 返回本章目录 · 下一节:可复现的向量检索 · 查看全书目录 · 查看索引中心

15.4 可复现的向量检索

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

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

15.4.1 维度、距离度量与归一化

一列必须有模型身份

最小表不是:

embedding vector(1536)

而是至少:

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

真实系统还可能需要:

input_template_version
source_text_hash
normalization
generation_status / error
provider/model revision
dimension

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

本章用:

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,以及 halfvecsparsevec 的相应 能力;本章不展开。

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

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

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

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

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

参见 pgvector 0.8.4 Querying

选距离要回到模型合同

L2:

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

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

cosine similarity:

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

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

inner product:

xy=ixiyi x\cdot y=\sum_i x_i y_i

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

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

xy22=22(xy) \|x-y\|_2^2 = 2 - 2(x\cdot y)

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

模型 ADR 要回答:

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?

一个常见错误:

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 维;halfvecbitsparsevec 有各自上限。 这些是当前版本事实,升级前重查官方文档。

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

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

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

15.4.2 精确近邻与近似索引

默认是精确搜索

没有近似索引时:

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 排名:

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

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

强制 exact plan:

SET enable_indexscan = off;
SET enable_bitmapscan = off;

得到:

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

这是本章的 reference path。

ANN 用召回换速度

pgvector 支持 HNSW 与 IVFFlat:

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。

本章建:

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=16ef_construction=64;本章用较小值 只是让夹具声明显式,并非生产建议。

HNSW 建多层图:

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

IVFFlat 把向量分到 lists,查询探测部分 lists:

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

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

索引查询形状必须匹配

让 ANN index 生效,典型查询要:

ORDER BY embedding <-> :query
LIMIT K

只写距离范围:

WHERE embedding <-> :query < :radius

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

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

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

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

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

ANN recall 必须相对 exact 定义

对于同一 query 与 filters:

Recall@KANN=ANNKExactKK Recall@K_{ANN} = \frac{|ANN_K \cap Exact_K|}{K}

本章 ann-compare.sql 在同一事务中:

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

结果:

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

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

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

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

15.4.3 索引参数、过滤条件与召回代价

HNSW 有构建参数和查询参数

构建参数:

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

查询参数:

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

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

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

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

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 简单但增加每次查询成本,仍要测。

本章同时创建:

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:

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

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

迭代不会无限进行,还受:

hnsw.max_scan_tuples
ivfflat.max_probes
memory limits

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

本章用 strict:

SET hnsw.iterative_scan = 'strict_order';

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

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

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

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 内存。生产建索引:

CREATE INDEX CONCURRENTLY ...

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

SELECT
  phase,
  blocks_done,
  blocks_total
FROM pg_stat_progress_create_index;

并检查:

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 冻结文本、模型标识、许可证、向量文件与校验和

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

可复现输入至少包括:

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 明确:

{
  "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 载入后,数据库导出:

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 固定:

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

最终数据库 business_checksum 则覆盖:

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 至少记录:

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_license   = project-owned synthetic fixture
vector_license = handcrafted numeric fixture

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

模型升级要双版本迁移

不要:

UPDATE product
SET embedding = new_model(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 列可转文本导出:

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 章持有的扩展;它演示了对象 边界,不是生产数据迁移演练。


上一节:模糊匹配与拼写容错 · 返回本章目录 · 下一节:混合检索与排序验证 · 查看全书目录 · 查看索引中心

15.5 混合检索与排序验证

混合检索的价值来自失败互补,不来自方法数量。每加一路候选,都同时增加 召回机会、查询成本和排序污染。

本节先保留每路独立排名,再用只依赖名次的 RRF 合并;最后用同一标注集找出 收益与退化。

15.5.1 词法、模糊和语义候选合并

每一路先独立成立

本章先建三个排名视图:

lexical_ranking
fuzzy_ranking
vector_exact_ranking

共同输出:

query_id
product_id
result_rank
score

向量视图还保留 distance。每路都使用相同资格条件:

p.active
AND (
  q.category_filter IS NULL
  OR p.category = q.category_filter
)

如果三路过滤不同,融合后的差异既可能来自检索能力,也可能来自候选资格, 指标无法解释。授权、租户和生命周期过滤应在每路都保持同一语义。

source rank 是融合接口

本章把每路前四名规范成:

SELECT
  query_id,
  product_id,
  'lexical' AS source_name,
  result_rank,
  1.0 AS source_weight
FROM shop_ch15.lexical_ranking
WHERE result_rank <= 4

UNION ALL
...

为什么用 UNION ALL

  • 同一商品被多路召回是有价值的支持证据;
  • 普通 UNION 会按整行去重,但 source/rank 不同,本来也不会合并;
  • 后续应显式按 (query_id, product_id) 聚合,而不是让 set operator 隐式决定。

每路只提供 candidate,不应在视图内部偷偷调用另一路。这样才能:

  • 单独关闭某一路;
  • 比较增量质量;
  • 观察每路延迟;
  • 定位失败;
  • 给最终结果解释 source。

候选深度是资源预算

本章:

source_depth = 4
final_depth  = 3

它只适合 17 行夹具。生产候选深度影响:

recall ceiling
union cardinality
dedup/sort work_mem
re-ranker cost
network payload
tail latency

应按 query segment 做曲线:

depth = 10,20,50,100
  -> Recall@final_K
  -> NDCG@final_K
  -> P95/P99
  -> candidate count after dedupe

当一条 source 加深只增加重复/低质量对象而不提高最终 recall,就没有继续 扩大的理由。

空结果与低分结果不同

全文视图只输出 @@ 命中对象,所以 q02/q07 没有行。

模糊视图对全部合格对象排名,即使低分也有行。这是评估 fixture 的有意设计, 生产则应加候选门槛。否则:

no lexical evidence
+ zero fuzzy evidence
+ weak vector evidence

仍可能被融合成一个看似精确的总排名。

候选接口应区分:

source unavailable
source returned no qualified candidate
source timed out
source returned candidates

超时不能被当作“该方法认为没有相关结果”,否则线上质量诊断会混淆能力与 故障。

去重后的 evidence 仍要保留

一个商品多路出现时,最终记录应能表达:

product 1:
  lexical rank 1
  fuzzy rank 1
  vector rank 3

本章 hybrid_rrf_ranking.sources 用 text array 保存 source 名称,完整排名 仍可回查原视图。生产可把细节写进响应 trace、审计表或离线日志,不必塞进 业务表永久保存。

SQL 内混合的边界

在 PostgreSQL 内完成候选和融合有明显优势:

  • 与事务快照、状态和权限过滤同处一处;
  • 少一次跨系统网络与数据同步;
  • SQL 可以直接被 EXPLAIN、统计和审计;
  • 小中规模问题结构简单。

但如果模型推理、复杂学习排序、跨多源索引或独立扩缩容占主导,融合可能更 适合应用/检索服务。决策标准不是“SQL 能不能写”,而是:

failure domain
data freshness
latency budget
operational ownership
quality evidence
exit cost

15.5.2 分数归一、倒数排名融合与重排

为什么不能直接加原始分数

本章三路原始值:

ts_rank_cd(..., 32)  -> bounded cosmetic score
trigram similarity  -> character overlap in 0..1
vector L2 distance  -> non-negative distance, lower is better

即使都变换到 0–1,也没有共同概率含义。下面只是拍脑袋:

0.4 * lexical_score
+ 0.3 * fuzzy_score
+ 0.3 * (1 / (1 + vector_distance))

它会隐含:

  • 三种尺度的形状可比;
  • 一单位变化意义相同;
  • 权重对所有 query segment 相同;
  • 极值与分布稳定;
  • 模型升级后仍适用。

若要线性加分,应使用标注数据做 calibration/learning-to-rank,并在 fresh holdout 上验证。

RRF 只比较名次

Reciprocal Rank Fusion 对 document dd

RRF(d)=ssourceswsk+ranks(d) RRF(d)=\sum_{s \in sources} \frac{w_s}{k+rank_s(d)}

其中:

  • ranks(d)rank_s(d) 是 document 在 source ss 中的名次;
  • 未出现在 source candidate depth 内就没有该项;
  • wsw_s 是可选 source 权重;
  • kk 平滑头部名次差。

本章:

k = 60
w_lexical = w_fuzzy = w_vector = 1
source_depth = 4

SQL:

SELECT
  query_id,
  product_id,
  sum(
    source_weight / (60.0 + result_rank)
  )::double precision AS score,
  array_agg(source_name ORDER BY source_name) AS sources
FROM source_rank
GROUP BY query_id, product_id;

再:

row_number() OVER (
  PARTITION BY query_id
  ORDER BY score DESC, product_id
)

pgvector 官方 Hybrid Search 小节也把 RRF 或 cross-encoder 列为融合路径; 参见 pgvector 0.8.4 Hybrid Search

k 不等于返回 K

RRF 中的小写 kk 是平滑常数,不是最终结果数。增大它会让头部第 1 与 第 4 的单路差距变小,多路共同支持相对更重要;减小它会放大头部名次差。

不要因为经典示例常用 60 就把它当数学常数。本章用 60 是显式 proposal 参数。调参时要同时版本化:

rrf_k
source weights
source candidate depths
tie breaker
final K

并报告每个 query segment 的变化。

RRF 的优点与盲区

优点:

  • 不要求原始 score 同尺度;
  • 一个 document 被多路高排会自然加分;
  • 实现与解释简单;
  • source 新旧版本可独立。

盲区:

  • 丢失 source 内分数差距:第 1 比第 2 好很多或只好一点都看不出来;
  • candidate depth 外信息完全消失;
  • 弱 source 也会投票;
  • 不直接使用业务新鲜度、库存、质量、安全等 feature;
  • 不会自动学会 query-dependent weight。

所以 RRF 是可靠 baseline,不是排序终点。

source weight 要谨慎

加权 RRF:

source_weight / (k + rank)

很容易实现,但“vector 权重 1.5”必须由指标支持。若某一路只在错拼 query 有帮助,更合理的方向可能是:

  • query segment 路由;
  • 以置信门槛启用;
  • 学习排序;
  • 或扩大/收窄该路 candidate depth。

全局权重会把局部收益和局部伤害平均掉。

两阶段重排

当候选生成需要快、最终质量需要更多 feature:

stage 1:
  FTS/trigram/ANN -> union top 100

stage 2:
  exact vector
  + structured business features
  + cross-encoder or learned ranker
  -> top 10

关键合同:

  • 第一阶段 recall 必须足够;被漏掉的对象无法被重排救回;
  • 第二阶段有独立超时与 fallback;
  • 重排模型有版本、输入与许可证;
  • fallback 次序仍经过评估;
  • 在线日志保留 candidate pool 与最终选择。

外部 cross-encoder 可能最贵、最慢、最敏感,不要让数据库事务长时间等待 网络推理。常见做法是在数据库取候选后结束事务,再用版本化快照数据重排; 若新鲜度要求更高,需要明确一致性策略。

精确重排近似候选

ANN 常用模式:

WITH candidate AS MATERIALIZED (
  SELECT product_id, embedding
  FROM product
  ORDER BY embedding <-> :q
  LIMIT 100
)
SELECT product_id
FROM candidate
ORDER BY embedding <-> :q, product_id
LIMIT 10;

第二层用原向量精确重排 candidate,可以修正量化/子向量/relaxed ordering 内部次序,却不能召回第一层没选中的对象。最终仍要用 exact-relative recall 评估 candidate generator。

15.5.3 用标注集比较质量,不用单个“好看案例”

结果表只是起点

运行:

PG36_EVIDENCE_DIR="$PWD/evidence/ch15" \
  ./static/labs/ch15/task.sh evaluate

得到 quality-summary.csv

strategy,queries,precision@3,recall@3,mrr@3,mean_ndcg@3,min_ndcg@3
fuzzy,8,0.916667,0.916667,1.000000,0.942881,0.842828
hybrid_rrf,8,1.000000,1.000000,1.000000,0.962929,0.759192
lexical,8,0.291667,0.291667,0.750000,0.613043,0.000000
vector_exact,8,1.000000,1.000000,1.000000,0.817314,0.631039

平均值显示 hybrid 在本集合上 mean NDCG 最高,但不能到此结束。

看每个 query 的退化

quality-detail.csv 暴露:

q02:
  fuzzy NDCG@3      = 0.842828
  hybrid NDCG@3     = 0.759192

q02/q07:
  lexical result_count = 0
  lexical NDCG@3       = 0

q02 的前三名:

fuzzy: 1,3,2
hybrid: 3,2,1

商品 1 是 grade 3,纯 fuzzy 放第一;混合受到另外两路名次影响,把 grade 2 商品 3 放第一。因此:

mean improved
does not imply every intent improved

生产 release gate 应同时有:

  • aggregate threshold;
  • 关键 query segment threshold;
  • worst-case/低分位保护;
  • 不允许退化的 canary query;
  • filter/security hard assertions。

四种策略各告诉了什么

全文

high precision when exact lexical evidence exists
zero results for two typo queries

它不是“差”,而是 candidate role 较窄。单独 NDCG 低不等于应删除;它可能 提供最可解释、最精确的头部证据。

模糊

excellent typo recovery on this title corpus
misses one relevant result in q04 and q06

它证明字符相似的价值,也暴露业务邻近但字面不近的盲区。

精确向量

all three relevant objects recalled for every query
mean NDCG lower because grade ordering is imperfect

这是“召回好、排序不够好”的典型 candidate generator。

RRF

full recall and best mean NDCG
one typo query worse than fuzzy

它是当前 proposal baseline,而不是普遍胜者。

8 个查询没有统计外推力

本章数字可以证明:

  • SQL 指标计算可复现;
  • fixture 改动会被发现;
  • 策略之间确有可解释差异;
  • 反例和 filter guard 可持续回归。

不能证明:

  • 真实流量的总体质量;
  • 各语言/用户/类别公平;
  • 指标提升具有统计显著性;
  • 点击、转化或任务完成提高;
  • 线上 P95/P99;
  • embedding 模型优劣。

真实评估集应从代表性查询日志和业务任务构建,保留 fresh holdout。对线上 A/B:

pre-register primary metric and guardrails
randomize at a safe unit
control novelty and carry-over
track no-result and latency
audit exposure/authorization
set stop conditions

离线相关性是上线前门槛,不是用户价值的最终替代。

指标 SQL 也要审校

本章 quality_per_query 明确建立所有策略 × 所有查询的 grid,再 LEFT JOIN 返回结果。这样没有返回行的 query 仍有 0 分;若从 ranking 表直接 GROUP BY, 空 query 会消失,平均值被悄悄抬高。

它还:

  • 固定前三;
  • 分母使用 3,不使用实际 result count;
  • 对未标注 result 记 grade 0;
  • 用 grade 计算 DCG 与 ideal DCG;
  • 稳定按 grade/product id 生成理想排序。

审查 retrieval 指标实现时,重点找:

missing queries silently dropped
precision denominator changed
unjudged treated inconsistently
ties unstable
ideal ranking calculated from retrieved set
approximate results used as truth

这些 bug 往往比检索算法差异更大。

发布结论的正确句式

可以写:

ch15-search-v1 的 8 个冻结查询、24 条分级标注和精确向量 golden 下,等权 RRF 的 mean NDCG@3 为 0.962929,并覆盖全部标注相关对象; q02 相对纯 fuzzy 退化,必须保留分段监控。结果不外推到真实语料。

不要写:

混合检索准确率 96%,优于其他方案。

后一句把 NDCG 当 accuracy,把小合成集当总体,也隐去了反例。


上一节:可复现的向量检索 · 返回本章目录 · 下一节:扩展部署与运行代价 · 查看全书目录 · 查看索引中心

15.6 扩展部署与运行代价

到目前为止,我们只证明了检索行为。生产采用还要回答:

每个节点有没有同一扩展构建?
数据库对象是谁创建和升级的?
索引建造、更新、WAL、备库与恢复要付什么代价?
文本怎样变成向量,谁有权把它发给谁?

本节把 PostgreSQL 机制映射到 Pigsty 4.5,但仍以 live 节点、系统目录和实际 证据为准。

15.6.1 安装检索扩展并核对版本

复用第 14 章的三层状态

检索扩展仍然有三层:

package/support files on every host
  -> backend can load compatible library
  -> pg_extension object exists in this database

pg_trgm 随 PostgreSQL contrib 交付;vector 的项目/包常叫 pgvector,而 SQL 扩展名是 vector。不要混写:

package alias: pgvector
SQL:           CREATE EXTENSION vector
catalog:       pg_extension.extname = 'vector'

本章不会安装或升级它们,而是要求第 14 章最终状态:

pg_trgm 1.6
vector  0.8.4
schema  shop_ch14
exact extension markers

如果版本、owner、schema 或 marker 不符,context.sql 拒绝。下游实验不能 悄悄接管上游扩展。

Pigsty 中分开“装包”和“启用”

Pigsty 4.5 当前文档指出:

  • pgvectorpgsql-main 默认安装;
  • pg_trgm 位于默认启用扩展列表;
  • 可通过 pg_packages/pg_extensions 影响包;
  • 可通过 pg_default_extensions 影响数据库默认启用对象。

参见 Pigsty Default Extensions

本章给出的 pigsty-declaration.example.yml 只是合并片段:

all:
  vars:
    pg_version: 18
    pg_packages:
      - pgsql-main
      - pgvector
    pg_default_extensions:
      - { name: pg_trgm, schema: public }
    pg_databases:
      - name: pg36_shop
        owner: pg36_owner
        extensions:
          - { name: vector, schema: public }

实际版本中 pgvector 可能已被 pgsql-main 包别名覆盖,重复声明是否允许、 包名如何展开、目标 OS/PG major 是否有构建,都要用目标 inventory 与 Pigsty 文档确认。

教学夹具把扩展放在 shop_ch14,是为了凸显 namespace/owner。生产片段使用 public 只是示例,不是推荐所有扩展都堆进 public。schema 选择必须同时 满足:

  • extension 是否 relocatable/是否强制 schema;
  • 应用是否用全限定名称;
  • search_path 安全;
  • dump/restore;
  • 运维与升级脚本;
  • 权限最小化。

L1 每个节点都要核对

物理复制会把数据库对象复制到备库,但不会把 OS 软件包和动态库通过 WAL 复制过去。主库 CREATE EXTENSION vector 成功,某备库仍可能因缺 vector.so 在查询、恢复或升主后失败。

对每个 L1 host 收集:

pg_config --version
pg_config --sharedir
pg_config --pkglibdir

test -r "$(pg_config --sharedir)/extension/vector.control"
test -r "$(pg_config --sharedir)/extension/pg_trgm.control"

再核对目标 major 的 package manager 版本与文件 hash。不能从当前 shell PATH 里的 pg_config 猜正在运行的 server;先从实例确认 server major, 再找对应安装树。

数据库内:

SELECT
  e.extname,
  e.extversion,
  n.nspname AS schema_name,
  pg_get_userbyid(e.extowner) AS owner,
  e.extrelocatable
FROM pg_extension AS e
JOIN pg_namespace AS n
  ON n.oid = e.extnamespace
WHERE e.extname IN ('pg_trgm', 'vector')
ORDER BY e.extname;

控制文件可见性:

SELECT
  name,
  version,
  installed,
  superuser,
  trusted,
  relocatable
FROM pg_available_extension_versions
WHERE name IN ('pg_trgm', 'vector')
ORDER BY name, version;

两份目录回答不同问题:

  • pg_available_extension_versions:server 支持文件允许哪些版本;
  • pg_extension:当前 database 创建了哪个对象版本。

安装权限不要交给应用

本章角色边界:

platform/admin:
  package / untrusted extension / lifecycle

pg36_owner (NOLOGIN or controlled SET ROLE):
  schema, tables, reviewed migrations

pg36_app:
  SELECT search interface
  no table DML
  no extension lifecycle

pg36_appproduct_search UPDATE 必须返回:

SQLSTATE 42501
permission denied for table product_search

真实商品服务当然需要写入,但写角色不等于在线读角色必须拥有表级任意 UPDATE。 可使用:

  • 受控迁移角色;
  • 列级权限;
  • 参数化函数;
  • 独立异步 embedding worker;
  • 队列/作业表状态机。

尤其不要让业务请求角色创建、升级或删除扩展。

证据边界

本章正式运行:

Homebrew PostgreSQL 18.6
direct PostgreSQL service
Pigsty 4.5 docs reviewed
Pigsty L1 execution not run

所以 manifest 明确:

validation_path=direct-postgresql
pigsty_l1=not-run

移植到 Pigsty 后要补:

inventory commit
repository/package resolution
every-node file/version/hash
database extension catalog
primary/replica query
failover/failback
backup clean restore

15.6.2 观察索引体积、构建、查询和维护

先列出四类物理对象

本章商品表有:

heap + toast (if needed)
FTS GIN
title trigram GIN
embedding HNSW
active/category partial B-tree

基础盘点:

SELECT
  pg_relation_size('shop_ch15.product_search') AS heap_bytes,
  pg_total_relation_size('shop_ch15.product_search') AS total_bytes,
  pg_relation_size('shop_ch15.product_search_fts_idx') AS fts_bytes,
  pg_relation_size('shop_ch15.product_search_title_trgm_idx') AS trgm_bytes,
  pg_relation_size('shop_ch15.product_search_embedding_hnsw_idx') AS hnsw_bytes;

17 行夹具看到的 8/16/32 KiB 级数字主要由最小页分配决定,不能计算 “每百万商品多少 GiB”。size-catalog.csv 只是证明对象存在且非零。

生产 size baseline 应记录:

row count and active count
average text/vector width
model dimension/type
index definition/options
data and query distribution
build/update age
server/filesystem/compression

否则两个 size 数字不可比较。

构建要观察过程与阻塞

初始离线载入通常:

load data
-> analyze
-> create indexes

比逐行维护索引高效。在线已有表则通常考虑:

CREATE INDEX CONCURRENTLY ...

它减少对写入的阻塞,却有更长构建时间、额外扫描、资源占用和失败状态。运行时 观察:

SELECT
  pid,
  datname,
  relid::regclass,
  index_relid::regclass,
  command,
  phase,
  lockers_total,
  lockers_done,
  blocks_total,
  blocks_done,
  tuples_total,
  tuples_done
FROM pg_stat_progress_create_index;

并结合:

SELECT *
FROM pg_locks
WHERE pid = :builder_pid;

发布后必须确认 pg_index.indisvalid/indisready/indislive,不能只看 DDL client exit。

查询观测分机械与用户两层

机械层:

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)
SELECT ...;

看:

  • index/seq/bitmap path;
  • actual rows 与估算;
  • filter removed rows;
  • buffers 与 temp I/O;
  • planning/execution time;
  • 生效的非默认 settings。

用户层:

query segment
candidate/result count
quality metric
P50/P95/P99 latency
timeout/error/no-result
model/index/release version

执行快但 recall 差,不是成功;quality 高但 P99 超时,也不是成功。

Pigsty 默认启用/预加载的 pg_stat_statements 可帮助聚合 SQL 调用与耗时, 但要为检索 query 保持可归一的 SQL 形状,并将业务 query id/segment 放在 受控日志或 trace,而不是拼进 SQL 注释造成 statement 指纹爆炸。

写入成本要单独压测

文本变化会更新两个 GIN 和 generated tsvector;向量变化会更新 HNSW。 测:

rows/s
WAL bytes/row
CPU
index growth
autovacuum cadence
dead tuples
replica replay lag
checkpoint/write latency

可以在受控事务前后比较:

SELECT pg_current_wal_lsn();
-- representative batch
SELECT pg_current_wal_lsn();

再用 pg_wal_lsn_diff 估算该受控批次产生的 WAL。并发生产环境还有其他 事务,不能把全局 LSN 差未经隔离就归给一个作业。

HNSW 维护不是普通 B-tree 的复制粘贴

pgvector 官方说明 HNSW vacuum 可能耗时,并给出先 REINDEX INDEX CONCURRENTLYVACUUM 的一种加速建议。这是需要谨慎评估的维护动作, 不是每晚固定模板:

  • reindex 需要额外磁盘与构建资源;
  • concurrently 有更长窗口和失败恢复;
  • vacuum 仍要处理 heap;
  • 副本会重放相关 WAL;
  • 索引重建期间 recall/latency 要观测;
  • 新 index 的参数与 opclass 必须一致。

先在代表性副本/演练环境测周期,再决定维护阈值。

监控矩阵

维度 PostgreSQL 证据 Pigsty/平台视图
SQL 调用/耗时 pg_stat_statements、logs dashboard/alerts
执行计划 EXPLAIN、auto_explain 集中日志
index 使用 pg_stat_user_indexes 表/索引面板
表生命周期 pg_stat_user_tables vacuum/bloat 面板
构建进度 pg_stat_progress_create_index 变更任务证据
WAL/副本 LSN、replication views HA/replication 面板
主机资源 PostgreSQL/OS stats node/PG exporter
质量/ANN recall 自建 eval job release/SLO dashboard

最后一行不能由通用数据库 exporter 自动推导。检索质量是业务测量,必须把 evaluation job 当成一等生产组件。

15.6.3 外部嵌入生成的权限、费用与数据边界

不要从数据库触发器同步调用外部 API

一个危险设计:

UPDATE product title
  -> trigger
  -> HTTP embedding API
  -> wait inside transaction

它把:

  • 外部网络延迟;
  • rate limit;
  • provider outage;
  • 费用;
  • 密钥;
  • 不确定重试;

放进数据库锁与事务寿命。远端已收费成功、本地事务却回滚时,还会出现不可 原子化的副作用。

更稳健的异步结构:

transaction:
  update source text
  record source_text_hash / desired_model / pending job
  commit

worker:
  claim job with bounded lease
  build exact versioned input
  call model service
  validate dimension/norm
  write vector + model_id + source_hash
  mark success or retry state

可用 outbox、作业表或消息系统实现;第 13 章已经讨论过触发器与异步边界。

幂等身份

一次 embedding 任务的自然 key 可以是:

(document_id, source_text_hash, model_id, template_version)

写回时做 compare-and-set:

UPDATE product_embedding
SET embedding = :vector,
    status = 'ready',
    embedded_at = clock_timestamp()
WHERE product_id = :id
  AND source_text_hash = :hash
  AND model_id = :model
  AND status = 'processing';

如果源文本在推理期间变化,旧结果不能覆盖新版本。重试同一 key 应复用已完成 结果或安全 upsert,避免重复收费。

worker 最小权限

embedding worker 通常需要:

  • 读取允许外发的字段;
  • 读取/更新自己的 job;
  • 写特定 vector/model/status 列;
  • 不需要 DDL、扩展 owner、超级用户;
  • 不需要任意读其他敏感 schema。

API secret 放在 secret manager/受控运行环境,不存进 SQL、YAML 仓库、表 comment 或 evidence manifest。Pigsty 负责 PostgreSQL 平台,不意味着模型 密钥应注入数据库 server 进程。

若文本不能离开边界,可以:

  • 在受控网络自托管模型;
  • 只对批准字段生成;
  • 做脱敏/分区处理;
  • 或拒绝向量方案。

“先接 API,之后再补合规”不是试点策略。

费用模型

至少估算:

[ Cost = InitialBackfillTokens \times Price

  • DailyChangedTokens \times Price
  • QueryTokens \times Price
  • RetryWaste
  • Storage/Index/Compute ]

还包括:

  • backfill 期间 API 并发与限流;
  • 模型升级全量重算;
  • 双版本存储与索引;
  • 失败重试和重复调用;
  • query embedding cache;
  • 数据出口与网络;
  • 本地推理 GPU/CPU 和运维。

费用控制要有:

per-job token/byte limit
daily/project budget
rate/concurrency limit
retry ceiling and dead-letter state
backfill pause/resume
cost attribution by model/version

缓存 query vector 时,key 必须包含 exact normalized text、model、template 与 版本;只按原始字符串缓存会在模型切换时串用旧空间。

数据生命周期要双向传播

删除或更正 source document 时,检查:

primary row
vector row/index
job queue and retry payload
query/result caches
logs/traces
offline evaluation exports
backups and retention
provider-side retained data

物理删除在 PostgreSQL 中还受 MVCC、vacuum、WAL 与备份保留影响。法律上的 删除承诺必须和备份/恢复政策一致,不能只执行一条 DELETE

故障与降级

模型服务不可用时,搜索不一定要整体不可用:

vector generation outage:
  keep last valid vector with staleness marker
  queue updates

query embedding outage:
  fall back to FTS + trigram
  expose degraded-mode metric

ANN/index incident:
  exact path for small filtered sets
  or lexical-only bounded fallback

每种 fallback 都要在离线标注集上测质量、在线压测容量,且不能放宽权限过滤。

上线前问题

  • 哪些字段能外发,依据是什么?
  • provider 是否保留输入/输出、是否用于训练?
  • 模型版本是否可固定,变更如何通知?
  • 单次、每日、回填、升级的费用上限是什么?
  • 谁能读原文、调用服务、写 vector?
  • 如何证明 vector 对应当前 source hash?
  • 失败如何重试,何时进入 dead letter?
  • 模型切换如何双写、评估、回退?
  • 删除、备份与 provider 侧数据如何协调?
  • 无模型服务时,业务还能提供什么质量的结果?

回答不了这些问题时,向量 PoC 可以继续离线,不能进入生产写链路。


上一节:混合检索与排序验证 · 返回本章目录 · 下一节:实战:pg36_shop 商品混合检索 PoC · 查看全书目录 · 查看索引中心

15.7 实战:`pg36_shop` 商品混合检索 PoC

本节把前六节压成一个可审计的 1.3-proposal

frozen inputs
  -> deterministic schema/index/ranking
  -> exact quality golden
  -> separate ANN probe
  -> application privilege failure
  -> reset guards
  -> exact reset
  -> rebuild and re-review

正式证据来自 Homebrew PostgreSQL 18.6 的受控开发数据库。Pigsty 4.5 的 声明与运维职责已经映射,但本地没有运行 L1,因此不把直接 PostgreSQL 结果 伪装成 Pigsty 集群验收。

破坏边界

task.sh all 会删除并重建带精确 marker 的 shop_ch15。它不删除 pg_trgmvector 或第 14 章 schema,只适合本书本地/开发夹具。 生产不得执行这条“删后重建”路径。

15.7.1 用冻结向量离线复现实验

前置状态

本章沿用前章 libpq service:

[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres
chmod 600 /path/to/pg_service.conf
export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin

不要把密码放进命令行、脚本或 evidence。

context.sql 要求:

database = pg36_shop
writable instance
PostgreSQL major = 14..18
session user = superuser
can SET ROLE pg36_owner
ch04-v1 physical model exists
pg36_app = constrained non-superuser LOGIN
shop_ch14 marker is exact
pg_trgm = 1.6 in shop_ch14 with exact marker
vector = 0.8.4 in shop_ch14 with exact marker

任何一项不符就停止。本章不会“顺便安装一个更接近的版本”,因为那会同时 改变扩展与检索两个变量。

输入清单

fixture-manifest.json 固定:

corpus:
  17 rows
  sha256=7136d6f1705e560c5d564407926b44c455cd59b3883cd640f4f51599806a90c8

queries:
  8 rows
  sha256=4b54b0ee322bf52649b5c682d468ea024ae301d8cac40d63aaaed908c9d8a45d

judgments:
  24 rows
  sha256=657ddc4d82f9b42af589fd64a6326407d5cfb6536b2f2c3c28862279eca41438

loader:
  sha256=2548978f3652f816452f22aed0d560a0d0263234a2f62a8fb77d4009c5545787

这些 hash 识别的是仓库输入。proposal checksum 识别的是版本、方法、质量和 验收合同,二者不要混淆。

单步建立

./static/labs/ch15/task.sh setup

setup 先检查已有 shop_ch15

  • schema owner 必须是 pg36_owner

  • schema comment 必须是:

    pg36 ch15 search quality lab; safe to rebuild
  • relation/index/view 必须在 21 个对象白名单中;

  • 每个对象必须有同一 marker;

  • schema 不能有未知 routine/operator/opclass。

碰撞保护通过后,它按依赖顺序删除旧视图/表/schema,不用 CASCADE,再以 pg36_owner 创建。

核心表:

CREATE TABLE shop_ch15.product_search (
  product_id bigint PRIMARY KEY,
  sku text NOT NULL UNIQUE,
  category text NOT NULL,
  active boolean NOT NULL,
  title text NOT NULL,
  description text NOT NULL,
  embedding shop_ch14.vector(4) NOT NULL,
  embedding_model text NOT NULL,
  search_document tsvector
    GENERATED ALWAYS AS (...) STORED
);

CREATE TABLE shop_ch15.eval_query (...);
CREATE TABLE shop_ch15.relevance_judgment (...);
CREATE TABLE shop_ch15.fixture_meta (...);

索引:

product_search_fts_idx              GIN pg_catalog.tsvector_ops
product_search_title_trgm_idx       GIN shop_ch14.gin_trgm_ops
product_search_embedding_hnsw_idx   HNSW shop_ch14.vector_l2_ops
product_search_filter_idx           partial B-tree WHERE active

排名/质量视图:

lexical_ranking
fuzzy_ranking
vector_exact_ranking
hybrid_rrf_ranking
all_ranking
quality_per_query
quality_summary

完成摘要:

status=fixture-ready
products=17
queries=8
judgments=24

逐字节回读

PG36_EVIDENCE_DIR="$PWD/evidence/ch15-cycle" \
  ./static/labs/ch15/task.sh evaluate

它用 COPY ... TO STDOUT CSV HEADER 从数据库导出 corpus/query/judgment, 再执行:

cmp frozen-corpus.csv evidence/corpus.csv
cmp frozen-queries.csv evidence/queries.csv
cmp frozen-judgments.csv evidence/judgments.csv

这验证:

  • SQL loader 没有手工录错;
  • vector::text 可稳定导出;
  • 布尔、字符串、顺序和 rationale 都一致;
  • 重建后数据身份没有漂移。

如果 CSV 行顺序没有显式 ORDER BY,逐字节比较没有意义。本章三条 export 分别按 product id、query id、query/product id 排序。

为什么不调用真实模型

若测试运行时调用外部 embedding API:

  • provider 可能改变输出;
  • 网络/限流会让数据库实验不稳定;
  • 凭据和费用进入教学流程;
  • 无法区分模型漂移与 SQL 漂移;
  • 离线读者无法复现。

所以本章把真实模型评估留作迁移任务。读者要替换为真实向量,应复制 ch15-search-v1 为新 fixture/version,保留旧 golden,不要覆盖四维输入后 继续沿用本章 checksum。

15.7.2 比较全文、模糊、向量与混合结果

查看词法解析

psql "service=pg36-admin" \
  -f static/labs/ch15/fts-analysis.sql

固定结果:

query parsed tsquery matches
q01 'wireless' & 'headphon' 1
q02 'wirel' & 'hedphon' 0
q03 'music' & 'go' 1
q04 'coffe' & 'bean' & 'grinder' 1
q05 'make' & 'espresso' & 'home' 1
q06 'trail' & 'hydrat' 2
q07 'postgr' & 'databs' & 'tune' 0
q08 'semant' & 'nearest' & 'neighbor' 1

q02/q07 的 lexeme 并不会因为“看起来像错拼”自动改正。这个证据把 FTS 零召回定位在语言处理层,不是 GIN index 故障。

前三名全景

固定 product id:

query lexical fuzzy exact vector hybrid RRF
q01 1 1,3,2 2,3,1 1,2,3
q02 3,1,2 2,3,1 3,2,1
q03 2 1,2,3 2,3,1 2,1,3
q04 4 4,6,14 5,6,4 4,6,5
q05 5 5,6,4 5,6,4 5,6,4
q06 7,8 7,8,15 8,9,7 7,8,9
q07 10,11,12 11,10,12 10,11,12
q08 12 12,10,11 12,11,10 12,10,11

是无行,不是三条零分结果。

注意 q04:

fuzzy third = product 14 / Digital Coffee Scale / unjudged
vector first = product 5 / Home Espresso Machine / grade 1
hybrid       = 4,6,5 / all judged relevant

这是互补成功例。

注意 q02:

fuzzy puts grade-3 product 1 first
hybrid puts grade-2 product 3 first

这是融合退化例。两种都必须写进报告。

质量结果

SELECT *
FROM shop_ch15.quality_summary
ORDER BY strategy;

得到:

strategy queries P@3 R@3 MRR@3 mean NDCG@3 min NDCG@3
fuzzy 8 .916667 .916667 1 .942881 .842828
hybrid_rrf 8 1 1 1 .962929 .759192
lexical 8 .291667 .291667 .75 .613043 0
vector_exact 8 1 1 1 .817314 .631039

质量视图把未返回 query 保留为 0,避免 survivorship bias。

计划证据

psql "service=pg36-admin" \
  -f static/labs/ch15/fts-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/trigram-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/vector-exact-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/vector-hnsw-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch15/vector-filtered-plan.sql

断言:

FTS      Bitmap Index Scan product_search_fts_idx
trigram  Bitmap Index Scan product_search_title_trgm_idx
exact    Seq Scan + Sort
HNSW     Index Scan product_search_embedding_hnsw_idx
filtered HNSW Index Scan + Filter

这些文件通过禁用 planner 备选路径建立“能力证明”。正常 17 行查询走顺序 扫描很合理;不要把强制 index plan 贴成性能结果。

目录证据:

SELECT
  index_name,
  access_method,
  operator_class,
  is_valid,
  is_ready,
  is_live
FROM (... index-catalog.sql ...);

最终四个 index 都必须 valid/ready/live,opclass 与查询 operator 一致。

ANN 对照

psql "service=pg36-admin" \
  --csv \
  -f static/labs/ch15/ann-compare.sql

结果:

exact_ids,ann_ids,recall_at_3
"7,8,9","7,8,9",1.000000

这个 probe:

  • 固定 q06 与 outdoor/active filter;
  • exact 结果来自全量 window ranking;
  • ANN 强制 HNSW;
  • 用集合交集测 recall。

它没有报告毫秒,因为 tiny fixture 的时间不稳定且无业务意义。

应用权限

psql "service=pg36-admin user=pg36_app" \
  -f static/labs/ch15/app-query.sql

应用可读 q02/q08 混合结果和质量摘要。

未授权更新:

UPDATE shop_ch15.product_search
SET title = 'unauthorized mutation'
WHERE product_id = 1;

必须:

psql exit=3
SQLSTATE=42501
permission denied for table product_search

自动化不是只检查非零 exit;它同时检查精确 SQLSTATE,避免连接失败或语法 错误被误当成权限测试通过。

15.7.3 输出 ADR、质量证据、生产代价与退出路径

ADR 结论

search-adr.md 决定:

FTS:
  pg_catalog.english
  stored weighted tsvector
  title A / description B
  GIN

fuzzy:
  lower(title)
  similarity + word_similarity
  GIN candidate predicate
  production must add a qualified threshold

vector:
  versioned model identity
  L2
  exact for quality golden
  HNSW only as measured serving candidate

fusion:
  equal-weight RRF
  k=60
  source depth=4
  final depth=3

它还明确否决:

  • ILIKE 替代完整检索;
  • 只用 FTS;
  • 只用向量;
  • 未校准原始分数直接相加;
  • 用 HNSW 结果生成质量 golden。

proposal 是版本化合同

baseline-v1.3-proposal.json 固定:

  • PostgreSQL/extension/Pigsty 参考版本;
  • fixture/model/距离;
  • index 与权限;
  • 四种质量指标;
  • ANN probe;
  • business checksum;
  • evidence 文件清单;
  • reset 边界;
  • 明确 limitation。

canonical JSON checksum:

bf92a6ad0f60dc3e125b39dbf67bf4d6c5e50275192bd01a7ca4c50d142f822e

更改 key 顺序或空白不会改变 canonical checksum;更改合同值会改变。

最终数据库状态:

release=1.3-proposal
fixture=ch15-search-v1
embedding_model=pg36-handcrafted-topic-4d-v1
products=17
active_products=16
queries=8
judgments=24
business_checksum=c637abf09edba88b7793f91201a57c34

business checksum 不包含运行时间、OID、index bytes 或绝对路径。

evidence 目录

一轮完整采集包含:

manifest.txt
setup.txt
corpus.csv
queries.csv
judgments.csv
document-catalog.csv
fts-analysis.csv
index-catalog.csv
size-catalog.csv
security-catalog.csv
quality-summary.csv
quality-detail.csv
ranking-results.csv
ann-compare.csv
fts-plan.txt
trigram-plan.txt
vector-exact-plan.txt
vector-hnsw-plan.txt
vector-filtered-plan.txt
app-query.csv
app-write.{exit,stdout,stderr}
final-state.csv
verify.txt
review.txt

manifest.txt 记录 server、database、in-recovery、扩展版本、模型、Pigsty 证据边界、proposal checksum、fixture manifest checksum 与全部实验源文件 SHA-256。

review.py 不信任脚本“跑完了”,它重新解析证据并断言:

  • 三份 export 与 source byte-identical;
  • manifest hash 与真实文件一致;
  • 17/8/24 行数;
  • 唯一 inactive 商品为 17;
  • 四种索引 AM/opclass/状态/marker;
  • 应用 ACL;
  • FTS match counts;
  • 32 个策略×查询质量格;
  • 79 条实际 top-3 ranking 记录;
  • hybrid 每个 query 的 id 次序;
  • ANN exact/approx intersection;
  • 五份计划的关键 node;
  • 42501 权限失败;
  • final checksum 与 verify summary。

生产代价清单

当前 proposal 给出性能线,因为本地 17 行不能回答:

latency/throughput under representative concurrency
heap/GIN/HNSW size at target scale
bulk and concurrent build duration
steady write/WAL cost
autovacuum/reindex cost
replica replay/failover
clean backup restore
real model quality and generation cost

迁移到业务数据后,ADR 需要附:

领域 最小证据
质量 frozen + fresh queries,分段 P/R/MRR/NDCG
ANN exact-relative recall curve
查询 P50/P95/P99、timeout、buffers、CPU
写入 rows/s、WAL/row、索引增长、vacuum
HA replica query、lag、switchover/failback
恢复 clean environment restore
模型 version/input/license/cost/failure
安全 tenant/ACL canaries、secret/data boundary

精确退出

手工 reset 需要两个一致的显式确认:

PG36_RESET_TOKEN=RESET_CH15_SEARCH_LAB \
PG36_RESET_TARGET=pg36_shop/shop_ch15 \
PG36_EVIDENCE_DIR="$PWD/evidence/ch15-reset" \
  ./static/labs/ch15/task.sh reset

还会检查:

  • context/database/writable instance;
  • schema owner 与 marker;
  • 21 个 relation 的精确白名单与 marker;
  • 未知 routine/operator/opclass;
  • application_name LIKE 'pg36-ch15-%' 的其他活跃 worker。

错误分别用自定义 SQLSTATE:

P3660 invalid action token
P3661 invalid target
P3662 identity/inventory collision
P3663 active workers

删除顺序:

quality views
-> ranking views
-> relevance/query/product/meta tables
-> shop_ch15 schema

没有 CASCADE。最终必须:

remaining_schema=0
preserved_extensions=pg_trgm:1.6,vector:0.8.4

生产退出不是 DROP schema。生产要先:

stop/read-switch vector path
export and verify vectors/model identity
remove async generation traffic
drop/rebuild indexes online
observe fallback quality/capacity
retain rollback window
then remove no-longer-needed objects/packages

15.7.4 验收采用 checklist:evidence,不设脱离场景的性能线

为什么不给“必须 10 ms”

延迟取决于:

rows and dimensions
document/vector width
cache state
hardware/storage
concurrency
filters and selectivity
candidate K
index params
quality/recall target
write workload
network/application path

在 17 行、热缓存、本地 socket 上测到的微秒/毫秒,既不能预测一亿行,也 不能作为读者机器失败线。硬写一个数字只会诱导为过测试而牺牲质量或关闭 安全过滤。

因此本章把 15.7.4 的规则定义为:

checklist:evidence

即每一类能力都必须有可复核证据;业务上线再给每项填入自己的 SLO。

本地机制验收

检查 证据 固定结论
输入身份 manifest + byte cmp 17/8/24,三份一致
FTS 行为 parsed query/match table q02/q07 零命中
fuzzy 行为 ranks/quality mean NDCG .942881
vector quality exact ranking Recall@3 1
fusion RRF ranks/quality mean NDCG .962929,有 q02 退化
ANN 机制 exact vs HNSW q06 Recall@3 1,仅单点
index 机制 catalog + forced plans GIN/GIN/HNSW 路径存在
filtering canary 17 所有排名均不出现
ACL catalog + expected failure read-only,UPDATE 42501
reset token/target/active tests P3660/P3661/P3663
rebuild second full cycle checksum 相同
Pigsty manifest L1 not run

这一表中没有任何一项可被“SQL 返回了三行”替代。

业务场景性能卡

移植时创建一份场景卡:

dataset:
  products: ...
  active_ratio: ...
  dimensions: ...
  update_rate: ...
query:
  segments: ...
  filters/selectivity: ...
  top_k: ...
quality:
  min_recall_at_k: ...
  min_ndcg_at_k: ...
  max_segment_regression: ...
ann:
  min_exact_relative_recall: ...
latency:
  p50: ...
  p95: ...
  p99: ...
capacity:
  peak_qps: ...
  write_rate: ...
operations:
  max_build_window: ...
  max_replica_lag: ...
  restore_rto/rpo: ...
cost:
  monthly_model_budget: ...

数字必须来自业务 owner/SLO 和代表性测试,而不是本书替读者决定。

L1 验收增量

在 Pigsty L1 上补:

inventory commit and rendered config
package resolution for target OS/PG major
control/library hashes on every node
pg_extension catalog in target database
primary and replica search query
monitoring dashboards and alerts
planned switchover and failback
backup and clean restore
extension/model/index upgrade rehearsal

若某项未运行,报告应写 not-run,而不是 pass

双周期正式运行

evidence_dir="$(mktemp -d /tmp/pg36-ch15-evidence.XXXXXX)"

PG36_EVIDENCE_DIR="$evidence_dir" \
  ./static/labs/ch15/task.sh all

只把新建的专用 evidence 路径传给实验;不要把仓库根或广泛目录当目标。

all 的内部顺序:

cycle-1 collect + review
-> wrong token reset must fail P3660
-> wrong target reset must fail P3661
-> active worker reset must fail P3663
-> exact reset
-> verify extensions preserved
-> cycle-2 collect + review

正式结果:

status=ok
fixture=frozen-byte-identical
quality=precision+recall+mrr+ndcg
ranking=fts+trigram+exact-vector+rrf
ann=q06-exact-vs-hnsw-recall-1.000000
guards=P3660+P3661+P3663
extensions=ch14-preserved
pigsty_l1=not-run
release_candidate_checksum=bf92a6ad0f60dc3e125b39dbf67bf4d6c5e50275192bd01a7ca4c50d142f822e

第二轮完成后数据库保留可查询的 shop_ch15 最终状态,便于继续第 16 章; evidence 保留两个独立 cycle,证明 reset 后不是依赖第一次残留才通过。

最终评审句

本章可以得出的最强结论是:

1.3-proposal 在固定 PostgreSQL 18.6、本章扩展版本与合成 fixture 上, 两轮可重复;全文、模糊、精确向量、RRF、HNSW 测量路径、权限与精确复位 均有证据。它可以进入真实语料与 Pigsty L1 的下一阶段试点,尚未获得生产 性能与真实模型质量批准。

这比一句“PostgreSQL 可以做混合搜索”更窄,也更有用。


上一节:扩展部署与运行代价 · 返回本章目录 · 下一章:经天纬地:时序、空间与时空查询 · 查看全书目录 · 查看索引中心