GIN / GiST 全文索引
to_tsvector(body) @@ to_tsquery('postgres') 这条表达式本身就能返回正确答案——但它默认要全表扫描,逐行把 body 转成 tsvector 再比对。十万行的商品表上,一次搜索要几百毫秒到几秒,这在真实业务里不可接受。
全文索引要解决的就是这一步:让 @@ 不再逐行计算,而是像查字典一样直接定位到「包含 postgres 这个词元的行」。PostgreSQL 为 tsvector 提供了两种索引类型——GIN 与 GiST,它们在结构、精度、写入成本、维护代价上差异很大,选错会让查询慢十倍、写入慢十倍,或者两者都慢。
本节讲清两种索引的原理差异、创建语法与必需的前提条件(IMMUTABLE)、GIN 的 fast update 机制与相关参数、GiST 的有损压缩、排名与索引的关系,以及索引的日常维护。
前置知识
阅读本节前,建议先了解:全文搜索配置与词典
基础概念
没有索引时 @@ 在做什么
SELECT * FROM articles WHERE to_tsvector('english', body) @@ q;
│
▼
┌──────────────────────────────────────────────┐
│ Seq Scan:逐行读出 body │
│ 第 1 行:to_tsvector('english', '...') → 比对 │
│ 第 2 行:to_tsvector('english', '...') → 比对 │
│ ... │
│ 第 N 行:同上 │
└──────────────────────────────────────────────┘
│
▼
返回命中行(可能只有几条)问题在于第 1 步就要把每一行都做一遍解析 + 词干化 + 归一化,这比单纯读一列文本还贵。索引的价值就是把这 N 次计算预先做掉、并把结果组织成可查找的结构。
GIN:倒排索引
GIN(Generalized Inverted Index,通用倒排索引)是全文搜索的首选,也是官方推荐的类型。它的结构是「词元 → 出现的行」。
GIN 索引(概念示意)
'bark' → { 行 5, 行 12, 行 88 }
'cat' → { 行 5, 行 31 }
'dog' → { 行 5, 行 12 }
'run' → { 行 12, 行 77 }
每个词元(key)只存一次,后面跟一串包含它的行号(posting list)查询 run 时直接跳到 'run' 那一项取出 {12, 77},不需要扫描其他行。GIN 的定位是「精确、快查、写入较重」。
GiST:签名树 + 有损压缩
GiST(Generalized Search Tree,通用搜索树)走的是另一条路:它不存词元本身,而是把每个文档的 tsvector 哈希成一个固定长度的「签名」(signature)。
文档 tsvector: 'bark':5 'cat':7 'dog':3 'run':2
│ 每个词元 hash 到 n 位串中的某一位,全部按位 OR
▼
签名(默认 124 字节 = 992 位)
查询时:把查询词元也 hash 成签名,
用「位包含」关系快速排除掉大量不可能匹配的文档
│
▼
命中的文档必须回表 recheck(因为签名是有损的,会有假阳性)GiST 是「有损的(lossy)」——签名会把不同词元映射到同一位,所以「签名匹配」只能说明「可能匹配」,最终必须回到堆表用原始 tsvector 复核一遍。官方明确说明:GiST 索引会产生假匹配(false match),因此必须做堆检查。GiST 的定位是「省空间、写入略轻、查询要比 GIN 多一步复核」。
两种索引差异速览
| 维度 | GIN | GiST |
|---|---|---|
| 结构 | 倒排(词元 → posting list) | 签名树(有损压缩) |
| 是否精确 | 精确,一般无需回表复核 | 有损,必须回表 recheck |
| 查询速度 | 快(大表上优势明显) | 较慢(假匹配带来额外堆扫描) |
| 索引体积 | 大 | 较小 |
| 构建速度 | 可通过增大 maintenance_work_mem 明显加快 | 对 maintenance_work_mem 不敏感 |
| 写入成本 | 高(一行可能触发多个索引项),有 pending list 缓解 | 相对低 |
| 支持权重查询 | 不存权重标签,涉及权重的查询需 recheck | 同样有损 |
| 支持的操作符类 | tsvector_ops(@@ (tsvector,tsquery)) | tsvector_ops、tsquery_ops |
| 官方推荐 | 首选 | 备选(空间敏感或写入极重时考虑) |
索引名称的坑:两种索引用的操作符类名都叫 tsvector_ops(GiST 另有 tsquery_ops),并没有 gist_ts_ops 这样的名字。写 USING gist (col gist_ts_ops) 会报 operator class "gist_ts_ops" does not exist。
索引不是必需的,但实际几乎必须
官方(12.9)明确写了:全文索引不是必需的,@@ 无索引也能工作;但常规查询应当建索引。判断标准很简单——用 EXPLAIN (ANALYZE, BUFFERS) 看当前是 Seq Scan 还是 Bitmap Index Scan:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM articles WHERE fts @@ to_tsquery('english', 'postgres'); Seq Scan on articles (cost=0.00..4210.00 rows=... width=4)
(actual time=0.02..185.44 rows=... loops=1)Seq Scan + actual time 随表增长线性上升 = 该建索引了。
GIN 为什么写入慢:fast update
插入一行 tsvector 时,GIN 需要为每个词元都做一次索引项插入。一行文档有 200 个不同词元,就是 200 次索引插入——这是 GIN 写放大的根源。
PostgreSQL 的解法是 GIN Fast Update Technique:新元组先插入一个临时的、未排序的 pending entries 列表,不立刻并入主结构。这样写入变成「追加到列表」,快得多。
pending list 里的条目会在三种情况下被并入主结构:
触发时机
─────────────────────────────────────────────────────
1. 表被 VACUUM 或 autoanalyze 时
2. 显式调用 gin_clean_pending_list(index) 时
3. pending list 超过 gin_pending_list_limit 时(自动清理)代价是查询要额外扫 pending list,列表越大搜索越慢;而且「过大」的那一次更新会因为触发清理而比其他更新慢得多。官方给出的缓解办法是让 autovacuum 正常工作。
fastupdate 的适用判断
官方明确:如果稳定、可预测的响应时间比更新速度更重要,可以关闭索引的 fastupdate 存储参数来禁用 pending entries。反之若写入吞吐是瓶颈,保留默认的 fastupdate = on 更合适。
排名与索引的关系
ts_rank / ts_rank_cd 不走索引,它们是在候选集已经缩小之后才执行的排序步骤:
WHERE fts @@ q ← 走 GIN / GiST 索引,把候选集缩到几十/几百行
↓
ORDER BY ts_rank(...) ← 对候选集逐行读 tsvector 打分(I/O 受限,不走索引)
↓
LIMIT 20所以「排名慢」几乎从来不是索引的问题,而是候选集太大。索引的任务是让第一步足够小。另外一个与 GIN 直接相关的点:GIN 只存词元、不存权重标签,因此凡是涉及权重的查询(setweight 后按档位过滤)需要回表 recheck,无法纯靠索引完成。
ts_headline() 与索引无关
ts_headline() 读取原始文档来生成高亮片段,因此无法使用全文索引。它只能用在「已经排好序、已经 LIMIT」的最终页数据上:
正确顺序:WHERE @@ → ORDER BY ts_rank → LIMIT 20 → ts_headline(只对这 20 行)
错误顺序:对全表命中行逐行 ts_headline语法与用法
创建 GIN 索引(推荐)
CREATE INDEX idx_articles_fts ON articles USING GIN (fts);GIN 索引的列必须是 tsvector 类型。 直接对 text 列建 USING GIN 会失败:
ERROR: data type text has no default operator class for access method "gin"
HINT: You must specify an operator class or define a default operator class for the data type.正确做法是先有一列 tsvector(或使用表达式索引,见下)。
创建 GiST 索引
CREATE INDEX idx_articles_fts_gist ON articles USING GIST (fts);带签名长度参数(GiST 专属):
CREATE INDEX idx_articles_fts_gist ON articles
USING GIST (fts tsvector_ops (siglen = 256));GiST 的索引列可以是 tsvector 或 tsquery。siglen 单位是字节,默认 124,最大 2024。
表达式索引:前提是表达式 IMMUTABLE
真实业务里往往不想额外存一列 tsvector,于是用表达式索引:
CREATE INDEX idx_articles_fts ON articles
USING GIN (to_tsvector('english', body));这里有一个决定性细节:to_tsvector 有两个重载,一个是 STABLE,一个是 IMMUTABLE:
to_tsvector(text) → STABLE(依赖 default_text_search_config)
to_tsvector(regconfig, text) → IMMUTABLE(配置名写死在参数里)而 CREATE INDEX 只接受 IMMUTABLE 表达式。用不带配置名的版本会直接报错:
ERROR: functions in index expression must be marked IMMUTABLE-- 错误!单参数 to_tsvector 是 STABLE
-- CREATE INDEX idx ON articles USING GIN (to_tsvector(body));
-- 正确:显式传配置名,得到 IMMUTABLE 版本
CREATE INDEX idx ON articles USING GIN (to_tsvector('english', body));注意 'english' 是字符串常量,会被隐式转换成 regconfig。 但它依然是「常量」,所以表达式满足 IMMUTABLE。不过这带来一个硬约束:查询时必须写一模一样的表达式,否则索引用不上:
-- 能用上上面的索引
SELECT * FROM articles WHERE to_tsvector('english', body) @@ to_tsquery('english', 'postgres');
-- 用不上(配置名不同 → 表达式不同)
SELECT * FROM articles WHERE to_tsvector('simple', body) @@ to_tsquery('simple', 'postgres');生成列 + GIN:更稳的写法
从 PostgreSQL 12 起,可以用存储生成列(STORED generated column)把 tsvector 物化成一列,再对这列建 GIN。这是目前推荐的做法:
ALTER TABLE articles
ADD COLUMN fts tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED;
CREATE INDEX idx_articles_fts ON articles USING GIN (fts);好处有三点:
1. 查询只需写 fts @@ q,写法短、不会与索引表达式不一致
2. 表达式里的配置名固定在表定义里,不会因 default_text_search_config 漂移
3. 可以对这个列再做 setweight、部分索引、多列索引等组合生成列里的表达式同样必须是 IMMUTABLE,所以仍然要显式传 'english'。若想修改表达式,必须 ALTER TABLE ... DROP COLUMN 再重新加(并重建索引)。
gin_trgm_ops 不适用于全文搜索
pg_trgm 扩展提供了 gin_trgm_ops(GIN)与 gist_trgm_ops(GiST)两个操作符类,用于三元组(trigram)模糊匹配:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_title_trgm ON articles USING GIN (title gin_trgm_ops);它解决的是另一种问题,不能替代全文索引:
| 需求 | 用什么 |
|---|---|
| 按词元精确匹配、词干化、布尔逻辑、排名 | 全文索引(tsvector_ops) |
拼写容错、LIKE '%foo%' 加速、相似度排序 | pg_trgm(gin_trgm_ops) |
-- trgm 索引不能加速 @@:@@ 与 trigram 的相似度是两套操作符
-- WHERE title @@ to_tsquery('postgres') ← 用不上 gin_trgm_ops
-- trgm 索引加速的是 LIKE / % / similarity
SELECT title FROM articles WHERE title % 'postgressql'
ORDER BY similarity(title, 'postgressql') DESC LIMIT 10;结论:全文搜索场景不要用 gin_trgm_ops。 但两者可以并存——tsvector 负责精确召回与排名,pg_trgm 负责兜底容错,这在中文场景尤其常见(见下一节)。
部分索引:只索引常被查询的子集
部分索引(partial index)用 WHERE 谓词限定索引覆盖的行:
CREATE INDEX idx_articles_fts_pub ON articles
USING GIN (fts)
WHERE status = 'published';使用条件是:查询的 WHERE 必须能推出索引的谓词。所以下面第一条能用上,第二条用不上:
-- 能用上(status = 'published' 与索引谓词一致)
SELECT id FROM articles
WHERE fts @@ to_tsquery('english', 'postgres') AND status = 'published';
-- 用不上(查询里没有 status 条件,planner 无法证明蕴含关系)
SELECT id FROM articles WHERE fts @@ to_tsquery('english', 'postgres');planner 只识别简单的不等式蕴含,且参数化查询的子句不能配合部分索引(因为值在计划时未知)。
不要用一堆非重叠部分索引代替分区
官方(11.8)明确警告:不要用大量非重叠的部分索引来模拟分区。部分索引适合「排除常见值」或「排除不感兴趣的值」,索引列与谓词列也不必相同。
多列索引
GIN 也支持多列,内部会为每个词元记录「列号 + 键值」的复合 key:
CREATE INDEX idx_articles_fts_multi ON articles
USING GIN (to_tsvector('english', title), to_tsvector('english', body));多列索引只在查询同时用到前列时才高效。实践中更推荐用生成列把多字段合并成一个 tsvector,配合 setweight 标记字段来源:
ALTER TABLE articles
ADD COLUMN fts tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title,'')), 'A') ||
setweight(to_tsvector('english', coalesce(body,'')), 'B')
) STORED;
CREATE INDEX idx_articles_fts ON articles USING GIN (fts);一个列、一个索引,就能同时支持「标题优先」的排名与全文检索。
GIN 存储参数与维护函数
-- 关闭 fast update(稳定性优先时)
CREATE INDEX idx_articles_fts ON articles USING GIN (fts) WITH (fastupdate = off);
-- 对已有索引改参数
ALTER INDEX idx_articles_fts SET (fastupdate = off);
-- 手动把 pending list 并入主结构
SELECT gin_clean_pending_list('idx_articles_fts');相关的 GUC 参数:
| 参数 | 位置 | 作用 |
|---|---|---|
gin_pending_list_limit | 客户端默认参数(19.11) | pending list 的大小上限,超过则触发自动清理 |
gin_fuzzy_search_limit | 客户端默认参数(19.11) | GIN 索引扫描返回集合大小的软上限 |
maintenance_work_mem | 资源消耗 | 影响 GIN 索引构建速度(GiST 对此不敏感) |
SHOW gin_pending_list_limit;
SHOW gin_fuzzy_search_limit;gin_fuzzy_search_limit 的正确理解:它是一个「软上限」,用于在超高频词查询(如匹配几百万行)时提前返回部分结果。默认值为 0(不限制)。设为非零值会牺牲结果完整性换取响应时间,只适合「能容忍召回不全」的场景。
GiST 的 siglen 调优
CREATE INDEX idx_articles_fts_gist ON articles
USING GIST (fts tsvector_ops (siglen = 512)); siglen 越大
├─ 签名越精确 → 假匹配越少 → 回表 recheck 的行越少 → 查询越快
├─ 索引体积越大
└─ 最大 2024 字节官方说明:更长签名让索引扫描能排除更大比例的索引、访问更少的堆页,但索引会更大。有损导致的性能下降程度取决于唯一词元的数量,因此通过词典减少唯一词元数量是有效的优化手段。
详细说明
为什么 GIN 是首选
官方在 12.9 中的表述是明确的:因为 GIN 索引在查询上表现更好,所以它是首选索引类型。 原因是 GIN 精确、无假匹配:
GIN 查询流程
WHERE fts @@ q
→ 定位 q 中每个词元对应的 posting list
→ 求交集,直接得到候选行
→ (仅在涉及权重时回表 recheck)
→ 返回
GiST 查询流程
WHERE fts @@ q
→ 用签名匹配排除大量文档
→ 剩余文档全部回表 recheck(可能很多是假匹配)
→ 返回写多读少的后台批处理任务可以考虑 GiST,读多写少的用户检索场景一律 GIN。
GIN 的写放大与批量导入
GIN 的写放大在高频写入时非常明显。批量导入的正确姿势是:
1. 先 DROP 索引(或先建表、后建索引)
2. COPY / INSERT 完成
3. CREATE INDEX ... USING GIN 一次性构建一次性构建比逐行维护索引快得多,且可以通过增大 maintenance_work_mem 显著加速。反过来,GiST 的构建时间对 maintenance_work_mem 不敏感,所以 GiST 在这条优化路径上没有收益。
权重与 GIN:为什么需要 recheck
setweight 给位置打上 A/B/C/D 标签,但 GIN 索引只保存词元,不保存权重标签。因此:
-- 这类「只匹配 A 档」的查询无法纯靠 GIN 索引完成
SELECT * FROM articles WHERE fts @@ to_tsquery('english', 'postgres') AND fts @@ 'postgres:A';索引能快速缩小到「包含 postgres 的行」,但「这个词元是不是 A 档」必须回表读 tsvector 才知道。这不是缺陷,而是 GIN 用空间换查询速度的设计代价。
维护:REINDEX 与 VACUUM
REINDEX 用于重建膨胀或损坏的索引、让新的存储参数生效、或修复 INVALID 索引:
REINDEX INDEX idx_articles_fts;
REINDEX TABLE articles;
REINDEX TABLE CONCURRENTLY articles; -- 不阻塞写REINDEX 的行为要点:
CONCURRENTLY 不阻塞写;普通重建阻塞写但不阻塞读
其他 option TABLESPACE new_tablespace / VERBOSE
执行期间 search_path 临时被改为 pg_catalog, pg_temp
权限 单个索引/表重建需要表的 MAINTAIN 权限VACUUM 对 GIN 有特殊意义——它会顺带把 pending list 并入主结构,所以 pending list 的自动清理依赖 VACUUM 与 autovacuum 正常工作。
另外,PostgreSQL 18 支持并行构建 GIN 索引,大表建索引时可以通过 max_parallel_maintenance_workers 受益。
一致性检查:amcheck
amcheck 提供了索引一致性检查函数:
CREATE EXTENSION IF NOT EXISTS amcheck;
-- B-tree 检查
SELECT bt_index_check('idx_some_btree');
SELECT bt_index_parent_check('idx_some_btree');
-- GIN 检查(PostgreSQL 18 新增)
SELECT gin_index_check('idx_articles_fts');
-- 堆表检查
SELECT * FROM verify_heapam('articles');版本差异:GIN 的 amcheck 支持
PostgreSQL 18 新增了 gin_index_check(index regclass) returns void,用于检查 GIN 索引的父子元组关系一致性与页图平衡树不变量。在 PG 18 之前,amcheck 只覆盖 B-tree(bt_index_check / bt_index_parent_check)与堆表(verify_heapam),GIN 没有对应的检查函数。如果你的环境低于 PG 18,只能用 REINDEX 或 pg_amcheck 的外部比对方式来排查 GIN 索引问题。
查询重写原则:让索引真的被用上
索引能不能用上,取决于 SQL 写法是否与索引表达式逐字一致:
索引定义:
CREATE INDEX idx ON articles USING GIN (to_tsvector('english', body));
能用上:
WHERE to_tsvector('english', body) @@ to_tsquery('english', :q)
用不上:
WHERE to_tsvector('simple', body) @@ ... ← 配置名不同
WHERE to_tsvector('english', coalesce(body,'')) @@ ... ← 表达式不同
WHERE to_tsvector('english', body) @@ plainto_tsquery(:q) ← 右侧不同不影响,
但左侧必须一致最稳的做法是使用生成列,把表达式固定成表结构的一部分,查询侧只写 fts @@ q。
何时该用 GiST 而不是 GIN
官方没有把 GiST 说成「过时」,但给出了它的适用边界:
选 GiST 的情形
─────────────────────────────────────────────────
• 磁盘空间极度受限,索引体积是首要约束
• 唯一词元数量很少(例如小语料 + 轻量词典),
假匹配可控,recheck 成本低
• 写入极重、且能接受查询稍慢的批处理场景
其余情形
─────────────────────────────────────────────────
→ 一律 GIN再次强调:唯一词元越少,GiST 的有损代价越低,所以「用词典收敛词元」既省索引空间又提升 GiST 精度。
实战示例
示例一:从零给商品表加全文索引
-- 1. 添加存储生成列(表达式必须 IMMUTABLE,故显式传配置名)
ALTER TABLE products
ADD COLUMN fts tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;
-- 2. 建 GIN 索引
CREATE INDEX idx_products_fts ON products USING GIN (fts);
-- 3. 查询:先筛选,再排名,最后 LIMIT
SELECT id, name,
ts_rank('{0.1, 0.2, 0.4, 1.0}', fts, q) AS score
FROM products, websearch_to_tsquery('english', :kw) q
WHERE fts @@ q
ORDER BY score DESC, id DESC
LIMIT 20;
-- 4. 验证走索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM products, websearch_to_tsquery('english', :kw) q WHERE fts @@ q; Bitmap Heap Scan on products
Recheck Cond: (fts @@ q)
-> Bitmap Index Scan on idx_products_fts
Index Cond: (fts @@ q)看到 Bitmap Index Scan 就说明索引生效了。
示例二:表达式索引与查询一致性验证
-- 表达式索引(不落生成列)
CREATE INDEX idx_articles_body_fts ON articles
USING GIN (to_tsvector('english', body));-- 能用上索引
EXPLAIN SELECT id FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('english', 'postgres');
-- → Bitmap Index Scan on idx_articles_body_fts
-- 用不上:配置名不一致
EXPLAIN SELECT id FROM articles
WHERE to_tsvector('simple', body) @@ to_tsquery('simple', 'postgres');
-- → Seq Scan这个对照是最简单也最有效的「索引是否被用上」的自检手段,建议写进代码审查清单。
示例三:部分索引加速「已发布文章」检索
CREATE INDEX idx_articles_fts_pub ON articles
USING GIN (fts)
WHERE status = 'published';
-- 查询必须带上同样的谓词
SELECT id FROM articles
WHERE status = 'published' AND fts @@ websearch_to_tsquery('english', :kw)
ORDER BY ts_rank(fts, websearch_to_tsquery('english', :kw)) DESC
LIMIT 20;对于「绝大多数查询都只看已发布内容」的站点,部分索引能同时减小索引体积与减少无用的索引写入。
示例四:维护与诊断
-- 查看索引体积
SELECT pg_size_pretty(pg_relation_size('idx_articles_fts')) AS gin_size;
-- 对比 GIN 与 GiST 的体积
CREATE INDEX idx_articles_fts_gist ON articles USING GIST (fts);
SELECT pg_size_pretty(pg_relation_size('idx_articles_fts')) AS gin,
pg_size_pretty(pg_relation_size('idx_articles_fts_gist')) AS gist;
-- 检查 GIN 的 pending list 情况(PG 18 的 gin_index_check 只查一致性)
SELECT gin_clean_pending_list('idx_articles_fts');
-- 重建膨胀索引(不阻塞写)
REINDEX INDEX CONCURRENTLY idx_articles_fts;示例五:ts_headline 只用在最终页
SELECT id, name,
ts_rank('{0.1, 0.2, 0.4, 1.0}', fts, q) AS score,
ts_headline('english', description, q, 'MaxWords=30, MinWords=10') AS snippet
FROM (
SELECT id, name, description, fts
FROM products, websearch_to_tsquery('english', :kw) q
WHERE fts @@ q
ORDER BY ts_rank('{0.1, 0.2, 0.4, 1.0}', fts, q) DESC, id DESC
LIMIT 20
) t, websearch_to_tsquery('english', :kw) q
ORDER BY score DESC, id DESC;先 LIMIT 20,再对 20 行做高亮——顺序反了会让整条查询慢几个数量级。
注意事项
GIN 索引的列必须是 tsvector 类型。 对 text 列直接建会报 data type text has no default operator class for access method "gin":
-- 错误
-- CREATE INDEX idx ON articles USING GIN (body);
-- 正确:先建 tsvector 列或生成列
ALTER TABLE articles ADD COLUMN fts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;
CREATE INDEX idx ON articles USING GIN (fts);表达式索引必须 IMMUTABLE。 单参数 to_tsvector(text) 是 STABLE,会报 functions in index expression must be marked IMMUTABLE:
-- 错误
-- CREATE INDEX idx ON articles USING GIN (to_tsvector(body));
-- 正确
CREATE INDEX idx ON articles USING GIN (to_tsvector('english', body));查询表达式必须与索引表达式逐字一致。 差一个配置名、差一层 coalesce,索引就完全用不上,而不会报错——表现为「明明建了索引,还是慢」:
-- 索引建在 to_tsvector('english', body)
-- 下面这条用不上索引
SELECT * FROM articles WHERE to_tsvector('english', coalesce(body,'')) @@ to_tsquery('english', :q);GiST 的全文操作符类叫 tsvector_ops / tsquery_ops。 不存在 gist_ts_ops,写错会报 operator class ... does not exist:
-- 错误
-- CREATE INDEX idx ON articles USING GIST (fts gist_ts_ops);
-- 正确(不带参数或带 siglen)
CREATE INDEX idx ON articles USING GIST (fts);
CREATE INDEX idx2 ON articles USING GIST (fts tsvector_ops (siglen = 256));GiST 的签名是有损的,必须回表 recheck。 命中数远大于真实匹配数是正常现象,不要据此认为索引失效:
EXPLAIN (ANALYZE) SELECT id FROM articles WHERE fts @@ to_tsquery('english','postgres');
-- GiST 下会看到 Recheck Cond 与较大的 rows 估计siglen 必须在合法范围内。 默认 124 字节,最大 2024 字节,超出会报错。调大前先确认索引体积可接受。
gin_fuzzy_search_limit 非零会丢结果。 它只是「软上限」,设为非零会让部分匹配行根本不返回。只有明确能接受召回不全时才设置:
-- 危险:可能返回不完整结果
SET gin_fuzzy_search_limit = 10000;fastupdate = off 会让写入变慢。 关闭 pending list 换来的是稳定响应时间,代价是每次插入都要直接改主结构:
ALTER INDEX idx_articles_fts SET (fastupdate = off);不要对全表命中行调用 ts_headline。 它读原始文档、不走索引,是全链路最贵的一步:
-- 错误!先高亮再 LIMIT
-- SELECT ts_headline('english', body, q) FROM articles, ... WHERE fts @@ q LIMIT 20;
-- 正确:先 ORDER BY + LIMIT 到子查询,再高亮不要用一堆非重叠部分索引代替分区。 官方明确警告,这会让 planner 的选择空间爆炸、维护成本陡增。
ts_rank 的排序不走索引。 排名慢说明候选集太大,应优化 WHERE 的索引条件,而不是给排名函数建索引(建不了)。
最佳实践
默认用 GIN,只有在空间或写入压力构成硬约束时才评估 GiST。 官方把 GIN 列为首选索引类型;GiST 是「空间换查询」的备选。
优先用「存储生成列 + GIN」而不是表达式索引。 生成列把
tsvector物化成表的一部分,查询侧只写fts @@ q,从根上消除了「查询表达式与索引表达式不一致」这类静默失效问题:
ALTER TABLE articles ADD COLUMN fts tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(body,''))) STORED;
CREATE INDEX idx_articles_fts ON articles USING GIN (fts);生成列里用
setweight合并多字段,别建多列 GIN。 一个fts列同时承载标题(A)与正文(B)权重,既能排名又能检索,索引数量最少。表达式里永远显式写配置名。
to_tsvector('english', body)才是IMMUTABLE;单参数版本依赖default_text_search_config,既不能建索引,也会让行为随环境漂移。批量导入时先删索引、导完再一次性建。 并临时调大
maintenance_work_mem加速 GIN 构建:
SET maintenance_work_mem = '512MB';
CREATE INDEX idx_articles_fts ON articles USING GIN (fts);让 autovacuum 正常工作,别关掉它。 GIN 的 pending list 依赖
VACUUM/autoanalyze 清理,autovacuum 异常会直接拖慢查询。只在「稳定延迟优先于写入吞吐」时设
fastupdate = off。 不要为了「写入更快」去关它(那与目标相反),要清楚这个参数的取舍方向。gin_fuzzy_search_limit保持默认0。 除非业务明确接受召回不全,否则不要让这个参数悄悄丢结果。用部分索引覆盖「高频子集」查询。 如
WHERE status = 'published'这类绝大多数查询都带的谓词,能显著减小索引体积;但查询必须显式带上同样的谓词。把
EXPLAIN (ANALYZE, BUFFERS)纳入开发流程。 每次新增或修改全文查询,都确认执行计划里出现Bitmap Index Scan,而不是Seq Scan。定期
REINDEX CONCURRENTLY与体积监控。 GIN 索引在大量更新后会膨胀,用pg_relation_size跟踪体积,用REINDEX ... CONCURRENTLY在不阻塞写的前提下重建。PG 18 上把
gin_index_check()纳入巡检;PG 18 以下用REINDEX或pg_amcheck兜底。 索引一致性问题的代价往往是「查询结果悄悄不对」,比性能问题更危险。
下一节
参考链接
- PostgreSQL 官方文档:Tables and Indexes(12.2,全文索引总览与 GIN 为首选)
- PostgreSQL 官方文档:GIN Indexes(65.4,倒排结构、fast update、pending list)
- PostgreSQL 官方文档:GiST Indexes(65.2,签名、有损、内置操作符类)
- PostgreSQL 官方文档:Indexes on Expressions(11.7,IMMUTABLE 要求)
- PostgreSQL 官方文档:Partial Indexes(11.8,部分索引与谓词蕴含)
- PostgreSQL 官方文档:Multicolumn Indexes(11.3)
- PostgreSQL 官方文档:REINDEX(含 CONCURRENTLY)
- PostgreSQL 官方文档:amcheck(bt_index_check / gin_index_check / verify_heapam)
- PostgreSQL 官方文档:pg_trgm(gin_trgm_ops / gist_trgm_ops 与模糊匹配)
- PostgreSQL 官方文档:Client Connection Defaults(19.11,gin_pending_list_limit / gin_fuzzy_search_limit)