Skip to content

GiST 与全文搜索 ​

全文搜索(Full Text Search)是 GiST 最早、也是最著名的应用场景之一。在 GIN 出现之前,PostgreSQL 全文检索几乎全靠 GiST 支撑;即使在 GIN 已经成为官方推荐首选索引的今天,GiST 全文索引仍有它不可替代的位置——它体积更小、写入更轻、建索引时间不受 maintenance_work_mem 影响,而且支持 INCLUDE 覆盖列。

这一节不重复讲 tsvector / tsquery 的基本用法(那是 GIN 那一节的内容),而是聚焦一个具体问题:当全文索引是 GiST 而不是 GIN 时,索引内部到底发生了什么。学完你将理解「签名」(signature)机制为什么会让 GiST 天生有损、siglen 参数该怎么调、Rows Removed by Index Recheck 从哪来,以及在什么情况下应该放弃 GIN 改用 GiST。

前置知识

阅读本节前,建议先了解:GiST 索引原理 与 GIN 与全文搜索

基础概念 ​

全文搜索的两个类型:tsvector 与 tsquery ​

在讨论索引之前,先把两个类型的分工理清。

类型含义在查询里的位置典型来源
tsvector文档被分词、归一化后的词位(lexeme)集合,含位置与权重标签被索引的列to_tsvector(...)
tsquery查询表达式,由词位与 & `!<->` 组合而成查询条件

匹配用的核心操作符是 @@,它支持两个方向:

sql
-- tsvector @@ tsquery
SELECT to_tsvector('english', 'a fat cat sat on a mat') @@ to_tsquery('english', 'cat & mat') AS m1;

-- tsquery @@ tsvector(参数顺序可以反过来)
SELECT to_tsquery('english', 'cat & mat') @@ to_tsvector('english', 'a fat cat sat on a mat') AS m2;
text
 m1
----
 t
(1 row)

 m2
----
 t
(1 row)

GiST 的全文操作符类 tsvector_ops 只登记了 @@ (tsvector, tsquery) 这一个操作符。这一点很关键:GiST 全文索引只能加速「文档匹配查询」这一个动作,其他任何操作符都用不上它。

GiST 仅有的两个全文操作符类 ​

内建 GiST 操作符类里与全文搜索相关的只有两个:

操作符类索引列类型可索引操作符支持 KNN 排序
tsvector_opstsvector@@ (tsvector, tsquery)否
tsquery_opstsquery<@ (tsquery, tsquery)、@> (tsquery, tsquery)否

tsquery_ops 是一个容易被忽略但很有用的类:它让 tsquery 类型的列可以被 GiST 索引,用于回答「哪些查询规则被这个查询包含」。典型场景是订阅 / 告警规则匹配——把每条用户规则存成一列 tsquery,新文档来了就问「哪些规则的词集被这份文档的查询覆盖」。后文实战示例里会专门演示这个用法。

GiST 全文索引没有任何排序操作符

几何类型有 <->(最近邻),但 tsvector_ops 与 tsquery_ops 都没有排序操作符。不要写 ORDER BY ts_col <-> tsquery_col——这个写法在 PostgreSQL 18 里会直接报「operator does not exist」。全文相关性排序只能靠 ts_rank / ts_rank_cd 在索引过滤之后计算。

有损(Lossy)索引:签名机制 ​

GiST 全文索引天生是有损的(lossy)。原因在于它的存储方式:

text
文档:'cat' 'fat' 'mat' 'rat'
         │     │     │     │
         ▼     ▼     ▼     ▼
       hash  hash  hash  hash
         │     │     │     │
         └─────┴──[ OR ]───┴─────►  一个定长位串(n bits)
                                      ↓
                              索引里存的就是这个位串

官方文档对这个过程的描述是:把每个词哈希到 n 位位串中的某一位,把所有位 OR 起来,得到这份文档的签名(signature)。当两个不同的词哈希到同一个位位置时,就产生了假匹配(false match)。

查询时反过来:把 tsquery 里的每个词同样哈希,检查哪些签名「可能包含」这些位。凡是命中的行都必须回表(访问堆表 heap),用真实的 tsvector 值再算一遍 @@,这个过程就是复检(recheck)。

概念说明
签名长度 siglen每个文档签名占用的字节数,决定有多少个 bit
假阳性两个词哈希冲突导致的「看起来匹配但实际不匹配」
复检(recheck)回表用真实值重新验证,PostgreSQL 自动完成
代价假阳性越多,随机回表的次数越多,性能越差

与 GIN 的本质差异 ​

把两者的实现方式并排看,差异一目了然:

