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。
基础概念
全文搜索的两个类型:tsvector 与 tsquery
在讨论索引之前,先把两个类型的分工理清。
| 类型 | 含义 | 在查询里的位置 | 典型来源 |
|---|---|---|---|
tsvector | 文档被分词、归一化后的词位(lexeme)集合,含位置与权重标签 | 被索引的列 | to_tsvector(...) |
tsquery | 查询表达式,由词位与 & ` | !<->` 组合而成 | 查询条件 |
匹配用的核心操作符是 @@,它支持两个方向:
-- 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; m1
----
t
(1 row)
m2
----
t
(1 row)GiST 的全文操作符类 tsvector_ops 只登记了 @@ (tsvector, tsquery) 这一个操作符。这一点很关键:GiST 全文索引只能加速「文档匹配查询」这一个动作,其他任何操作符都用不上它。
GiST 仅有的两个全文操作符类
内建 GiST 操作符类里与全文搜索相关的只有两个:
| 操作符类 | 索引列类型 | 可索引操作符 | 支持 KNN 排序 |
|---|---|---|---|
tsvector_ops | tsvector | @@ (tsvector, tsquery) | 否 |
tsquery_ops | tsquery | <@ (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)。原因在于它的存储方式:
文档:'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 的本质差异
把两者的实现方式并排看,差异一目了然:
| 维度 | GIN | GiST |
|---|---|---|
| 索引结构 | 倒排索引,每个词位一条索引项 + 位置列表 | 平衡树,每个文档一个定长签名 |
| 是否精确 | 精确(词位级) | 有损(签名级) |
| 权重标签 | 不存储 | 不存储 |
| 官方推荐度 | 首选 | 备选 |
| 索引体积 | 较大(与词位数量成正比) | 较小(每个文档固定长度) |
| 更新代价 | 高(fastupdate 缓解,但有 pending list) | 相对低,同步更新无延迟合并 |
| 建索引时间 | 受 maintenance_work_mem 影响 | 不受该参数影响 |
是否支持 INCLUDE | 否 | 是(且覆盖列不压缩) |
是否支持 EXCLUDE | 否 | 是(但全文场景用不到) |
| 多列索引 | 支持 | 支持 |
官方文档明确写道:GIN 索引是全文搜索的首选索引类型(preferred text search index type)。所以选 GiST 不是因为它更好,而是因为它在某些具体约束下更合适。
为什么仍然需要 GiST 全文索引
GiST 全文索引的生存空间来自下面几点:
- 写多读少或更新极频繁。GIN 的更新代价高,
fastupdate打开时写入先进 pending list,读性能会波动;GiST 同步更新,行为更可预测。 - 索引体积敏感。一个文档只占一个定长签名,几百万篇文档的 GiST 索引可能只有 GIN 的几分之一到十几分之一。
- 建索引时间敏感。GIN 建索引可以靠调大
maintenance_work_mem提速,但如果内存受限,GiST 建索引的时间不随该参数变化,更容易预估。 - 需要覆盖列(
INCLUDE)。GiST 支持INCLUDE,可以把排序或展示需要的少量标量列塞进索引,做索引-only 扫描;GIN 不支持。 - 与 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 全文索引语法如下:
-- 基本形式(操作符类取默认值)
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 索引,再插两条数据做匹配:
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);CREATE TABLE
CREATE INDEX插入数据并查询:
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'); id | title
----+----------------------------
1 | PostgreSQL GiST internals
(1 row)详细说明
建 GiST 全文索引的两种列类型
GiST 全文索引可以建在 tsvector 列上,也可以直接建在 tsquery 列上,两者用途完全不同。
-- 场景 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'; 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
签名长度是这一节最需要记住的数字。
| 参数 | 默认值 | 最大值 | 单位 |
|---|---|---|---|
siglen | 124 | 2024 | 字节(byte) |
siglen = 124 意味着约 124 × 8 = 992 个 bit 的位串。把 siglen 调到上限 2024,位串长度约 16192 bit,假阳性概率显著下降。
-- 默认签名长度
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));CREATE INDEX
CREATE INDEX
CREATE INDEX对比体积(接下来实战示例里会给出更完整的测量):
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); 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。
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM docs WHERE search_vec @@ to_tsquery('english', 'postgres & index'); 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)是最重要的因素之一。
签名只有 992 bit 的时候:
索引里的唯一词数 ≈ 10,000
随机两词哈希碰撞概率 ≈ 1 - (1 - 1/992)^(10000×9999/2) ≈ 几乎必然碰撞
签名 992 bit,唯一词数 ≈ 500
碰撞概率明显下降,但仍存在两种缓解手段互为补充:
- 减小
siglen之外的因素:用停止词(stop words)字典、同义词(thesaurus)字典、词干(stemming)字典把「唯一词数」压下来。官方原文即建议「使用字典减少词的数量」(using dictionaries to reduce this number is recommended)。 - 加大
siglen:直接提高位串长度,降低碰撞率。
-- 看看当前配置下,一段文本会产出多少词位(词位越多、碰撞越多)
SELECT length(to_tsvector('english', body)) AS lexeme_count
FROM articles
ORDER BY lexeme_count DESC
LIMIT 5; 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 的建索引时间是可预估的,不会因为内存小而比预期慢一个数量级。
-- 批量导入前临时调整(对 GIN 有效,对 GiST 帮助有限)
SET maintenance_work_mem = '1GB';
-- GiST 建索引;这里的 SET 只是为了说明它并不依赖该参数
CREATE INDEX docs_gist_idx ON docs USING gist (search_vec);SET
CREATE INDEX反过来,GiST 有一个自己的加速机制:缓冲构建(buffered build)。
-- 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 覆盖列,且覆盖列不压缩存储。
-- 查询只用到 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; 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)注意两点:
INCLUDE的列不需要有对应的 GiST 操作符类——官方明确说明可以用没有操作符类的数据类型,因为它只做载荷不做检索。- 覆盖列是无法压缩存储的(included attributes will be stored uncompressed),大宽列塞进去会显著放大索引。
权重查询绕不开回表
前面说过权重标签不进索引,这里给出具体表现。
-- 加权生成列(标题权重 A、正文权重 B)
-- 查询里带上权重限定:只匹配权重 A/B 的词
SELECT id, title
FROM articles
WHERE doc @@ to_tsquery('english', 'gist:AB'); id | title
----+----------------------------
1 | PostgreSQL GiST internals
(1 row)这条查询虽然能走 GiST 索引做候选过滤,但因为索引里没有权重信息,权重限定必须回表用真实 tsvector 判断。相对地,GIN 索引也存不了权重标签,同样需要回表。所以:
| 查询形态 | GIN | GiST |
|---|---|---|
纯词位匹配(cat & rat) | 索引即可精确判定 | 有损,需复检 |
带权重(cat:AB) | 需回表 | 需回表 |
前缀匹配(supern:*) | 支持 | 支持但更易假阳性 |
短语匹配(<->) | 支持(需位置) | 支持但精度依赖签名 |
结论是:只要查询涉及权重,索引都只能帮你过滤,无法帮你精确定位。这也是为什么「按权重排序结果」应当用 ts_rank 而不是指望索引。
用 tsquery 列做「订阅规则」匹配
tsquery_ops 是 GiST 独有的一个有意思的类。它支持 <@ 与 @> 两个操作符,用来比较两个查询表达式的词集包含关系。典型场景是告警 / 订阅规则:每条规则是一个 tsquery,新文档进来时找出「被这份文档满足」的规则。
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; owner
-------
alice
(1 row)这里 <@ 的语义是「左边的查询所涉及的词位是否全部出现在右边」,官方文档特意说明它忽略组合操作符(ignoring the combining operators)——也就是说它比较的是词集而非逻辑结构。写规则匹配逻辑时必须把这个特性考虑进去。
GiST 与 GIN 的实测对比
同一份数据、同一个查询,用两张索引各跑一遍,差异会非常直观。
-- 建同结构的 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; relname | index_size
--------------------+------------
docs_gin_idx | 4128 kB
docs_gist_default | 2000 kB
(2 rows)扫描代价对比(同为 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)能大幅缩小索引体积。
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'); 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 可能就不够用了,假阳性会上一个台阶。
-- 看索引的写入与读取统计
SELECT indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelname = 'docs_gist_default'; indexrelname | idx_scan | idx_tup_read | idx_tup_fetch
--------------------+----------+--------------+---------------
docs_gist_default | 128 | 5820 | 4210
(1 row)idx_tup_read 与 idx_tup_fetch 的比值能粗略反映复检代价:两者差距越大,说明返回的候选越多、真正命中的越少。
# 重建索引:语料变化或膨胀严重时
psql -d appdb -c "REINDEX INDEX CONCURRENTLY docs_gist_default;"实战示例
下面用一个完整的「知识库文章检索」场景,把本节所有要点串一遍。
第 1 步:建表与加权生成列
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
);DROP TABLE
CREATE TABLE用生成列(GENERATED ALWAYS AS ... STORED)而不是触发器,能保证 doc 与业务列永远一致,也避免了应用层忘记更新的问题。
第 2 步:建 GiST 索引并灌数据
-- 部分索引:只索引未删除的文章,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;CREATE INDEX
INSERT 0 20000第 3 步:验证 @@ 确实走索引
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; 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 步:制造假阳性,观察复检代价
把测试数据换成「词表更杂」的内容,假阳性就会显现。
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'); 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 就是纯浪费的回表。改用更大的签名对比:
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'); 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。代价是索引体积增长:
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'); relname | index_size
--------------------------+------------
kb_articles_doc_gist | 2224 kB
kb_articles_doc_gist_big | 6656 kB
(2 rows)这就是 siglen 的核心权衡:用索引体积换回表次数。
第 5 步:与 GIN 对比
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); relname | index_size
--------------------------+------------
kb_articles_doc_gist | 2224 kB
kb_articles_doc_gin | 9528 kB
(2 rows)GiST 索引大约只有 GIN 的四分之一。但再看扫描:
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 做覆盖扫描
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'); 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)实战
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; owner | rule
-------+--------------------------------
alice | 'postgr' & 'replic' & 'lag'
(1 row)这个模式可以扩展成「文档 → 命中规则」的流水线。注意 <@ 忽略组合操作符,只比较词位集合,所以如果规则里用了 !(NOT),语义会和直觉不同,设计规则时要避开。
第 8 步:汇总检查索引健康状况
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; 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,不要用触发器:sqldoc tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(body, '')), 'B') ) STOREDsiglen从 256 起调,按实测加减。默认 124 在唯一词很多的语料上偏小,256 通常是性价比不错的起点:sqlCREATE INDEX docs_gist_idx ON docs USING gist (search_vec tsvector_ops (siglen = 256));写入密集的场景显式指定
buffering,避免规划不清:sqlCREATE INDEX docs_gist_idx ON docs USING gist (search_vec) WITH (buffering = on);把
ts_rank排序与索引过滤分工写清楚。索引负责缩小候选集,排序在过滤后算:sqlSELECT 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:sqlCREATE INDEX docs_gist_cover ON docs USING gist (search_vec) INCLUDE (published_at);大表建索引用
CONCURRENTLY:bashpsql -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 与最近邻搜索