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_tsvector 和 to_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');
我在项目中遇到过一个问题:如果更新了 title 或 body,tsv 列不会自动更新。你得用触发器来同步。下面这个触发器模板我一直在用:
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
你看,它把「我爱北京天安门」切成了「我」「爱」「北京」「天安门」四个词。这就是中文分词的威力。
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_rank 和 ts_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 最低。
知识体系图
下面这张图把全文检索的核心流程串起来了:
公众号:蓝海资料掘金营,微信deep3321