# 实战：`pg36_shop` 商品混合检索 PoC

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

---

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

```text
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_trgm`、`vector` 或第 14 章 schema，只适合本书本地/开发夹具。
> 生产不得执行这条“删后重建”路径。

## 15.7.1 用冻结向量离线复现实验 {#item-15-7-1}

### 前置状态

本章沿用前章 libpq service：

```ini
[pg36-admin]
host=/path/to/socket-or-host
port=5432
dbname=pg36_shop
user=postgres
```

```bash
chmod 600 /path/to/pg_service.conf
export PGSERVICEFILE=/path/to/pg_service.conf
export PGSERVICE=pg36-admin
```

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

`context.sql` 要求：

```text
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
```

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

### 输入清单

```filetree {title="ch15 关键实验输入"}
- static/labs/ch15/
  - [frozen-corpus.csv](/labs/ch15/frozen-corpus.csv)
  - [frozen-queries.csv](/labs/ch15/frozen-queries.csv)
  - [frozen-judgments.csv](/labs/ch15/frozen-judgments.csv)
  - [fixture.sql](/labs/ch15/fixture.sql)
  - [fixture-manifest.json](/labs/ch15/fixture-manifest.json)
  - [setup.sql](/labs/ch15/setup.sql)
  - [ranking-views.sql](/labs/ch15/ranking-views.sql)
  - [verify.sql](/labs/ch15/verify.sql)
  - 其他实验与验收资产
```

`fixture-manifest.json` 固定：

```text
corpus:
  17 rows
  sha256=7136d6f1705e560c5d564407926b44c455cd59b3883cd640f4f51599806a90c8

queries:
  8 rows
  sha256=4b54b0ee322bf52649b5c682d468ea024ae301d8cac40d63aaaed908c9d8a45d

judgments:
  24 rows
  sha256=657ddc4d82f9b42af589fd64a6326407d5cfb6536b2f2c3c28862279eca41438

loader:
  sha256=2548978f3652f816452f22aed0d560a0d0263234a2f62a8fb77d4009c5545787
```

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

### 单步建立

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

setup 先检查已有 `shop_ch15`：

- schema owner 必须是 `pg36_owner`；
- schema comment 必须是：

  ```text
  pg36 ch15 search quality lab; safe to rebuild
  ```

- relation/index/view 必须在 21 个对象白名单中；
- 每个对象必须有同一 marker；
- schema 不能有未知 routine/operator/opclass。

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

核心表：

```sql
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 (...);
```

索引：

```text
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
```

排名/质量视图：

```text
lexical_ranking
fuzzy_ranking
vector_exact_ranking
hybrid_rrf_ranking
all_ranking
quality_per_query
quality_summary
```

完成摘要：

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

### 逐字节回读

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

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

```bash
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 比较全文、模糊、向量与混合结果 {#item-15-7-2}

### 查看词法解析

```bash
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：

```text
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：

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

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

### 质量结果

```sql
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。

### 计划证据

```bash
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
```

断言：

```text
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 贴成性能结果。

目录证据：

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

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

### ANN 对照

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

结果：

```text
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 的时间不稳定且无业务意义。

### 应用权限

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

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

未授权更新：

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

必须：

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

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

## 15.7.3 输出 ADR、质量证据、生产代价与退出路径 {#item-15-7-3}

### ADR 结论

[`search-adr.md`](/labs/ch15/search-adr.md) 决定：

```text
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`](/labs/ch15/baseline-v1.3-proposal.json)
固定：

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

canonical JSON checksum：

```text
bf92a6ad0f60dc3e125b39dbf67bf4d6c5e50275192bd01a7ca4c50d142f822e
```

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

最终数据库状态：

```text
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 目录

一轮完整采集包含：

```text
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 行不能回答：

```text
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 需要两个一致的显式确认：

```bash
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：

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

删除顺序：

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

没有 `CASCADE`。最终必须：

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

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

```text
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`，不设脱离场景的性能线 {#item-15-7-4}

### 为什么不给“必须 10 ms”

延迟取决于：

```text
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` 的规则定义为：

```text
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 返回了三行”替代。

### 业务场景性能卡

移植时创建一份场景卡：

```yaml
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 上补：

```text
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`。

### 双周期正式运行

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

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

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

`all` 的内部顺序：

```text
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
```

正式结果：

```text
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 可以做混合搜索”更窄，也更有用。

---

[上一节：扩展部署与运行代价](../06/) · [返回本章目录](../) · [下一章：经天纬地：时序、空间与时空查询](/spatiotemporal/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
