Skip to content

全文搜索概述 ​

全文搜索(Full-Text Search,简称 FTS)是数据库对自然语言文本进行「语义级」检索的能力:它不比较字符子串,而是把文本切分、归一化成词位(Lexeme),再判断一篇文档是否满足查询条件,并按相关度排序。PostgreSQL 从 8.3 版本起就把这套能力做进了内核——tsvector、tsquery 两个数据类型,@@ 匹配操作符,加上 GIN / GiST 索引,构成了一个不需要任何外部服务的站内搜索引擎。

本节不深入细节,只回答三个问题:全文搜索和 LIKE 到底差在哪、整套东西由哪些部件组成、一个完整的检索流程长什么样。 后续各节再逐层展开。

前置知识

阅读本节前,建议先了解:字符串类型(char / varchar / text)、硬件选型建议

基础概念 ​

为什么需要全文搜索 ​

假设你有一张 500 万行的文章表,用户输入「postgres index」想找相关文章。用传统写法:

sql
-- 传统写法:子串匹配
SELECT id, title
FROM articles
WHERE body LIKE '%postgres%'
  AND body LIKE '%index%';

这条 SQL 有三个致命问题:

  1. 无法用普通 B-Tree 索引加速。%xxx% 前置通配符导致索引失效,只能全表扫描 500 万行,每次查询都要读完整张表。
  2. 词形变化搜不到。用户搜 index,正文里写的是 indexes、indexing、indexed——LIKE '%index%' 只能碰巧覆盖 indexes,覆盖不了 indexing 之外的变体(比如 indices)。
  3. 没有相关性排序。匹配 3 万行,哪一行最相关?LIKE 给不出答案,只能按 id 或时间排。

全文搜索同时解决这三件事:

问题全文搜索的做法
性能把文档预计算成 tsvector,建 GIN 索引,查询走索引位图扫描
词形内置词干化(Stemming),indexes / indexing / indexed 统一还原为 index
排序ts_rank() / ts_rank_cd() 计算相关度,可结合权重(标题 > 正文)

全文搜索与 LIKE / 正则的对比 ​

能力LIKE / ILIKE正则 ~全文搜索
匹配单位字符子串字符模式词位(Lexeme)
词干还原不支持不支持支持(foxes → fox)
停用词过滤不支持不支持支持(the、a、is 被丢弃)
短语 / 邻近匹配只能靠字符串相邻可用但耗 CPU<->、<2> 基于位置数组,快
布尔组合手工拼多个 LIKE部分支持&、|、! 原生
相关度排序无无ts_rank
结果高亮手工实现手工实现ts_headline
索引支持pg_trgm 有限支持pg_trgm 有限支持GIN / GiST 原生
语言感知无无有(按语言配置停用词与词干规则)
中文分词子串匹配可用子串匹配可用需扩展(zhparser / pg_jieba)

结论:两者不是替代关系。 前缀补全、电话号码模糊匹配、LIKE 'ABC-%' 这类结构化前缀查找,仍然应该用 LIKE 或 pg_trgm。全文搜索的战场是「用户用自然语言描述想找什么」的场景。

核心术语表 ​

术语英文含义
文档Document被检索的单位,通常是一行记录的一个或多个文本字段
词位Lexeme归一化后的词(foxes → fox),是 tsvector 的元素
解析器Parser把文本切成 token 并标注类型(asciiword、numword、email…)
词典Dictionary把 token 归一化成词位,并决定是否丢弃(停用词)
配置Configuration解析器 + 一组「token 类型 → 词典」映射的绑定
停用词Stop Word无检索意义的词(the、a、is、的、了)
词干化Stemming去掉词缀还原词根(jumped → jump、lazi)
归一化Normalization转小写、去标点、词干化等处理的总称

工作原理示意 ​

