tsvector 与 tsquery 详解
tsvector 和 tsquery 是 PostgreSQL 全文搜索的两个基石类型:前者是文档被归一化后的产物,后者是查询条件的表达式树,两者用 @@ 做匹配。上一节讲了它们「做什么」,本节讲清它们「长什么样、怎么拼、有什么限制」——这些细节直接决定了你能不能写出既正确又能命中索引的查询。
很多全文搜索的疑难问题(搜不到、短语不生效、索引不命中、前缀匹配失效),根源都在对这两个类型内部结构的理解不到位。本节把它们彻底拆开。
前置知识
阅读本节前,建议先了解:全文搜索概述
基础概念
两个类型的定位
| 类型 | 中文习惯叫法 | 本质 | 类比 |
|---|---|---|---|
tsvector | 文档向量 | 有序去重的「词位 + 位置 + 权重」列表 | 倒排索引里的一条 posting list |
tsquery | 查询树 | 词位与布尔操作符构成的表达式树 | 一颗 AST |
文档侧 查询侧
──────── ────────
text text
│ to_tsvector(config, ...) │ to_tsquery(config, ...)
▼ ▼
tsvector ────── @@ ──────► tsquery
'fat':2 'rat':3 'fat' & 'rat'tsvector 的文本表示
tsvector 的外部表示是「按词位排序的、空格分隔的条目列表」:
'brown':3 'dog':8 'fox':4 'jump':5 'lazi':7 'over':6 'quick':2
│ │
│ └── 位置列表(逗号分隔可多个::2,5,9)
└───────── 词位(一定带单引号)带权重的形式:
'guide':1A 'postgresql':2B
│ │ │
│ │ └── 权重字母 A / B / C / D
│ └──── 位置
└─────────── 词位顺序规则:tsvector 输出时按词位排序。这意味着 'a':1 'b':2 与 'b':2 'a':1 在比较时是相等的——顺序不影响语义。
SELECT 'a:1 b:2'::tsvector = 'b:2 a:1'::tsvector AS same; same
------
t
(1 row)tsquery 的文本表示
'fat' & 'rat' AND,两词都出现
'fat' | 'rat' OR,任一出现
'fat' & !'rat' NOT,rat 不得出现
'fat' <-> 'rat' 紧邻且顺序固定(等价于 <1>)
'fat' <3> 'rat' 两者位置差不超过 3
'fat':* 前缀匹配
'fat' <0> 'rat' 两个模式匹配同一个词
'fat' <-> 'rat' & !'cat' 混合表达tsquery 也不做任何归一化:'Fat & Rats'::tsquery 里的词位是原样的 Fat 和 Rats,大写不会折叠。要用 to_tsquery() 才能得到归一化后的形式:
SELECT 'Fat & Rats'::tsquery AS raw,
to_tsquery('english', 'Fat & Rats') AS normalized; raw | normalized
------------------+------------
'Fat' & 'Rats' | 'fat' & 'rat'
(1 row)位置与权重的取值范围
| 要素 | 取值 | 越界行为 |
|---|---|---|
| 位置(position) | 1 ~ 16383 的整数 | 超过 16383 静默截断为 16383 |
| 权重(weight) | A / B / C / D | 只能由 setweight() 设置,直接写字符串也只接受这四个字母 |
| 同一词位位置重复 | 去重 | 重复位置被丢弃,不报错 |
-- 位置 20000 会被静默截断为 16383
SELECT 'word:20000'::tsvector AS clamped; clamped
----------------
'word':16383
(1 row)这个 16383 的上限来自内部用 14 位存位置。它只在「超长文档 + 精确短语定位」时才可能成为问题。
长度限制
| 限制项 | 数值 | 说明 |
|---|---|---|
| 单个词位长度 | 2047 字节 | 超长 token 被截断 |
tsvector 整体长度 | 约 1 MB | 接近 TOAST 阈值后走行外存储 |
-- 单个超长「词」会被截断:结果是 2047 字节词位 + 引号 + 位置标记 ≈ 2058 字符
SELECT length(to_tsvector('simple', repeat('a', 3000))::text) AS vlen; vlen
------
2058语法与用法
强制转换 vs 函数调用
这是最容易踩的坑:::tsvector 只做语法解析,不做语言处理。 对照下面的输出可以看到,强制转换原样保留了 The,函数调用则完成了转小写、去停用词(the)、词干化(rats → rat)并记录了位置。
SELECT 'The fat rats'::tsvector AS casted,
to_tsvector('english', 'The fat rats') AS converted; casted | converted
----------------------------------+-----------------------
'The' 'fat' 'rats' | 'fat':2 'rat':3
(1 row)| 方式 | 大小写 | 词干化 | 停用词 | 位置 | 权重 |
|---|---|---|---|---|---|
'...'::tsvector | 保留原样 | 无 | 不滤 | 不记录 | 全 D |
to_tsvector(cfg, ...) | 转小写 | 有 | 过滤 | 记录 | 全 D |
结论:生产环境永远用 to_tsvector(),不要用 ::tsvector。 ::tsvector 只在「手工构造测试数据」或「从外部导入了已经归一化好的词位」时有价值。
同样是 tsquery:
SELECT 'fat & rat'::tsquery AS casted,
to_tsquery('english', 'fat & rat') AS converted; casted | converted
----------------+----------------
'fat' & 'rat' | 'fat' & 'rat'
(1 row)这里两者看起来一样,是因为 fat、rat 已经是归一化后的形式。换成 The & Rats 就不同了:
SELECT 'The & Rats'::tsquery AS casted,
to_tsquery('english', 'The & Rats') AS converted; casted | converted
----------------------+-------------
'The' & 'Rats' | 'rat'
(1 row)强制转换保留了 The 和 Rats 的原样(区分大小写),而 to_tsquery 把 The 当停用词丢掉、把 Rats 词干化为 rat。
tsquery 的操作符与优先级
| 操作符 | 含义 | 优先级(数字越小越紧) |
|---|---|---|
! | NOT | 1 |
<-> | 短语(紧邻) | 2 |
& | AND | 3 |
| | OR | 4 |
无括号时的解析方向:
'a' | 'b' & 'c' & !'d' <-> 'e'
① !'d' (! 最紧)
② 'd' <-> 'e' (<-> 次之)
③ 'b' & 'c' (& 再次)
④ 'a' | (...) (| 最松)建议:永远加括号。 运算符优先级不是所有人都会背,显式括号能让 SQL 在 code review 时一眼读懂。
-- 推荐:加括号,意图明确
SELECT to_tsquery('english', 'fat & (rat | cat) & !dog') AS q; q
-----------------------------------
'fat' & ( 'rat' | 'cat' ) & !'dog'
(1 row)短语与邻近语法
-- 紧邻:b 出现在 a 的下一个位置(等价于 <1>)
SELECT to_tsquery('english', 'fat <-> rat') AS adjacent;
-- 距离 N:b 的位置减去 a 的位置不超过 N
SELECT to_tsquery('english', 'fat <3> rat') AS near3;
-- 链式:a 紧邻 b 紧邻 c
SELECT to_tsquery('english', 'a <-> b <-> c') AS chain; adjacent | near3 | chain
------------+-----------+-------------------------
'fat' <-> 'rat' | 'fat' <3> 'rat' | 'a' <-> 'b' <-> 'c'
(1 row)<N> 的语义要精确理解:'a' <N> 'b' 匹配当且仅当存在 a 的位置 pa 与 b 的位置 pb,满足 0 ≤ pb − pa ≤ N,即 b 出现在 a 之后(顺序固定),且两者间隔不超过 N−1 个词位。<-> 是 <1> 的简写。
| 查询 | 文档 diff=1 | diff=2 | diff=3 | diff=4 |
|---|---|---|---|---|
'a' <-> 'b'(即 <1>) | ✓ | ✗ | ✗ | ✗ |
'a' <2> 'b' | ✓ | ✓ | ✗ | ✗ |
'a' <3> 'b' | ✓ | ✓ | ✓(恰好 diff=3 也命中) | ✗ |
'a' <5> 'b' | ✓ | ✓ | ✓ | ✓ |
注意 <N> 是上界而不是精确值:<3> 同样命中 diff 为 1 或 2 的情况。它的语义是「b 不要离 a 太远」,而非「b 必须离 a 恰好 N」。
SELECT to_tsvector('english', 'The fat brown rats')
@@ to_tsquery('english', 'fat <3> rat') AS d2_ok,
to_tsvector('english', 'The fat brown rats')
@@ to_tsquery('english', 'fat <-> rat') AS d1_fail; d2_ok | d1_fail
-------+---------
t | f
(1 row)前缀匹配 😗
-- 匹配以 postgre 开头的词位
SELECT to_tsvector('english', 'PostgreSQL and Postgres')
@@ to_tsquery('english', 'postgre:*') AS prefix_ok; prefix_ok
-----------
t
(1 row)规则:
:*只能加在词位末尾,如'postgre':*。- 不能写成
postgre*(缺少冒号),那是to_tsquery的语法错误。
-- 错误!缺少冒号,会抛 syntax error in tsquery: "postgre*"
-- SELECT to_tsquery('english', 'postgre*');- 前缀匹配会让索引扫描范围变大,前缀越短命中越多,
'p:*'基本等于全表扫。
tsvector 拼接
|| 拼接两个 tsvector 时,右侧向量的位置会自动按左侧的起始位置平移,因此拼接后再做短语匹配仍然有意义:
SELECT to_tsvector('english', 'fat rats')
|| to_tsvector('english', 'on the mat') AS combined,
(to_tsvector('english', 'the cat') || to_tsvector('english', 'sat down'))
@@ to_tsquery('english', 'cat <-> sat') AS cross_phrase; combined | cross_phrase
--------------------------------------------------+--------------
'fat':1 'mat':6 'rat':2 | t
(1 row)但要注意:跨字段拼接时位置会被「连接」起来,可能产生跨字段的假短语。比如标题末尾是 foo、正文开头是 bar,拼接后 'foo' <-> 'bar' 会误命中。解决办法是用 setweight 区分权重,或对跨字段短语查询另做处理。
tsquery 拼接
| 操作符 | 语义 | 示例结果 |
|---|---|---|
&& | AND 合并 | 'a' && 'b' → 'a' & 'b' |
|| | OR 合并 | 'a' || 'b' → 'a' | 'b' |
!! | 取反 | !!'a' → !'a' |
SELECT to_tsquery('english', 'a') && to_tsquery('english', 'b') AS and_q,
to_tsquery('english', 'a') || to_tsquery('english', 'b') AS or_q,
!!to_tsquery('english', 'a') AS not_q; and_q | or_q | not_q
-------------+------------+---------
'a' & 'b' | 'a' | 'b' | !'a'
(1 row)tsquery 之间还支持 @>(包含)与 <@(被包含),用于「查询改写」「同义扩展」场景:
SELECT to_tsquery('a & b & c') @> to_tsquery('a & b') AS contains,
to_tsquery('a & b') <@ to_tsquery('a & b & c') AS contained; contains | contained
----------+-----------
t | t
(1 row)详细说明
为什么不保留原始文本
初学时的困惑:搜到了结果,怎么把命中的原文展示出来?答案是——tsvector 里没有原文,必须回到原表字段。
tsvector 的设计权衡
┌────────────────────────────┬──────────────────────────────┐
│ 保留原文的实现(不可取) │ PostgreSQL 的实现 │
├────────────────────────────┼──────────────────────────────┤
│ 索引体积 ≈ 原文体积 │ 索引只存词位,通常小一个数量级 │
│ 匹配要逐字符比较 │ 匹配是整数集合运算 │
│ 停用词/词干规则变化即失效 │ 归一化后与原文形态解耦 │
└────────────────────────────┴──────────────────────────────┘所以标准做法是:
SELECT id, title, body -- 原文列照常查询
FROM articles
WHERE fts @@ to_tsquery('english', 'postgres & index');推论:tsvector 列是「派生数据」,不是「存储数据」。任何展示需求(摘要、高亮)都要回原表,或额外存一份摘要字段。
位置信息有什么用
位置信息是短语与邻近查询的唯一依据:
| 场景 | 是否依赖位置 |
|---|---|
'a' & 'b'(同时出现) | 不需要 |
'a' <-> 'b'(相邻) | 需要 |
'a' <3> 'b'(邻近) | 需要 |
ts_rank_cd(覆盖密度) | 需要 |
'a':*(前缀) | 不需要 |
ts_headline 高亮定位 | 不需要(它重新解析原文) |
去掉位置能显著减小 tsvector 体积,但代价是短语查询直接失效:
-- 查看有无位置信息的体积差异
SELECT length(to_tsvector('english', repeat('alpha beta gamma delta ', 50))::text) AS with_pos,
length(strip(to_tsvector('english', repeat('alpha beta gamma delta ', 50)))::text) AS without_pos; with_pos | without_pos
----------+-------------
2334 | 242体积差接近 10 倍。所以 strip() 只在「明确不需要短语和 ts_rank_cd」时才用。
GIN 索引为什么不存权重
这是一个重要的实现细节,直接影响查询设计:GIN 索引里只记录词位本身,tsvector 中的权重与位置都被丢弃。
tsvector: 'guide':1A 'postgres':2B 'index':3D
│ │ │
▼ ▼ ▼
GIN 索引只记录:guide postgres index ← 权重与位置都丢弃后果:
| 查询 | GIN 能否独立完成 |
|---|---|
fts @@ to_tsquery('english', 'guide') | 能 |
fts @@ to_tsquery('english', 'guide & postgres') | 能(两个词位求交) |
fts @@ to_tsquery('english', 'guide <-> postgres') | 不能,需回表 recheck |
ts_rank(...) 涉及权重 | 不能,需回表读 tsvector |
所以「短语查询 + GIN 索引」的执行计划里会看到 Rows Removed by Index Recheck:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM articles WHERE fts @@ to_tsquery('english', 'postgres <-> index'); QUERY PLAN
---------------------------------------------------------------------
Bitmap Heap Scan on articles
Recheck Cond: (fts @@ '''postgres'' <-> ''index'''::tsquery)
Rows Removed by Index Recheck: 12
-> Bitmap Index Scan on idx_articles_fts
Index Cond: (fts @@ '''postgres'' <-> ''index'''::tsquery)
(4 rows)这说明索引先给出了候选集,PostgreSQL 再回表用真实 tsvector 精确验证。这是正常的、预期的行为,不是问题。
废弃写法与手工构造
PostgreSQL 9.6 之前 @@ 有一个同义词 @@@,现已废弃,新代码统一用 @@:
-- 不推荐:@@@ 已废弃,仅为历史兼容保留
-- SELECT to_tsvector('english', 'fat rats') @@@ to_tsquery('english', 'fat');有时需要手工构造 tsvector(例如从外部导入分词结果):
SELECT 'a:1 b:2,3 c:5A d:6B'::tsvector AS manual; manual
-----------------------------------
'a':1 'b':2,3 'c':5A 'd':6B
(1 row)| 写法 | 含义 |
|---|---|
'a:1' | 词位 a,位置 1,权重默认 D |
'b:2,3' | 词位 b,位置 2 和 3 |
'c:5A' | 词位 c,位置 5,权重 A |
'a b'(无引号) | 会报错;词位必须加引号或是不含空格的简单标识 |
但注意:这样构造的 tsvector 中词位是原样保留的,不会转小写。所以 'A:1'::tsvector ≠ 'a:1'::tsvector(结果为 f)。
numnode() 与 querytree()
numnode() 返回查询树的节点数,用来判断查询是否「为空」:
SELECT numnode(to_tsquery('english', 'fat & rat')) AS n1,
numnode(to_tsquery('english', 'the')) AS n2,
numnode(to_tsquery('english', '')) AS n3; n1 | n2 | n3
----+----+----
3 | 0 | 0
(1 row)fat & rat 有 3 个节点(&、'fat'、'rat'),而停用词和空串得到 0 个节点。
关键规则:空 tsquery 与任何 tsvector 匹配都返回 false。
SELECT to_tsvector('english', 'anything at all') @@ to_tsquery('english', 'the') AS never_match; never_match
-------------
f
(1 row)所以用户提交的查询必须先检查:
SELECT id, title
FROM articles, websearch_to_tsquery('english', $1) q
WHERE numnode(q) > 0
AND fts @@ q;querytree() 输出查询的内部树形文本,返回类型是 text(空查询返回空字符串),调试时很有用:
SELECT querytree(to_tsquery('english', 'fat & (rat | cat)')) AS tree; tree
-------------------------------------
'fat' & ( 'rat' | 'cat' )
(1 row)tsvector 与 tsquery 的比较操作
两者支持 =、<>,以及排序,因此可以 ORDER BY、GROUP BY。排序按词位字典序,实践中用处不大,但在「按 tsvector 去重」时可用(SELECT DISTINCT fts FROM articles;)。
tsvector 二进制格式的注意事项
tsvector 不能直接转成 bytea 再转回来做「压缩存储」——如果需要跨系统传输,用文本形式(::text)即可,它是稳定的、可读的。
-- 不推荐:不要试图手工物化 tsvector 的二进制形式
-- SELECT ('\x' || encode(to_tsvector('english','a b')::text::bytea, 'hex'))::bytea;实战示例
示例一:从零拆解一次完整匹配
-- ① 看文档被归一化成什么
SELECT to_tsvector('english', 'PostgreSQL 18 supports GIN indexing, and indexes are fast!') AS doc; doc
----------------------------------------------------------------------
'18':2 'fast':10 'gin':4 'index':12 'index':6 'postgresql':1 'support':3
(1 row)-- ② 看用户输入被归一化成什么,并验证匹配
SELECT to_tsquery('english', 'PostgreSQL AND Indexes') AS q,
to_tsvector('english', 'PostgreSQL 18 supports GIN indexing, and indexes are fast!')
@@ to_tsquery('english', 'PostgreSQL AND Indexes') AS matched; q | matched
-----------------------------+---------
'postgresql' & 'index' | t
(1 row)注意 index 出现了两次(位置 6 和 12)——indexing 与 indexes 都被词干化为同一个词位,位置信息把两处都记下来了:
'gin':4 'index':6 'index':12
│ │
└────────┴── 同一词位的两个位置这里有个容易误读的点:to_tsvector 的输出里同一词位会带多个位置,但在去重比较时只算一个词位。所以 ts_rank 能利用频次(出现两次得分更高),而 & 匹配只判断「是否存在」。
示例二:短语查询的边界验证
建一张小表,验证各种位置关系:
CREATE TEMP TABLE phrase_test (id int, body text);
INSERT INTO phrase_test VALUES
(1, 'the quick brown fox'),
(2, 'the quick fox brown'),
(3, 'quick brown fox'),
(4, 'quick fox'),
(5, 'brown quick fox');CREATE TABLE
INSERT 0 5SELECT id, body,
to_tsvector('english', body) @@ to_tsquery('english', 'quick <-> fox') AS adjacent,
to_tsvector('english', body) @@ to_tsquery('english', 'quick <3> fox') AS within3
FROM phrase_test
ORDER BY id; id | body | adjacent | within3
----+---------------------+----------+---------
1 | the quick brown fox | f | t
2 | the quick fox brown | t | t
3 | quick brown fox | f | t
4 | quick fox | t | t
5 | brown quick fox | t | t
(5 rows)逐行解释:
| id | 词位与位置 | quick <-> fox | quick <3> fox |
|---|---|---|---|
| 1 | quick:2 brown:3 fox:4 | 差 2 → f | 差 2 < 3 → t |
| 2 | quick:2 fox:3 brown:4 | 差 1 → t | t |
| 3 | quick:1 brown:2 fox:3 | 差 2 → f | t |
| 4 | quick:1 fox:2 | 差 1 → t | t |
| 5 | brown:1 quick:2 fox:3 | 差 1 → t | t |
第 1 行和第 3 行最能说明 <-> 的严格性:quick 与 fox 之间存在其他词,即使语义上「相邻」,位置差为 2,就已不满足紧邻。
示例三:用 tsquery 拼接实现「关键词高亮优先级」
&& 把多个查询合并成 AND(更严格),|| 合并成 OR(更宽松)。配合 setweight 分级后,就能实现「标题命中一定在前,正文命中作为补充」:
SELECT to_tsquery('english', 'postgres') && to_tsquery('english', 'index') AS strict_q,
to_tsquery('english', 'postgres') || to_tsquery('english', 'mysql') AS loose_q; strict_q | loose_q
-------------------------+------------------------
'postgres' & 'index' | 'postgres' | 'mysql'
(1 row)SELECT id, title, ts_rank(fts, q) AS r
FROM articles, to_tsquery('english', 'postgres') q
WHERE fts @@ q
ORDER BY r DESC
LIMIT 10;示例四:验证位置截断与去重
SELECT 'w:100000'::tsvector AS clamped, -- 位置超限被截断
to_tsvector('english', 'cat cat cat') AS dedup; -- 重复位置合并 clamped dedup
----------------- ---------------------
'w':16383 'cat':1,2,3
(1 row)'cat':1,2,3 说明同一词位出现 3 次,位置被合并进同一个条目——这就是「去重但保留位置」。
注意事项
不要用 ::tsvector 做语言处理。它只做语法解析,不转小写、不词干化、不滤停用词。用它建的索引和 to_tsvector() 的结果无法互相匹配。
-- 错误!两种方式产出的 tsvector 完全不同
SELECT 'The fat rats'::tsvector = to_tsvector('english', 'The fat rats') AS same; same
------
f
(1 row)to_tsquery 的语法很严格,用户输入必须预处理。一个未转义的 &、) 就会让整条 SQL 报错:
-- 错误!用户输入 'c++ & (python' 会抛异常
-- ERROR: syntax error in tsquery
-- SELECT to_tsquery('english', 'c++ & (python');
-- 推荐:改用 websearch_to_tsquery,永不报错
SELECT websearch_to_tsquery('english', 'c++ & (python') AS safe_q;ts_rank 在只做 @@ 判断时不要调用。ts_rank 需要回表读取完整 tsvector(含位置与权重),GIN 索引无法加速。如果只需要「有哪些行命中」,去掉 ts_rank 能快很多。
短语查询对位置的要求不可绕过。如果用 strip() 去掉了位置,<-> 查询会静默失效(返回 f),不会报错:
-- 不推荐:strip 之后短语匹配失效且不报错
SELECT strip(to_tsvector('english', 'quick fox')) @@ to_tsquery('english', 'quick <-> fox') AS ok; ok
----
f
(1 row)前缀匹配不要写太短。'a:*' 会让 GIN 扫描几乎所有词位:
-- 不推荐:过短的前缀等于全表扫描
-- WHERE fts @@ to_tsquery('english', 'a:*')
-- 推荐:至少 3~4 个字符
WHERE fts @@ to_tsquery('english', 'postgre:*')拼接跨字段的 tsvector 会产生假短语。标题末尾与正文开头的词会被当成相邻:
-- 不推荐:跨字段拼接后短语匹配可能误命中
SELECT (setweight(to_tsvector('english','guide'),'A') ||
setweight(to_tsvector('english','index'),'D'))
@@ to_tsquery('english', 'guide <-> index') AS false_positive; false_positive
----------------
t
(1 row)标题是 guide、正文开头是 index,语义上毫无关系,但位置差为 1,短语匹配成立。解决方法是短语查询时限定权重或分列查询。
位置上限 16383 是静默截断,不是错误。超长文档中所有超过 16383 的词位置都变成 16383,短语查询会因此失效。超大文档建议分段处理。
-- 不推荐:单文档词数超过 16383 后,后续短语查询不可靠
SELECT 'w:99999'::tsvector AS truncated; -- 显示为 'w':16383tsvector 的文本表示在不同 major 版本间是稳定的,但不要依赖 ::text 的内部格式做业务逻辑。 需要读取词位时用官方函数(tsvector_to_array()、unnest()),不要正则解析 ::text。
tsquery 的 @@@ 已废弃,新代码用 @@。
numnode() 为 0 时必须短路。空查询会让 @@ 恒为 false,如果业务逻辑依赖「空查询返回全部」,需要自己实现:
-- 不推荐:空查询下结果永远为空,用户会觉得「坏了」
SELECT * FROM articles, to_tsquery('english', $1) q WHERE fts @@ q;
-- 推荐:空查询时返回最近记录
SELECT * FROM articles, websearch_to_tsquery('english', $1) q
WHERE numnode(q) = 0
OR fts @@ q
ORDER BY created_at DESC
LIMIT 20;不要试图存 tsvector 的二进制形式做优化。TOAST 已经会自动压缩大 tsvector,手工截断或编码只会带来正确性风险。
最佳实践
文档侧与查询侧使用同一个配置名,且显式写出。把这个规则固化成代码规范:
-- 文档侧
to_tsvector('english', body)
-- 查询侧(必须一致)
to_tsquery('english', $1)统一用 to_tsvector() / to_tsquery(),禁用 ::tsvector / ::tsquery 强制转换。后者只用于构造测试数据。
用户输入统一走 websearch_to_tsquery()。它对语法错误的容忍度最高,语义也最接近用户预期:
| 输入 | to_tsquery | websearch_to_tsquery |
|---|---|---|
"a b" | 报错(引号非法) | 'a' <-> 'b' |
a or b | 'a' | 'b'(or 非关键字,实际是 &!) | 'a' | 'b' |
a -b | 报错 | 'a' & !'b' |
a & ) | 报错 | 容错处理 |
短语查询前先确认 tsvector 里保留了位置,不要对 strip() 过的列做 <-> 查询。
用括号明确 tsquery 的优先级,读代码的人不需要背优先级表:
-- 推荐
to_tsquery('english', 'fat & (rat | cat) & !dog')需要频次信息时用 ts_rank,需要「是否命中」时只用 @@,这样能避免回表:
-- 只要命中列表(快)
SELECT id FROM articles WHERE fts @@ q;
-- 需要排序(慢一些,必须回表)
SELECT id, ts_rank(fts, q) AS r FROM articles, to_tsquery('english', $1) q
WHERE fts @@ q ORDER BY r DESC LIMIT 20;前缀搜索限定最小长度。在应用层校验,短于 3 个字符的输入不走前缀匹配,改走 pg_trgm:
<?php
// PHP 侧:前缀太短时降级处理
$q = trim($keyword);
if (mb_strlen($q) < 3) {
// 走 pg_trgm 的 ILIKE 模糊匹配,而不是 tsquery 前缀匹配
$sql = "SELECT id, title FROM articles WHERE title ILIKE '%' || :q || '%' LIMIT 20";
} else {
$sql = "SELECT id, title FROM articles WHERE fts @@ to_tsquery('english', :q || ':*') LIMIT 20";
}空查询要有兜底策略。numnode(q) = 0 时返回「最近发布」或「热门」,而不是空列表。
为跨字段短语场景单独设计。如果业务需要「标题中的短语」,就为标题单独建一个 tsvector 列和索引,不要靠拼接后的向量:
ALTER TABLE articles ADD COLUMN fts_title tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,''))) STORED;
CREATE INDEX idx_articles_fts_title ON articles USING gin (fts_title);测试要覆盖位置相关的边界。至少验证:紧邻、间隔 1 词、间隔 2 词、跨标点、大小写混排、停用词位于短语中间。
停用词会占位,但不出现在 tsvector 里——这是位置编号最容易被误解的一点:
SELECT to_tsvector('english', 'quick the fox') AS v; v
-------------------------
'fox':3 'quick':1
(1 row)注意是 fox:3 而不是 fox:2:the 虽然被丢弃、不出现在结果里,但它仍然占据位置 2。官方文档的例子也印证了这一点——'a fat cat sat on a mat - it ate a fat rats' 得到 'ate':9 'cat':3 'fat':2,11 'mat':7 'rat':12 'sat':4,其中 on、it、- 都被丢弃,但后续词的位置编号照常递增。
因此 'quick' <-> 'fox' 不会命中 'quick the fox':
SELECT to_tsvector('english', 'quick the fox')
@@ to_tsquery('english', 'quick <-> fox') AS with_stopword,
to_tsvector('english', 'quick the fox')
@@ to_tsquery('english', 'quick <2> fox') AS within2; with_stopword | within2
---------------+---------
f | t
(1 row)这也是 phraseto_tsquery() 存在的意义:它知道停用词占位,会为停用词留出距离。例如 phraseto_tsquery('english', 'the cats ate the rats') 生成 'cat' <-> 'ate' <2> 'rat'——第二个停用词 the 让 ate 与 rat 之间隔了一个位置,函数自动把这个距离编码进了 <2>。
为 tsvector 列建索引时优先 GIN,并确认查询表达式与索引表达式逐字一致:
CREATE INDEX idx_articles_fts ON articles USING gin (fts);监控 tsvector 列的膨胀。频繁更新的表上,tsvector 列的旧版本会留在表中直到 VACUUM:
SELECT pg_size_pretty(pg_total_relation_size('articles')) AS total;