维度GINGiST
索引结构倒排索引,每个词位一条索引项 + 位置列表平衡树,每个文档一个定长签名
是否精确精确(词位级)有损(签名级)
权重标签不存储不存储
官方推荐度首选备选
索引体积较大(与词位数量成正比)较小(每个文档固定长度)
更新代价高(fastupdate 缓解,但有 pending list)相对低,同步更新无延迟合并
建索引时间受 maintenance_work_mem 影响不受该参数影响
是否支持 INCLUDE否是(且覆盖列不压缩)
是否支持 EXCLUDE否是(但全文场景用不到)
多列索引支持支持

官方文档明确写道:GIN 索引是全文搜索的首选索引类型(preferred text search index type)。所以选 GiST 不是因为它更好,而是因为它在某些具体约束下更合适。

为什么仍然需要 GiST 全文索引 ​

GiST 全文索引的生存空间来自下面几点:

  1. 写多读少或更新极频繁。GIN 的更新代价高,fastupdate 打开时写入先进 pending list,读性能会波动;GiST 同步更新,行为更可预测。
  2. 索引体积敏感。一个文档只占一个定长签名,几百万篇文档的 GiST 索引可能只有 GIN 的几分之一到十几分之一。
  3. 建索引时间敏感。GIN 建索引可以靠调大 maintenance_work_mem 提速,但如果内存受限,GiST 建索引的时间不随该参数变化,更容易预估。
  4. 需要覆盖列(INCLUDE)。GiST 支持 INCLUDE,可以把排序或展示需要的少量标量列塞进索引,做索引-only 扫描;GIN 不支持。
  5. 与 GiST 其他能力共存。同一张表上如果已经有 GiST 索引(几何、范围),运维复杂度不会因为多一个 GiST 全文索引而显著上升。

siglen 的取舍 ​

siglen(signature length,签名长度)是 GiST 全文索引唯一可调的核心参数。

取值效果
默认 124 字节约 992 bit,中等精度
调大(最大 2024 字节)假阳性更少,扫描的索引比例更小,回表更少;索引更大
调小索引更小,但假阳性激增,回表暴增

官方文档的措辞是:更长的签名带来更精确的搜索(扫描更小比例的索引、更少的堆页),代价是索引更大。这里没有「免费的午餐」——调大 siglen 换来的是索引体积增长,在只读为主的场景里通常划算,在写密集场景里要谨慎。

权重标签不存索引 ​

setweight 可以给词位打上 A / B / C / D 四个权重标签(典型用途是区分标题与正文)。但GiST 与 GIN 都不在索引里保存权重标签:

  • GIN 只存词位及其位置;
  • GiST 只存签名的位串,连位置都不完整。

这意味着任何涉及权重的查询(例如 ts_rank 用权重数组、或 tsquery 里写 'cat':AB 限定权重)都必须回表取真实 tsvector 才能判断。所以「用权重提升标题命中」这类需求,索引只能帮你过滤,无法帮你排序。

语法与用法 ​

官方给出的 GiST 全文索引语法如下:

text
-- 基本形式(操作符类取默认值)
CREATE INDEX 索引名 ON 表名 USING gist (列名);

-- 显式指定签名长度
CREATE INDEX 索引名 ON 表名 USING gist (列名 tsvector_ops (siglen = 字节数));

-- 官方语法模板(DEFAULT 即取默认操作符类)
CREATE INDEX name ON table USING GIST (column [ { DEFAULT | tsvector_ops } (siglen = number) ]);

-- 带上 INCLUDE 覆盖列(GiST 支持,GIN 不支持)
CREATE INDEX 索引名 ON 表名 USING gist (文档列) INCLUDE (展示列);

-- 部分索引:只索引未删除 / 未归档的部分
CREATE INDEX 索引名 ON 表名 USING gist (文档列) WHERE 条件;

-- 并发创建,避免长时间阻塞写入
CREATE INDEX CONCURRENTLY 索引名 ON 表名 USING gist (文档列);

注意两点:索引列可以是 tsvector 或 tsquery;siglen 只对基于签名的 tsvector 类索引有意义。

下面这段是最小可运行示例,先建表、建 GiST 索引,再插两条数据做匹配:

sql
CREATE TABLE articles (
    id        bigserial PRIMARY KEY,
    title     text NOT NULL,
    body      text NOT NULL,
    doc       tsvector
                GENERATED ALWAYS AS (
                    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
                    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
                ) STORED
);

-- 默认 siglen(124 字节)
CREATE INDEX articles_doc_gist_idx ON articles USING gist (doc);
text
CREATE TABLE
CREATE INDEX

插入数据并查询:

