搜索排名与权重(ts_rank)
前几节解决了「哪些文档命中查询」的问题:tsvector @@ tsquery 只回答是或否。但真实搜索场景里,一个关键词往往命中几千条记录,用户只会看前 20 条——谁排前面才是体验的关键。
PostgreSQL 提供两个内置排名函数 ts_rank 与 ts_rank_cd 来给每条命中文档打分,配合 setweight 标记字段重要性,再加上 ts_headline 生成命中片段高亮,就构成了一条完整的搜索排序链路。
本节讲清打分公式的输入(权重、归一化)、两个函数的差异、高亮函数的选项,以及排名为什么是全链路里最贵的一步。
前置知识
阅读本节前,建议先了解:to_tsvector / to_tsquery / plainto_tsquery
基础概念
排名在整个链路里的位置
排名发生在筛选之后、排序之时。它不是「让更多文档命中」,而是「让已命中的文档有个先后」:
SELECT ...
FROM articles
│
├─ 1. WHERE fts @@ q ← 用 GIN/GiST 索引把候选集缩到几十/几百行(快)
│
├─ 2. ORDER BY ts_rank(...) ← 对候选集逐行读 tsvector 打分排序(慢,I/O 受限)
│
├─ 3. LIMIT 20 OFFSET 0 ← 只取第一页
│
└─ 4. ts_headline(...) ← 只为这 20 行回原表取正文、生成高亮片段(更慢)关键认知:第 2 步的成本与候选集大小成正比,而不是与表总行数成正比。 所以第 1 步的索引质量直接决定排名能不能撑住——这也是为什么索引与排名必须放在一起设计。
两个排名函数的差异
| 维度 | ts_rank | ts_rank_cd |
|---|---|---|
| 全称 | rank | cover density(覆盖密度) |
| 依据 | 匹配词位的出现频率 | 频率 + 词位之间的邻近程度 |
| 是否依赖位置信息 | 否 | 是(需要 tsvector 里的 position) |
输入被 strip() 过的向量 | 仍可算分 | 忽略被剥离的词位;若全部被剥离则返回 0 |
| 典型值 | 0.06079271 | 0.1 |
| 计算成本 | 低 | 更高(要算 extent 与调和距离) |
| 适用场景 | 长文档、正文关键词密度 | 短语搜索、标题式短文本 |
官方给的最小对照示例:
SELECT ts_rank(to_tsvector('raining cats and dogs'), 'cat') AS rank_freq,
ts_rank_cd(to_tsvector('raining cats and dogs'), 'cat') AS rank_cover; rank_freq | rank_cover
------------+------------
0.06079271 | 0.1
(1 row)同一个文档、同一个查询,两个函数给出的数值完全不同,且不能互相比较。一旦选定函数,全站必须统一,否则「第 1 页与第 2 页的排序口径不一致」会导致翻页时出现重复或漏项。
权重(weight)的四档
PostgreSQL 用四个字母给 tsvector 的每个位置打上重要性标签:
标签 常见来源字段 ts_rank 默认权重
──── ────────────────── ──────────────
A 标题 title 1.0 ← weights 数组的第 4 个元素
B 关键词 keywords 0.4 ← weights 数组的第 3 个元素
C 摘要 abstract 0.2 ← weights 数组的第 2 个元素
D 正文 body 0.1 ← weights 数组的第 1 个元素注意上下两列的书写顺序是相反的。 文档里习惯写「标题 A、关键词 B、摘要 C、正文 D」(A 最重),而 ts_rank 的 weights 数组按 {D, C, B, A} 的顺序排列,即从最轻到最重。这是本节最容易写错的点,务必记住:
weights 数组下标: [0] [1] [2] [3]
对应标签: D C B A
默认值: 0.1 0.2 0.4 1.0每一档的取值必须在 0.0 ~ 1.0 之间——这是实现层的硬校验,写出 4.0 会直接报 ERROR: weight out of range。也就是说 A 档的默认值 1.0 已经是上限,无法再往上加;想让标题相对更突出,唯一办法是把其他三档压低:
思路 weights 数组 效果
──────────────────────────────────────────────────────────────
放大标题 {0.1, 0.2, 0.4, 4.0} ← 报错,A 不能超过 1.0
压低正文 {0.01, 0.05, 0.1, 1.0} ← 正确,标题/正文权重差拉到 100 倍排名是「启发式」,不是「相关性真理」
官方在 12.3.3 节明确写过两点,写业务代码前必须先接受它们:
- 相关性是模糊的、高度依赖应用的。 不同应用可能需要额外信息参与排序,比如文档修改时间、点击率、作者权威度。内置函数只是示例,官方鼓励你自己写排序函数或把
ts_rank的结果与其他因子叠加。 - 排名函数不使用任何全局信息(不知道总分、不知道最高分),因此不可能得到真正公平的「百分比相关度」。归一化参数 32 把分数压到 0~1 只是为了好看,不改变顺序。
排名函数的完整签名
ts_rank( [ weights float4[], ] vector tsvector, query tsquery [, normalization integer ] ) returns float4
ts_rank_cd([ weights float4[], ] vector tsquery, query tsquery [, normalization integer ] ) returns float4两个要点:
weights参数在最前面,不是第二个。写成ts_rank(fts, q, '{0.1,0.2,0.4,1.0}')会报参数类型错误。- 返回类型是
float4(real),不是numeric,比较时注意浮点精度。
语法与用法
最基本形式:两个必选参数
-- 只有向量与查询,用默认权重、默认不归一化
SELECT ts_rank(fts, q) FROM ...;ts_rank(vector, query) 等价于 ts_rank('{0.1, 0.2, 0.4, 1.0}', vector, query, 0)。
带权重数组
-- weights 在最前;顺序是 {D, C, B, A} —— 从最轻到最重
SELECT ts_rank('{0.1, 0.2, 0.4, 1.0}', fts, q) FROM ...;
-- 让标题更突出:把正文权重从 0.1 压到 0.01(A 已经是上限 1.0,不能再加)
SELECT ts_rank('{0.01, 0.05, 0.1, 1.0}', fts, q) FROM ...;权重数组是 float4[],必须正好 4 个元素、每个元素在 0.0 ~ 1.0 之间(详见下文「weights 数组」)。
带归一化参数
-- 除以文档长度(抑制长文档优势)
SELECT ts_rank(fts, q, 2) FROM ...;
-- 组合归一化:先除以文档长度,再除以唯一词数
SELECT ts_rank(fts, q, 2 | 8) FROM ...;
-- 排名最高的 10 条(官方示例,用 ts_rank_cd + 归一化 32)
SELECT title, ts_rank_cd(textsearch, query) AS rank
FROM apod, to_tsquery('neutrino|(dark & matter)') query
WHERE query @@ textsearch
ORDER BY rank DESC
LIMIT 10; title | rank
-----------------------------------------------+----------
Neutrinos in the Sun | 3.1
The Sudbury Neutrino Detector | 2.4
A MACHO View of Galactic Dark Matter | 2.01317
Hot Gas and Dark Matter | 1.91171
The Virgo Cluster: Hot Plasma and Dark Matter | 1.90953
Rafting for Solar Neutrinos | 1.9
NGC 4650A: Strange Galaxy and Dark Matter | 1.85774
Hot Gas and Dark Matter | 1.6123
Ice Fishing for Cosmic Neutrinos | 1.6
Weak Lensing Distorts the Universe | 0.818218
(10 rows)注意 FROM apod, to_tsquery(...) query 这种写法:把 tsquery 放在 FROM 里当单行表用,只在 WHERE 里算一次,比在 SELECT 与 WHERE 里各写一遍更清爽。这是官方惯用技巧。
高亮函数
ts_headline([ config regconfig, ] document text, query tsquery [, options text ]) returns text
ts_headline([ config regconfig, ] document json, query tsquery [, options text ]) returns text
ts_headline([ config regconfig, ] document jsonb, query tsquery [, options text ]) returns textSELECT ts_headline('english',
'The most common type of search
is to find all documents containing given query terms
and return them in order of their similarity to the
query.',
to_tsquery('english', 'query & similarity')); ts_headline
------------------------------------------------------------
containing given <b>query</b> terms +
and return them in order of their <b>similarity</b> to the+
<b>query</b>.
(1 row)默认用 <b> / </b> 包裹命中词;+ 是 psql 对多行文本值的续行标记,不是输出内容本身。
详细说明
ts_rank:基于词频的打分
ts_rank 只看「匹配词位在向量里出现了多少次」,再按权重加权。它不是简单的计数:同一个词出现多次时,第 n 次出现按 1/n² 衰减计入,所以词频的边际收益递减——出现 1 次与出现 8 次的分数差距远小于 8 倍,最终收敛到该词位的权重值附近。目的很明确:防止在正文里堆砌关键词就能霸榜(关键词堆砌 spam)。
官方给的最小示例('cat' 权重为 D):
SELECT ts_rank(to_tsvector('raining cats and dogs'), 'cat') AS rank_freq; rank_freq
------------
0.06079271
(1 row)ts_rank 不需要位置信息,所以在被 strip() 过的向量上也能算分。如果你为了省空间把位置信息剥掉了,ts_rank 仍可用,但 ts_rank_cd 就废了。
ts_rank_cd:覆盖密度与邻近性
ts_rank_cd 的算法出自 Clarke、Cormack 与 Tudhope 1999 年发表在 Information Processing and Management 上的论文 Relevance Ranking for One to Three Term Queries。官方对它的定义是:覆盖密度与 ts_rank 类似,但额外考虑匹配词位之间的邻近程度。
同一个查询、同一份文档,两个函数的分数不同(官方示例):
SELECT ts_rank (to_tsvector('raining cats and dogs'), 'cat') AS rank_freq,
ts_rank_cd(to_tsvector('raining cats and dogs'), 'cat') AS rank_cover; rank_freq | rank_cover
------------+------------
0.06079271 | 0.1
(1 row)两个函数的数值不可互相比较,一旦选定,全站必须统一,否则「第 1 页与第 2 页的排序口径不一致」会导致翻页时出现重复或漏项。
必须记住的两条官方约束:
ts_rank_cd依赖位置信息。如果向量被strip()过,被剥离的词位会被忽略。- 如果输入向量里没有任何未被剥离的词位,结果是
0。
-- 被 strip 之后,ts_rank_cd 只能返回 0;而 ts_rank 仍能正常给分
SELECT ts_rank (strip(to_tsvector('english', 'postgres index')), 'postgres & index') AS rank_ok,
ts_rank_cd(strip(to_tsvector('english', 'postgres index')), 'postgres & index') AS cd_zero; rank_ok | cd_zero
---------+---------
0.5 | 0
(1 row)重点在 cd_zero 这一列:只要位置信息被剥掉,ts_rank_cd 就一定是 0。strip() 之后 ts_rank 仍能给出非零分数(上表中的具体数值会随版本与算法细节变化,不必记忆)。
这就是「用 ts_rank_cd 的列绝对不能 strip」的原因。生产环境若既想省空间又想用 cd,只能整表放弃。
补充一个容易误解的点:ts_rank 也会用到距离。 当查询的顶层操作符是 &(AND)或 <->(FOLLOWED BY)时,ts_rank 内部会走「AND 分支」,其中同样包含与词距相关的因子;只有当查询是纯 |(OR)组合时才完全忽略距离。所以不要把两者理解成「一个看距离、一个不看距离」,准确的差异是:ts_rank_cd 用覆盖密度算法系统性地奖励邻近,ts_rank 只在 AND/短语查询里附带考虑距离。
weights 数组:顺序是
官方原文:
The weight arrays specify how heavily to weigh each category of word, in the order:
{D-weight, C-weight, B-weight, A-weight}
默认值:
If no weights are provided, then these defaults are used:
{0.1, 0.2, 0.4, 1.0}
用中文逻辑复述一遍,避免混淆:数组第 1 个元素给 D(正文,最不值钱),最后一个元素给 A(标题,最值钱)。从小到大排列。
数值上限是 1.0。 这不是文档里的建议而是实现层的硬校验(getWeights() 中 if (ws[i] > 1.0) ereport(ERROR, errmsg("weight out of range"))),所以下面这条语句会直接报错:
-- 错误!ERROR: weight out of range
-- SELECT ts_rank('{0.1, 0.2, 0.4, 4.0}', fts, q);因此「让标题更重要」只能通过压低正文实现,不能让 A 超过 1.0。权重如何影响结果,用一个可复现的对照实验看最清楚:
-- 同一个词,一个打在 A(标题)上,一个打在 D(正文)上
WITH docs AS (
SELECT setweight(to_tsvector('english', 'postgres'), 'A') AS v_title,
setweight(to_tsvector('english', 'postgres'), 'D') AS v_body
)
SELECT ts_rank(v_title, 'postgres') AS title_default,
ts_rank(v_body, 'postgres') AS body_default,
ts_rank('{1.0, 1.0, 1.0, 1.0}', v_body, 'postgres') AS body_all_one
FROM docs; title_default | body_default | body_all_one
---------------+--------------+--------------
0.607927 | 0.06079271 | 0.607927
(1 row)两个结论:
- 默认权重已经给了标题 10 倍优势(A=1.0 vs D=0.1),
0.607927 / 0.06079271 = 10,不是「权重没配就没效果」。 - 把 D 也设为
1.0(即{1.0, 1.0, 1.0, 1.0})后,正文命中的分数与标题命中完全相等——证明权重就是纯粹的类别乘子,标签本身不带任何偏好。
注意权重是乘在类别分数上的系数,不是绝对排序键。 一条命中标题的文档分数高,但如果查询词在正文里出现了 20 次,正文的累积分仍可能反超。
另一个易踩的坑:权重数组里传入负数会被静默替换成该档的默认值(实现里是 ws[i] = (arrdata[i] >= 0) ? arrdata[i] : default_weights[i]),不报错也不警告:
-- 不推荐:-1 不会报错,而是被悄悄换成 D 档默认值 0.1
SELECT ts_rank('{-1, 0.2, 0.4, 1.0}', fts, q) FROM ...;权重相等时,不同标签的贡献也相等。 这解释了为什么 {1.0,1.0,1.0,1.0} 会让 A 与 D 得分一致——setweight 打标签只是分类,weights 数组才是定价。
normalization:位掩码,可组合
normalization 是一个整数位掩码,用 | 组合多个行为。官方逐条定义如下:
| 位 | 值 | 含义 | 备注 |
|---|---|---|---|
0 | 0 | 忽略文档长度(默认) | 什么都不除 |
1 | 1 | 除以 1 + log(文档长度) | 对数级抑制 |
2 | 2 | 除以文档长度 | 线性抑制,最激进 |
4 | 4 | 除以 extent 之间的平均调和距离 | 仅 ts_rank_cd 实现,ts_rank 上无效果 |
8 | 8 | 除以文档中唯一词数 | 抑制「词表重复」型长文 |
16 | 16 | 除以 1 + log(唯一词数) | 8 的对数版 |
32 | 32 | 除以 rank + 1,即 rank/(rank+1) | 把结果压到 0~1,纯装饰 |
多位组合时,按上表列举的顺序依次施加(官方原文:「If more than one flag bit is specified, the transformations are applied in the order listed.」)。所以 2|4 是先除长度、再除调和距离,顺序不可交换。
-- 常用组合:先按文档长度压制,再压到 0~1
SELECT ts_rank(fts, q, 2 | 32) AS normalized;32 是唯一「只改外观不改顺序」的位:
-- 官方示例:加 normalization=32 之后数值全落到 0~1,但排序完全一致
SELECT title, ts_rank_cd(textsearch, query, 32 /* rank/(rank+1) */ ) AS rank
FROM apod, to_tsquery('neutrino|(dark & matter)') query
WHERE query @@ textsearch
ORDER BY rank DESC
LIMIT 10; title | rank
-----------------------------------------------+-------------------
Neutrinos in the Sun | 0.756097569485493
The Sudbury Neutrino Detector | 0.705882361190954
A MACHO View of Galactic Dark Matter | 0.668123210574724
Hot Gas and Dark Matter | 0.65655958650282
The Virgo Cluster: Hot Plasma and Dark Matter | 0.656301290640973
Rafting for Solar Neutrinos | 0.655172410958162
NGC 4650A: Strange Galaxy and Dark Matter | 0.650072921219637
Hot Gas and Dark Matter | 0.617195790024749
Ice Fishing for Cosmic Neutrinos | 0.615384618911517
Weak Lensing Distorts the Universe | 0.450010798361481
(10 rows)对比上一节未归一化的输出,顺序一模一样(3.1 → 2.4 → 2.01317 … 对应 0.756 → 0.706 → 0.668 …)。
什么时候该用归一化? 判断标准是:你的业务是否希望「短而精」压过「长而全」。
- 论坛帖子、商品标题、文章标题这类长度差异大的语料:建议至少用
2或1。 - 已按长度分档(比如所有文档都是 200 字左右的摘要):用
0即可。 - 需要把分数展示给用户(如「相关度 87%」):加
32,但不要把它当作真实百分比。
setweight 与查询侧权重限制是两件事
前一节讲过 setweight 给 tsvector 的位置打标签。这里要区分两个完全不同的「权重」概念,它们经常被混为一谈:
| 机制 | 作用对象 | 写在何处 | 效果 |
|---|---|---|---|
setweight(vector, 'A') | 文档侧 tsvector | 生成列 / 触发器 / 索引表达式 | 给位置打标签,供 ts_rank 的 weights 数组使用 |
to_tsquery('english', 'term:A') | 查询侧 tsquery | 用户查询 | 限制只匹配标签为 A 的词位,是过滤器不是打分器 |
第二种的语义是硬过滤:term:A 表示「这个词必须出现在标题里」,出现在正文里不算命中。
-- 文档:标题里是 postgres,正文里是 tuning
SELECT setweight(to_tsvector('english', 'postgres'), 'A') ||
setweight(to_tsvector('english', 'tuning'), 'D') AS fts \gset
-- 查询「postgres 出现在标题」→ 命中
SELECT :'fts'::tsvector @@ to_tsquery('english', 'postgres:A') AS hit;
-- 查询「tuning 出现在标题」→ 不命中(它在正文)
SELECT :'fts'::tsvector @@ to_tsquery('english', 'tuning:A') AS hit; hit
-----
t
(1 row)
hit
-----
f
(1 row)只有在「必须出现在标题」这类强约束场景才用查询侧权重,否则会让召回率骤降——用户搜一个词,只因为它在正文里就搜不到,体验很差。更多时候正确的做法是:文档侧打标签 + 用 ts_rank 的 weights 数组做加权,让正文命中仍能返回,只是排名靠后。
完整的加权生成列配方
官方推荐的字段分层配方(原文见 12.3.1 节),配合生成列落库:
ALTER TABLE articles
ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('pg_catalog.english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('pg_catalog.english', coalesce(keyword, '')), 'B') ||
setweight(to_tsvector('pg_catalog.english', coalesce(abstract,'')), 'C') ||
setweight(to_tsvector('pg_catalog.english', coalesce(body, '')), 'D')
) STORED;几个必须注意的点:
coalesce不可省。to_tsvector(NULL)返回NULL,而NULL || anything仍是NULL,一个空字段就会让整列向量变成NULL。setweight打在「位置」上,不是「词位」上。 如果同一字段在不同部分各出现一次,||拼接后标签会正确保留在各段位置上。- 拼接顺序决定位置编号。 标题在
||左边、正文在右边,所以正文里词的位置编号会把标题的长度也算进去。这对ts_rank_cd的距离计算有影响,但通常无碍。 - 权重标签只是「提示」,具体权重值仍由
ts_rank的weights数组决定。 打标签是「分类」,数组是「定价」。
ts_headline:生成命中片段
ts_headline 的输入是原始文档文本(不是 tsvector),输出是带高亮标记的摘录。它的签名支持 text / json / jsonb 三种文档类型。
全部选项
options 是一个逗号分隔的 option=value 列表。可用选项与默认值如下(名称区分大小写):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
StartSel | string | <b> | 高亮起始标记 |
StopSel | string | </b> | 高亮结束标记 |
MaxWords | int | 35 | 输出片段最长的词数 |
MinWords | int | 15 | 输出片段最短的词数 |
ShortWord | int | 3 | 长度 ≤ 此值的词会在片段首尾被丢弃,除非它是查询词 |
HighlightAll | bool | false | 为 true 时整篇文档当作片段(忽略前三个参数) |
MaxFragments | int | 0 | 要显示的片段最大数量;0 表示非片段模式 |
FragmentDelimiter | string | ... | 多片段之间的分隔串 |
字符串值如果包含空格或逗号,必须用双引号括起来,例如 StartSel="<mark class='hl'>" 这种写法是错的(含空格未加引号)。正确做法:整个 options 用单引号包裹,内部的字符串值用双引号:
SELECT ts_headline('english', body, q,
'StartSel="<em class=""hl"">", StopSel="</em>", MaxWords=25, MinWords=8')
FROM articles;("" 是 SQL 字符串里表示单个双引号的方式。)
片段模式 vs 非片段模式
- 非片段模式(
MaxFragments=0,默认):ts_headline找到所有匹配,挑一个最能体现查询的连续区间输出,优先选择「在允许长度内包含更多查询词」的匹配。 - 片段模式(
MaxFragments > 0):把每个匹配切成最多MaxWords个词的「片段」,优先保留含更多查询词的片段,并尽可能向两侧扩展以凑够MinWords。
官方对选择的建议:当查询词跨越文档大部分区域,或你希望展示多个匹配点时,用片段模式。
-- 官方片段模式示例
SELECT ts_headline('english',
'Search terms may occur
many times in a document,
requiring ranking of the search matches to decide which
occurrences to display in the result.',
to_tsquery('english', 'search & term'),
'MaxFragments=10, MaxWords=7, MinWords=3, StartSel=<<, StopSel=>>'); ts_headline
------------------------------------------------------------
<<Search>> <<terms>> may occur +
many times ... ranking of the <<search>> matches to decide
(1 row)可以看到中点被 FragmentDelimiter 的默认值 ... 连接成了两段。
XSS 警告(官方原文要点)
官方在 12.3.4 节专门加了一段警告,这是本节最容易被忽略的安全问题:
从
ts_headline输出的内容不能保证可以直接包含在网页中。当HighlightAll为false(默认)时,会从文档中删除一些简单的 XML 标签,但这不能保证删除所有 HTML 标记。因此,在处理不受信任的输入时,这不能有效防范 XSS。
结论:ts_headline 的输出必须当作「未转义的危险 HTML」处理。 两种安全做法(选其一,或都做):
- 入库前把用户提交的正文里的 HTML 标签全部剥离,只存纯文本。
- 输出到页面前,把
StartSel/StopSel临时替换成不会与正文冲突的哨兵串,做 HTML 转义,再把哨兵换回真正的<b>/</b>。
-- 方案 1 的落地:入库时洗掉 HTML,检索与高亮都基于纯文本
UPDATE articles SET body = regexp_replace(body, '<[^>]*>', '', 'g') WHERE id = 1;性能
官方最后一句提醒:ts_headline 使用原始文档而不是 tsvector 摘要,因此可能很慢,应谨慎使用。
它无法走索引,且要为每一行做一次完整解析。绝对不要在 WHERE 或 ORDER BY 里调用它,只在 SELECT 中对已经 LIMIT 过的行调用:
-- 错误!对全表每一行都做高亮
-- SELECT ts_headline('english', body, q) FROM articles WHERE fts @@ q ORDER BY ts_rank(fts, q);
-- 正确:先筛、先排、先限行,再高亮
SELECT id, title, ts_headline('english', body, q, 'MaxWords=30') AS snippet
FROM (
SELECT id, title, body, fts, q
FROM articles, websearch_to_tsquery('english', $1) q
WHERE fts @@ q
ORDER BY ts_rank(fts, q) DESC
LIMIT 20 OFFSET 0
) t;分页与稳定排序
ts_rank 的返回值是 float4,分数相同的行大量存在(尤其在短文档语料里)。如果 ORDER BY rank DESC 不带第二排序键,PostgreSQL 可以任意排列同分行,导致:
- 第 1 页出现的记录,第 2 页又出现一次(重复);
- 或者两次翻页之间某条记录从未出现过(漏项)。
必须追加唯一确定性排序键:
ORDER BY ts_rank(fts, q) DESC, id DESCid 可以是主键、也可以是用作游标的 (rank, id) 组合。用 OFFSET 分页在高 OFFSET 时性能很差(要算完前 N 页的分),生产建议用键集分页(keyset pagination):
-- 键集分页:记住上一页最后一行的 (rank, id)
SELECT id, title, ts_rank(fts, q) AS rank
FROM articles, websearch_to_tsquery('english', :q) q
WHERE fts @@ q
AND (ts_rank(fts, q), id) < (:last_rank, :last_id) -- 上一页最后一行
ORDER BY rank DESC, id DESC
LIMIT 20;注意这里 ts_rank 被调用了两次(SELECT 一次、WHERE 一次),行级元组比较 (a, b) < (c, d) 是 SQL 标准写法。如果嫌丑,可以套一层子查询把 rank 变成列名后再过滤。
空查询与 rank = 0 的边界
前几节提过 numnode(q) = 0 的空查询会命中所有行(因为 tsquery 退化成 T)。这时 ts_rank 对每行都返回同一个值(通常是 0),于是:
- 没有任何索引能帮上忙(全表扫描);
- 排序结果完全由物理顺序决定,翻页极不稳定。
-- 空查询时不要直接排序,短路处理
SELECT id, title
FROM articles, websearch_to_tsquery('english', :q) q
WHERE numnode(q) = 0 -- 空查询:走另一条分支
OR fts @@ q
ORDER BY
CASE WHEN numnode(q) = 0 THEN 0 ELSE 1 END DESC,
ts_rank(fts, q) DESC,
id DESC
LIMIT 20;空查询时通常业务另有需求(展示最新、最热、推荐列表),在应用层拦截比在 SQL 里绕更干净。
pg_trgm:当全文搜索不够用时
ts_rank 系基于词元匹配,对「拼写错误」「形近词」「中文无分词」这类场景无能为力。pg_trgm 扩展提供了三元组(trigram)相似度,正好补这一块。它属于 contrib,需要显式安装:
CREATE EXTENSION pg_trgm;核心能力(详见官方 pgtrgm 页):
-- 相似度打分:0~1
SELECT similarity('postgresql', 'postgres') AS sim; -- 0.7692308
-- 阈值运算符:相似度超过 pg_trgm.similarity_threshold(默认 0.3)时返回真
SELECT 'postgresql' % 'postgres' AS fuzzy_hit; -- t配合 GiST 索引可以做有序近邻查询(KNN),这是全文搜索做不到的「拼错也能搜」:
CREATE INDEX idx_articles_title_trgm ON articles USING gist (title gist_trgm_ops);
-- 按三元组距离从近到远取前 10 条(注意这里的 <-> 是 pg_trgm 的距离运算符,
-- 与全文搜索里 tsquery 的 <-> FOLLOWED BY 运算符同名但完全不同)
SELECT title FROM articles ORDER BY title <-> 'postgres' LIMIT 10;两个 ts_rank 与 pg_trgm 的组合策略:
| 需求 | 首选方案 |
|---|---|
| 关键词精确命中、长文档、需相关性排序 | tsvector + ts_rank |
| 短语、词序敏感 | ts_rank_cd + phraseto_tsquery |
| 拼写容错、模糊匹配、自动补全 | pg_trgm |
| 中文、日文等无空格语言 | zhparser / pg_jieba(见中文方案一节) |
不要混用两套分数做排序:ts_rank 的 0.6 与 similarity 的 0.6 不是一回事,直接相加/比较没有意义。要用就先各自归一化到同一量纲,或者用「先全文、无结果时再退到模糊」的两段式策略。
实战示例
示例一:带权重与高亮的完整搜索表
建表、造数据、建索引、查排名、带高亮,一条链路走完:
-- 1) 建表:用生成列维护加权 tsvector
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text NOT NULL,
keyword text,
abstract text,
body text,
created_at timestamptz NOT NULL DEFAULT now(),
fts tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('pg_catalog.english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('pg_catalog.english', coalesce(keyword, '')), 'B') ||
setweight(to_tsvector('pg_catalog.english', coalesce(abstract,'')), 'C') ||
setweight(to_tsvector('pg_catalog.english', coalesce(body, '')), 'D')
) STORED
);
-- 2) GIN 索引(生成列可以直接建索引,无需表达式)
CREATE INDEX idx_articles_fts ON articles USING gin (fts);
-- 3) 造三条测试数据
INSERT INTO articles (title, keyword, abstract, body) VALUES
('PostgreSQL Full Text Search', 'fts, gin', 'intro to fts', 'postgres search internals'),
('GIN Index Internals', 'gin', 'index internals','btree gin gist comparison'),
('Tuning PostgreSQL Search Relevance', 'ts_rank', 'ranking guide', 'how to rank and tune search results');-- 4) 查询:显示默认权重(等价于不写 weights),带高亮
SELECT id,
title,
ts_rank('{0.1, 0.2, 0.4, 1.0}', fts, q) AS score,
ts_headline('pg_catalog.english', coalesce(abstract, title), q,
'MaxWords=20, MinWords=5, StartSel=<em>, StopSel=</em>') AS snippet
FROM articles, websearch_to_tsquery('pg_catalog.english', 'postgres search') q
WHERE fts @@ q
ORDER BY score DESC, id DESC
LIMIT 10;模拟输出(分数随向量布局与版本变化,此处仅示意量级):
id | title | score | snippet
----+--------------------------------------+-----------+-----------------------------
3 | Tuning PostgreSQL Search Relevance | 0.991062 | ranking guide
1 | PostgreSQL Full Text Search | 0.876317 | intro to <em>fts</em>
(2 rows)id=3 排在第一:postgres 与 search 在它的标题里紧挨着(位置 2 与 3),而 id=1 的标题里两词隔得更远(位置 1 与 4)。由于 websearch_to_tsquery 生成的是 'postgres' & 'search'(AND),ts_rank 会走 AND 分支并把词距纳入计算,因此邻近的 id=3 得分更高。若想进一步强化这个效果,改用 ts_rank_cd 会得到更明显的区分度。
示例二:PHP 搜索接口(分页 + 高亮 + 参数绑定)
<?php
declare(strict_types=1);
/**
* 全文搜索:分页 + 加权排序 + 命中高亮
*
* @param string $keyword 用户原始输入(可为任意脏字符)
* @param int $page 从 1 开始
* @param int $perPage 每页条数
*/
function searchArticles(PDO $pdo, string $keyword, int $page = 1, int $perPage = 20): array
{
$offset = max(0, ($page - 1) * $perPage);
// 1) 用 websearch_to_tsquery 处理原始输入:永不报语法错误
// 2) 用 CTE 先算 rank 并筛出候选,避免在 SELECT 与 ORDER BY 里重复调用 ts_rank
$sql = <<<'SQL'
WITH q AS (
SELECT websearch_to_tsquery('pg_catalog.english', :q) AS query
), matched AS (
SELECT a.id, a.title, a.abstract, a.created_at,
q.query,
-- weights 顺序是 {D, C, B, A}:正文、摘要、关键词、标题
ts_rank('{0.1, 0.2, 0.4, 4.0}', a.fts, q.query) AS score
FROM articles a, q
WHERE numnode(q.query) > 0 -- 空查询直接返回空集,交给应用层走热榜
AND a.fts @@ q.query
)
SELECT id, title, score,
ts_headline('pg_catalog.english', coalesce(abstract, title), query,
'MaxWords=30, MinWords=10, StartSel=<em>, StopSel=</em>') AS snippet
FROM matched
ORDER BY score DESC, id DESC -- 第二排序键保证分页稳定
LIMIT :limit OFFSET :offset
SQL;
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':q', $keyword, PDO::PARAM_STR);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 3) 关键:ts_headline 的输出是「未转义的危险 HTML」,必须再走一遍白名单
// 这里先把 <em>/</em> 换成哨兵,转义整串,再把哨兵换回来
foreach ($rows as &$row) {
$row['snippet'] = str_replace(
['<em>', '</em>'],
['<em>', '</em>'],
htmlspecialchars((string)($row['snippet'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
);
}
unset($row);
return $rows;
}
// ---- 调用 ----
$pdo = new PDO('pgsql:host=127.0.0.1;dbname=app', 'app', 'secret', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$rows = searchArticles($pdo, $_GET['q'] ?? '', (int)($_GET['page'] ?? 1));
foreach ($rows as $r) {
// $r['snippet'] 此时已完成了「高亮保留 + 其余转义」
printf("#%d %s (score=%.4f)%s\n", $r['id'], htmlspecialchars($r['title']), $r['score'], $r['snippet']);
}模拟输出:
id | title | score | snippet
----+--------------------------------------+------------+----------------------------------------
3 | Tuning PostgreSQL Search Relevance | 0.9759418 | ranking guide
1 | PostgreSQL Full Text Search | 0.73195625 | intro to <em>fts</em>
(2 rows)注意事项
weights 数组顺序写反是最常见的错误。 顺序是 {D, C, B, A}(从最轻到最重),不是 {A, B, C, D}:
-- 错误!把标题(A)当成了最轻的 D,正文(D)当成了最重的 A
-- ts_rank('{1.0, 0.4, 0.2, 0.1}', fts, q)
-- 正确:A(标题)在最后一位,权重最大
SELECT ts_rank('{0.1, 0.2, 0.4, 1.0}', fts, q);写反的后果很隐蔽:查询不报错,结果也不报错,只是排序完全错了——标题命中反而被降权、正文命中被抬高,往往上线很久才被发现。校验方法很简单:把权重数组倒序再跑一次,如果结果变了,说明排序确实依赖顺序(这本身正常);真正要防的是你以为自己写的是正序。写代码时把数组拆成常量并在注释里标注 -- {D, C, B, A}。
weights 参数必须放在第一个位置。 它是最前面的可选参数,不是最后一个:
-- 错误!参数位置不对,会报 "function ts_rank(tsvector, tsquery, unknown) does not exist"
-- SELECT ts_rank(fts, q, '{0.1,0.2,0.4,1.0}');
-- 正确
SELECT ts_rank('{0.1, 0.2, 0.4, 1.0}', fts, q);权重数组必须正好 4 个元素。 少于 4 个会报错,多于 4 个会报错,元素类型必须是浮点:
-- 错误!
-- SELECT ts_rank('{1.0}', fts, q);
-- ERROR: weight array must have length 4normalization = 4 对 ts_rank 无效。 位 4(除以 extents 的平均调和距离)只由 ts_rank_cd 实现。在 ts_rank 上写 4 不会报错但毫无效果,属于静默失效:
-- 不推荐:ts_rank 上的 4 位被忽略,等于什么都没做
SELECT ts_rank(fts, q, 4) FROM articles;
-- 想用 4,请换 ts_rank_cd
SELECT ts_rank_cd(fts, q, 4) FROM articles;ts_rank_cd 与 strip() 不能共存。 一旦向量被剥离位置信息,ts_rank_cd 只能返回 0:
-- 错误!全表 strip 之后 ts_rank_cd 全部返回 0
-- CREATE INDEX ... USING gin (strip(fts));不要对已 LIMIT 的集合再算排名做二次排序。 子查询里 ORDER BY ts_rank(...) LIMIT 20 之后再 ORDER BY created_at,只是把「最相关的 20 条」按时间重排,这是对的;但如果子查询里没有 ORDER BY 就直接 LIMIT,取到的可能是任意 20 条:
-- 错误!子查询没有 ORDER BY,LIMIT 取到的是任意 20 行
-- SELECT * FROM (SELECT * FROM articles WHERE fts @@ q LIMIT 20) t ORDER BY ts_rank(fts, q) DESC;ORDER BY ts_rank(...) DESC 必须配一个唯一第二排序键。 同分行顺序不稳定会造成翻页重复与漏项:
-- 不推荐
ORDER BY ts_rank(fts, q) DESC
-- 推荐
ORDER BY ts_rank(fts, q) DESC, id DESC不要跨函数比较分数。 ts_rank 与 ts_rank_cd 的数值不可比,同一列上混用会让排序结果自相矛盾:
-- 错误!同一 ORDER BY 里混了两种口径
-- ORDER BY GREATEST(ts_rank(fts, q), ts_rank_cd(fts, q)) DESCts_headline 的输出禁止直接进 HTML。 官方明确警告它不能防 XSS,必须自行转义或预先剥离文本中的 HTML:
-- 危险:tmp 可能包含用户提交的 <script>,已被原样带出
SELECT ts_headline('english', body, q) FROM articles;ts_headline 不能出现在 WHERE / ORDER BY / 大结果集的 SELECT 里。 它读取原始文档无法走索引,成本极高:
-- 错误!对全表命中行逐行做高亮
-- SELECT ts_headline('english', body, q) FROM articles, ... WHERE fts @@ q;
-- 正确:先排序 + LIMIT,再高亮;或干脆由应用层用返回值里的位置信息自行高亮选项名区分大小写,字符串值含空格或逗号必须加双引号。
-- 错误!选项名大小写不对,被当成未知选项忽略
-- 'maxwords=30'
-- 错误!值含空格未加引号
-- 'StartSel=<span class="hl">'
-- 正确:选项名按表中原样写,值用双引号包裹
SELECT ts_headline('english', body, q, 'MaxWords=30, StartSel="<span class=""hl"">", StopSel="</span>"');不要用 ts_rank 的分数做业务阈值判断。 它的绝对数值依赖语料、权重、归一化参数,换一批数据就全变。若要「相关度低于 X 就不展示」,应在应用层基于相对排名(如 score / max_score)或用 pg_trgm 的 similarity 阈值(pg_trgm.similarity_threshold)来判断。
最佳实践
全站固定一个排名口径,写进封装函数/视图。 把 ts_rank 的参数固化下来,避免各处写法不一致:
CREATE VIEW v_articles_search AS
SELECT id, title, abstract, created_at, fts,
'{0.1, 0.2, 0.4, 4.0}'::float4[] AS weights
FROM articles;
-- 或封装成 SQL 函数
CREATE FUNCTION article_rank(fts tsvector, q tsquery) RETURNS real
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
$$ SELECT ts_rank('{0.1, 0.2, 0.4, 4.0}'::float4[], $1, $2, 1) $$;权重只调 A 与 D 两档就够用。 实践中标题与正文的区分最重要,B、C 保持默认即可。频繁调整全部四档会让排序行为难以解释、难以回归测试:
-- 推荐:只把标题提到 4 倍
ts_rank('{0.1, 0.2, 0.4, 4.0}', fts, q)短语类查询用 ts_rank_cd,宽泛关键词查询用 ts_rank。 前者奖励邻近,后者奖励频次,与查询形态匹配才能发挥优势:
-- 近似短语搜索:优先选词位相邻的文档
SELECT id, ts_rank_cd(fts, q) AS score
FROM articles, phraseto_tsquery('english', :q) q
WHERE fts @@ q
ORDER BY score DESC, id DESC
LIMIT 20;文档长度差异大时,务必开启归一化(推荐 2 或 1)。 否则一本手册永远压过一条问答:
-- 论坛帖子、商品标题这类长度差异大的语料
SELECT id, ts_rank(fts, q, 2) AS score FROM posts, websearch_to_tsquery('english', :q) q
WHERE fts @@ q ORDER BY score DESC, id DESC LIMIT 20;只在需要向用户展示分数时加 32。 它不改顺序,纯装饰,且不要把它当百分比:
-- 前端要显示"相关度 76%",加 32 后乘 100
SELECT round(ts_rank(fts, q, 32)::numeric * 100, 0) AS percent FROM ...;分页优先用键集分页而不是 OFFSET。 深翻页时 OFFSET 需要算出前面全部页的排名,成本随页数线性增长:
-- 键集分页:把上一页最后一行的 (rank, id) 传回来
WHERE fts @@ q AND (ts_rank(fts, q), id) < (:last_rank, :last_id)
ORDER BY ts_rank(fts, q) DESC, id DESC LIMIT 20;先 WHERE fts @@ q 缩小候选集,再排名。 排名成本与候选行数成正比,WHERE 里的索引条件越有效,排名越快:
-- 推荐顺序:筛选 → 排序 → 限行 → 高亮
SELECT ... FROM articles, websearch_to_tsquery('english', :q) q
WHERE fts @@ q
ORDER BY ts_rank(fts, q) DESC, id DESC
LIMIT 20;候选集仍然很大时,用 EXPLAIN (ANALYZE, BUFFERS) 确认走的是 Bitmap Index Scan,而不是 Seq Scan。排名步骤本身在 EXPLAIN 里表现为 Sort,它的耗时与输入行数直接相关:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM articles, websearch_to_tsquery('english', 'postgres') q
WHERE fts @@ q
ORDER BY ts_rank(fts, q) DESC, id DESC
LIMIT 20;ts_headline 只对最终一页数据调用,且优先传 abstract 等短字段。 传整篇 body 会让片段生成变慢,还可能因文档过长而效果不佳:
-- 推荐:优先用摘要字段,为空时退回标题
ts_headline('pg_catalog.english', coalesce(abstract, title), q, 'MaxWords=30, MinWords=10')高亮可视化由前端完成时,可直接用向量里的位置信息。 若页面已有自己的高亮组件,用 unnest(fts) 取位置,避免 ts_headline 的开销与 XSS 风险:
SELECT lexeme, positions FROM unnest(fts) WHERE lexeme = ANY(tsvector_to_array(q));用 ts_stat 定期回看高频词,反推权重与停用词是否需要调整:
SELECT word, ndoc, nentry
FROM ts_stat($$SELECT fts FROM articles$$)
ORDER BY ndoc DESC, nentry DESC
LIMIT 20;把排序逻辑做成可回归的测试用例。 排名是「隐式契约」,改动权重、归一化、字段分层都会影响结果;至少固化 10 条「查询 → 期望前 3 名」的断言:
-- 断言示例(可用于 CI)
SELECT id FROM articles, websearch_to_tsquery('english', 'postgres search') q
WHERE fts @@ q ORDER BY ts_rank('{0.1,0.2,0.4,4.0}', fts, q) DESC, id DESC LIMIT 3;
-- 期望返回:3, 1多因子排序时,把 ts_rank 归一化后再与业务因子加权相加。 内置函数只是「示例」,官方鼓励叠加修改时间、热度等信号:
SELECT id,
-- 相关度占 70%,时间新鲜度占 30%(示例,按业务调整)
0.7 * ts_rank(fts, q, 32) + 0.3 * (1.0 / (1 + EXTRACT(EPOCH FROM now() - created_at) / 86400.0)) AS score
FROM articles, websearch_to_tsquery('english', :q) q
WHERE fts @@ q
ORDER BY score DESC, id DESC
LIMIT 20;模糊容错需求交给 pg_trgm,不要试图用 ts_rank 硬撑。 全文搜索基于词元精确匹配,拼错一个字母就查不到:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_articles_title_trgm ON articles USING gin (title gin_trgm_ops);
-- 拼错也能召回
SELECT title FROM articles WHERE title % 'postgressql' ORDER BY similarity(title, 'postgressql') DESC LIMIT 10;下一节
继续学习:全文搜索配置与词典
参考链接
- PostgreSQL 官方文档:Controlling Text Search(12.3,含 Ranking Search Results 与 Highlighting Results)
- PostgreSQL 官方文档:Text Search Functions and Operators(9.13,ts_rank / ts_rank_cd / ts_headline 签名)
- PostgreSQL 官方文档:Additional Features(12.4,setweight / strip / 操作文档)
- PostgreSQL 官方文档:Tables and Indexes(12.2)
- PostgreSQL 官方文档:Text Search Types(8.11,tsvector 权重与位置)
- PostgreSQL 官方文档:pg_trgm(三元组相似度与模糊匹配)
- PostgreSQL 官方文档:Indexes on Expressions(表达式索引与 IMMUTABLE 要求)