Skip to content

GIN / GiST 全文索引 ​

to_tsvector(body) @@ to_tsquery('postgres') 这条表达式本身就能返回正确答案——但它默认要全表扫描,逐行把 body 转成 tsvector 再比对。十万行的商品表上,一次搜索要几百毫秒到几秒,这在真实业务里不可接受。

全文索引要解决的就是这一步:让 @@ 不再逐行计算,而是像查字典一样直接定位到「包含 postgres 这个词元的行」。PostgreSQL 为 tsvector 提供了两种索引类型——GIN 与 GiST,它们在结构、精度、写入成本、维护代价上差异很大,选错会让查询慢十倍、写入慢十倍,或者两者都慢。

本节讲清两种索引的原理差异、创建语法与必需的前提条件(IMMUTABLE)、GIN 的 fast update 机制与相关参数、GiST 的有损压缩、排名与索引的关系,以及索引的日常维护。

前置知识

阅读本节前,建议先了解:全文搜索配置与词典

基础概念 ​

没有索引时 @@ 在做什么 ​

text
   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,通用倒排索引)是全文搜索的首选,也是官方推荐的类型。它的结构是「词元 → 出现的行」。

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

text
   文档 tsvector: 'bark':5 'cat':7 'dog':3 'run':2
        │  每个词元 hash 到 n 位串中的某一位,全部按位 OR
        ▼
   签名(默认 124 字节 = 992 位)

   查询时:把查询词元也 hash 成签名,
          用「位包含」关系快速排除掉大量不可能匹配的文档
        │
        ▼
   命中的文档必须回表 recheck(因为签名是有损的,会有假阳性)

GiST 是「有损的(lossy)」——签名会把不同词元映射到同一位,所以「签名匹配」只能说明「可能匹配」,最终必须回到堆表用原始 tsvector 复核一遍。官方明确说明:GiST 索引会产生假匹配(false match),因此必须做堆检查。GiST 的定位是「省空间、写入略轻、查询要比 GIN 多一步复核」。

两种索引差异速览 ​

维度GINGiST
结构倒排(词元 → 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:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM articles WHERE fts @@ to_tsquery('english', 'postgres');
text
 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 里的条目会在三种情况下被并入主结构:

text
   触发时机
   ─────────────────────────────────────────────────────
   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 不走索引,它们是在候选集已经缩小之后才执行的排序步骤:

text
   WHERE fts @@ q        ← 走 GIN / GiST 索引,把候选集缩到几十/几百行
        ↓
   ORDER BY ts_rank(...)  ← 对候选集逐行读 tsvector 打分(I/O 受限,不走索引)
        ↓
   LIMIT 20

所以「排名慢」几乎从来不是索引的问题,而是候选集太大。索引的任务是让第一步足够小。另外一个与 GIN 直接相关的点:GIN 只存词元、不存权重标签,因此凡是涉及权重的查询(setweight 后按档位过滤)需要回表 recheck,无法纯靠索引完成。

ts_headline() 与索引无关 ​

ts_headline() 读取原始文档来生成高亮片段,因此无法使用全文索引。它只能用在「已经排好序、已经 LIMIT」的最终页数据上:

text
   正确顺序:WHERE @@ → ORDER BY ts_rank → LIMIT 20 → ts_headline(只对这 20 行)
   错误顺序:对全表命中行逐行 ts_headline

语法与用法 ​

创建 GIN 索引(推荐) ​

sql
CREATE INDEX idx_articles_fts ON articles USING GIN (fts);

GIN 索引的列必须是 tsvector 类型。 直接对 text 列建 USING GIN 会失败:

text
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 索引 ​

sql
CREATE INDEX idx_articles_fts_gist ON articles USING GIST (fts);

带签名长度参数(GiST 专属):

sql
CREATE INDEX idx_articles_fts_gist ON articles
    USING GIST (fts tsvector_ops (siglen = 256));

GiST 的索引列可以是 tsvector 或 tsquery。siglen 单位是字节,默认 124,最大 2024。

表达式索引:前提是表达式 IMMUTABLE ​

真实业务里往往不想额外存一列 tsvector,于是用表达式索引:

sql
CREATE INDEX idx_articles_fts ON articles
    USING GIN (to_tsvector('english', body));

这里有一个决定性细节:to_tsvector 有两个重载,一个是 STABLE,一个是 IMMUTABLE:

text
   to_tsvector(text)                 → STABLE(依赖 default_text_search_config)
   to_tsvector(regconfig, text)      → IMMUTABLE(配置名写死在参数里)

而 CREATE INDEX 只接受 IMMUTABLE 表达式。用不带配置名的版本会直接报错:

text
ERROR:  functions in index expression must be marked IMMUTABLE
sql
-- 错误!单参数 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。不过这带来一个硬约束:查询时必须写一模一样的表达式,否则索引用不上:

sql
-- 能用上上面的索引
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。这是目前推荐的做法:

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

好处有三点:

text
   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)模糊匹配:

sql
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)
sql
-- 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 谓词限定索引覆盖的行:

sql
CREATE INDEX idx_articles_fts_pub ON articles
    USING GIN (fts)
    WHERE status = 'published';

使用条件是:查询的 WHERE 必须能推出索引的谓词。所以下面第一条能用上,第二条用不上:

sql
-- 能用上(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:

sql
CREATE INDEX idx_articles_fts_multi ON articles
    USING GIN (to_tsvector('english', title), to_tsvector('english', body));

多列索引只在查询同时用到前列时才高效。实践中更推荐用生成列把多字段合并成一个 tsvector,配合 setweight 标记字段来源:

sql
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 存储参数与维护函数 ​

sql
-- 关闭 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 对此不敏感)
sql
SHOW gin_pending_list_limit;
SHOW gin_fuzzy_search_limit;

gin_fuzzy_search_limit 的正确理解:它是一个「软上限」,用于在超高频词查询(如匹配几百万行)时提前返回部分结果。默认值为 0(不限制)。设为非零值会牺牲结果完整性换取响应时间,只适合「能容忍召回不全」的场景。

GiST 的 siglen 调优 ​

sql
CREATE INDEX idx_articles_fts_gist ON articles
    USING GIST (fts tsvector_ops (siglen = 512));
text
   siglen 越大
     ├─ 签名越精确 → 假匹配越少 → 回表 recheck 的行越少 → 查询越快
     ├─ 索引体积越大
     └─ 最大 2024 字节

官方说明:更长签名让索引扫描能排除更大比例的索引、访问更少的堆页,但索引会更大。有损导致的性能下降程度取决于唯一词元的数量,因此通过词典减少唯一词元数量是有效的优化手段。

详细说明 ​

为什么 GIN 是首选 ​

官方在 12.9 中的表述是明确的:因为 GIN 索引在查询上表现更好,所以它是首选索引类型。 原因是 GIN 精确、无假匹配:

text
   GIN 查询流程
   WHERE fts @@ q
     → 定位 q 中每个词元对应的 posting list
     → 求交集,直接得到候选行
     → (仅在涉及权重时回表 recheck)
     → 返回

   GiST 查询流程
   WHERE fts @@ q
     → 用签名匹配排除大量文档
     → 剩余文档全部回表 recheck(可能很多是假匹配)
     → 返回

写多读少的后台批处理任务可以考虑 GiST,读多写少的用户检索场景一律 GIN。

GIN 的写放大与批量导入 ​

GIN 的写放大在高频写入时非常明显。批量导入的正确姿势是:

text
   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 索引只保存词元,不保存权重标签。因此:

sql
-- 这类「只匹配 A 档」的查询无法纯靠 GIN 索引完成
SELECT * FROM articles WHERE fts @@ to_tsquery('english', 'postgres') AND fts @@ 'postgres:A';

索引能快速缩小到「包含 postgres 的行」,但「这个词元是不是 A 档」必须回表读 tsvector 才知道。这不是缺陷,而是 GIN 用空间换查询速度的设计代价。

维护:REINDEX 与 VACUUM ​

REINDEX 用于重建膨胀或损坏的索引、让新的存储参数生效、或修复 INVALID 索引:

sql
REINDEX INDEX idx_articles_fts;
REINDEX TABLE articles;
REINDEX TABLE CONCURRENTLY articles;   -- 不阻塞写

REINDEX 的行为要点:

text
   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 提供了索引一致性检查函数:

sql
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 写法是否与索引表达式逐字一致:

text
   索引定义:
     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 说成「过时」,但给出了它的适用边界:

text
   选 GiST 的情形
   ─────────────────────────────────────────────────
   • 磁盘空间极度受限,索引体积是首要约束
   • 唯一词元数量很少(例如小语料 + 轻量词典),
     假匹配可控,recheck 成本低
   • 写入极重、且能接受查询稍慢的批处理场景

   其余情形
   ─────────────────────────────────────────────────
   → 一律 GIN

再次强调:唯一词元越少,GiST 的有损代价越低,所以「用词典收敛词元」既省索引空间又提升 GiST 精度。

实战示例 ​

示例一:从零给商品表加全文索引 ​

sql
-- 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;
text
 Bitmap Heap Scan on products
   Recheck Cond: (fts @@ q)
   ->  Bitmap Index Scan on idx_products_fts
         Index Cond: (fts @@ q)

看到 Bitmap Index Scan 就说明索引生效了。

示例二:表达式索引与查询一致性验证 ​

sql
-- 表达式索引(不落生成列)
CREATE INDEX idx_articles_body_fts ON articles
    USING GIN (to_tsvector('english', body));
sql
-- 能用上索引
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

这个对照是最简单也最有效的「索引是否被用上」的自检手段,建议写进代码审查清单。