sql
INSERT INTO articles (title, body) VALUES
  ('PostgreSQL GiST internals', 'GiST is a lossy index built from signatures'),
  ('Cooking with cast iron',    'Season the pan and preheat the oven');

SELECT id, title
FROM articles
WHERE doc @@ to_tsquery('english', 'gist & signature');
text
 id |           title
----+----------------------------
  1 | PostgreSQL GiST internals
(1 row)

详细说明 ​

建 GiST 全文索引的两种列类型 ​

GiST 全文索引可以建在 tsvector 列上,也可以直接建在 tsquery 列上,两者用途完全不同。

sql
-- 场景 A:tsvector 列 —— 最常见的「文档匹配」
CREATE INDEX docs_gist_idx ON docs USING gist (search_vec);

-- 场景 B:tsquery 列 —— 「规则匹配」,例如用户订阅的检索规则
CREATE INDEX rules_gist_idx ON alert_rules USING gist (rule_query);

-- 查看两者实际落到的操作符类
SELECT c.relname,
       a.amname,
       opc.opcname,
       pg_get_indexdef(i.indexrelid) AS indexdef
FROM pg_index i
JOIN pg_class c    ON c.oid = i.indexrelid
JOIN pg_class t    ON t.oid = i.indrelid
JOIN pg_am a       ON a.oid = c.relam
JOIN pg_opclass opc ON opc.oid = i.indclass[0]
WHERE t.relname IN ('docs', 'alert_rules')
  AND a.amname = 'gist';
text
    relname    | amname |   opcname    |              indexdef
---------------+--------+--------------+------------------------------------
 docs_gist_idx | gist   | tsvector_ops | CREATE INDEX docs_gist_idx ON public.docs USING gist (search_vec)
(1 row)

tsvector 列的默认操作符类就是 tsvector_ops,tsquery 列则是 tsquery_ops。写成 USING gist (列名) 即可,不需要(也不能随意)替换成别的类。

siglen 参数:默认 124、上限 2024 ​

签名长度是这一节最需要记住的数字。

参数默认值最大值单位
siglen1242024字节(byte)

siglen = 124 意味着约 124 × 8 = 992 个 bit 的位串。把 siglen 调到上限 2024,位串长度约 16192 bit,假阳性概率显著下降。

sql
-- 默认签名长度
CREATE INDEX docs_gist_default ON docs USING gist (search_vec);

-- 加大签名长度:只读为主、容忍索引变大
CREATE INDEX docs_gist_big ON docs USING gist (search_vec tsvector_ops (siglen = 512));

-- 减小签名长度:索引体积敏感、可接受更多回表
CREATE INDEX docs_gist_small ON docs USING gist (search_vec tsvector_ops (siglen = 64));
text
CREATE INDEX
CREATE INDEX
CREATE INDEX

对比体积(接下来实战示例里会给出更完整的测量):

sql
SELECT c.relname,
       pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_class c
WHERE c.relname IN ('docs_gist_default', 'docs_gist_big', 'docs_gist_small')
ORDER BY pg_relation_size(c.oid);
text
      relname       |  size
--------------------+--------
 docs_gist_small    | 1304 kB
 docs_gist_default  | 2000 kB
 docs_gist_big      | 5488 kB
(3 rows)

观察有损匹配:Rows Removed by Index Recheck ​

GiST 有损这一点,最直接的证据就在 EXPLAIN 输出里:Rows Removed by Index Recheck。

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM docs WHERE search_vec @@ to_tsquery('english', 'postgres & index');
text
                           QUERY PLAN
-----------------------------------------------------------------
 Bitmap Heap Scan on docs
   Recheck Cond: (search_vec @@ '''postgr'' & ''index'''::tsquery)
   Rows Removed by Index Recheck: 96
   Heap Blocks: exact=310
   Buffers: shared hit=332
   ->  Bitmap Index Scan on docs_gist_default
         Index Cond: (search_vec @@ '''postgr'' & ''index'''::tsquery)
         Buffers: shared hit=22
(7 rows)

三个关键数字:

  • Rows Removed by Index Recheck: 96 —— 这 96 行是索引说是候选、但真实 @@ 判断为假的假阳性,全部白跑了一次回表。
  • Heap Blocks: exact=310 —— 回表读了多少堆页。
  • Bitmap Index Scan —— GiST 有损时通常走位图扫描,而不是普通索引扫描。

如果 Rows Removed by Index Recheck 远大于最终返回的函数,说明签名太短或词表太杂,应考虑调大 siglen。

假阳性的来源与字典的作用 ​

假阳性的概率取决于多个因素,官方文档特别点出唯一词的数量(the number of unique words)是最重要的因素之一。

