40、PostgreSQL全文检索:tsvector/tsquery、GIN索引、中文分词、排名算法

全文检索,说白了就是让数据库能像搜索引擎一样,帮你搜出「包含某个词」或者「跟某句话相关」的记录。而不是用 LIKE '%关键词%' 那种笨办法——那种写法不仅慢,而且搜不出「苹果手机」和「iPhone」这种同义关系。

PostgreSQL 的全文检索,是我用过的数据库里最顺手的之一。它原生支持,不需要装插件(中文分词除外)。今天我就带你把这套东西捋一遍。

核心概念:tsvector 和 tsquery

先记住两个类型:

  • tsvector:把一段文本拆成「词 + 位置 + 权重」的向量
  • tsquery:你的搜索条件,支持与(&)、或(|)、非(!) 逻辑

举个例子:

SELECT to_tsvector('english', 'I love PostgreSQL full-text search');
-- 输出:'full':4 'love':2 'postgresql':3 'search':5 'text':4

SELECT to_tsquery('english', 'love & search');
-- 输出:'love' & 'search'

然后你用 @@ 操作符匹配:

SELECT to_tsvector('english', 'I love PostgreSQL full-text search') 
       @@ to_tsquery('english', 'love & search');
-- 返回 true

嗯,这里要注意:to_tsvectorto_tsquery 的第一个参数是「文本搜索配置」,指定用什么语言的分词规则。英文用 english,中文嘛……后面单独说。

GIN 索引:让全文检索飞起来

没有索引的全文检索,就是全表扫描。数据量一上去,慢到你怀疑人生。

PostgreSQL 为全文检索专门设计了 GIN(Generalized Inverted Index)索引。它的原理是倒排索引——记录每个词出现在哪些行、什么位置。

建索引的语法很简单:

CREATE INDEX idx_articles_gin ON articles 
USING GIN(to_tsvector('english', title || ' ' || body));

我个人习惯把 tsvector 单独存成一个列,这样查询时不用每次都计算:

ALTER TABLE articles ADD COLUMN tsv tsvector;

UPDATE articles SET tsv = to_tsvector('english', title || ' ' || body);

CREATE INDEX idx_articles_tsv ON articles USING GIN(tsv);

然后查询:

SELECT id, title FROM articles 
WHERE tsv @@ to_tsquery('english', 'postgresql & search');

我在项目中遇到过一个问题:如果更新了 titlebodytsv 列不会自动更新。你得用触发器来同步。下面这个触发器模板我一直在用:

CREATE FUNCTION articles_tsv_update() RETURNS trigger AS $$
BEGIN
    NEW.tsv := to_tsvector('english', NEW.title || ' ' || NEW.body);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_articles_tsv 
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION articles_tsv_update();
避坑指南: 我曾经在线上环境直接对几百万行执行 UPDATE ... SET tsv = ...,结果把磁盘写爆了。建议分批更新,或者用 pg_repack 之类的工具。

中文分词:zhparser 与 jieba

PostgreSQL 自带的 english 配置对中文无效。因为中文词之间没有空格,必须用专门的分词器。

目前主流方案有两个:

方案 安装方式 特点
zhparser 编译安装(基于 SCWS) 速度快,支持自定义词典
jieba(pg_jieba) 编译安装 分词准确率高,但稍慢

以 zhparser 为例,安装后需要创建配置:

CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);

ALTER TEXT SEARCH CONFIGURATION chinese 
ADD MAPPING FOR n,v,a,i,e,l WITH simple;

然后就可以用了:

SELECT to_tsvector('chinese', '我爱北京天安门');
-- 输出:'北京':3 '天安门':4 '爱':2 '我':1

你看,它把「我爱北京天安门」切成了「我」「爱」「北京」「天安门」四个词。这就是中文分词的威力。

小技巧: 如果你用 zhparser,建议把 n(名词)、v(动词)、a(形容词)映射到 simple,其他词性可以忽略。这样能减少索引大小,提升查询速度。

排名算法:谁排前面?

搜出来了,但哪个结果更相关?这就轮到排名算法上场了。

PostgreSQL 提供了两个排名函数:

  • ts_rank:基于词频(TF)和文档长度
  • ts_rank_cd:基于覆盖密度(词之间的距离越近,得分越高)

用法:

SELECT id, title, 
       ts_rank(tsv, query) AS rank
FROM articles, 
     to_tsquery('english', 'postgresql & search') AS query
WHERE tsv @@ query
ORDER BY rank DESC
LIMIT 10;

你想想看,为什么有时候搜出来的结果不理想?因为默认的 ts_rank 只考虑词频,不考虑词的位置。比如「PostgreSQL 全文检索」和「全文检索 PostgreSQL」,前者更自然,但后者得分可能一样。

这时候 ts_rank_cd 就派上用场了。它会计算词之间的「覆盖密度」——词挨得越近,得分越高。

SELECT id, title, 
       ts_rank_cd(tsv, query) AS rank
FROM articles, 
     to_tsquery('english', 'postgresql & search') AS query
WHERE tsv @@ query
ORDER BY rank DESC;

我个人习惯把 ts_rankts_rank_cd 结合起来,再加一个权重因子:

SELECT id, title,
       ts_rank(tsv, query) * 0.4 + ts_rank_cd(tsv, query) * 0.6 AS final_rank
FROM articles, 
     to_tsquery('english', 'postgresql & search') AS query
WHERE tsv @@ query
ORDER BY final_rank DESC;

权重怎么调?看你的业务场景。标题匹配比正文匹配更重要?那就在 tsvector 里给标题赋更高权重:

UPDATE articles SET tsv = 
  setweight(to_tsvector('english', title), 'A') ||
  setweight(to_tsvector('english', body), 'B');

然后 ts_rank 可以指定权重数组:

SELECT ts_rank('{0.1, 0.2, 0.4, 1.0}', tsv, query) FROM ...;

四个权重分别对应 D、C、B、A 四个等级。A 权重最高,D 最低。

核心总结: 全文检索不是简单的 LIKE 匹配。用 tsvector 做分词,用 GIN 索引加速,用 ts_rank/ts_rank_cd 排序,用中文分词器处理中文。这套组合拳打下来,大部分搜索场景都能搞定。

知识体系图

下面这张图把全文检索的核心流程串起来了:

PostgreSQL 全文检索核心流程 原始文本(标题 + 正文) 分词器(英文: english / 中文: zhparser) tsvector(词向量 + 位置 + 权重) GIN 索引(倒排索引) tsquery(搜索条件) @@ 匹配 → ts_rank / ts_rank_cd 排名 → 结果排序 输入 分词 存储 索引 查询 排序
我的建议: 如果你刚开始接触 PostgreSQL 全文检索,先别急着上中文分词。先用英文数据跑通流程,理解 tsvector、tsquery、GIN 索引、排名函数这几个核心概念。然后再切换到中文,你会发现思路是一样的,只是分词器换了一下。

公众号:蓝海资料掘金营,微信deep3321