刷到一个挺有意思的话题,结合自己之前的经验,整理了一下核心要点。
KingbaseES 全文检索实战:中文分词、GIN 索引与相关性排名
有个工程要做东西搜索,几百万条文章数据,用 LIKE '%关键词%' 查询,慢得要命。CPU 飙到 80% 还查不出几条。后来上了全文检索,查询从秒级降到毫秒级,还自动按相关性排序、关键词高亮。金仓 V9 的全文检索基于 PostgreSQL 的 tsvector/tsquery 体系,但中文得额外配分词插件,这里把完整流程记一下。
金仓全文检索的基本原理
先说清楚三件事:
tsvector 是把文本分词后存成的一种结构,形如 '数据库':1 '金仓':2,数字是词位。一段文本变成 tsvector 之后就能被高效检索。
tsquery 是查询条件,支持与(&)、或(|)、非(!)、前缀(:*)等逻辑。像是 数据库 & 金仓 表示同时包含这两个词。
@@ 运算符判断 tsvector 是否匹配 tsquery,返回 true/false。
默认的英文分词器对中文没用——中文没有空格分词,整句话会被当成一个词:
SELECT to_tsvector('人大金仓致力于提供高可靠的数据库产品');
-- 结果:'人大金仓致力于提供高可靠的数据库产品':1
-- 整句话变成一个词,根本没法搜
所以中文得装分词插件。金仓有两个选择:zhparser 和 sys_jieba。zhparser 支持 GBK 和 UTF8,sys_jieba 只支持 UTF8。下面用 zhparser,覆盖面广一些。
装 zhparser 并配置中文分词
先创建扩展:
CREATE EXTENSION zhparser;
之后创建一个文本搜索配置,把分词器挂上去:
CREATE TEXT SEARCH CONFIGURATION zhcfg (parser = zhparser);
接着映射 token 类型——分词器会把文本拆成不同词性的 token,各位告诉它哪些要保留:
ALTER TEXT SEARCH CONFIGURATION zhcfg
ADD MAPPING FOR n,v,a,i,e,l,j WITH simple;
这里映射了名词(n)、动词(v)、形容词(a)、成语(i)、叹词(e)、习用语(l)、缩写(j)七种。其他词性的 token 会被丢弃。想看 zhparser 支持哪些 token 类型:
SELECT ts_token_type('zhparser');
-- 97 a 形容词, 98 b 区别词, 99 c 连词, ... 110 n 名词, 118 v 动词 ...
配好之后测试分词效果:
SELECT to_tsvector('zhcfg', '人大金仓致力于提供高可靠的数据库产品');
-- 结果:'产品':7 '人大':1 '可靠':5 '提供':3 '数据库':6 '致力于':2 '高':4
每段文本都被正确拆词了,还标了词位。和最着手那种整句当一词的结果对比,天壤之别。
如果想把它设成默认搜索配置,省得每次都写 'zhcfg':
ALTER SYSTEM SET default_text_search_config = 'zhcfg';
SELECT sys_reload_conf();
之后 to_tsvector('中文文本') 不带配置名也会用 zhcfg。
基本搜索:与或非和前缀匹配
建张测试表灌点数据:
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title VARCHAR(200),
body TEXT
);
INSERT INTO articles (title, body) VALUES
('金仓数据库V9新特性', 'KingbaseES V9支持闪回查询和分区表,性能提升显著'),
('数据库选型指南', '国产数据库选型要考虑兼容性、性能和生态'),
('全文检索入门', 'PostgreSQL的全文检索基于tsvector和tsquery');
搜索同时包含"数据库"和"金仓"的文章:
SELECT title FROM articles
WHERE to_tsvector('zhcfg', body) @@ to_tsquery('zhcfg', '数据库 & 金仓');
-- 结果:金仓数据库V9新特性
& 是逻辑与。换成 | 就是或——包含任意一个词就行:
SELECT title FROM articles
WHERE to_tsvector('zhcfg', body) @@ to_tsquery('zhcfg', '数据库 | 闪回');
-- 结果:金仓数据库V9新特性、数据库选型指南
排除某个词用 !:
SELECT title FROM articles
WHERE to_tsvector('zhcfg', body) @@ to_tsquery('zhcfg', '数据库 & !选型');
-- 结果:金仓数据库V9新特性、全文检索入门(含"数据库"但不含"选型"的)
前缀匹配用 :*,像是搜以"数据"开头的词:
SELECT title FROM articles
WHERE to_tsvector('zhcfg', body) @@ to_tsquery('zhcfg', '数据:*');
-- 匹配"数据库""数据量"等所有以"数据"开头的词
有个容易忽略的点——搜索时一定用 to_tsquery 而不是直接写字符串。因为 to_tsquery 会做标准化转换(去停用词、词干提取等),直接写字符串可能搜不到。
GIN 索引:让全文检索飞起来
上面那种查询是全表扫的——每行都跑一遍 to_tsvector 再匹配,数据量一大就慢。建 GIN 索引之后直接走索引,快几个量级。
两种建法。第一种是表达式索引,直接在查询表达式上建:
CREATE INDEX idx_articles_body_ts ON articles
USING GIN (to_tsvector('zhcfg', body));
留意这里 to_tsvector 必须用双参数版本(带 'zhcfg'),单参数版本不能用在表达式索引里。查询的时候 WHERE 条件必须和索引表达式完全一致才能走索引:
-- 能走索引(和索引表达式一致)
SELECT title FROM articles
WHERE to_tsvector('zhcfg', body) @@ to_tsquery('zhcfg', '数据库');
-- 走不了索引(配置名不一致)
SELECT title FROM articles
WHERE to_tsvector('zhcfg', body) @@ to_tsquery('english', '数据库');
第二种是生成列加索引,更灵活。先加一个 tsvector 类型的生成列:
-- 加生成列,自动维护分词结果
ALTER TABLE articles ADD COLUMN body_ts tsvector
GENERATED ALWAYS AS (to_tsvector('zhcfg', body)) STORED;
-- 在生成列上建 GIN 索引
CREATE INDEX idx_articles_body_ts2 ON articles USING GIN (body_ts);
查询直接用 body_ts 列:
SELECT title FROM articles
WHERE body_ts @@ to_tsquery('zhcfg', '数据库');
生成列是自动维护的——INSERT/UPDATE 的时候自动更新分词,不用写触发器。这是我推荐的方式,查询写起来也简洁。
验证索引确实生效:
EXPLAIN ANALYZE
SELECT title FROM articles
WHERE body_ts @@ to_tsquery('zhcfg', '数据库');
-- 计划里出现 "Bitmap Index Scan on idx_articles_body_ts2" 就说明走索引了
相关性排名:ts_rank
全文检索不只是找到匹配的行,还要按相关性排序。ts_rank 函数给每条匹配结果打分:
SELECT title,
ts_rank(body_ts, to_tsquery('zhcfg', '数据库 & 金仓')) AS rank
FROM articles
WHERE body_ts @@ to_tsquery('zhcfg', '数据库 & 金仓')
ORDER BY rank DESC;
ts_rank 的分数基于匹配词的频率和位置——词出现得越多、越靠前,分越高。排在前面的就是最相关的结果。
想给标题更高的权重?可以用权重覆盖。tsvector 有 A/B/C/D 四个权重(A 最高),默认都是 D。把标题和正文拼一起时给标题标 A:
-- 建一个标题+正文的复合 tsvector,标题权重设为 A
ALTER TABLE articles ADD COLUMN full_ts tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('zhcfg', coalesce(title,'')), 'A') ||
setweight(to_tsvector('zhcfg', coalesce(body,'')), 'D')
) STORED;
CREATE INDEX idx_articles_full_ts ON articles USING GIN (full_ts);
setweight 给 tsvector 标权重,|| 把两个 tsvector 拼一起。这样搜索时标题匹配的文章排名更高:
SELECT title,
ts_rank(full_ts, to_tsquery('zhcfg', '数据库')) AS rank
FROM articles
WHERE full_ts @@ to_tsquery('zhcfg', '数据库')
ORDER BY rank DESC;
-- 标题含"数据库"的文章排在前面
关键词高亮:ts_headline
搜索结果里把匹配的关键词高亮显示,用户体验好很多。ts_headline 干这个:
SELECT title,
ts_headline('zhcfg', body, to_tsquery('zhcfg', '数据库'),
'StartSel=*, StopSel=*, MaxWords=35, MinWords=15') AS headline
FROM articles
WHERE body_ts @@ to_tsquery('zhcfg', '数据库');
StartSel 和 StopSel 是高亮标签,这里用 <em>...</em>。MaxWords/MinWords 控制摘要长度。输出大概这样:
title | headline
---------------------+-----------------------------------------------------------
金仓数据库V9新特性 | KingbaseES V9支持闪回查询和分区表,*性能*提升显著
数据库选型指南 | 国产*数据库*选型要考虑兼容性、*性能*和生态
匹配的词被 <em> 标签包起来了,前端直接渲染就行。这个功能在搜索场景格外实用。
单字搜索的问题
默认情况下 zhparser 不能把单个字当词来搜。像是搜"产"这个字,匹配不到"产品":
SELECT contains('人大金仓致力于提供高可靠的数据库产品', '产');
-- 结果:f(匹配不到)
开启多字分词模式解决:
ALTER SYSTEM SET zhparser.multi_zmain = 'true';
SELECT sys_reload_conf();
SELECT contains('人大金仓致力于提供高可靠的数据库产品', '产');
-- 结果:t(现在能匹配了)
contains 是金仓内置函数,本质是 to_tsvector @@ to_tsquery 的封装,写起来简洁。如果业务需要单字搜索就开这个参数,但会增加索引体积,按需取舍。
几个实战小坑
分词配了但搜索搜不到。 大概率是 to_tsvector 和 to_tsquery 用的配置名不一致。一个用 'zhcfg' 一个用默认的 'english',当然搜不到。两边配置必须一致,或者都设成默认。
GIN 索引没生效。 用 EXPLAIN 看计划,如果还是 Seq Scan,检查查询表达式和索引表达式是否完全一致。生成列方式不会有这个问题,推荐用生成列。
to_tsquery 报语法错误。 这个函数对输入格式有要求,特殊字符要转义。如果搜索词来自用户输入,用 plainto_tsquery 更安全,它把输入当普通文本处理:
-- to_tsquery 遇到特殊字符会报错
SELECT to_tsquery('zhcfg', '数据库 & (金仓');
-- ERROR: syntax error
-- plainto_tsquery 安全,但只支持与逻辑
SELECT plainto_tsquery('zhcfg', '数据库 (金仓');
-- 结果:'数据库' & '金仓' & '('
生产环境搜索框的输入建议用 plainto_tsquery,避免用户输入特殊字符导致报错。
索引更新慢。 GIN 索引的更新成本比普通索引高,大批量写入时可能拖慢 INSERT。可以调 gin_pending_list_limit 让索引延迟合并,写入完了再统一刷新:
ALTER SYSTEM SET gin_pending_list_limit = '4MB';
SELECT sys_reload_conf();
批量导入数据后手动刷新一下:
SELECT gin_clean_pending_list('idx_articles_full_ts');
最后
全文检索这套东西,核心就是分词 + GIN 索引 + 排名。分词是前提——中文不装插件根本没法用;GIN 索引是性能关键——没索引全表扫和 LIKE 没区别;排名和高亮是体验加分项——让搜索结果更像那么回事。
和 LIKE 相比,全文检索的优势不止是速度。LIKE '%数据库%' 只能精确匹配子串,搜不到"数据"或"数据存储"这种相关词;全文检索基于分词,能理解"数据库"是一个词,还能做前缀匹配和逻辑组合。几百万条数据的搜索场景,上全文检索基本是必选项。
那个工程上线之后搜索响应稳定在 10ms 以内,之前 LIKE 动不动几百毫秒还占 CPU。前后对比太明显了,所以专门记一下,希望对碰到类似需求的人有帮助。
这篇笔记就先到这里,后面用到新的思路或者发现有问题再补充。
评论 (0)
暂无评论