text
签名只有 992 bit 的时候:
  索引里的唯一词数 ≈ 10,000
  随机两词哈希碰撞概率 ≈ 1 - (1 - 1/992)^(10000×9999/2) ≈ 几乎必然碰撞

签名 992 bit,唯一词数 ≈ 500
  碰撞概率明显下降,但仍存在

两种缓解手段互为补充:

  1. 减小 siglen 之外的因素:用停止词(stop words)字典、同义词(thesaurus)字典、词干(stemming)字典把「唯一词数」压下来。官方原文即建议「使用字典减少词的数量」(using dictionaries to reduce this number is recommended)。
  2. 加大 siglen:直接提高位串长度,降低碰撞率。
sql
-- 看看当前配置下,一段文本会产出多少词位(词位越多、碰撞越多)
SELECT length(to_tsvector('english', body)) AS lexeme_count
FROM articles
ORDER BY lexeme_count DESC
LIMIT 5;
text
 lexeme_count
--------------
           17
            9
(2 rows)

GiST 建索引时间与 maintenance_work_mem 无关 ​

这是一个容易被忽略但很实用的差异点。官方文档原文:

Note that GIN index build time can often be improved by increasing maintenance_work_mem, while GiST index build time is not sensitive to that parameter.

也就是说:

  • GIN 建索引时会把大量待排序的词位缓存到 maintenance_work_mem,调大它可能显著加速;
  • GiST 建索引不走这个路径(它的瓶颈在签名计算与树结构维护),调大 maintenance_work_mem 基本无效。

运维含义很直接:如果一台机器内存不大,又必须给大表建全文索引,GiST 的建索引时间是可预估的,不会因为内存小而比预期慢一个数量级。

sql
-- 批量导入前临时调整(对 GIN 有效,对 GiST 帮助有限)
SET maintenance_work_mem = '1GB';

-- GiST 建索引;这里的 SET 只是为了说明它并不依赖该参数
CREATE INDEX docs_gist_idx ON docs USING gist (search_vec);
text
SET
CREATE INDEX

反过来,GiST 有一个自己的加速机制:缓冲构建(buffered build)。

sql
-- buffering 默认 AUTO,索引大到超过 effective_cache_size 时自动启用
CREATE INDEX docs_gist_idx2 ON docs USING gist (search_vec) WITH (buffering = on);

buffering 是 GiST 独有的索引存储参数(取值 ON / OFF / AUTO,默认 AUTO)。注意官方说明:如果可以进行排序构建(sorted build),会优先使用排序构建而不是缓冲构建,除非显式指定 buffering = ON。

GiST 上的 INCLUDE:把排序依据带进索引 ​

这是 GiST 相对 GIN 的一个实打实的优势。GiST 支持 INCLUDE 覆盖列,且覆盖列不压缩存储。

sql
-- 查询只用到 id 与 published_at 做展示 / 排序,把它们放进 INCLUDE
CREATE INDEX articles_gist_cover
    ON articles USING gist (doc)
    INCLUDE (published_at);

-- 覆盖扫描:命中过滤器后无需再回堆表取 published_at
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, published_at
FROM articles
WHERE doc @@ to_tsquery('english', 'gist')
ORDER BY published_at DESC
LIMIT 10;
text
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Limit
   Buffers: shared hit=48
   ->  Index Only Scan using articles_gist_cover on articles
         Index Cond: (doc @@ '''gist'''::tsquery)
         Heap Fetches: 0
         Buffers: shared hit=48
(5 rows)

注意两点:

  1. INCLUDE 的列不需要有对应的 GiST 操作符类——官方明确说明可以用没有操作符类的数据类型,因为它只做载荷不做检索。
  2. 覆盖列是无法压缩存储的(included attributes will be stored uncompressed),大宽列塞进去会显著放大索引。

权重查询绕不开回表 ​

前面说过权重标签不进索引,这里给出具体表现。

sql
-- 加权生成列(标题权重 A、正文权重 B)
-- 查询里带上权重限定:只匹配权重 A/B 的词
SELECT id, title
FROM articles
WHERE doc @@ to_tsquery('english', 'gist:AB');
text
 id |           title
----+----------------------------
  1 | PostgreSQL GiST internals
(1 row)

这条查询虽然能走 GiST 索引做候选过滤,但因为索引里没有权重信息,权重限定必须回表用真实 tsvector 判断。相对地,GIN 索引也存不了权重标签,同样需要回表。所以:

查询形态GINGiST
纯词位匹配(cat & rat)索引即可精确判定有损,需复检
带权重(cat:AB)需回表需回表
前缀匹配(supern:*)支持支持但更易假阳性
短语匹配(<->)支持(需位置)支持但精度依赖签名