text
   原始文本
   "The quick brown foxes jumped over the lazy dogs"
        │
        │ ① 解析器 Parser(pg_catalog.default)
        │    按字符类别切分,标注 token 类型
        ▼
   [ The:asciiword ] [ quick:asciiword ] [ brown:asciiword ]
   [ foxes:asciiword ] [ jumped:asciiword ] [ over:asciiword ]
   [ the:asciiword ] [ lazy:asciiword ] [ dogs:asciiword ]
        │
        │ ② 词典 Dictionary(按配置映射)
        │    asciiword → english_stem(snowball 词干词典)
        │    "The"/"the" 命中停用词表 → 丢弃
        ▼
   quick, brown, fox, jump, over, lazi, dog
        │
        │ ③ 组装 tsvector(词位 + 位置 + 权重)
        ▼
   'brown':3 'dog':8 'fox':4 'jump':5 'lazi':7 'over':6 'quick':2

查询侧走同样的流程:用户输入的 The Foxes! 先被解析、归一化成 tsquery,再与 tsvector 做匹配。

text
   "The Foxes!"
        │ 解析 + 归一化(丢弃 the,foxes → fox)
        ▼
   'fox'                     ← tsquery

   'brown':3 'dog':8 'fox':4 ...  @@  'fox'   → true

关键点:两侧用同一套配置,才能匹配上。 建 tsvector 时用 english,查询时也必须用 english,否则 foxes 存成 fox、查成 foxes,永远不匹配。

核心类型与操作符总览 ​

类型 / 操作符作用示例
tsvector文档的归一化形式(词位 + 位置 + 权重)'cat':1 'sat':2
tsquery查询条件(布尔表达式树)'cat' & 'sat'
@@文档是否匹配查询tsvector @@ tsquery
||(tsvector)拼接两个 tsvectora || b
&& / || / !!(tsquery)tsquery 之间的 AND / OR / NOT'a' && 'b'
@> / <@(tsquery)查询包含关系'a & b' @> 'a'

@@ 的两个操作数可以互换位置(tsquery @@ tsvector 也合法),语义相同。

主要函数总览 ​

函数用途所属小节
to_tsvector([config,] document)文本 → tsvector转换函数
to_tsquery([config,] query)查询串 → tsquery(严格语法)转换函数
plainto_tsquery([config,] query)自然文本 → tsquery(自动 AND)转换函数
phraseto_tsquery([config,] query)自然文本 → tsquery(自动短语)转换函数
websearch_to_tsquery([config,] query)网页搜索语法 → tsquery转换函数
setweight(vector, weight)给词位打权重标签 A/B/C/D转换函数
ts_rank([weights,] vector, query)相关度评分排名
ts_rank_cd([weights,] vector, query)基于覆盖密度的评分排名
ts_headline([config,] doc, query)生成带高亮片段的摘要排名
ts_debug([config,] document)逐步展示解析与归一化过程调试
numnode(query)查询树中的节点数调试
querytree(query)查询的树形文本表示调试
ts_stat(sql)统计某个 tsvector 列的词频运维

基本工作流 ​

一条最小的全文搜索链路:

sql
-- ① 把文档转成 tsvector
SELECT to_tsvector('english', 'The fat rats') AS doc;
text
        doc
--------------------
 'fat':2 'rat':3
(1 row)
sql
-- ② 把查询串转成 tsquery
SELECT to_tsquery('english', 'fat & rat') AS q;
text
       q
----------------
 'fat' & 'rat'
(1 row)
sql
-- ③ 用 @@ 匹配
SELECT to_tsvector('english', 'The fat rats') @@ to_tsquery('english', 'fat & rat') AS matched;
text
 matched
---------
 t
(1 row)

注意 The 和 rats 的处理:The 是停用词被丢弃,rats 被词干化为 rat。这正是全文搜索区别于子串匹配的地方。

语法与用法 ​

最小可用形式 ​

省略配置名时,PostgreSQL 使用 default_text_search_config 参数指定的默认配置:

sql
SELECT to_tsvector('The fat rats');
SELECT to_tsquery('fat & rat');
text
              to_tsvector
