# PostgreSQL 全文检索

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

---

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

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

## 15.2.1 文档、词典、配置与 `tsvector` {#item-15-2-1}

### 文档是检索单位，不一定是一列

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

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

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

从原文到 `tsvector` 的管线是：

```text
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](https://www.postgresql.org/docs/18/textsearch-intro.html)。

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

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

```sql
to_tsvector(title || ' ' || description)
```

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

本章始终显式写：

```sql
'pg_catalog.english'::pg_catalog.regconfig
```

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

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

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

检查当前配置：

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

调试某段文本：

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

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

### 多字段用权重表达结构

本章定义：

```sql
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](https://www.postgresql.org/docs/18/ddl-generated-columns.html)。

因此它适合：

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

它不适合直接调用：

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

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

### 用目录证明生成关系

看值：

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

看生成属性与表达式：

```sql
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 查询语法、权重与相关性排序 {#item-15-2-2}

### `tsquery` 不是原始字符串

`@@` 比较的是 document 与 query：

```sql
search_document @@ parsed_query
```

常用构造函数：

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

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

本章选择：

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

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

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

参见
[Controlling Text Search](https://www.postgresql.org/docs/18/textsearch-controls.html)。

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

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

本章词法排名：

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

```text
rank / (rank + 1)
```

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

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

```sql
-- 错：看到都在 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 决定：

```sql
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。调权重时应记录：

```text
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 索引、更新与语言边界 {#item-15-2-3}

### GIN 是通常的全文首选

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

```sql
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](https://www.postgresql.org/docs/18/textsearch-indexes.html)。

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

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

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

```sql
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 和副本延迟。生产实验要
分别测：

```text
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 承担尖峰。检查：

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

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

```sql
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 与延迟尖峰，而
不是全局照抄一组值。

### 语言边界是功能边界

本章的英语结果：

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

这证明：

- stemming/stop word 能处理词法变体与普通短语；
- 它不会自动修正 `wireles`、`hedphones`、`databse`；
- AND 语义可能把只命中部分词的 document 排除。

对中文或混合语言，先回答：

```text
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、扩展与
外部搜索系统，保持双写、回放、校验和退出路径。

---

[上一节：先定义检索任务与评估集](../01/) · [返回本章目录](../) · [下一节：模糊匹配与拼写容错](../03/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