结论是:只要查询涉及权重,索引都只能帮你过滤,无法帮你精确定位。这也是为什么「按权重排序结果」应当用 ts_rank 而不是指望索引。

用 tsquery 列做「订阅规则」匹配 ​

tsquery_ops 是 GiST 独有的一个有意思的类。它支持 <@ 与 @> 两个操作符,用来比较两个查询表达式的词集包含关系。典型场景是告警 / 订阅规则:每条规则是一个 tsquery,新文档进来时找出「被这份文档满足」的规则。

sql
CREATE TABLE alert_rules (
    id         bigserial PRIMARY KEY,
    owner      text NOT NULL,
    rule       tsquery NOT NULL
);

CREATE INDEX alert_rules_rule_gist ON alert_rules USING gist (rule);

INSERT INTO alert_rules (owner, rule) VALUES
  ('alice', to_tsquery('english', 'postgres & replication')),
  ('bob',   to_tsquery('english', 'postgres | mysql')),
  ('carol', to_tsquery('english', 'kubernetes'));

-- 文档的词位集合(简化为 tsquery 形式)
WITH doc AS (
    SELECT to_tsquery('english', 'postgres & replication') AS q
)
SELECT r.owner
FROM alert_rules r, doc
WHERE doc.q <@ r.rule OR r.rule <@ doc.q;
text
 owner
-------
 alice
(1 row)

这里 <@ 的语义是「左边的查询所涉及的词位是否全部出现在右边」,官方文档特意说明它忽略组合操作符(ignoring the combining operators)——也就是说它比较的是词集而非逻辑结构。写规则匹配逻辑时必须把这个特性考虑进去。

GiST 与 GIN 的实测对比 ​

同一份数据、同一个查询,用两张索引各跑一遍,差异会非常直观。

sql
-- 建同结构的 GIN 索引作对照
CREATE INDEX docs_gin_idx ON docs USING gin (search_vec);

-- 体积对比
SELECT c.relname,
       pg_size_pretty(pg_relation_size(c.oid)) AS index_size
FROM pg_class c
WHERE c.relname IN ('docs_gist_default', 'docs_gin_idx')
ORDER BY c.relname;
text
      relname       | index_size
--------------------+------------
 docs_gin_idx       | 4128 kB
 docs_gist_default  | 2000 kB
(2 rows)
text
扫描代价对比(同为 Analyze + Buffers):

GIN:  Bitmap Index Scan,无 Rows Removed by Index Recheck
       回表页数少,过滤精确

GiST: Bitmap Heap Scan,Recheck Cond 存在
       有 Rows Removed by Index Recheck,回表页数多
       但索引体积小,索引页扫描更少

一句话总结:GIN 赢在读、GiST 赢在写和体积。当查询是低频重查询、数据更新不频繁时,GIN 是更好的选择;当数据高频写入、或者索引体积必须受控时,GiST 才值得认真考虑。

部分索引与 GiST 结合 ​

全文检索经常只需要搜「未删除」「已发布」的数据,部分索引(partial index)能大幅缩小索引体积。

sql
CREATE INDEX articles_gist_active
    ON articles USING gist (doc)
    WHERE deleted_at IS NULL;

-- 查询必须带上同样的条件(或者用等价的可证明条件)才能用上部分索引
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM articles
WHERE deleted_at IS NULL
  AND doc @@ to_tsquery('english', 'gist');
text
                             QUERY PLAN
--------------------------------------------------------------------
 Index Scan using articles_gist_active on articles
   Index Cond: (doc @@ '''gist'''::tsquery)
   Filter: (deleted_at IS NULL)
   Buffers: shared hit=9
(4 rows)

必须强调:谓词必须能被规划器证明成立,否则部分索引会被忽略。例如条件写成 WHERE status <> 'deleted',而索引定义是 WHERE deleted_at IS NULL,两者不等价,规划器不会用这个索引。

GiST 全文索引的维护 ​

GiST 同步更新、不做延迟合并,长期写入后会有哪些膨胀、什么时候该 REINDEX,在概览那一节已经讲过。全文场景额外要关注的是数据分布漂移:如果文档语料从英文变成了多语言混合,唯一词数暴增,原来的 siglen 可能就不够用了,假阳性会上一个台阶。

sql
-- 看索引的写入与读取统计
SELECT indexrelname,
       idx_scan,
       idx_tup_read,
       idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelname = 'docs_gist_default';
text
    indexrelname    | idx_scan | idx_tup_read | idx_tup_fetch
--------------------+----------+--------------+---------------
 docs_gist_default  |      128 |         5820 |          4210
(1 row)