-------------------------------------
 'fat':2 'rat':3
(1 row)

 to_tsquery
------------
 'fat' & 'rat'
(1 row)

default_text_search_config 默认是 pg_catalog.english(在 lc_ctype 为英文环境时)。生产环境强烈建议显式写出配置名,原因见后文「注意事项」。

显式指定配置 ​

sql
-- 显式指定:推荐
SELECT to_tsvector('english', 'The fat rats');
SELECT to_tsvector('simple', 'The fat rats');
SELECT to_tsquery('english', 'fat & rat');
text
   to_tsvector (simple 配置)
-------------------------------
 'fat':2 'rats':3 'the':1
(1 row)

simple 配置不做词干化、不滤停用词,只做小写化与去标点。适合做「精确词匹配」或调试。

配置名可以带引号与大小写 ​

sql
-- 以下三种写法等价,配置名不区分大小写(内部会折叠为小写)
SELECT to_tsvector('english', 'The fat rats');
SELECT to_tsvector('ENGLISH', 'The fat rats');
SELECT to_tsvector('"english"', 'The fat rats');
sql
-- 交互式会话中也可以直接用 regconfig 常量
SET default_text_search_config = 'english';
SELECT to_tsvector('The fat rats');

tsquery 的布尔操作符 ​

操作符含义示例
&AND,两个词位都出现'fat' & 'rat'
|OR,任一出现'fat' | 'rat'
!NOT,不出现'fat' & !'rat'
<->紧邻短语(等价于 <1>)'fat' <-> 'rat'
<N>邻近:b 在 a 之后且位置差不超过 N'fat' <3> 'rat'
:*前缀匹配'fat':*
sql
SELECT to_tsvector('english', 'The fat rats')
       @@ to_tsquery('english', 'fat & rat') AS and_ok,
       to_tsvector('english', 'The fat rats')
       @@ to_tsquery('english', 'fat & cat') AS and_fail,
       to_tsvector('english', 'The fat rats')
       @@ to_tsquery('english', 'fat | cat') AS or_ok,
       to_tsvector('english', 'The fat rats')
       @@ to_tsquery('english', '!cat') AS not_ok;
text
 and_ok | and_fail | or_ok | not_ok
--------+----------+-------+--------
 t      | f        | t     | t
(1 row)

短语与邻近匹配 ​

sql
-- 紧邻:rat 必须紧跟 fat 之后(位置差为 1)
SELECT to_tsvector('english', 'The fat rats')
       @@ to_tsquery('english', 'fat <-> rat') AS adjacent;

-- 邻近:rat 在 fat 之后,位置差不超过 3
SELECT to_tsvector('english', 'The fat brown rats')
       @@ to_tsquery('english', 'fat <3> rat') AS near3;
text
 adjacent | near3
----------+-------
 t        | t
(1 row)

短语匹配依赖 tsvector 中的位置信息,这也是为什么 tsvector 不只是一个词表:

text
   'fat':2 'rat':3              短语 <-> :pb - pa = 1,0 ≤ 1 ≤ 1   ✓
   'fat':2 'brown':3 'rat':4    短语 <-> :pb - pa = 2,超出 1     ✗
   'fat':2 'brown':3 'rat':4    邻近 <3> :pb - pa = 2,2 ≤ 3      ✓

<N> 是上界而不是精确距离:<3> 同样命中位置差为 1 或 2 的情况。

前缀匹配 ​

sql
SELECT to_tsvector('english', 'PostgreSQL indexing is great')
       @@ to_tsquery('english', 'index:*') AS prefix_ok;
text
 prefix_ok
-----------
 t
(1 row)

:* 只能用在 tsquery 的词位末尾,不能放在中间,也不能与 <-> 混用到无意义的位置。

详细说明 ​

tsvector:词位、位置与权重 ​

tsvector 是「有序的、去重的词位列表」,每个词位可以带若干位置和一个权重标签:

text
   'word':1A 'other':2,5B
   │      │  │  │  └── 权重 B
   │      │  │  └───── 位置 5
   │      │  └──────── 位置 2
   │      └─────────── 位置
   └────────────────── 词位
要素说明
词位小写、词干化后的词,同一词位只存一次
位置1 ~ 16383 的整数;超出 16383 会被静默截断为 16383;重复位置被丢弃
权重A(最高)> B > C > D(默认,显示时省略)

位置是可选信息。 用 strip() 去掉位置后,tsvector 体积更小但不支持短语匹配:

sql
SELECT strip(to_tsvector('english', 'The fat rats')) AS no_pos;
text
   no_pos
-------------
 'fat' 'rat'
(1 row)

tsquery:布尔查询表达式 ​

tsquery 是一棵表达式树,叶子是词位,内部节点是操作符:

text
   'fat' & 'rat' & !'cat'

           &
          / \
         &   !
        / \   \
     'fat' 'rat' 'cat'

用 querytree() 可以看到内部表示:

sql
SELECT querytree(to_tsquery('english', 'fat & rat & !cat')) AS tree;
text
              tree
--------------------------------
 'fat' & 'rat' & !'cat'
(1 row)

运算符优先级(无括号时,从紧到松):! > <-> > & > |。有疑问就加括号。

匹配操作符 @@ ​

@@ 是全文搜索唯一的匹配操作符,返回 boolean:

sql
SELECT to_tsvector('english', 'The fat rats') @@ to_tsquery('english', 'fat') AS a,
       to_tsquery('english', 'fat') @@ to_tsvector('english', 'The fat rats') AS b;
text
 a | b
---+---
 t | t
(1 row)

@@ 也可用于 text @@ text 的简写形式(左侧自动用默认配置转 tsvector),但这会绕过索引,只适合临时测试:

sql
-- 便捷但不可索引(除非建表达式索引且表达式完全一致)
SELECT 'The fat rats'::text @@ to_tsquery('english', 'fat') AS via_text;

文本搜索配置(regconfig) ​

配置(Configuration)把「解析器」和「token 类型 → 词典」的映射绑定在一起,决定文本怎么切、怎么归一化。

配置适用场景
simple不词干化、不滤停用词;精确匹配、中文前处理
english英文(Snowball 词干 + 英文停用词表)
german / french / russian…其他语言的内置 Snowball 配置
zhparser / testzhcfg中文(第三方扩展提供)
sql
-- 查看当前默认配置
SHOW default_text_search_config;
text
 default_text_search_config
----------------------------
 pg_catalog.english
(1 row)
sql
-- 查看所有可用配置(psql 元命令)
\dF
text
               List of text search configurations
   Schema   |    Name    |              Description
------------+------------+---------------------------------------
 pg_catalog | danish     | configuration for danish language
 pg_catalog | english    | configuration for english language
 pg_catalog | french     | configuration for french language
 pg_catalog | german     | configuration for german language
 pg_catalog | russian    | configuration for russian language
 pg_catalog | simple     | configuration for simple language
 pg_catalog | spanish    | configuration for spanish language
(7 rows)

语言与停用词、词干化 ​

不同配置对同一段文本的处理结果完全不同:

sql
SELECT to_tsvector('english', 'The quick brown foxes') AS en,
       to_tsvector('simple',  'The quick brown foxes') AS si;
text
                       en                        |                          si
-------------------------------------------------+-------------------------------------------------------
 'brown':3 'fox':4 'quick':2                     | 'brown':3 'foxes':4 'quick':2 'the':1
(1 row)
现象englishsimple
The丢弃(停用词)保留为 'the'
foxesfox(词干化)foxes(原样)
quickquickquick

三种存储策略 ​

这是全文搜索落地时必须做的第一个架构决策。

策略一:实时计算(不建索引)

