跳至内容

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_tsqueryweb 风格文本、引号、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、扩展与 外部搜索系统,保持双写、回放、校验和退出路径。


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

最后更新于