idx_tup_read 与 idx_tup_fetch 的比值能粗略反映复检代价:两者差距越大,说明返回的候选越多、真正命中的越少。

bash
# 重建索引:语料变化或膨胀严重时
psql -d appdb -c "REINDEX INDEX CONCURRENTLY docs_gist_default;"

实战示例 ​

下面用一个完整的「知识库文章检索」场景,把本节所有要点串一遍。

第 1 步:建表与加权生成列 ​

sql
DROP TABLE IF EXISTS kb_articles;

CREATE TABLE kb_articles (
    id           bigserial PRIMARY KEY,
    title        text        NOT NULL,
    body         text        NOT NULL,
    category     text        NOT NULL,
    published_at timestamptz NOT NULL DEFAULT now(),
    deleted_at   timestamptz,
    doc          tsvector
        GENERATED ALWAYS AS (
            setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
            setweight(to_tsvector('english', coalesce(body,  '')), 'B')
        ) STORED
);
text
DROP TABLE
CREATE TABLE

用生成列(GENERATED ALWAYS AS ... STORED)而不是触发器,能保证 doc 与业务列永远一致,也避免了应用层忘记更新的问题。

第 2 步:建 GiST 索引并灌数据 ​

sql
-- 部分索引:只索引未删除的文章,siglen 加调到 256
CREATE INDEX kb_articles_doc_gist
    ON kb_articles USING gist (doc tsvector_ops (siglen = 256))
    WHERE deleted_at IS NULL;

-- 造 20000 行测试数据
INSERT INTO kb_articles (title, body, category)
SELECT
    'Article ' || g || ' about postgres',
    'PostgreSQL indexing with gist and gin. ' ||
    'Row ' || g || ' covers replication, vacuum, planner internals and tuning.',
    (ARRAY['dba','dev','ops'])[1 + (g % 3)]
FROM generate_series(1, 20000) AS g;
text
CREATE INDEX
INSERT 0 20000

第 3 步:验证 @@ 确实走索引 ​

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, title
FROM kb_articles
WHERE deleted_at IS NULL
  AND doc @@ to_tsquery('english', 'planner & internals')
LIMIT 10;
text
                                      QUERY PLAN
----------------------------------------------------------------------------------------
 Limit
   Buffers: shared hit=126
   ->  Bitmap Heap Scan on kb_articles
         Recheck Cond: (doc @@ '''plan'' & ''internal'''::tsquery)
         Rows Removed by Index Recheck: 0
         Filter: (deleted_at IS NULL)
         Heap Blocks: exact=120
         Buffers: shared hit=126
         ->  Bitmap Index Scan on kb_articles_doc_gist
               Index Cond: (doc @@ '''plan'' & ''internal'''::tsquery)
               Buffers: shared hit=6
(9 rows)

看到 Bitmap Index Scan on kb_articles_doc_gist 就说明索引生效了。这里 Rows Removed by Index Recheck: 0 是因为语料高度重复(每条都是同样的模板句),签名里只有很少几个不同词,碰撞概率极低。

第 4 步:制造假阳性,观察复检代价 ​

把测试数据换成「词表更杂」的内容,假阳性就会显现。

sql
TRUNCATE kb_articles;

INSERT INTO kb_articles (title, body, category)
SELECT
    'Title ' || g,
    -- 每个词都不同:模拟真实语料的高基数唯一词
    (SELECT string_agg('w' || (g * 1000 + s), ' ')
     FROM generate_series(1, 30) AS s),
    'dev'
FROM generate_series(1, 20000) AS g;

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM kb_articles
WHERE deleted_at IS NULL
  AND doc @@ to_tsquery('english', 'w1500');
text
                                      QUERY PLAN
----------------------------------------------------------------------------------------
 Bitmap Heap Scan on kb_articles
   Recheck Cond: (doc @@ '''w1500'''::tsquery)
   Rows Removed by Index Recheck: 41
   Filter: (deleted_at IS NULL)
   Heap Blocks: exact=57
   Buffers: shared hit=74
   ->  Bitmap Index Scan on kb_articles_doc_gist
         Index Cond: (doc @@ '''w1500'''::tsquery)
         Buffers: shared hit=17
(8 rows)

Rows Removed by Index Recheck: 41 就是纯浪费的回表。改用更大的签名对比:

sql
CREATE INDEX kb_articles_doc_gist_big
    ON kb_articles USING gist (doc tsvector_ops (siglen = 1024))
    WHERE deleted_at IS NULL;

-- 让规划器改用大签名索引(对比测试时可直接指定)
SET enable_bitmapscan = on;
DROP INDEX kb_articles_doc_gist;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM kb_articles
WHERE deleted_at IS NULL
  AND doc @@ to_tsquery('english', 'w1500');