sql
SELECT * FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('english', 'postgres & index');
  • 优点:零维护、永远最新。
  • 缺点:每行都要跑一遍解析归一化,极慢,且无法走索引。

策略二:表达式索引(不额外加列)

sql
CREATE INDEX idx_articles_fts ON articles
USING gin (to_tsvector('english', body));
  • 优点:不冗余存储、无需触发器、永远最新。
  • 缺点:to_tsvector 两参数版本必须逐字一致才能命中索引;多字段拼接时可读性差。

策略三:独立 tsvector 列(生成列或触发器)

sql
-- PostgreSQL 12+ 推荐:存储生成列
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);
  • 优点:查询写法简单(直接 fts @@ q)、可加权重、可多字段合并。
  • 缺点:多占一列空间;生成列表达式必须是 immutable(所以必须显式写配置名)。

三种策略的对比:

维度实时计算表达式索引独立列(生成列/触发器)
查询性能差(全表算)好好
索引可命中否是(表达式必须一致)是(直接匹配列)
存储开销0仅索引索引 + 一列
维护成本0低中(生成列自动;触发器需自担)
支持权重可以可以(表达式里 setweight)可以(表达式里 setweight)
多字段合并每次拼表达式里拼表达式里拼
推荐场景小表、临时分析单字段、结构简单生产环境首选

default_text_search_config 参数 ​

sql
-- 会话级
SET default_text_search_config = 'english';

-- 数据库级(会影响所有未显式指定配置的调用)
ALTER DATABASE mydb SET default_text_search_config = 'english';

-- 配置文件级(postgresql.conf,需包含 schema 名)
-- default_text_search_config = 'pg_catalog.english'
设置方式生效范围说明
SET当前会话临时调试用
ALTER DATABASE ... SET该库新连接生产常用
postgresql.conf整个实例需含 schema 前缀
ALTER ROLE ... SET该角色的连接多语言多租户场景

风险:一旦依赖默认配置,改动它会让已有索引失效(索引里存的是用旧配置算出的词位)。所以生产 SQL 里请显式写配置名。

ts_debug() 调试 ​

ts_debug() 是排查「为什么搜不到」的第一工具,它把每一步处理结果摊开:

sql
SELECT alias, description, token, lexemes
FROM ts_debug('english', 'The fat rats');
text
   alias   |               description               | token | lexemes
-----------+-----------------------------------------+-------+----------
 asciiword | Word, all ASCII                         | The   | {}
 blank     | Space symbols                           |       | {}
 asciiword | Word, all ASCII                         | fat   | {fat}
 blank     | Space symbols                           |       | {}
 asciiword | Word, all ASCII                         | rats  | {rat}
(5 rows)
列含义
aliastoken 类型(asciiword、numword、blank、email…)
description类型说明
token原始切出的文本
dictionaries命中的词典名列表(\dF+ 视图里可见)
dictionary最终采用的词典
lexemes归一化结果;{} 表示被丢弃(停用词或空白)

关键读法:lexemes 为空 = 这个词进不了索引。如果用户抱怨「搜 the 搜不到」,这就是答案。

排名与高亮 ​

命中之后还需要排序和呈现:

sql
SELECT title,
       ts_rank(fts, q) AS rank
FROM articles, to_tsquery('english', 'postgres & index') q
WHERE fts @@ q
ORDER BY rank DESC
LIMIT 10;
  • ts_rank:基于词频的评分。
  • ts_rank_cd:基于覆盖密度(Cover Density),对「词挨得近」更敏感。
  • ts_headline:生成带 <b> 高亮的摘要片段。

与 Elasticsearch 的取舍 ​

这是后端开发者最常问的问题。

维度PostgreSQL 全文搜索Elasticsearch
部署无新增组件独立集群 + JVM
事务一致性强一致(同事务可见)近实时(默认 1s refresh)
中文分词需装扩展内置 IK / jieba 插件
相关度算法ts_rank / ts_rank_cd(TF 类)BM25、自定义打分脚本
聚合分析SQL 全能力聚合 DSL,更灵活
拼写纠错 / 同义词需 thesaurus 词典内置 suggester、同义词图
水平扩展靠读副本原生分片
运维成本已有 PG 就为零需专人维护
数据同步无需同步需 CDC / 双写

