关于 PostgreSQL 全文检索的实战 —— 中文分词、查询、索引、权重、排序
如果有人新启一个项目需要实现全文检索的功能。 不少人可能第一时间就想到ES, 但ES实在是有点重了。 再怎么说也要吃个2G内存吧。
并且由于ES是个中间件, 使用的话系统复杂度又会有一个指数级的上升, 我认为可预计的需要搜索的数据量级不会达到千万级别的话, 没太大必要。
干脆直接用 PostgreSQL 解决这个问题得了, 使用简单, 功能强大。 支持各种常规的搜索条件(与、或、非、包含、被包含、距离等) 、也可以添加索引, 如果机器够顶, 就算是亿级别的数据也可以控制在毫秒级返回。 架构上能简化很多。
一般的个人项目、 中小公司的项目, 需要搜索的数据能达到千万这个量么?
我由于自身维护了一个网站,当时的技术选型DB层就是使用的 Posrgres 。 自然, 也会有搜索相关的功能 ,所以我对此有一定使用层面的经验分享
下面就来从字段、函数、分词、使用、实践、示例等方面概述一下如何优雅使用 PostgreSQL 来进行全文检索。
相关文档
这俩文档对不太熟悉 PostgreSQL 全文搜索的小伙伴应该会有一定作用。
看下操作符和官方示例可以更好的知道如何使用。
文本搜索的函数与操作符中文说明:
http://www.postgres.cn/docs/9.3.4/functions-textsearch.html
PostgreSQL 文本搜索文档 :
https://www.postgresql.org/docs/9.4/textsearch-controls.html
核心函数、关键字
全文搜索的思想主要分两步:
- 将文本解析为对应的倒排索引
- 搜索索引来找到对应的文本
要使用 PostgreSQL 的全文搜索, 那么就要了解到底是要用什么关键字和函数来构建索引和搜索, 这里主要涉及到一个关键字和四个函数
可以看上边的文档了解函数的具体参数
关键字 tsvector:
tsvector 是 PostgreSQL 内置的一种字段类型, 用来保存的是分词后的结果 (文本向量) 它是由 [词,序列, 权重] 三个东西共同组成的, 权重可能会没有
函数:
- to_tsvector()
- 分词用, 将文本转为向量。 用它可以将字符串转成上边说的 tsvector , 遗憾的是默认不支持中文分词
- to_tsquery()
- 构建搜索的关键字, 支持各种符号表示条件。 详情查看文档
- setweight()
- 设置关键词权重, 总共四个权重从高到低为 A-B-C-D
- ts_rank ()
- 排序用, 可以根据 to_tsquery 和 tsvector 的匹配度计算
一个简单的使用:
//查询表中包含 aaa 并且包含 BBB或CCC任意一个 的记录 SELECT * FROM table WHERE to_tsvector('parser_name', field) @@ to_tsquery('aaa & (bbb | ccc)')
最佳实践
最重要的就是分词逻辑。
如果你去网上搜索 PostgreSQL 中文分词, 你应该会找到洋洋洒洒一大堆叫你装什么插件然后分词的。
我觉得不太行, 一个是安装起来麻烦,二个是难以控制。
我推荐的方式是不装插件, 在应用程序内部分词, 将分词后的结果加上权重 设置进待搜索列的字段中。
优势:
- 不需要折腾, 省时间
- 冗余向量字段, 空间换时间,避免每次查询时还得调用 to_tsvector 、setweight 去计算分词结果和权重 。
- 程序分词, 更自由地选择分词器
- 自定义字典方便
而程序内部分词, 一般都有相应的库开箱即用, 比如Java 使用 jieba 分词, 直接引入依赖就能用
<dependency> <groupId>com.huaban</groupId> <artifactId>jieba-analysis</artifactId> <version>1.0.2</version> </dependency>
引入后就可以很轻松的使用这个库来进行中文分词, 效果也挺不错的
private List<String> segmente(String text, JiebaSegmenter.SegMode mode) { JiebaSegmenter segmenter = new JiebaSegmenter(); List<SegToken> tokens = segmenter.process(text, mode); List<String> result = tokens.stream() .map(tk -> tk.word) .filter(t -> t != null && !t.isBlank()) .collect(Collectors.toCollection(LinkedList::new)); return result; }
当我们可以轻松的获取分词结果时, 那么接下来要做的就很简单了。
构建倒排索引: 针对于我们的业务模型找出需要搜索的字段, 分配权重, 分词后配合 setweight 函数插入到表中的 tsvector 类型字段中
全文检索: 使用 to_tsquery 构建搜索关键字, 匹配到结果后使用 ts_rank 进行排序
例子:
这里都是我个人项目中的真实SQL, 将其去敏后贴出, 下边详细解释这些SQL在干什么。
使用的是 MyBatis , #{} 为 变量。
1、 构建索引, 这是一个构建索引的SQL, 在数据行创建完毕后执行
UPDATE table1 SET tokens = setweight(to_tsvector('simple', #{aTokens}), 'A') || setweight(to_tsvector('simple', #{bTokens}), 'B') WHERE id = #{id}
tokens 字段即为我这个数据模型的用来保存文本向量的字段, 即一个 tsvector 类型的字段。
这里就是在程序端分词完毕后将分词的结果设置进此字段中。需要注意的是 to_tsvector('simple', xxx)
表示自定义分词, PostgreSQL会按照空格进行分词, 所以我在传入值的时候将所有的词都用空格分隔并进行了拼接。
由于我这个数据模型有两个字段需要搜索, 并且搜索时权重有优先级之分。 可以理解为 标题、简介 这种优先级关系。 所以我在设置时使用 setweight 将其中一个的优先级设置为 A, 另一个设置为 B 。 ‘||’ 符号不是或, 是PostgreSQL的拼接符号
2、检索, 这是一个全文检索SQL,用户点击查询时, 将搜索内容分词后传入执行
SELECT hc.id AS id, hc.name AS name, hc.create_at AS createAt, hc.create_time AS createTime, ts_rank(hc.tokens, query) AS score FROM table1 hc, to_tsquery('simple', #{keyword}) query WHERE hc.tokens @@ query ORDER BY score DESC
keyword 即用户试图搜索的内容, 在程序端分词完毕后使用 ‘|’ (或) 拼接传入。使用 to_tsquery 来构建查询。
使用 @@
语法来匹配已经构建完毕的倒排索引, 只有匹配到我传入的词组任意一个内容才会被筛选出来。
筛选完毕了还不行, 用户搜索时当然期望查到最匹配的数据,所以这里用 ts_rank() 函数来计算 筛选结果的索引&查询关键字 之间的分数, 计算完毕后 ORDER BY 排序没啥好说的。
注意点:
构建索引时一般拆分粒度会尽可能的细, 最好用索引模式。 而针对用户的搜索内容一般使用搜索模式, 拆分粒度粗一点。
冗余的索引字段在业务中没有任何作用, 只用来搜索, 所以在其他业务里最好不要查询。
如果使用了 ORM 框架, 请给实体类的字段加上 @Transient 注解 (Java)
索引
当数据量庞大时, 那么不可避免地查询速度就会变慢, 此时就需要去加索引。
PostgreSQL自然也提供了强大的索引支持, 使用以下语句增加 pg_trgm
拓展就可以引入两个索引 gin
、 gist
, 需要注意的是执行语句需要提权到 postgres 用户。
CREATE EXTENSION pg_trgm;
gin和gist的区别就是 gin查询更快, 但是构建速度可能会慢一点。 而 gist 的构建速度快, 查询会慢一点。
一般建议预计数据量不大时可以使用gist索引, 如果预计数据量很大请直接上gin。
结
PostgreSQL 真的很香。特性太棒了, 做东西又快又好。
就光这个全文搜索, 中小体量的应用直接用 PostgreSQL 提供的支持可以减少很多麻烦。 既省去了运维新的中间件, 也节约了服务器资源。
最终实现的效果还强力。
就我亲身体验而言, 爽
可以,学到了