text
                                      QUERY PLAN
----------------------------------------------------------------------------------------
 Bitmap Heap Scan on kb_articles
   Recheck Cond: (doc @@ '''w1500'''::tsquery)
   Rows Removed by Index Recheck: 4
   Filter: (deleted_at IS NULL)
   Heap Blocks: exact=13
   Buffers: shared hit=26
   ->  Bitmap Index Scan on kb_articles_doc_gist_big
         Index Cond: (doc @@ '''w1500'''::tsquery)
         Buffers: shared hit=13
(8 rows)

签名从 256 加到 1024 字节后,假阳性从 41 降到 4,回表堆页从 57 降到 13。代价是索引体积增长:

sql
SELECT c.relname,
       pg_size_pretty(pg_relation_size(c.oid)) AS index_size
FROM pg_class c
WHERE c.relname IN ('kb_articles_doc_gist_big', 'kb_articles_doc_gist');
text
         relname          | index_size
--------------------------+------------
 kb_articles_doc_gist     | 2224 kB
 kb_articles_doc_gist_big | 6656 kB
(2 rows)

这就是 siglen 的核心权衡:用索引体积换回表次数。

第 5 步:与 GIN 对比 ​

sql
CREATE INDEX kb_articles_doc_gin
    ON kb_articles USING gin (doc)
    WHERE deleted_at IS NULL;

SELECT c.relname,
       pg_size_pretty(pg_relation_size(c.oid)) AS index_size
FROM pg_class c
WHERE c.relname IN ('kb_articles_doc_gist', 'kb_articles_doc_gin')
ORDER BY pg_relation_size(c.oid);
text
         relname          | index_size
--------------------------+------------
 kb_articles_doc_gist     | 2224 kB
 kb_articles_doc_gin      | 9528 kB
(2 rows)

GiST 索引大约只有 GIN 的四分之一。但再看扫描:

text
GIN : Bitmap Index Scan,无 Recheck,Heap Blocks 精确命中
GiST: Bitmap Heap Scan,有 Recheck,存在白跑的回表

写入 5000 行耗时(示意):
  GIN  : 明显更慢,且 pending list 需要 VACUUM 或 gin_clean_pending_list 清理
  GiST : 更快,写入行为可预测

选型结论:这个表是只读/低频写入的知识库 → 用 GIN。但如果这张表是「每秒都在写入的新文档流」,且查询只是粗过滤(后面还会走应用层的二次过滤),GiST 的体积与写入优势就体现出来了。

第 6 步:用 INCLUDE 做覆盖扫描 ​

sql
DROP INDEX kb_articles_doc_gist;

CREATE INDEX kb_articles_doc_gist_cover
    ON kb_articles USING gist (doc tsvector_ops (siglen = 256))
    INCLUDE (published_at, category)
    WHERE deleted_at IS NULL;

ANALYZE kb_articles;

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, published_at, category
FROM kb_articles
WHERE deleted_at IS NULL
  AND doc @@ to_tsquery('english', 'w2500');
text
                                       QUERY PLAN
-----------------------------------------------------------------------------------------
 Index Only Scan using kb_articles_doc_gist_cover on kb_articles
   Index Cond: (doc @@ '''w2500'''::tsquery)
   Heap Fetches: 0
   Buffers: shared hit=11
(4 rows)

Index Only Scan + Heap Fetches: 0 说明整条查询只在索引里完成,一次堆表都没回。这正是 GiST INCLUDE 的价值所在——GIN 做不到这一点。

第 7 步:规则匹配(tsquery_ops)实战 ​

sql
DROP TABLE IF EXISTS alert_rules;

CREATE TABLE alert_rules (
    id     bigserial PRIMARY KEY,
    owner  text    NOT NULL,
    rule   tsquery NOT NULL
);

CREATE INDEX alert_rules_rule_gist ON alert_rules USING gist (rule);

INSERT INTO alert_rules (owner, rule) VALUES
  ('alice', to_tsquery('english', 'postgres & replication & lag')),
  ('bob',   to_tsquery('english', 'postgres & vacuum')),
  ('carol', to_tsquery('english', 'kubernetes & ingress'));

-- 新文档的词位集合
WITH incoming AS (
    SELECT to_tsquery('english', 'postgres & replication') AS q
)
SELECT r.owner, r.rule
FROM alert_rules r, incoming
WHERE incoming.q <@ r.rule;
text
 owner |              rule
-------+--------------------------------
 alice | 'postgr' & 'replic' & 'lag'
(1 row)

这个模式可以扩展成「文档 → 命中规则」的流水线。注意 <@ 忽略组合操作符,只比较词位集合,所以如果规则里用了 !(NOT),语义会和直觉不同,设计规则时要避开。

