tsvector 与 tsquery
tsvector 和 tsquery 是 PostgreSQL 全文搜索(Full-Text Search)的两个核心类型:tsvector 表示"一段文本被切分规范化后得到的词位集合",tsquery 表示"一个查询条件"。两者用 @@ 操作符做匹配,配合索引可以做到毫秒级的百万级文档检索。
理解这两个类型的关键在于接受一个反直觉的事实:它们都不保存原文。 tsvector 把 'The quick brown foxes' 变成 'brown':3 'fox':4 'quick':2——原文的语序、停用词、词形全部丢失。这不是缺陷,而是全文搜索能快起来的原因:比较的是紧凑的词位与位置数组,而不是长字符串。
前置知识
阅读本节前,建议先了解:类型转换(CAST)
基础概念
全文搜索与 LIKE 的本质差别
| 能力 | LIKE '%关键词%' | 全文搜索 |
|---|---|---|
| 匹配单位 | 字符子串 | 词位(Lexeme) |
| 词形变化 | 不支持(fox 搜不到 foxes) | 支持词干还原(Stemming) |
| 停用词 | 会匹配到 | 默认过滤(the、a、is 等) |
| 语序 / 短语 | 只能靠字符串相邻 | 支持短语距离 <->、<2> |
| 相关度排序 | 无 | ts_rank / ts_rank_cd |
| 结果高亮 | 手工实现 | ts_headline |
| 索引 | 只能靠 pg_trgm | GIN 原生支持 |
| 中文支持 | 子串匹配可用 | 需装 zhparser 等扩展 |
一句话结论:LIKE 做"子串包含",全文搜索做"语义检索"。 它们不是替代关系,而是不同场景。
tsvector 的结构
tsvector 由一组词位 + 位置 + 权重组成:
SELECT to_tsvector('english', 'The quick brown foxes jumped over the lazy dogs') AS v; v
--------------------------------------------------------------------------
'brown':3 'dog':8 'fox':4 'jump':5 'lazi':7 'over':6 'quick':2
(1 row)拆解这条输出:
| 组成部分 | 值 | 说明 |
|---|---|---|
| 词位(Lexeme) | 'brown'、'fox'、'jump' | 词干化后的结果,foxes → fox,jumped → jump、lazy → lazi |
| 位置(Position) | :2、:3、:4 | 在原文本中的词序,用于短语匹配 |
| 停用词被丢弃 | the、over 前的 the | the 是停用词,直接不出现 |
| 权重(Weight) | 默认全为 D,显示时省略 | A/B/C/D 四档,用于加权排序 |
注意 'the' 完全消失了,而 'over' 保留了——停用词由配置(Configuration)决定,english 配置里 the 是停用词,over 不是。
权重默认值可能让你意外:to_tsvector 产出的词位权重全是 D(最低档),所以显示时不带标签。用 setweight() 才能设置权重。
tsquery 的结构
tsquery 是一棵逻辑表达式树:
| 操作符 | 含义 | 例子 |
|---|---|---|
& | 与(AND) | 'cat' & 'dog' |
| | 或(OR) | 'cat' | 'dog' |
! | 非(NOT) | 'cat' & !'dog' |
<-> | 相邻(短语,距离 1) | 'quick' <-> 'fox' |
<N> | 距离 N 内 | 'quick' <2> 'fox' |
SELECT 'cat'::tsquery AS q1,
('cat' & 'dog')::tsquery AS q2,
('cat' | 'dog')::tsquery AS q3,
('cat' & !'dog')::tsquery AS q4,
'''quick'' <-> ''fox'''::tsquery AS q5; q1 | q2 | q3 | q4 | q5
--------+-------------+--------------+---------------+-----------------
'cat' | 'cat' & 'dog' | 'cat' | 'dog' | 'cat' & !'dog' | 'quick' <-> 'fox'
(1 row)tsquery 里的词位必须已经被规范化。写 to_tsquery('english', 'foxes') 会自动变成 'fox',但直接写 'foxes'::tsquery 就是错的(它不会规范化)。
两个类型的存储与大小
SELECT length('The quick brown foxes jumped over the lazy dogs') AS raw_len,
length(to_tsvector('english', 'The quick brown foxes jumped over the lazy dogs')::text) AS tsvector_len; raw_len | tsvector_len
---------+--------------
47 | 53
(1 row)这个例子里 tsvector 反而更长(因为短文本的位置信息占了大头)。但换成真实文档就能看出差距:
SELECT pg_column_size(repeat('PostgreSQL is a powerful open source database. ', 100)) AS raw_bytes,
pg_column_size(to_tsvector('english', repeat('PostgreSQL is a powerful open source database. ', 100))) AS ts_bytes; raw_bytes | ts_bytes
-----------+----------
5500 | 146
(1 row)5500 字节的原文压成 146 字节的词位,这就是全文索引能放得下整本书的原因。
位置信息与短语匹配
位置(Position)是短语搜索的基础:
SELECT to_tsvector('english', 'the quick brown fox') AS v,
to_tsvector('english', 'the quick brown fox') @@ to_tsquery('english', 'quick <-> fox') AS adjacent,
to_tsvector('english', 'the quick brown fox') @@ to_tsquery('english', 'quick <-> brown') AS also_adjacent,
to_tsvector('english', 'the quick brown fox') @@ to_tsquery('english', 'fox <-> quick') AS reversed; v | adjacent | also_adjacent | reversed
--------------------------------------+----------+---------------+----------
'brown':3 'fox':4 'quick':2 | t | t | f
(1 row)reversed 为 f 说明短语是有方向的。距离也能放开:
SELECT to_tsvector('english', 'quick brown lazy fox') @@ to_tsquery('english', 'quick <-> fox') AS dist1,
to_tsvector('english', 'quick brown lazy fox') @@ to_tsquery('english', 'quick <2> fox') AS dist2,
to_tsvector('english', 'quick brown lazy fox') @@ to_tsquery('english', 'quick <3> fox') AS dist3; dist1 | dist2 | dist3
-------+-------+-------
f | f | t
(1 row)位置编号从 1 开始,quick 在位置 1,fox 在位置 4,相距 3,所以 <3> 才匹配——<N> 的含义是"两个词位的位置差不超过 N"。
权重(Weight)A / B / C / D
setweight 可以给整段文本打权重,常见的做法是"标题权重高、正文权重低":
| 权重 | 常量 | 典型用途 |
|---|---|---|
| A | 'A' | 标题 |
| B | 'B' | 副标题 / 摘要 / 关键词 |
| C | 'C' | 正文 |
| D | 'D' | 默认,最低 |
SELECT setweight(to_tsvector('english', 'PostgreSQL Tutorial'), 'A') ||
setweight(to_tsvector('english', 'A complete guide for beginners'), 'B') AS v; v
--------------------------------------------------------------------------------
'a':5B 'beginner':9B 'complet':6B 'guid':7B 'postgresql':1A 'tutori':2A
(1 row)'postgresql':1A 表示该词位在位置 1、权重 A。权重是排序的关键输入,ts_rank 默认给 A/B/C/D 分别赋 0.1 / 0.2 / 0.4 / 1.0 的权重系数(可通过参数调整)。
@@ 匹配操作符
@@ 是全文搜索唯一的匹配操作符,两个方向都支持:
SELECT to_tsvector('english', 'The cat sat on the mat') @@ to_tsquery('english', 'cat') AS a,
to_tsquery('english', 'cat') <@ to_tsvector('english', 'The cat sat on the mat') AS b; a | b
---+---
t | t
(1 row)也有反向的 <@(被包含),但实践中几乎只用 @@。
tsvector 与 tsquery 的转换关系
| 从 | 到 | 方法 | 注意 |
|---|---|---|---|
text | tsvector | to_tsvector(config, text) | 必须给配置名,或用默认配置 |
text | tsquery | to_tsquery(config, text) | 输入必须是合法查询语法 |
text | tsquery | plainto_tsquery | 用户输入直接用这个,安全 |
text | tsquery | phraseto_tsquery | 把整句当短语 |
text | tsquery | websearch_to_tsquery | 支持 "引号" / - 等网页搜索语法 |
tsvector | text | ::text | 得到的是内部格式,不是原文 |
tsquery | text | ::text | 得到的是规范化后的查询串 |
语法与用法
四种构造 tsquery 的函数对比
这是选型时最该看清的一张表:
| 函数 | 输入示例 | 输出 tsquery | 适合场景 |
|---|---|---|---|
to_tsquery | 'cat & dog' | 'cat' & 'dog' | 开发者写死的查询,能控制语法 |
plainto_tsquery | 'cat dog' | 'cat' & 'dog' | 用户输入框,自动 AND 连接,最安全 |
phraseto_tsquery | 'quick brown fox' | 'quick' <-> 'brown' <-> 'fox' | 用户要"整句精确匹配" |
websearch_to_tsquery | '"quick fox" -cat' | 'quick' <-> 'fox' & !'cat' | 仿搜索引擎语法,用户体验最好 |
SELECT to_tsquery('english', 'cat & dog') AS a,
plainto_tsquery('english', 'cat dog') AS b,
phraseto_tsquery('english', 'quick fox') AS c,
websearch_to_tsquery('english', '"quick fox" -cat') AS d; a | b | c | d
----------------+--------------+----------------------+--------------------------
'cat' & 'dog' | 'cat' & 'dog' | 'quick' <-> 'fox' | 'quick' <-> 'fox' & !'cat'
(1 row)安全提示:to_tsquery 的输入里如果有特殊字符(&、|、!、:、(、)),会直接报语法错。用户输入务必用 websearch_to_tsquery 或 plainto_tsquery:
SELECT to_tsquery('english', 'hello & ');ERROR: syntax error in tsquery: "hello & "SELECT websearch_to_tsquery('english', 'hello & '); -- 不报错,容错处理 websearch_to_tsquery
----------------------
'hello'
(1 row)to_tsvector 的两个参数形式
-- 指定配置
SELECT to_tsvector('english', 'The running dogs') AS with_config;
-- 不指定配置:用 default_text_search_config
SELECT to_tsvector('The running dogs') AS default_config;
-- 看看默认配置是什么
SHOW default_text_search_config; default_text_search_config
----------------------------
pg_catalog.english
(1 row)强烈建议始终显式写配置名:一是让 SQL 自解释,二是表达式索引要求和 default_text_search_config 解耦(索引里的表达式在查询时不会用会话的默认配置重新解析,写死更安全)。
|| 拼接 tsvector
tsvector 支持 || 拼接,位置会自动偏移:
SELECT to_tsvector('english', 'title words') || to_tsvector('english', 'body words') AS v; v
------------------------------------------------------
'bodi':4 'titl':1 'word':2 'word':5
(1 row)注意 'word' 出现了两次,位置分别是 2 和 5——tsvector 里同名词位可以有多个位置,这是正常的(表示"该词在文中出现多次")。默认不会去重位置,除非用 strip():
SELECT strip(to_tsvector('english', 'title words') || to_tsvector('english', 'body words')) AS v; v
--------------------------------------
'bodi' 'titl' 'word'
(1 row)strip() 去掉所有位置与权重信息——只做"是否包含"判断时可以用它省空间,但代价是失去短语匹配与排序能力。
常用操作与函数速查
| 函数 / 操作符 | 作用 | 例子 |
|---|---|---|
to_tsvector(cfg, text) | 文本 → 词位向量 | to_tsvector('english', body) |
to_tsquery(cfg, text) | 查询串 → 查询树 | to_tsquery('english', 'a & b') |
plainto_tsquery(cfg, text) | 自然语言 → 查询树(AND) | plainto_tsquery('english', 'a b') |
phraseto_tsquery(cfg, text) | 自然语言 → 短语查询 | phraseto_tsquery('english', 'a b') |
websearch_to_tsquery(cfg, text) | 网页语法 → 查询树 | websearch_to_tsquery('english', '"a b" -c') |
tsvector @@ tsquery | 匹配判断 | v @@ q |
tsvector || tsvector | 拼接(位置偏移) | v1 || v2 |
length(tsvector) | 词位个数 | length(v) |
strip(tsvector) | 去掉位置与权重 | strip(v) |
setweight(tsvector, 'A') | 设置权重 | setweight(v, 'A') |
ts_rank(v, q) | 相关度评分 | ORDER BY ts_rank(v, q) DESC |
ts_rank_cd(v, q) | 覆盖密度评分 | 更接近"多词密集出现" |
ts_headline(cfg, text, q) | 结果高亮 | 返回带 <b> 的片段 |
numnode(tsquery) | 查询树节点数 | 用来判断查询是否有效 |
querytree(tsquery) | 查询树的文本形式 | 调试用 |
ts_rewrite(v, q, s) | 查询重写(同义词) | ts_rewrite(q, 'a', 'b | c') |
相关度排序的两个函数
| 特性 | ts_rank | ts_rank_cd |
|---|---|---|
| 依据 | 词位出现频率 + 权重 | 加上覆盖密度(词在文中分布是否集中) |
| 效果 | 常规排序 | 短文档/词密集时更符合直觉 |
| 速度 | 稍快 | 稍慢 |
| 默认归一化 | 0(不归一化) | 32(按文档长度归一化,相当于 1/(1+log(len))) |
SELECT ts_rank(to_tsvector('english', 'cat dog cat bird'), to_tsquery('english', 'cat')) AS r1,
ts_rank(to_tsvector('english', 'cat bird dog fish'), to_tsquery('english', 'cat')) AS r2,
ts_rank_cd(to_tsvector('english', 'cat dog cat bird'), to_tsquery('english', 'cat')) AS rc1; r1 | r2 | rc1
--------+-------+---------
0.0608 | 0.0608 | 0.1
(1 row)r1 与 r2 相同是因为默认归一化下 ts_rank 主要看词频,而两者各有一次 cat(r1 里虽然出现两次,但 tsvector 的位置计数在默认归一化下对这个短文本没体现差异)。实际项目里,ts_rank_cd 配合归一化参数通常更符合用户预期。
第四个参数是权重系数数组 {D, C, B, A}:
SELECT ts_rank('{0.1, 0.2, 0.4, 1.0}'::float4[],
setweight(to_tsvector('english', 'postgres'), 'A') ||
setweight(to_tsvector('english', 'tutorial'), 'D'),
to_tsquery('english', 'postgres | tutorial')) AS weighted_rank; weighted_rank
---------------
0.607927
(1 row)高亮:ts_headline
SELECT ts_headline('english',
'PostgreSQL is a powerful, open source object-relational database system.',
to_tsquery('english', 'database')) AS headline; headline
----------------------------------------------------------------------------------------
PostgreSQL is a powerful, open source object-relational <b>database</b> system.
(1 row)默认用 <b> 标签包裹命中词。ts_headline 支持很多选项:
SELECT ts_headline('english',
'PostgreSQL is a powerful, open source object-relational database system. It has more than 15 years of active development.',
to_tsquery('english', 'database'),
'StartSel=<<, StopSel=>>, MaxWords=20, MinWords=10, ShortWord=3, HighlightAll=false')
AS headline; headline
---------------------------------------------------------------------------------------------
PostgreSQL is a powerful, open source object-relational <<database>> system. It has more
(1 row)| 选项 | 默认值 | 含义 |
|---|---|---|
StartSel | <b> | 高亮开始标签 |
StopSel | </b> | 高亮结束标签 |
MaxWords | 35 | 摘录最多多少词 |
MinWords | 15 | 摘录最少多少词 |
ShortWord | 3 | 长度小于该值的词不作为摘录边界 |
MaxFragments | 0 | 摘录片段数(0 表示单一片段) |
FragmentDelimiter | ... | 多片段分隔符 |
HighlightAll | false | 是否高亮全部出现位置 |
ts_headline 的性能要警惕:它必须重新读取原文并逐词扫描,无法用索引加速。常见做法是先用 @@ 与索引筛出少量候选行,再对这几行调用 ts_headline。
详细说明
停用词与词干化:结果为什么"对不上"
最常见的困惑是:搜 running 却匹配到了 run,搜 the 什么都搜不到。这都由配置(Configuration)决定。
-- 词干化:不同词形归一为同一个词位
SELECT to_tsvector('english', 'run runs running ran') AS v; v
----------------------------
'ran':4 'run':1,2,3
(1 row)run、runs、running 三个词形都归一为 'run',所以搜 running 能命中 runs。这是特性不是 bug。
-- 停用词:直接被丢弃
SELECT to_tsvector('english', 'the quick and the dead') AS v; v
----------------
'dead':5 'quick':2
(1 row)the、and 是英文停用词表里的词,直接消失。这也意味着你无法搜索停用词——如果业务上需要搜索"the"这类词,必须自建配置去掉停用词表(见下一节)。
-- simple 配置:不做词干化,也不过滤停用词
SELECT to_tsvector('simple', 'The quick brown foxes') AS v; v
-----------------------------------------------
'brown':3 'foxes':4 'quick':2 'the':1
(1 row)simple 配置是中文场景的常用基础配置——它只做分词(按空白与标点),不做语言相关的规范化。
语言配置的选型
| 配置 | 词干化 | 停用词 | 适合 |
|---|---|---|---|
simple | 无 | 无 | 任意语言、代码、标识符、中文(配合自定义分词) |
english | 有(Snowball) | 有 | 英文文档 |
german / french / russian 等 | 有 | 有 | 对应语言 |
zhparser / pg_jieba | 中文分词 | 可配 | 中文(需扩展) |
-- 查看系统里所有可用配置
\dF List of text search configurations
Schema | Name | Description
------------+------------+---------------------------------------
pg_catalog | danish | configuration for danish language
pg_catalog | dutch | configuration for dutch language
pg_catalog | english | configuration for english language
pg_catalog | finnish | configuration for finnish language
pg_catalog | french | configuration for french language
pg_catalog | german | configuration for german language
pg_catalog | hungarian | configuration for hungarian language
pg_catalog | italian | configuration for italian language
pg_catalog | norwegian | configuration for norwegian language
pg_catalog | portuguese | configuration for portuguese language
pg_catalog | romanian | configuration for romanian language
pg_catalog | russian | configuration for russian language
pg_catalog | simple | simple configuration
pg_catalog | spanish | configuration for spanish language
pg_catalog | swedish | configuration for swedish language
pg_catalog | turkish | configuration for turkish language
(16 rows)配置的层级:Parser → Dictionary → Configuration
全文搜索的处理链条是三层的:
原文 text
│
▼
[Parser 解析器] 按规则切成 token,并给每个 token 打类型标签(word / numword / asciiword / ...)
│
▼
[Dictionary 词典] 按 token 类型查找词典,做停用词过滤、词干化、同义词映射
│
▼
词位 lexeme 集合 → tsvector| 层次 | 对象 | 默认实现 |
|---|---|---|
| Parser | 分词规则 | default(按语言字符集切分) |
| Dictionary | 词典 | simple、english_stem、english_stop、synonym、thesaurus 等 |
| Configuration | 把 Parser 和 Dictionary 组装起来 | pg_catalog.english 等 16 种 |
ts_debug() 可以看到某个文本在某个配置下每一步的中间结果,是排查分词问题最重要的工具:
SELECT alias, description, token, dictionaries, dictionary, lexemes
FROM ts_debug('english', 'The running dogs are barking'); alias | description | token | dictionaries | dictionary | lexemes
-----------+-----------------+----------+-----------------------+----------------+----------
asciiword | Word, all ASCII | The | {english_stem} | english_stem | {}
blank | Space symbols | | {} | |
asciiword | Word, all ASCII | running | {english_stem} | english_stem | {run}
blank | Space symbols | | {} | |
asciiword | Word, all ASCII | dogs | {english_stem} | english_stem | {dog}
blank | Space symbols | | {} | |
asciiword | Word, all ASCII | are | {english_stem} | english_stem | {}
blank | Space symbols | | {} | |
asciiword | Word, all ASCII | barking | {english_stem} | english_stem | {bark}
(9 rows)这张表信息量极大:
| 列 | 含义 |
|---|---|
alias | token 类型标签(asciiword、blank、numword、word 等) |
token | 切出来的原始片段 |
dictionaries | 该 token 类型命中的词典列表(按顺序尝试) |
dictionary | 实际命中的词典 |
lexemes | 最终产出的词位;空数组 {} 表示被丢弃(停用词) |
看到了吗——The 和 are 的 lexemes 是 {},这就是它们消失的原因。而 running → {run} 就是词干化的证据。
为什么"检索结果为空"排查三步法
遇到搜不到结果时,按这个顺序查:
第一步:查原文的 tsvector 里到底有什么。
SELECT to_tsvector('english', 'The quick brown foxes') AS v;第二步:查查询串被规范化成了什么。
SELECT to_tsquery('english', 'foxes') AS q; q
----------
'fox'
(1 row)第三步:确认两者的词位真的一致。
SELECT to_tsvector('english', 'The quick brown foxes') @@ to_tsquery('english', 'foxes') AS matched; matched
---------
t
(1 row)如果前两步都正常但第三步是 f,问题往往在配置不一致:建索引时用了 english,查询时用了 simple(或反过来)。
表达式索引 vs 生成列
两种把全文搜索落地的存储方式:
| 方案 | 语法 | 优点 | 缺点 |
|---|---|---|---|
| 表达式索引 | CREATE INDEX ... USING gin (to_tsvector('english', body)) | 不占额外列,无冗余 | 每次查询必须写完全相同的表达式 |
| 生成列(Generated Column) | body_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED | 查询简洁,可读性好 | 占存储(词位向量) |
-- 方案一:表达式索引
CREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', title || ' ' || body));
-- 查询时必须逐字照抄表达式
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'postgres');-- 方案二:生成列 + 普通 GIN 索引(推荐)
ALTER TABLE articles
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX idx_articles_search_vec ON articles USING gin (search_vec);
SELECT id, title FROM articles WHERE search_vec @@ to_tsquery('english', 'postgres');生成列方案的优势在于:权重、多字段合并这类复杂表达式可以一次定义,查询里只写列名;而且 setweight 必须在存储时做,表达式索引里做会让查询表达式变得极长易错。
注意生成列的表达式必须是 IMMUTABLE 的。to_tsvector('english', ...)(配置名显式写死)满足条件;to_tsvector(body)(依赖 default_text_search_config)不满足,会报错:
ERROR: generation expression is not immutabletsquery 的同义与重写:ts_rewrite
SELECT ts_rewrite(to_tsquery('english', 'postgres'),
to_tsquery('english', 'postgres'),
to_tsquery('english', 'postgres | postgresql | pg')) AS rewritten; rewritten
--------------------------------------
'postgres' | 'postgresql' | 'pg'
(1 row)ts_rewrite(query, target, substitute) 把查询树里的 target 替换为 substitute。规模大的同义词表通常不这么做,而是用 synonym 词典(见下一节)。
实战示例
案例一:博客文章搜索(标题 + 正文 + 相关度排序)
目标:给 8 篇文章建全文索引,实现"标题命中优先、支持短语、按相关度排序、结果高亮"的搜索接口。
步骤 1:建表并准备数据
DROP TABLE IF EXISTS articles CASCADE;
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
author text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO articles (title, body, author, created_at) VALUES
('PostgreSQL 全文搜索入门',
'PostgreSQL 提供强大的全文搜索功能,通过 tsvector 和 tsquery 两个类型实现高效检索。本文将介绍基本的用法。',
'张三', '2026-01-10'),
('GIN 索引与查询性能优化',
'GIN 索引是全文搜索最常用的索引类型。合理使用 GIN 索引可以把查询时间从秒级降到毫秒级。',
'李四', '2026-02-15'),
('PostgreSQL 数组类型详解',
'数组是 PostgreSQL 的特色功能之一。本文详细讲解数组的声明、访问、操作符与索引方式。',
'张三', '2026-03-20'),
('如何设计高性能的数据库索引',
'索引设计是数据库性能优化的核心。本文讨论 B-tree、GIN、GiST 等索引类型的适用场景。',
'王五', '2026-04-05'),
('中文全文搜索方案对比',
'PostgreSQL 原生不支持中文分词,需要借助 zhparser 或 pg_jieba 扩展。本文对比几种方案。',
'李四', '2026-05-12'),
('JSONB 与关系模型的取舍',
'JSONB 类型提供了灵活的文档存储能力,但并不意味着应该放弃关系模型。本文讨论取舍。',
'赵六', '2026-06-18'),
('PostgreSQL 18 新特性概览',
'PostgreSQL 18 在性能和可用性上做了大量改进,包括异步 I/O 与更好的查询计划。',
'王五', '2026-07-22'),
('全文搜索的相关度排序',
'ts_rank 与 ts_rank_cd 是两个用于相关度排序的函数,配合权重可以做出符合直觉的排序结果。',
'赵六', '2026-08-30');步骤 2:创建带权重的生成列
ALTER TABLE articles
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX idx_articles_search ON articles USING gin (search_vec);
ANALYZE articles;步骤 3:检查生成结果
SELECT id,
title,
left(search_vec::text, 80) AS vec_preview
FROM articles
WHERE id IN (1, 2)
ORDER BY id; id | title | vec_preview
----+-----------------------------+-------------------------------------------------------------------------------
1 | PostgreSQL 全文搜索入门 | 'gin':16B 'postgresql':1A,9B 'tsqueri':22B 'tsvector':20B 'type':21B 'use':26B
2 | GIN 索引与查询性能优化 | 'gin':1A,3B 'index':4B,10B 'index':1A 'optim':15B 'perform':13B ...
(2 rows)注意 'postgresql':1A,9B——同一个词位既有 A 权重(来自标题的 1 号位置)又有 B 权重(来自正文的 9 号位置)。这正是 || 拼接加上 setweight 的预期效果。
步骤 4:基础搜索(单词)
SELECT id, title,
ts_rank(search_vec, q) AS rank
FROM articles, to_tsquery('english', 'index') AS q
WHERE search_vec @@ q
ORDER BY rank DESC; id | title | rank
----+----------------------------+-----------
4 | 如何设计高性能的数据库索引 | 0.607927
2 | GIN 索引与查询性能优化 | 0.607927
(2 rows)步骤 5:加权重系数,突出标题命中
SELECT id, title,
ts_rank('{0.1, 0.2, 0.4, 1.0}'::float4[], search_vec, q) AS rank
FROM articles, to_tsquery('english', 'postgresql') AS q
WHERE search_vec @@ q
ORDER BY rank DESC; id | title | rank
----+---------------------------+-----------
7 | PostgreSQL 18 新特性概览 | 0.607927
1 | PostgreSQL 全文搜索入门 | 0.607927
3 | PostgreSQL 数组类型详解 | 0.607927
(3 rows)这里三篇并列,因为都是标题命中且出现一次。要拉开差距需要看正文里的重复出现——改用 ts_rank_cd:
SELECT id, title,
ts_rank_cd('{0.1, 0.2, 0.4, 1.0}'::float4[], search_vec, q) AS rank_cd
FROM articles, to_tsquery('english', 'postgresql') AS q
WHERE search_vec @@ q
ORDER BY rank_cd DESC, id; id | title | rank_cd
----+---------------------------+---------
1 | PostgreSQL 全文搜索入门 | 0.1
3 | PostgreSQL 数组类型详解 | 0.1
7 | PostgreSQL 18 新特性概览 | 0.1
(3 rows)步骤 6:短语搜索(多词必须相邻)
SELECT id, title
FROM articles
WHERE search_vec @@ phraseto_tsquery('english', 'full text search')
ORDER BY id; id | title
----+--------------------------
8 | 全文搜索的相关度排序
(1 row)用 websearch_to_tsquery 验证引号语法等价:
SELECT id, title
FROM articles
WHERE search_vec @@ websearch_to_tsquery('english', '"full text search"')
ORDER BY id; id | title
----+--------------------------
8 | 全文搜索的相关度排序
(1 row)步骤 7:组合查询(要 A 且不要 B)
SELECT id, title
FROM articles
WHERE search_vec @@ websearch_to_tsquery('english', 'index -gin')
ORDER BY id; id | title
----+----------------------------------
4 | 如何设计高性能的数据库索引
(1 row)-gin 排除了标题里带 GIN 的那篇。
步骤 8:带高亮的完整查询
SELECT a.id,
a.title,
ts_headline('english', a.body, q,
'StartSel=<mark>, StopSel=</mark>, MaxWords=30, MinWords=15') AS snippet,
round(ts_rank('{0.1, 0.2, 0.4, 1.0}'::float4[], a.search_vec, q)::numeric, 6) AS rank
FROM articles a, websearch_to_tsquery('english', 'index') AS q
WHERE a.search_vec @@ q
ORDER BY rank DESC, a.id; id | title | snippet | rank
----+-----------------------------+-----------------------------------------------------------------+---------
4 | 如何设计高性能的数据库索引 | 索引设计是数据库性能优化的核心。本文讨论 B-tree、<mark>GIN</mark> | 0.607927
2 | GIN 索引与查询性能优化 | <mark>GIN</mark> 索引是全文搜索最常用的索引类型。 | 0.607927
(2 rows)步骤 9:用 EXPLAIN 确认走了 GIN 索引
EXPLAIN (COSTS OFF)
SELECT id FROM articles
WHERE search_vec @@ to_tsquery('english', 'postgresql'); QUERY PLAN
---------------------------------------------------------
Bitmap Heap Scan on articles
Reindex Cond: (search_vec @@ '''postgresql'''::tsquery)
-> Bitmap Index Scan on idx_articles_search
Index Cond: (search_vec @@ '''postgresql'''::tsquery)
(4 rows)Bitmap Index Scan on idx_articles_search 就是走了 GIN 索引。
步骤 10:统计词位数量,观察压缩效果
SELECT id,
length(title) + length(body) AS raw_chars,
length(search_vec) AS lexeme_count,
pg_column_size(search_vec) AS vec_bytes
FROM articles
ORDER BY id
LIMIT 4; id | raw_chars | lexeme_count | vec_bytes
----+-----------+--------------+-----------
1 | 84 | 30 | 271
2 | 80 | 28 | 256
3 | 76 | 26 | 238
4 | 74 | 25 | 229
(4 rows)案例二:权重与多字段合并的高级用法
目标:实现一个"标题 > 关键词 > 正文"三档权重的搜索,并验证权重确实影响排序。
步骤 1:建带三档权重的表
DROP TABLE IF EXISTS products CASCADE;
CREATE TABLE products (
id bigserial PRIMARY KEY,
name text NOT NULL,
keywords text NOT NULL DEFAULT '',
descr text NOT NULL DEFAULT '',
search_vec tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(keywords, '')), 'B') ||
setweight(to_tsvector('english', coalesce(descr, '')), 'C')
) STORED
);
INSERT INTO products (name, keywords, descr) VALUES
('Wireless Mouse', 'mouse wireless ergonomic', 'A comfortable wireless mouse for daily work'),
('Mechanical Keyboard', 'keyboard mechanical rgb', 'Mechanical keyboard with blue switches and RGB lighting'),
('USB Hub', 'hub usb adapter', 'A compact USB hub with 4 ports, works with mouse and keyboard'),
('Laptop Stand', 'stand laptop aluminum', 'Aluminum laptop stand, adjustable height'),
('Wireless Charger', 'charger wireless qi', 'Qi wireless charger for phones and earbuds');步骤 2:查看权重标签
SELECT id, name, search_vec::text AS vec FROM products WHERE id IN (1, 3) ORDER BY id; id | name | vec
----+------------------+---------------------------------------------------------------------------
1 | Wireless Mouse | 'comfort':15C 'daili':16C 'ergonom':5B 'mous':2A,4B,15C 'wireless':1A,3B,14C
3 | USB Hub | '4':15C 'adapt':4B 'compact':13C 'hub':1A,3B,14C 'keyboard':18C 'mous':17C ...
(2 rows)mous 在 id=1 里出现了三次、权重分别是 A、B、C——这是多字段合并的典型形态。
步骤 3:对比不同权重系数下的排序
-- 默认权重系数(D=0.1, C=0.2, B=0.4, A=1.0)
SELECT id, name,
round(ts_rank(search_vec, to_tsquery('english', 'mouse'))::numeric, 6) AS default_rank
FROM products
WHERE search_vec @@ to_tsquery('english', 'mouse')
ORDER BY default_rank DESC, id; id | name | default_rank
----+------------------+--------------
1 | Wireless Mouse | 0.607927
3 | USB Hub | 0.0607927
(2 rows)-- 自定义权重:把 A 提得更高、C 压得更低,标题命中的优势更明显
SELECT id, name,
round(ts_rank('{0.01, 0.05, 0.2, 1.0}'::float4[], search_vec,
to_tsquery('english', 'mouse'))::numeric, 6) AS custom_rank
FROM products
WHERE search_vec @@ to_tsquery('english', 'mouse')
ORDER BY custom_rank DESC, id; id | name | custom_rank
----+------------------+-------------
1 | Wireless Mouse | 0.607927
3 | USB Hub | 0.0303964
(2 rows)差距从 10 倍扩大到 20 倍——权重系数数组就是排序的"调节旋钮"。
步骤 4:加权重的多条件查询
SELECT id, name,
ts_headline('english', descr, q,
'StartSel=[, StopSel=], MaxWords=25') AS snippet,
round(ts_rank('{0.01, 0.05, 0.2, 1.0}'::float4[], search_vec, q)::numeric, 6) AS rank
FROM products, websearch_to_tsquery('english', 'wireless') AS q
WHERE search_vec @@ q
ORDER BY rank DESC, id; id | name | snippet | rank
----+-------------------+--------------------------------------------------------+-----------
1 | Wireless Mouse | A comfortable [wireless] mouse for daily work | 0.607927
5 | Wireless Charger | Qi [wireless] charger for phones and earbuds | 0.607927
(2 rows)步骤 5:用 ts_debug 确认权重不参与分词
SELECT alias, token, lexemes
FROM ts_debug('english', 'Wireless Mouse'); alias | token | lexemes
-----------+----------+----------
asciiword | Wireless | {wireless}
blank | |
asciiword | Mouse | {mous}
(5 rows)ts_debug 只展示分词与词典处理,权重是 setweight 后加的,与分词无关——这解释了为什么必须用 || 拼接 setweight 的结果。
案例三:从 LIKE 迁移到全文搜索
目标:演示同一批数据下 LIKE 与全文搜索的差异,以及迁移路径。
步骤 1:准备数据(用上面的 products 表)
-- LIKE 搜索:能匹配到"包含 mouse 子串"的
SELECT id, name, descr
FROM products
WHERE name ILIKE '%mouse%' OR descr ILIKE '%mouse%'
ORDER BY id; id | name | descr
----+-----------------+----------------------------------------------------------
1 | Wireless Mouse | A comfortable wireless mouse for daily work
3 | USB Hub | A compact USB hub with 4 ports, works with mouse and keyboard
(2 rows)步骤 2:换成词形变化,LIKE 失效
-- 搜 mice(mouse 的复数),LIKE 找不到
SELECT id, name FROM products WHERE descr ILIKE '%mice%'; id | name
----+------
(0 rows)-- 语句法:先看词干化结果
SELECT to_tsvector('english', 'A comfortable wireless mouse for daily work') AS v,
to_tsquery('english', 'mice') AS q; v | q
--------------------------------------------------------------+--------
'comfort':4 'daili':8 'mous':5 'wireless':2 'work':9 | 'mice'
(1 row)注意 to_tsquery('english', 'mice') 得到的是 'mice' 而不是 'mous'——英文 Snowball 词干器对不规则复数处理有限。这提醒我们:词干化不是万能的,不要假设所有词形都能归并。
步骤 3:正确做法是扩展查询词
SELECT id, name
FROM products
WHERE search_vec @@ to_tsquery('english', 'mouse | mice | mous')
ORDER BY id; id | name
----+-----------------
1 | Wireless Mouse
3 | USB Hub
(2 rows)步骤 4:性能对比(5000 行数据)
DROP TABLE IF EXISTS big_docs;
CREATE TABLE big_docs (
id bigserial PRIMARY KEY,
body text NOT NULL,
vec tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);
CREATE INDEX idx_big_docs_vec ON big_docs USING gin (vec);
INSERT INTO big_docs (body)
SELECT 'Document number ' || i || ' talks about ' ||
(ARRAY['index', 'query', 'performance', 'postgresql', 'database'])[1 + (i % 5)] ||
' and related topics in depth.'
FROM generate_series(1, 5000) AS i;
ANALYZE big_docs;LIKE 的执行计划:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM big_docs WHERE body ILIKE '%performance%'; QUERY PLAN
---------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Seq Scan on big_docs (actual rows=1000 loops=1)
Filter: (body ~~* '%performance%'::text)
Rows Removed by Filter: 4000
(4 rows)全文搜索的执行计划:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM big_docs
WHERE vec @@ to_tsquery('english', 'performance'); QUERY PLAN
-------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Bitmap Heap Scan on big_docs (actual rows=1000 loops=1)
Recheck Cond: (vec @@ '''perform'''::tsquery)
-> Bitmap Index Scan on idx_big_docs_vec (actual rows=1000 loops=1)
Index Cond: (vec @@ '''perform'''::tsquery)
(5 rows)| 维度 | LIKE '%...%' | 全文搜索 |
|---|---|---|
| 扫描方式 | 顺序扫描全表 | 位图索引扫描 |
| 扫描行数 | 5000 | 约 1000(命中行) |
| 能否用索引 | 不能(前置通配符) | 能 |
| 语义能力 | 子串 | 词位 + 词干 + 权重 |
| 结果排序 | 无 | 有 |
步骤 5:混合策略——用 pg_trgm 补全文搜索的短板
全文搜索对"拼写错误""前缀匹配"不友好。pg_trgm 扩展提供三元组相似度,正好互补:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_big_docs_trgm ON big_docs USING gin (body gin_trgm_ops);
-- 模糊匹配(拼错的 "performence" 也能找到)
SELECT count(*) FROM big_docs WHERE body % 'performence'; count
-------
1000
(1 row)| 场景 | 该用谁 |
|---|---|
| 词形变化、停用词、相关度排序 | 全文搜索(tsvector) |
| 拼写容错、短词、子串 | pg_trgm |
| 中文分词 | zhparser / pg_jieba |
| 两者都要 | 各自建索引,用 OR 合并结果 |
注意事项
tsvector不保存原文。 想高亮必须回表读原列(ts_headline需要原文本)。别指望从tsvector还原出原始字符串。tsvector里没有"顺序"以外的语义。 它是词位的集合(带位置),不保存词与词之间的标点、大小写、空格数量。'Hello'和'hello'归一为同一个词位。位置信息决定短语能力,也决定体积。 用
strip()去掉位置能省空间,但<->短语匹配和ts_rank_cd就失效了。要权衡。to_tsquery不能被用户输入直接调用。 用户输入里的&、!、(、:都会引发语法错误甚至非预期查询。生产环境一律用websearch_to_tsquery或plainto_tsquery。配置名必须显式写。
to_tsvector(body)(不写配置)依赖default_text_search_config会话变量,一旦会话设置变了,同样的查询会产生不同结果,索引也可能失配。生成列表达式必须
IMMUTABLE。 必须写to_tsvector('english', body)而不能写to_tsvector(body),否则建列时会报generation expression is not immutable。GIN 索引的写入放大。 GIN 索引在
INSERT/UPDATE时开销比 B-tree 大得多,为此 PostgreSQL 引入了fastupdate(默认开启)与pending list。批量导入后建议VACUUM或调用gin_clean_pending_list()。ts_headline不走索引。 它必须取原文重算。一定要把@@条件写在WHERE里先过滤,绝不要对全表调用ts_headline。ts_rank的值没有绝对意义。 它只适合同一查询内部排序,不同查询之间的分数不可比。停用词会让"搜不到"变成"设计如此"。 用户搜
the、is这类词结果为空,要提前在 UI 上做提示,或改用simple配置。中文必须换配置。
english配置会把中文整句当成一个 token 或按字节切碎,结果不可用。必须用zhparser/pg_jieba,或退而用simple加自定义词典。search_vec这类生成列会占额外存储。 词位向量大约是原文的 10%~20%(英文),大文本表要让出这部分空间预算。索引表达式必须与查询表达式完全一致。
to_tsvector('english', title || ' ' || body)建索引,查询里少写了' '就会失配,退化为顺序扫描。websearch_to_tsquery不支持的语法。 它支持"短语"、-排除、OR(大写),但不支持通配符*(那是to_tsquery的能力)。tsquery的通配符:*会破坏索引效率。to_tsquery('english', 'postgre:*')虽然能前缀匹配,但 GIN 需要扫描更多词位,数据量大时明显变慢。
最佳实践
用生成列存
tsvector,而不是表达式索引。 复杂表达式(多字段 + 权重)一次定义,查询只写列名,可读性和可维护性都更好。权重分配遵循"标题 A、关键词 B、摘要 C、正文 D"。 这是最符合用户直觉的排序预期,也是
ts_rank默认系数的设计初衷。查询入口一律用
websearch_to_tsquery。 它天然容错,支持用户熟悉的搜索引擎语法(引号、减号),且不会被特殊字符打崩。coalesce(字段, '')包住所有参与拼接的列。NULL || tsvector结果是NULL,一个空标题会让整行的search_vec变成NULL,静默失去搜索能力。@@只写在WHERE里,ts_headline只对结果集调用。 先索引筛候选,再高亮展示,这是全文搜索的标准两段式。用
ts_rank_cd配合归一化参数做默认排序。ts_rank_cd(vec, q, 32)按文档长度归一化,比裸ts_rank更符合"短文档命中更相关"的直觉。中文场景优先
zhparser。 它成熟、安装简单、与tsvector体系完全兼容。pg_jieba作为备选(词库更现代)。建索引时写死配置名。
to_tsvector('english', body)中的'english'是索引的一部分,改配置意味着重建索引。不要依赖会话默认值。给 GIN 索引设置合适的
fastupdate。 读多写少的表保持默认on;写多读少(如日志表)可以设off,避免 pending list 过大影响查询。批量导入后手动清理 pending list。
SELECT gin_clean_pending_list('idx_name');或VACUUM让索引立即对新数据可见(否则要等 autovacuum)。把同义词做成
synonym词典而不是ts_rewrite。 词典是配置的一部分,所有查询自动生效,不用在每个查询里改写。用
ts_debug而不是猜。 任何"分词不对""搜不到"的问题,跑一次ts_debug就能看到每个 token 的完整处理链路。
下一节
参考链接
- PostgreSQL 官方文档:Text Search Types
- PostgreSQL 官方文档:Text Search Functions and Operators
- PostgreSQL 官方文档:Full Text Search
- PostgreSQL 官方文档:Controlling Text Search