决策建议:

  • 数据量在千万级以下、已有 PostgreSQL、需要强一致 → 直接用 PostgreSQL 全文搜索。
  • 需要复杂搜索 DSL、拼写纠错、海量并发检索、独立扩缩容 → 上 Elasticsearch。
  • 常见做法是「PG 做主存储 + 全文搜索兜底,ES 只做复杂检索」的混合架构。

实战示例 ​

示例一:文章搜索(生成列 + GIN + 排名 + 高亮) ​

sql
-- ① 建表
CREATE TABLE articles (
    id         bigserial PRIMARY KEY,
    title      text NOT NULL,
    body       text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

-- ② 生成列:标题权重 A,正文权重 D,合并成一个 tsvector
ALTER TABLE articles
ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'D')
) STORED;

-- ③ GIN 索引
CREATE INDEX idx_articles_fts ON articles USING gin (fts);
text
CREATE TABLE
ALTER TABLE
CREATE INDEX

写入测试数据:

sql
INSERT INTO articles (title, body) VALUES
('PostgreSQL Indexing Guide',
 'GIN indexes are the recommended choice for full-text search. They store lexemes without weights.'),
('MySQL vs PostgreSQL',
 'A comparison of two relational databases. PostgreSQL supports GIN and GiST indexes natively.'),
('Cooking Pasta',
 'Boil water, add salt, cook the pasta for eight minutes. The recipe is simple.'),
('Advanced PostgreSQL',
 'Tuning autovacuum, WAL, and checkpoints. Index maintenance matters for large tables.');
text
INSERT 0 4

查询:标题命中权重高,排在前面。

sql
SELECT id,
       title,
       round(ts_rank(fts, q)::numeric, 4) AS rank,
       ts_headline('english', body, q,
                   'StartSel=<mark>, StopSel=</mark>, MaxWords=18, MinWords=8') AS snippet
FROM articles, to_tsquery('english', 'postgres & index') q
WHERE fts @@ q
ORDER BY rank DESC;
text
 id |           title            |  rank  |                       snippet
----+----------------------------+--------+------------------------------------------------------
  1 | PostgreSQL Indexing Guide  | 0.0990 | <mark>GIN</mark> <mark>indexes</mark> are the recommended
    |                            |        | choice for full-text search. They store lexemes ...
  2 | MySQL vs PostgreSQL        | 0.0608 | ... PostgreSQL supports <mark>GIN</mark> and <mark>GiST</mark>
    |                            |        | <mark>indexes</mark> natively.
  4 | Advanced PostgreSQL        | 0.0608 | ... <mark>Index</mark> maintenance matters for large tables.
(3 rows)

注意 ID=3 的「Cooking Pasta」没有出现——它既不含 postgres 也不含 index。

验证索引被使用:

sql
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM articles WHERE fts @@ to_tsquery('english', 'postgres & index');
text
                             QUERY PLAN
--------------------------------------------------------------------
 Bitmap Heap Scan on articles
   Recheck Cond: (fts @@ '''postgres'' & ''index'''::tsquery)
   ->  Bitmap Index Scan on idx_articles_fts
         Index Cond: (fts @@ '''postgres'' & ''index'''::tsquery)
(4 rows)

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

示例二:websearch_to_tsquery 实现「网页搜索框」语义 ​

用户输入可能带引号、OR、- 排除符。websearch_to_tsquery()(PostgreSQL 11+)能直接吃下这类输入且永不报语法错误:

sql
-- 输入:"postgres index" 或 mysql -oracle
SELECT websearch_to_tsquery('english', '"postgres index" or mysql -oracle') AS q;
text
                                    q