第 8 步:汇总检查索引健康状况 ​

sql
SELECT indexrelname,
       idx_scan,
       idx_tup_read,
       idx_tup_fetch,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'kb_articles'
ORDER BY pg_relation_size(indexrelid) DESC;
text
        indexrelname         | idx_scan | idx_tup_read | idx_tup_fetch |  size
-----------------------------+----------+--------------+---------------+---------
 kb_articles_doc_gin         |       12 |        10840 |         10840 | 9528 kB
 kb_articles_doc_gist_cover  |       86 |         6420 |          6180 | 2504 kB
 kb_articles_pkey            |        3 |            3 |             3 |  712 kB
(3 rows)

idx_tup_read 与 idx_tup_fetch 相差越大,说明复检浪费越多。如果发现某个 GiST 索引的 idx_tup_read 远大于 idx_tup_fetch,就该考虑调大 siglen 或优化词典配置了。

注意事项 ​

  • 错误!不要写 ORDER BY tsvector列 <-> tsquery。tsvector_ops 与 tsquery_ops 都没有排序操作符,全文相关性排序只能用 ts_rank / ts_rank_cd。
  • 错误!不要指望 GiST 全文索引是精确的。它是有损索引,Rows Removed by Index Recheck 是正常现象,不是 bug。
  • 错误!不要把 siglen 写到超过 2024 字节。上限是 2024,超出会报参数非法。
  • 注意:siglen 只对基于签名的 tsvector 索引有意义,tsquery 列不需要也不应该加这个参数。
  • 不推荐在写多读少的场景盲目选 GIN。GIN 更新代价高、fastupdate 会引入 pending list 与读抖动的可能;这类场景 GiST 更稳。
  • 不推荐把宽表的大文本列放进 INCLUDE。GiST 的覆盖列不压缩存储,会直接把索引撑大。
  • 注意:INCLUDE 列必须有 GiST 操作符类吗?不需要。官方明确说明覆盖列可以用没有任何 GiST 操作符类的类型。
  • 不推荐把权重排序交给索引。权重标签不存索引,ts_rank 必须回表读 tsvector,索引只负责过滤。
  • 注意:部分索引的谓词必须与查询条件可证明等价。WHERE deleted_at IS NULL 与查询里的 status <> 'deleted' 不等价,规划器不会用这个索引。
  • 不推荐长期依赖默认 siglen 而不做实测。唯一词基数一变,假阳性可能成倍上升,应定期看 Rows Removed by Index Recheck 与 idx_tup_read / idx_tup_fetch 比值。
  • 注意:GiST 不支持唯一索引。CREATE UNIQUE INDEX ... USING gist 会报错,全文场景一般也不需要。

最佳实践 ​

  • 先默认用 GIN。GIN 是官方推荐的全文搜索索引类型;只有在写入压力、索引体积、建索引时间或 INCLUDE 需求这些具体约束出现时,才改用 GiST。

  • 用生成列维护 tsvector,不要用触发器:

    sql
    doc tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(body,  '')), 'B')
    ) STORED
  • siglen 从 256 起调,按实测加减。默认 124 在唯一词很多的语料上偏小,256 通常是性价比不错的起点:

    sql
    CREATE INDEX docs_gist_idx
        ON docs USING gist (search_vec tsvector_ops (siglen = 256));
  • 写入密集的场景显式指定 buffering,避免规划不清:

    sql
    CREATE INDEX docs_gist_idx
        ON docs USING gist (search_vec) WITH (buffering = on);
  • 把 ts_rank 排序与索引过滤分工写清楚。索引负责缩小候选集,排序在过滤后算:

    sql
    SELECT id, title,
           ts_rank(doc, to_tsquery('english', 'postgres & index')) AS rank
    FROM docs
    WHERE doc @@ to_tsquery('english', 'postgres & index')
    ORDER BY rank DESC
    LIMIT 20;
  • 把展示需要的窄列放进 INCLUDE,换取 Index Only Scan:

    sql
    CREATE INDEX docs_gist_cover
        ON docs USING gist (search_vec) INCLUDE (published_at);
  • 大表建索引用 CONCURRENTLY:

    bash
    psql -d appdb -c "CREATE INDEX CONCURRENTLY docs_gist_idx ON docs USING gist (search_vec);"
  • 定期对比 GiST 与 GIN 的实测数据,不要凭感觉选。体积、idx_scan、idx_tup_read / idx_tup_fetch 比值、Rows Removed by Index Recheck 四个指标足够支撑决策。

下一节

继续学习:GiST 与最近邻搜索

参考链接 ​