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

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

---

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

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

## 15.1.1 精确筛选、词法相关与语义相似 {#item-15-1-1}

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

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

```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` 按三个连续字符的集合衡量重合，
所以对局部拼写错误很有效；`database` 与 `databse` 很近，但字符很近并不
保证商品意图相同。

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

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

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

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

### 把任务写成合同

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

```yaml
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 查询、候选、排序和过滤的分层 {#item-15-1-2}

### 一条检索链有六个阶段

```text
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` 表达式。

本章显式使用：

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

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

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

逻辑目标是：

```sql
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 明确显示：

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

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

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

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

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

候选源还必须带上：

```text
query_id
document_id
source
source_rank
source_score_or_distance
filter/version/model identity
```

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

### 排名必须确定

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

```sql
ORDER BY score DESC, product_id;
```

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

### 过滤正确性优先于相关性

本章商品 17：

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

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

这条原则可推广为：

```text
authorization / tenant / lifecycle correctness
beats relevance score
```

## 15.1.3 建立带人工相关性标签的查询集合 {#item-15-1-3}

### 三份输入，三个不同身份

本章把输入拆成：

- [`frozen-corpus.csv`](/labs/ch15/frozen-corpus.csv)：可被检索的对象；
- [`frozen-queries.csv`](/labs/ch15/frozen-queries.csv)：查询、过滤与查询
  向量；
- [`frozen-judgments.csv`](/labs/ch15/frozen-judgments.csv)：查询—商品的
  分级相关性。

数据库中对应：

```sql
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，例如：

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

### 四个指标回答四个问题

令前 \(K\) 个结果为 \(R_K\)，相关集合为 \(G\)。

Precision@K：

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

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

Recall@K：

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

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

MRR@K：

\[
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 = \sum_{i=1}^{K}
\frac{2^{grade_i}-1}{\log_2(i+1)}
\]

\[
NDCG@K = \frac{DCG@K}{IDCG@K}
\]

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

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

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

### 冻结不等于永久

[`fixture-manifest.json`](/labs/ch15/fixture-manifest.json) 固定：

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

自动化执行：

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

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

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

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

发布报告要同时写：

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

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

### 本章验收

运行：

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

审校器要求：

```text
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 全文检索](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