示例三:部分索引加速「已发布文章」检索 ​

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

对于「绝大多数查询都只看已发布内容」的站点,部分索引能同时减小索引体积与减少无用的索引写入。

示例四:维护与诊断 ​

sql
-- 查看索引体积
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 只用在最终页 ​

sql
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":

sql
-- 错误
-- 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:

sql
-- 错误
-- CREATE INDEX idx ON articles USING GIN (to_tsvector(body));

-- 正确
CREATE INDEX idx ON articles USING GIN (to_tsvector('english', body));

查询表达式必须与索引表达式逐字一致。 差一个配置名、差一层 coalesce,索引就完全用不上,而不会报错——表现为「明明建了索引,还是慢」:

sql
-- 索引建在 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:

sql
-- 错误
-- 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。 命中数远大于真实匹配数是正常现象,不要据此认为索引失效:

sql
EXPLAIN (ANALYZE) SELECT id FROM articles WHERE fts @@ to_tsquery('english','postgres');
-- GiST 下会看到 Recheck Cond 与较大的 rows 估计

siglen 必须在合法范围内。 默认 124 字节,最大 2024 字节,超出会报错。调大前先确认索引体积可接受。

gin_fuzzy_search_limit 非零会丢结果。 它只是「软上限」,设为非零会让部分匹配行根本不返回。只有明确能接受召回不全时才设置:

sql
-- 危险:可能返回不完整结果
SET gin_fuzzy_search_limit = 10000;

fastupdate = off 会让写入变慢。 关闭 pending list 换来的是稳定响应时间,代价是每次插入都要直接改主结构:

sql
ALTER INDEX idx_articles_fts SET (fastupdate = off);

不要对全表命中行调用 ts_headline。 它读原始文档、不走索引,是全链路最贵的一步:

sql
-- 错误!先高亮再 LIMIT
-- SELECT ts_headline('english', body, q) FROM articles, ... WHERE fts @@ q LIMIT 20;

-- 正确:先 ORDER BY + LIMIT 到子查询,再高亮

不要用一堆非重叠部分索引代替分区。 官方明确警告,这会让 planner 的选择空间爆炸、维护成本陡增。

ts_rank 的排序不走索引。 排名慢说明候选集太大,应优化 WHERE 的索引条件,而不是给排名函数建索引(建不了)。

最佳实践 ​

  1. 默认用 GIN,只有在空间或写入压力构成硬约束时才评估 GiST。 官方把 GIN 列为首选索引类型;GiST 是「空间换查询」的备选。

  2. 优先用「存储生成列 + GIN」而不是表达式索引。 生成列把 tsvector 物化成表的一部分,查询侧只写 fts @@ q,从根上消除了「查询表达式与索引表达式不一致」这类静默失效问题:

sql
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);
  1. 生成列里用 setweight 合并多字段,别建多列 GIN。 一个 fts 列同时承载标题(A)与正文(B)权重,既能排名又能检索,索引数量最少。

  2. 表达式里永远显式写配置名。 to_tsvector('english', body) 才是 IMMUTABLE;单参数版本依赖 default_text_search_config,既不能建索引,也会让行为随环境漂移。

  3. 批量导入时先删索引、导完再一次性建。 并临时调大 maintenance_work_mem 加速 GIN 构建:

sql
SET maintenance_work_mem = '512MB';
CREATE INDEX idx_articles_fts ON articles USING GIN (fts);
  1. 让 autovacuum 正常工作,别关掉它。 GIN 的 pending list 依赖 VACUUM/autoanalyze 清理,autovacuum 异常会直接拖慢查询。

  2. 只在「稳定延迟优先于写入吞吐」时设 fastupdate = off。 不要为了「写入更快」去关它(那与目标相反),要清楚这个参数的取舍方向。

  3. gin_fuzzy_search_limit 保持默认 0。 除非业务明确接受召回不全,否则不要让这个参数悄悄丢结果。

  4. 用部分索引覆盖「高频子集」查询。 如 WHERE status = 'published' 这类绝大多数查询都带的谓词,能显著减小索引体积;但查询必须显式带上同样的谓词。

  5. 把 EXPLAIN (ANALYZE, BUFFERS) 纳入开发流程。 每次新增或修改全文查询,都确认执行计划里出现 Bitmap Index Scan,而不是 Seq Scan。

  6. 定期 REINDEX CONCURRENTLY 与体积监控。 GIN 索引在大量更新后会膨胀,用 pg_relation_size 跟踪体积,用 REINDEX ... CONCURRENTLY 在不阻塞写的前提下重建。

  7. PG 18 上把 gin_index_check() 纳入巡检;PG 18 以下用 REINDEX 或 pg_amcheck 兜底。 索引一致性问题的代价往往是「查询结果悄悄不对」,比性能问题更危险。

参考链接 ​