Postgresql杂谈 23——Postgresql中的全文检索

       今天我们来聊一下全文检索,想必做搜索相关业务朋友对这个概念不会陌生,尤其是做搜索引擎,或者类似CSDN、知乎类的社区网站,全文检索是逃不开的业务。文,即文章、文档。全文搜索就是给定关键词,在所有的文档数据中找到符合关键词的文档。不管是哪种业务模式下的全文检索功能,其实大体的实现思路类似,如下所示:

       使用文字进行描述,就是:

(1)获取原始文档数据。

(2)对文档进行分析,分词(所为分词,就是按照分词符,如空格,将一句话分隔成若干的单词)

(3)存档存入数据库,并通过分词建立索引。

(4)查询时根据关键词,通过索引查询到索引指向的数据。

       Postgresql本身就支持全文检索的功能,尤其是Postgresql10.0之后,对于全文检索的支持更加成熟。配合它的GIN索引,Postgresql的全文检索具有很高的查询性能。下面,我们来演示下Postgresql中全文索引的使用。

一、测试数据的准备

       在继续下面的内容之前,我们还是要先创建一个测试表,用来进行后面内容的演示:

postgres=# create table blog (id serial,recoredtime timestamp default now(),content text);
CREATE TABLE
postgres=# \d+ blog
                                                             Table "public.blog"
   Column    |            Type             | Collation | Nullable |             Default              | Storage  | Stats target | Description 
-------------+-----------------------------+-----------+----------+----------------------------------+----------+--------------+-------------
 id          | integer                     |           | not null | nextval('blog_id_seq'::regclass) | plain    |              | 
 recoredtime | timestamp without time zone |           |          | now()                            | plain    |              | 
 content     | text                        |           |          | 

       可以看到,笔者创建了一个名叫blog的测试表,用来模拟博客网站存储的博文,表中一共有3个字段:

  • id —— 为每一篇博文分配的唯一的自增长ID
  • recoredtime —— 博文提交的时间,默认是提交的当前时间
  • content —— 博文的内容

       接下来,向blog表里面插入一些测试数据:

postgres=# COPY blog(content) FROM '/data/1.txt';
COPY 20
postgres=# COPY blog(content) FROM '/data/2.txt';
COPY 12
postgres=# COPY blog(content) FROM '/data/3.txt';
COPY 6
postgres=# COPY blog(content) FROM '/data/4.txt';
COPY 22
postgres=# COPY blog(content) FROM '/data/5.txt';
COPY 9

       笔者通过Copy命令将位于本地的5个文本文件的内容插入到了blog表中,每个文本文件里面实际上都是一片英文的文章,但是Copy命令遇到换行符会结束然后插入新行,所以每篇文章实际插入了多行数据。

postgres=# select count(*) from blog;
-[ RECORD 1 ]
count | 69

       可以看到,整个blog表一共有69行数据,而且其中不乏空行。但是为了验证在大数据量下全文检索的性能,69行数据还是远远不够的,我们可以以1.txt为源数据,重复插入:

postgres=# do $$
postgres$# declare
postgres$# v_idx integer := 1;
postgres$# begin
postgres$#   while v_idx < 10000 loop
postgres$#   v_idx = v_idx+1;
postgres$#     COPY blog(content) FROM '/data/1.txt';
postgres$#   end loop;
postgres$# end $$;

DO

       最终我们插入了200W+的数据:

postgres=# select count(*) from blog;
 count  
--------
 201009
(1 row)

       接下来,我们就以这200W+行数据为数据源来介绍下Postgresql中全文检索功能的使用。

二、Postgresql的全文检索原理

       Postgresql会对长文本进行分词,分词的标准一般是按照空格进行拆分。分词之后长文本实际上被分成了很多个key的集合,这个key的集合叫做tsvector。所有的搜索都是在tsvector中进行的。

       我们先来简单验证下,Postgresql是怎么对一个简单文本字符串进行分词的:

postgres=# select 'I will be back'::tsvector;
        tsvector        
------------------------
 'I' 'back' 'be' 'will'
(1 row)

       我们将长字符串声明成了tsvector类型,Postgresql就自动按照空格对其进行了分词,并打印出来。Postgresql中也提供了一个to_tsvector的函数,可以实现类似的功能:

postgres=# select to_tsvector('I will be back');
 to_tsvector 
-------------
 'back':4
(1 row)

       先看下to_tsvector函数返回的结果,第4行‘back’是提取的关键字,冒号后面的4表示词在句子中的位置。有朋友一定会觉得奇怪,为什么前面使用tsvector分词时分出来4个词,而使用to_tsvector只分出来了back一个?实际上tssvector会自动忽略掉I、Well等等这类主语词或者谓词、虚词,这也很容易理解,因为这类词往往是量最多,但是却很少使用来进行查询的。

       我们再来看一个Postgresql官方文档中使用to_tsvector的例子:

