3

在 PostgreSQL 中,如何有效地对一列进行全文搜索,对另一列进行排序?

假设我有一个tbl包含列a, b, c, ... 和许多(> 一百万)行的表。我想对列进行全文搜索a并按其他列对结果进行排序。

所以我va从 column创建一个 tsvector a

ALTER TABLE tbl
ADD COLUMN va tsvector GENERATED ALWAYS AS (to_tsvector('english', a)) STORED;

为此创建一个索引iva

CREATE INDEX iva ON tbl USING GIN (va);

ib和列的索引b

CREATE INDEX ib ON tbl (b);

然后我像这样查询

SELECT * FROM tbl WHERE va @@ to_tsquery('english', 'test') ORDER BY b LIMIT 100

现在 Postgres 明显的执行策略是:

  1. ib使用、过滤va @@ 'test'::tsquery和在 100 次匹配后停止对常用词进行索引扫描,

  2. 而对于稀有词使用ivawith condition 进行(位图)索引扫描va @@ 'test'::tsquery,然后b手动排序

但是,Postgres 的查询规划器似乎没有考虑词频:

  • 对于低LIMIT(例如100),它总是使用策略 1(正如我检查过的EXPLAIN),并且在我的情况下,对于稀有(或不出现)的单词需要一分钟以上的时间。但是,如果我通过设置 large (或 no) 来欺骗它使用策略 2 LIMIT,它会在一毫秒内返回!

  • 反之亦然,对于较大的LIMIT(例如200),它总是使用策略 2,该策略适用于稀有词,但对于常用词非常慢

那么如何让 Postgres 在每种情况下都使用一个好的查询计划呢?

由于目前似乎没有办法让 Postgres 自动选择正确的计划,

  • 如何获得包含特定词位的行数,以便决定最佳策略?

    SELECT COUNT(*) FROM tbl WHERE va @@ to_tsquery('english', 'test')非常慢(对于 10000 行中出现的词位约 1 秒),而且ts_stat似乎也无济于事,除了建立我自己的词频列表)

  • 然后我如何告诉 Postgres 使用这个策略?


这里有一个具体的例子

我有一个items包含 150 万行的表,其中有一个 tsvector 列v3,我在其中进行文本搜索,还有一个列rating进行排序。在这种情况下,如果 LIMIT 小于或等于 135,我确定查询规划器始终选择策略 1,否则选择策略 2

这里是对 LIMIT 135 的稀有词“aberdeen”(出现在 132 行中)的 EXPLAIN ANALYZE:

EXPLAIN (ANALYZE, BUFFERS) SELECT nm FROM items WHERE v3 @@ to_tsquery('english', 'aberdeen')
  ORDER BY rating DESC NULLS LAST LIMIT 135

Limit  (cost=0.43..26412.78 rows=135 width=28) (actual time=5915.455..499917.390 rows=132 loops=1)
  Buffers: shared hit=4444267 read=2219412
  I/O Timings: read=485517.381
  ->  Index Scan using ir on items  (cost=0.43..1429202.13 rows=7305 width=28) (actual time=5915.453..499917.242 rows=132 loops=1)
        Filter: (v3 @@ '''aberdeen'''::tsquery)"
        Rows Removed by Filter: 1460845
        Buffers: shared hit=4444267 read=2219412
        I/O Timings: read=485517.381
Planning:
  Buffers: shared hit=253
Planning Time: 1.270 ms
Execution Time: 499919.196 ms

并使用 LIMIT 136:

EXPLAIN (ANALYZE, BUFFERS) SELECT nm FROM items WHERE v3 @@ to_tsquery('english', 'aberdeen')
  ORDER BY rating DESC NULLS LAST LIMIT 136

Limit  (cost=26245.53..26245.87 rows=136 width=28) (actual time=29.870..29.889 rows=132 loops=1)
  Buffers: shared hit=57 read=83
  I/O Timings: read=29.085
  ->  Sort  (cost=26245.53..26263.79 rows=7305 width=28) (actual time=29.868..29.876 rows=132 loops=1)
        Sort Key: rating DESC NULLS LAST
        Sort Method: quicksort  Memory: 34kB
        Buffers: shared hit=57 read=83
        I/O Timings: read=29.085
        ->  Bitmap Heap Scan on items  (cost=88.61..25950.14 rows=7305 width=28) (actual time=1.361..29.792 rows=132 loops=1)
              Recheck Cond: (v3 @@ '''aberdeen'''::tsquery)"
              Heap Blocks: exact=132
              Buffers: shared hit=54 read=83
              I/O Timings: read=29.085
              ->  Bitmap Index Scan on iv3  (cost=0.00..86.79 rows=7305 width=0) (actual time=1.345..1.345 rows=132 loops=1)
                    Index Cond: (v3 @@ '''aberdeen'''::tsquery)"
                    Buffers: shared hit=3 read=2
                    I/O Timings: read=1.299
Planning:
  Buffers: shared hit=253
Planning Time: 1.296 ms
Execution Time: 29.932 ms

这里是 LIMIT 135 的常用词“游戏”(出现在 240464 行中):

EXPLAIN (ANALYZE, BUFFERS) SELECT nm FROM items WHERE v3 @@ to_tsquery('english', 'game')
  ORDER BY rating DESC NULLS LAST LIMIT 135

Limit  (cost=0.43..26412.78 rows=135 width=28) (actual time=3.240..542.252 rows=135 loops=1)
  Buffers: shared hit=2876 read=1930
  I/O Timings: read=529.523
  ->  Index Scan using ir on items  (cost=0.43..1429202.13 rows=7305 width=28) (actual time=3.239..542.216 rows=135 loops=1)
        Filter: (v3 @@ '''game'''::tsquery)
        Rows Removed by Filter: 867
        Buffers: shared hit=2876 read=1930
        I/O Timings: read=529.523
