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.4 的受控开发数据库。Pigsty 4.4 的 声明与运维职责已经映射,但本地没有运行 L1,因此不把直接 PostgreSQL 结果 伪装成 Pigsty 集群验收。
破坏边界
task.sh all会删除并重建带精确 marker 的shop_ch15。它不删除pg_trgm、vector或第 14 章 schema,只适合本书本地/开发夹具。 生产不得执行这条“删后重建”路径。
15.7.1 用冻结向量离线复现实验
前置状态
本章沿用前章 libpq service:
[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgreschmod 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任何一项不符就停止。本章不会“顺便安装一个更接近的版本”,因为那会同时 改变扩展与检索两个变量。
输入清单
static/labs/ch15/
├── frozen-corpus.csv
├── frozen-queries.csv
├── frozen-judgments.csv
├── fixture.sql
├── fixture-manifest.json
├── setup.sql
├── ranking-views.sql
├── verify.sql
└── ...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 setupsetup 先检查已有 shop_ch15:
schema owner 必须是
pg36_owner;schema comment 必须是:
pg36 ch15 search quality lab; safe to rebuildrelation/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=c637abf09edba88b7793f91201a57c34business 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.txtmanifest.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/packages15.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.4、本章扩展版本与合成 fixture 上, 两轮可重复;全文、模糊、精确向量、RRF、HNSW 测量路径、权限与精确复位 均有证据。它可以进入真实语料与 Pigsty L1 的下一阶段试点,尚未获得生产 性能与真实模型质量批准。
这比一句“PostgreSQL 可以做混合搜索”更窄,也更有用。
上一节:扩展部署与运行代价 · 返回本章目录 · 下一章:经天纬地:时序、空间与时空查询 · 查看全书目录 · 查看索引中心