postgres=# SELECT to_tsvector('english', 'a fat  cat sat on a mat - it ate a fat rats');
                     to_tsvector                     
-----------------------------------------------------
 'ate':9 'cat':3 'fat':2,11 'mat':7 'rat':12 'sat':4
(1 row)

       在上面这个例子中我们看到,作为结果的tsvector不包含词a、on或it,词rats变成了rat,并且标点符号-被忽略了。

       to_tsvector函数在内部调用了一个解析器,它把文档文本分解成记号并且为每一种记号分配一个类型。对于每一个记号,会去查询一个词典列表,该列表会根据记号的类型而变化。第一个识别记号的词典产生一个或多个正规化的词位来表示该记号。例如,rats变成rat是因为一个词典识别到该词rats是rat的复数形式。一些词会被识别为停用词,这将导致它们被忽略,因为它们出现得太频繁以至于在搜索中起不到作用。在我们的例子中有a、on和it是停用词。如果在列表中没有词典能识别该记号,那它将也会被忽略。在这个例子中标点符号-就属于这种情况,因为事实上没有词典会给它分配记号类型(空间符号),即空间记号不会被索引。对于解析器、词典以及要索引哪些记号类型是由所选择的文本搜索配置决定的。可以在同一个数据库中有多种不同的配置,并且有用于很多种语言的预定义配置。在我们的例子中,我们使用用于英语的默认配置english。

       介绍完了tsvector,要完成全文检索功能,我们还需要引入另外一个类型——检索条件tsquery,tsquery是一个由简单逻辑运算符组成的字符串,如下:

postgres=# select 'we & back'::tsquery;
    tsquery    
---------------
 'we' & 'back'
(1 row)

       'we & back'的意思就是查询条件中既包含‘we’这个单词,也包括'back'这个单词。如果使用这个tsquery进行查询,就可以组成类似下面的SQL语句:

postgres=# select 'I will be back'::tsvector @@ 'we & back'::tsquery;
 ?column? 
----------
 f
(1 row)

       上面查询语句的意思,当然就是在'I will be back'这个tsvector类型中确认是不是符合既包含‘we’这个单词,也包括'back'这个单词?答案当然是否定的,所以结果为false。如果我们把tsquery中的&换成|,就会是另一种结果:

postgres=# select 'I will be back'::tsvector @@ 'we | back'::tsquery;
 ?column? 
----------
 t
(1 row)

       在'I will be back'中查找,确认其是否满足含有‘we’或者'back',因为它含有'back',索引结果就是true。

       和tsvector类似,将字符串转换成tsquery类型,Postgresql也提供了对应的to_tsquery函数:

postgres=# select to_tsquery('we & back');
 to_tsquery 
------------
 'back'
(1 row)

       从上面的结果中也可以看到,to_tsquery也忽略了we这个主语单词。

三、在数据表中使用全文检索

       前面,我们介绍了Postgresql中实现全文检索的原理,接下来,开始在之前创建的blog表中使用全文检索。我们从上文中了解到:Postgresql实现全文检索是在tsvector类型之上的,因此要想在blog表中实现这一功能,我们还必须添加一个tsvector的列,在此列的基础之上进行全文检索。

       先添加列:

postgres=# alter table blog add column tscontent tsvector;
ALTER TABLE

       加完成列之后,然后将content里面的内容分词转换成tsvector类型:

postgres=# update blog set tscontent=to_tsvector(content);
UPDATE 69

       然后,在此基础之上,我们进行查找包含单词mother的数据行,为了方便查看性能,我们在执行计划里面去执行:

postgres=# explain (analyze,verbose,buffers,costs,timing) select * from blog where tscontent @@ 'mother'::tsquery;
                                                            QUERY PLAN                                                             
-----------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..302075.58 rows=1 width=347) (actual time=18140.837..18140.878 rows=1 loops=1)
   Output: id, recoredtime, content, tscontent
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=16156 read=273456
   ->  Parallel Seq Scan on public.blog  (cost=0.00..301075.48 rows=1 width=347) (actual time=14030.975..17958.129 rows=0 loops=3)
         Output: id, recoredtime, content, tscontent
         Filter: (blog.tscontent @@ '''mother'''::tsquery)
         Rows Removed by Filter: 733663
         Buffers: shared hit=16156 read=273456
         Worker 0: actual time=6087.447..17868.909 rows=1 loops=1
           Buffers: shared hit=5343 read=88589
         Worker 1: actual time=17864.982..17864.982 rows=0 loops=1
           Buffers: shared hit=5292 read=86997
 Planning Time: 1.990 ms
 Execution Time: 18144.970 ms