Planning:
  Buffers: shared hit=208 read=45
  I/O Timings: read=15.626
Planning Time: 25.174 ms
Execution Time: 542.306 ms

并使用 LIMIT 136:

EXPLAIN (ANALYZE, BUFFERS) SELECT nm FROM items WHERE v3 @@ to_tsquery('english', 'game')
  ORDER BY rating DESC NULLS LAST LIMIT 136
  
Limit  (cost=26245.53..26245.87 rows=136 width=28) (actual time=69419.656..69419.675 rows=136 loops=1)
  Buffers: shared hit=1757820 read=457619
  I/O Timings: read=65246.893
  ->  Sort  (cost=26245.53..26263.79 rows=7305 width=28) (actual time=69419.654..69419.662 rows=136 loops=1)
        Sort Key: rating DESC NULLS LAST
        Sort Method: top-N heapsort  Memory: 41kB
        Buffers: shared hit=1757820 read=457619
        I/O Timings: read=65246.893
        ->  Bitmap Heap Scan on items  (cost=88.61..25950.14 rows=7305 width=28) (actual time=110.959..69326.343 rows=240464 loops=1)
              Recheck Cond: (v3 @@ '''game'''::tsquery)
              Rows Removed by Index Recheck: 394527
              Heap Blocks: exact=49894 lossy=132284
              Buffers: shared hit=1757817 read=457619
              I/O Timings: read=65246.893
              ->  Bitmap Index Scan on iv3  (cost=0.00..86.79 rows=7305 width=0) (actual time=100.537..100.538 rows=240464 loops=1)
                    Index Cond: (v3 @@ '''game'''::tsquery)
                    Buffers: shared hit=1 read=60
                    I/O Timings: read=26.870
Planning:
  Buffers: shared hit=253
Planning Time: 1.195 ms
Execution Time: 69420.399 ms
4

2 回答 2

3

这并不容易解决:全文搜索需要 GIN 索引,但 GIN 索引不支持ORDER BY. 此外,如果您有一个ORDER BY用于全文搜索的 B-tree 索引和一个 GIN 索引,则可以使用位图索引扫描将它们组合起来,但位图索引扫描也不支持ORDER BY

如果您创建自己的“停用词”列表,其中包含数据中的所有常用词(除了正常的英语停用词),我看到了一定的可能性。然后,您可以定义使用该停用词文件的文本搜索词典和english_rare使用该词典的文本搜索配置。

然后,您可以使用该配置创建全文索引,并分两步进行查询,如下所示:

  1. 寻找稀有词:

    SELECT *
    FROM (SELECT *
          FROM tbl
          WHERE va @@ to_tsquery('english_rare', 'test')
          OFFSET 0) AS q
    ORDER BY b LIMIT 100;
    

    子查询OFFSET 0将阻止优化器扫描b.

    对于罕见的单词,这将很快返回正确的结果。对于频繁出现的单词,这将不返回任何内容,因为to_tsquery将返回一个空结果。要区分因单词未出现而导致的未命中和因单词频繁出现而导致的未命中,请注意以下注意事项:

    NOTICE:  text-search query contains only stop words or doesn't contain lexemes, ignored
    
  2. 寻找常用词(如果第一个查询给了您通知):

    SELECT *
    FROM (SELECT *
          FROM tbl
          ORDER BY b) AS q
    WHERE va @@ to_tsquery('english', 'test')
    LIMIT 100;
    

    注意我们这里使用的是正常的英文配置。这将始终扫描索引,b并且对于频繁的搜索词将相当快。

于 2022-01-27T13:43:26.257 回答
2

我的场景的解决方案,我认为它适用于许多实际案例:

让 Postgres 始终或大部分使用“稀有词策略”(问题中的 2.)。原因是用户总是应该有可能按相关性排序(例如使用ts_rank),在这种情况下不能使用其他策略,因此必须确保“稀有词策略”足够快无论如何,所有搜索。

为了强制 Postgres 使用这种策略,可以使用 subquery,正如 Laurenz Albe 指出的那样:

SELECT * FROM 
    (SELECT * FROM tbl WHERE va @@ to_tsquery('english', 'test') OFFSET 0) AS q
ORDER BY b LIMIT 100;

或者,可以简单地设置LIMIT足够高(同时只获取所需的结果)。

我可以通过以下方式获得足够的性能(几乎所有查询都需要 < 1 秒)

  • 首先对包含每个文档最相关部分(例如标题和摘要)的较小部分进行每次搜索ts_vector,并且仅当第一次查询产生的结果不足时才检查完整文档。
  • 对非常频繁出现的词进行特殊处理,例如只允许它们与其他词进行 AND 组合(将它们添加到停用词是有问题的,因为例如在短语中出现时这些词没有得到合理的处理)
  • 增加 RAM 并增加shared_buffers,以便可以缓存整个表(目前为 8.5 GB)

对于这些优化还不够的情况,要为所有查询(即那些按相关性排序,这是最难的)实现更好的性能,我认为必须使用更复杂的文本搜索索引而不是 GIN。有看起来很有希望的RUM 索引扩展,但我还没有尝试过。


PS:与我在问题中的观察相反,我现在发现在某些情况下,计划者确实会考虑词频并朝着正确的方向做出决定:

对于稀有词,它选择“稀有词策略”的边界LIMIT低于频繁词,并且在一定范围内这种选择似乎非常好。然而,这绝不是可靠的,有时选择是非常错误的,例如对于low LIMITs,它也为非常罕见或不出现的单词选择“常用词策略”,这会导致非常慢。

它似乎取决于许多因素,而且似乎不可预测。

于 2022-02-02T02:31:56.103 回答