--------------------------------------------------------------------------
 'postgres' <-> 'index' | 'mysql' & !'oracl'
(1 row)

语法映射:

用户输入生成的 tsquery
foo bar(无引号)'foo' & 'bar'
"foo bar"(有引号)'foo' <-> 'bar'
foo or bar'foo' | 'bar'
foo -bar'foo' & !'bar'
空串 / 纯停用词空 tsquery(numnode 为 0)

空 tsquery 会让 @@ 恒为 false,所以要先判断 numnode(q) > 0 再执行查询,避免「搜索结果永远为空」这类隐蔽故障。

注意事项 ​

不要依赖 default_text_search_config。它可以在会话、库、角色、实例四个层级被覆盖,一旦变化,用旧配置算出的索引词位就与新查询对不上,表现为「明明有数据却搜不到」。

sql
-- 不推荐:依赖默认配置
SELECT * FROM articles WHERE to_tsvector(body) @@ to_tsquery('index');

-- 推荐:显式写出配置名
SELECT * FROM articles WHERE to_tsvector('english', body) @@ to_tsquery('english', 'index');

索引表达式必须与查询表达式逐字一致。差一个引号、少一个 coalesce,索引就用不上:

sql
CREATE INDEX idx_a ON articles USING gin (to_tsvector('english', body));

-- 错误!表达式不一致(多了 coalesce),索引无法命中
-- 会退化为对索引表达式重算 + 全表 Seq Scan
SELECT * FROM articles WHERE to_tsvector('english', coalesce(body, '')) @@ to_tsquery('english', 'x');

to_tsvector(text) 单参数版本不是 immutable,不能用于表达式索引或生成列。必须用两参数版本并显式传配置:

sql
-- 错误!生成列要求表达式 immutable
-- ERROR: generation expression is not immutable
ALTER TABLE articles ADD COLUMN fts tsvector
GENERATED ALWAYS AS (to_tsvector(body)) STORED;

ts_rank 不是字典序的替代品。它只反映词频与权重,不理解语义。ts_rank 的值没有绝对意义,只用来在同一次查询内相对排序。

GIN 索引不存权重标签。涉及权重过滤的查询(如「只要标题命中」)需要回表 recheck,性能会下降。若权重过滤是主要场景,考虑拆成两个 tsvector 列分别建索引。

tsvector 单 lexeme 有 2047 字节上限,整体约 1MB。超长 token(例如一整段没有标点的 base64 字符串)会被截断或报错。

sql
-- 极长的「词」不会完整进入索引
SELECT length(to_tsvector('simple', repeat('x', 3000))::text);

停用词不可检索。the、a、的 这类词在 english 配置下被丢弃,to_tsquery('english', 'the') 得到空查询:

sql
SELECT to_tsquery('english', 'the') AS q, numnode(to_tsquery('english', 'the')) AS n;
text
 q | n
---+---
   | 0
(1 row)

中文不会自动分词。PostgreSQL 内置解析器按「连续的 CJK 字符」处理,整句会被当成一个 token。必须装 zhparser / pg_jieba,或退化为 pg_trgm 子串匹配。详见中文全文搜索一节。

ts_headline 很贵。它对每行重新解析原文并生成片段,是全表扫描级别的成本。务必先 WHERE fts @@ q 过滤、再 LIMIT,最后才对少量结果调用。

大 tsvector 会触发 TOAST。文档很大时,tsvector 列可能被压缩/行外存储,读取成本上升。通常这不是问题,但监控表大小时要把它算进去。

strip() 后位置信息丢失,短语查询会失效——strip(to_tsvector('english','The fat rats')) @@ to_tsquery('english','fat <-> rat') 返回 f。

最佳实践 ​

生产环境始终显式指定配置名。把 to_tsvector('english', ...)、to_tsquery('english', ...) 里的 'english' 当成不可省略的参数,而不是可选项。

用 PostgreSQL 12+ 的存储生成列维护 tsvector。比触发器简单、不会漏更新、不需要额外函数:

sql
ALTER TABLE articles
ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'D')
) STORED;

多字段合并时用 setweight 分级。标题给 A、摘要给 B、正文给 D,让 ts_rank 自动实现「标题命中优先」:

sql
setweight(to_tsvector('english', coalesce(title,   '')), 'A') ||
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('english', coalesce(body,    '')), 'D')

索引统一选 GIN。GIN 查询更快,是官方推荐;只有在写入极其频繁且能接受有损匹配时才考虑 GiST。

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

给 coalesce(x, '') 兜底。to_tsvector(NULL) 返回 NULL,会让整行 fts 变成 NULL(生成列中 NULL || tsvector 也是 NULL),导致该行永远搜不到。

查询入口统一封装成视图,避免每个调用点重复拼配置名与字段表达式,也便于后续换配置时只改一处。

用户输入优先用 websearch_to_tsquery()。它不会因引号不配对、OR 大小写等问题抛语法错误,比 to_tsquery() 更适合直接暴露给终端用户。

sql
-- 推荐:对用户输入健壮
WHERE fts @@ websearch_to_tsquery('english', $1)

-- 不推荐:用户输入 'a & )' 会直接报错
-- WHERE fts @@ to_tsquery('english', $1)

参数化查询杜绝 SQL 注入。to_tsquery 的参数本身也是数据,在 PHP 侧必须走绑定参数,绝不能字符串拼接:

php
<?php
// PHP PDO 示例:正确使用绑定参数
$stmt = $pdo->prepare(
    "SELECT id, title FROM articles WHERE fts @@ websearch_to_tsquery('english', :q)"
);
$stmt->execute([':q' => $keyword]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

先过滤再排序再高亮。正确的执行顺序是:WHERE fts @@ q → ORDER BY ts_rank(...) DESC → LIMIT n → 只对结果做 ts_headline。

sql
WITH hits AS (
    SELECT id, title, body, ts_rank(fts, q) AS r
    FROM articles, websearch_to_tsquery('english', :q) q
    WHERE fts @@ q
    ORDER BY r DESC
    LIMIT 20
)
SELECT id, title, ts_headline('english', body, websearch_to_tsquery('english', :q),
                              'MaxWords=25, MinWords=10') AS snippet
FROM hits ORDER BY r DESC;

对超大表做分页要避免深 OFFSET。用 ts_rank 的值做游标(keyset pagination):

sql
-- 第一页
SELECT id, ts_rank(fts, q) AS r FROM articles, websearch_to_tsquery('english', :q) q
WHERE fts @@ q ORDER BY r DESC, id DESC LIMIT 20;

-- 后续页:带上上一页最后的 (r, id)
SELECT id, ts_rank(fts, q) AS r FROM articles, websearch_to_tsquery('english', :q) q
WHERE fts @@ q AND (ts_rank(fts, q), id) < (:last_r, :last_id)
ORDER BY r DESC, id DESC LIMIT 20;

监控索引膨胀。全文搜索表通常高频写入,GIN 索引会产生 pending list 与膨胀,需要常规 VACUUM / REINDEX(REINDEX INDEX CONCURRENTLY 不阻塞写)。

为中文场景提前定方案。如果业务含中文,在建模阶段就确定用 zhparser(分词)还是 pg_trgm(子串),不要等上线后再改,因为索引表达式一旦变了就要重建全部索引。

写测试用例覆盖「搜不到」的情况。停用词、超长词、空查询是最常见的线上问题来源:

sql
-- 回归测试:这些查询必须不报错
SELECT numnode(websearch_to_tsquery('english', ''))    AS empty_ok;      -- 0
SELECT numnode(websearch_to_tsquery('english', 'the'))  AS stopword_ok;   -- 0
SELECT to_tsvector('english', repeat('x', 5000)) IS NOT NULL AS long_ok;  -- t

下一节

继续学习:tsvector 与 tsquery 详解

参考链接 ​