(16 rows)

      从上面的执行计划信息中可以看到,整个查询采用了并行扫描全表,一共查询到了1条数据,查询实际耗时18144.970ms。为了加快查询速度,我们还可以在tscontent字段上加上GIN索引:

postgres=# create index on blog using gin(tscontent);
CREATE INDEX

       创建成功GIN索引之后,再次执行查询计划:

postgres=# explain (analyze,verbose,buffers,costs,timing) select * from blog where tscontent @@ 'mother'::tsquery;
                                                         QUERY PLAN                                                         
----------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on public.blog  (cost=28.00..32.01 rows=1 width=347) (actual time=3.176..3.179 rows=1 loops=1)
   Output: id, recoredtime, content, tscontent
   Recheck Cond: (blog.tscontent @@ '''mother'''::tsquery)
   Heap Blocks: exact=1
   Buffers: shared hit=1 read=3
   ->  Bitmap Index Scan on blog_tscontent_idx  (cost=0.00..28.00 rows=1 width=0) (actual time=0.690..0.691 rows=1 loops=1)
         Index Cond: (blog.tscontent @@ '''mother'''::tsquery)
         Buffers: shared hit=1 read=2
 Planning Time: 2.786 ms
 Execution Time: 3.238 ms
(10 rows)

       加了索引之后,再去查询,查询过程走了索引,采用位图扫描,整个的查询过程只消耗了3.238ms,单单从数字上比较,性能提高了6000倍不止。

四、使用tsquery的全文查询和like模糊查询的性能比较

       有的朋友可能会有疑问:如果全文搜索使用like等模糊查询方式是不是也可以实现呢?可以实现,但是如果使用like等模糊查询,主要有两个弊端:

(1)like模糊查询要进行全表扫描,查询起来会相当吃力,性能很低;

(2)查询结果中包含了所有mother这个字符串的数据,无法做到精确匹配。

       我们可以再次在执行计划中使用like模糊查询测试下:

^Cpostgres=explain (analyze,verbose,buffers,costs,timing) select * from blog where content like '%mother%';
                                                                QUERY PLAN                                                                
------------------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..313181.99 rows=111627 width=649) (actual time=6209.370..18396.139 rows=110048 loops=1)
   Output: id, recoredtime, content, tscontent
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=16145 read=273467
   ->  Parallel Seq Scan on public.blog  (cost=0.00..301019.29 rows=46511 width=649) (actual time=6248.323..16996.203 rows=36683 loops=3)
         Output: id, recoredtime, content, tscontent
         Filter: (blog.content ~~ '%mother%'::text)
         Rows Removed by Filter: 696980
         Buffers: shared hit=16145 read=273467
         Worker 0: actual time=6249.688..16447.847 rows=21945 loops=1
           Buffers: shared hit=5242 read=66229
         Worker 1: actual time=6286.170..16309.549 rows=21570 loops=1
           Buffers: shared hit=5181 read=65342
 Planning Time: 0.109 ms
 Execution Time: 18484.319 ms
(16 rows)

       因为也采用的是并行的全表扫描,所以使用like查询的耗时和使用索引前的全文检索耗时差不多,用了18484.319 ms。而且从查询结果中,我们可以看到,使用模糊查询我们查出来了110048条结果,而实际上包含mothor这个单词的数据只有一行。

五、支持中文全文检索的zhparser

       可能细心的朋友已经发现,我们现在做的全文检索功能,是完全建立在检索英文的基础之上的。实际上,Postgresql默认的全文检索只支持英文,如果需要支持中文的全文检索,我们需要安装zhparser插件。由于篇幅有限,笔者就不再这里展开了,如果感兴趣可以自行百度或google。

六、总结

       按照惯例,我们还是对本篇的内容进行总结:

(1)Postgresql支持全文检索的功能,它提供了两个类型tsvector和tsquery分别表示全文检索索引的集合以及查询条件

(2)全文检索的原理就是将长的字符串按照空格进行分词,将分词存入到类型为tsvector的集合中,tsvector中存储每个单词和其在长语句中的位置。

(3)tsquery类型是由查询的key和&、|等逻辑运算符拼接在一起。

(4)在某个表上进行全文检索,需要创建专门的tsvector类型的字段,而且字段上可以创建gin索引来加速查询。

(5)Postgresql默认的全文检索只支持英文,需要需要使用支持中文的全文检索,需要安装zhparser插件。

  • 11
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值