我们已经讨论了PostgreSQL
索引引擎 ,访问方法的接口以及主要访问方法,例如:
哈希索引 ,
B树 ,
GiST ,
SP-GiST和
GIN 。 在本文中,我们将观察杜松子酒如何变成朗姆酒。
朗姆酒
尽管作者声称杜松子酒是一种强大的精灵,但饮料的主题最终获得了胜利:下一代GIN被称为RUM。
这种访问方法扩展了GIN基础的概念,并使我们能够更快地执行全文搜索。 在本系列文章中,这是标准PostgreSQL交付中未包含的唯一方法,并且是外部扩展。 有几个安装选项可供使用:
- 从PGDG存储库中获取“ yum”或“ apt”包。 例如,如果从“ postgresql-10”软件包安装了PostgreSQL,则还要安装“ postgresql-10-rum”。
- 从github上的源代码进行构建,然后自行安装(说明也存在)。
- 用作Postgres Pro Enterprise的一部分(或至少从那里阅读文档 )。
杜松子酒的局限性
RUM使我们能够超越GIN的哪些限制?
首先,“ tsvector”数据类型不仅包含词素,还包含有关它们在文档中的位置的信息。 正如我们
上次观察到的,GIN索引不存储此信息。 因此,GIN索引无法有效地支持9.6版中出现的短语搜索操作,并且必须访问原始数据以进行重新检查。
其次,搜索系统通常返回按相关性排序的结果(无论如何)。 为此,我们可以使用排名函数“ ts_rank”和“ ts_rank_cd”,但是必须为结果的每一行计算它们,这肯定很慢。
对于第一近似,RUM访问方法可以被视为GIN,它另外存储位置信息并可以按所需顺序返回结果(例如
GiST可以返回最近的邻居)。 让我们一步一步地前进。
搜索词组
全文搜索查询可以包含特殊运算符,这些运算符考虑了词素之间的距离。 例如,我们可以找到文档,其中“手”与“大腿”分开,并且还有两个单词:
postgres=# select to_tsvector('Clap your hands, slap your thigh') @@ to_tsquery('hand <3> thigh');
?column? ---------- t (1 row)
或者我们可以指出单词必须一个接一个地定位:
postgres=# select to_tsvector('Clap your hands, slap your thigh') @@ to_tsquery('hand <-> slap');
?column? ---------- t (1 row)
常规GIN索引可以返回包含两个词素的文档,但是我们只能通过查看tsvector来检查它们之间的距离:
postgres=# select to_tsvector('Clap your hands, slap your thigh');
to_tsvector -------------------------------------- 'clap':1 'hand':3 'slap':4 'thigh':6 (1 row)
在RUM索引中,每个词素不仅引用表行:每个TID都提供了词素在文档中出现的位置列表。 这是我们可以设想在“ slit-sheet”表上创建的索引的方法,这个索引已经为我们所熟悉(默认情况下,“ ts_vector_ops”运算符类用于tsvector):
postgres=# create extension rum; postgres=# create index on ts using rum(doc_tsv);

图中的灰色方块包含添加的位置信息:
postgres=# select ctid, left(doc,20), doc_tsv from ts;
ctid | left | doc_tsv -------+----------------------+--------------------------------------------------------- (0,1) | Can a sheet slitter | 'sheet':3,6 'slit':5 'slitter':4 (0,2) | How many sheets coul | 'could':4 'mani':2 'sheet':3,6 'slit':8 'slitter':7 (0,3) | I slit a sheet, a sh | 'sheet':4,6 'slit':2,8 (1,1) | Upon a slitted sheet | 'sheet':4 'sit':6 'slit':3 'upon':1 (1,2) | Whoever slit the she | 'good':7 'sheet':4,8 'slit':2 'slitter':9 'whoever':1 (1,3) | I am a sheet slitter | 'sheet':4 'slitter':5 (2,1) | I slit sheets. | 'sheet':3 'slit':2 (2,2) | I am the sleekest sh | 'ever':8 'sheet':5,10 'sleekest':4 'slit':9 'slitter':6 (2,3) | She slits the sheet | 'sheet':4 'sit':6 'slit':2 (9 rows)
当指定了“ fastupdate”参数时,GIN还提供了延迟的插入。 此功能已从RUM中删除。
要查看索引如何处理实时数据,让我们使用熟悉的pgsql-hackers邮件列表
档案 。
fts=# alter table mail_messages add column tsv tsvector; fts=# set default_text_search_config = default; fts=# update mail_messages set tsv = to_tsvector(body_plain);
... UPDATE 356125
这是通过GIN索引执行使用短语搜索的查询的方式:
fts=# create index tsv_gin on mail_messages using gin(tsv); fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello <-> hackers');
QUERY PLAN --------------------------------------------------------------------------------- Bitmap Heap Scan on mail_messages (actual time=2.490..18.088 rows=259 loops=1) Recheck Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Rows Removed by Index Recheck: 1517 Heap Blocks: exact=1503 -> Bitmap Index Scan on tsv_gin (actual time=2.204..2.204 rows=1776 loops=1) Index Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Planning time: 0.266 ms Execution time: 18.151 ms (8 rows)
从计划中可以看出,使用了GIN索引,但它返回了1776个潜在匹配项,其中剩余259个,在重新检查阶段删除了1517个。
让我们删除GIN索引并构建RUM。
fts=# drop index tsv_gin; fts=# create index tsv_rum on mail_messages using rum(tsv);
现在,索引包含所有必要的信息,并且搜索准确地执行:
fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello <-> hackers');
QUERY PLAN -------------------------------------------------------------------------------- Bitmap Heap Scan on mail_messages (actual time=2.798..3.015 rows=259 loops=1) Recheck Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Heap Blocks: exact=250 -> Bitmap Index Scan on tsv_rum (actual time=2.768..2.768 rows=259 loops=1) Index Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Planning time: 0.245 ms Execution time: 3.053 ms (7 rows)
按相关性排序
为了方便地按所需顺序返回文档,RUM索引支持排序运算符,我们在与
GiST相关的文章中对此进行了讨论。 RUM扩展定义了这样的运算符
<=>
,该运算符返回文档(“ tsvector”)和查询(“ tsquery”)之间的某个距离。 例如:
fts=# select to_tsvector('Can a sheet slitter slit sheets?') <=>l to_tsquery('slit');
?column? ---------- 16.4493 (1 row)
fts=# select to_tsvector('Can a sheet slitter slit sheets?') <=> to_tsquery('sheet');
?column? ---------- 13.1595 (1 row)
该文档似乎与第一个查询比与第二个查询更相关:单词出现的频率越高,它的“价值”就越小。
让我们再次尝试在相对大的数据大小上比较GIN和RUM:我们将选择十个最相关的文档,其中包含“ hello”和“ hackers”。
fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello & hackers') order by ts_rank(tsv,to_tsquery('hello & hackers')) limit 10;
QUERY PLAN --------------------------------------------------------------------------------------------- Limit (actual time=27.076..27.078 rows=10 loops=1) -> Sort (actual time=27.075..27.076 rows=10 loops=1) Sort Key: (ts_rank(tsv, to_tsquery('hello & hackers'::text))) Sort Method: top-N heapsort Memory: 29kB -> Bitmap Heap Scan on mail_messages (actual ... rows=1776 loops=1) Recheck Cond: (tsv @@ to_tsquery('hello & hackers'::text)) Heap Blocks: exact=1503 -> Bitmap Index Scan on tsv_gin (actual ... rows=1776 loops=1) Index Cond: (tsv @@ to_tsquery('hello & hackers'::text)) Planning time: 0.276 ms Execution time: 27.121 ms (11 rows)
GIN索引返回1776个匹配项,然后将其作为单独的步骤进行排序以选择十个最佳匹配项。
使用RUM索引,可以使用简单的索引扫描来执行查询:无需查看额外的文档,并且不需要单独的排序:
fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello & hackers') order by tsv <=> to_tsquery('hello & hackers') limit 10;
QUERY PLAN -------------------------------------------------------------------------------------------- Limit (actual time=5.083..5.171 rows=10 loops=1) -> Index Scan using tsv_rum on mail_messages (actual ... rows=10 loops=1) Index Cond: (tsv @@ to_tsquery('hello & hackers'::text)) Order By: (tsv <=> to_tsquery('hello & hackers'::text)) Planning time: 0.244 ms Execution time: 5.207 ms (6 rows)
附加信息
RUM索引以及GIN可以建立在几个字段上。 但是,尽管GIN可以独立存储每一列中的词素,而不存储另一列中的词素,但RUM使我们能够将主字段(在这种情况下为“ tsvector”)与另一字段“关联”。 为此,我们需要使用专门的运算符类“ rum_tsvector_addon_ops”:
fts=# create index on mail_messages using rum(tsv RUM_TSVECTOR_ADDON_OPS, sent) WITH (ATTACH='sent', TO='tsv');
我们可以使用此索引返回按附加字段排序的结果:
fts=# select id, sent, sent <=> '2017-01-01 15:00:00' from mail_messages where tsv @@ to_tsquery('hello') order by sent <=> '2017-01-01 15:00:00' limit 10;
id | sent | ?column? ---------+---------------------+---------- 2298548 | 2017-01-01 15:03:22 | 202 2298547 | 2017-01-01 14:53:13 | 407 2298545 | 2017-01-01 13:28:12 | 5508 2298554 | 2017-01-01 18:30:45 | 12645 2298530 | 2016-12-31 20:28:48 | 66672 2298587 | 2017-01-02 12:39:26 | 77966 2298588 | 2017-01-02 12:43:22 | 78202 2298597 | 2017-01-02 13:48:02 | 82082 2298606 | 2017-01-02 15:50:50 | 89450 2298628 | 2017-01-02 18:55:49 | 100549 (10 rows)
在这里,我们搜索匹配的行,这些行尽可能早于指定日期,而不管早晚。 要获得严格在指定日期之前(或之后)的结果,我们需要使用
<=|
(或
|=>
)运算符。
如我们所料,查询仅通过简单的索引扫描执行:
ts=# explain (costs off) select id, sent, sent <=> '2017-01-01 15:00:00' from mail_messages where tsv @@ to_tsquery('hello') order by sent <=> '2017-01-01 15:00:00' limit 10;
QUERY PLAN --------------------------------------------------------------------------------- Limit -> Index Scan using mail_messages_tsv_sent_idx on mail_messages Index Cond: (tsv @@ to_tsquery('hello'::text)) Order By: (sent <=> '2017-01-01 15:00:00'::timestamp without time zone) (4 rows)
如果创建索引时没有字段关联的附加信息,则对于类似的查询,我们将必须对索引扫描的所有结果进行排序。
除了日期,我们当然可以将其他数据类型的字段添加到RUM索引中。 几乎所有基本类型都受支持。 例如,在线商店可以按新颖性(日期),价格(数字),受欢迎程度或折扣值(整数或浮点数)快速显示商品。
其他操作员类别
为了使图片更完整,我们应该提到其他可用的运算符类。
让我们从
“ rum_tsvector_hash_ops”和
“ rum_tsvector_hash_addon_ops”开始 。 它们类似于已经讨论过的“ rum_tsvector_ops”和“ rum_tsvector_addon_ops”,但是索引存储的是词位的哈希码,而不是词位本身。 这样可以减小索引的大小,但是搜索的准确性当然会降低,需要重新检查。 此外,索引不再支持部分匹配的搜索。
看看
“ rum_tsquery_ops”运算符类很有趣。 它使我们能够解决“逆向”问题:查找与文档匹配的查询。 为什么需要这个? 例如,根据用户的过滤器为用户订阅新商品或自动对新文档进行分类。 看这个简单的例子:
fts=# create table categories(query tsquery, category text); fts=# insert into categories values (to_tsquery('vacuum | autovacuum | freeze'), 'vacuum'), (to_tsquery('xmin | xmax | snapshot | isolation'), 'mvcc'), (to_tsquery('wal | (write & ahead & log) | durability'), 'wal'); fts=# create index on categories using rum(query); fts=# select array_agg(category) from categories where to_tsvector( 'Hello hackers, the attached patch greatly improves performance of tuple freezing and also reduces size of generated write-ahead logs.' ) @@ query;
array_agg -------------- {vacuum,wal} (1 row)
其余的运算符类
“ rum_anyarray_ops”和
“ rum_anyarray_addon_ops”旨在操作数组,而不是“ tsvector”。
上次已经针对GIN进行了讨论,因此无需重复。
索引和预写日志(WAL)的大小
显然,由于RUM比GIN存储更多的信息,因此它必须具有更大的大小。 我们上次正在比较不同索引的大小; 让我们将RUM添加到此表中:
rum | gin | gist | btree --------+--------+--------+-------- 457 MB | 179 MB | 125 MB | 546 MB
我们可以看到,大小的增长非常明显,这就是快速搜索的成本。
值得一提的是,RUM是一个扩展,也就是说,可以在不对系统核心进行任何修改的情况下安装RUM。 感谢
Alexander Korotkov的补丁,此功能已在9.6版中启用。 为此必须解决的问题之一是日志记录的生成。 用于记录操作的技术必须绝对可靠,因此,不能在此厨房中进行扩展。 代替了允许扩展程序创建自己的日志记录类型,而是执行以下操作:扩展程序的代码传达了其修改页面的意图,对其进行了任何更改,并发出完成信号,这是系统的核心,比较页面的旧版本和新版本,并生成所需的统一日志记录。
当前的日志生成算法逐字节比较页面,检测更新的片段,并记录这些片段中的每一个以及其与页面开始的偏移量。 仅更新几个字节或整个页面时,这可以很好地工作。 但是,如果我们在页面内添加一个片段,将其余内容向下移动(反之亦然,删除一个片段,向上移动内容),则更改的字节数将比实际添加或删除的字节多得多。
因此,大量更改的RUM索引可能会生成比GIN大得多的日志记录(GIN不是扩展,而是核心的一部分,它自己管理日志)。 这种烦人的影响的程度在很大程度上取决于实际的工作量,但是为了深入了解该问题,让我们尝试多次删除并添加许多行,并将这些操作与“真空”交织在一起。 我们可以评估日志记录的大小,如下所示:在开始和结束时,请使用“ pg_current_wal_location”函数(在十个之前的版本中为“ pg_current_xlog_location”)记住日志中的位置,然后查看它们之间的区别。
但是,当然,我们应该在这里考虑很多方面。 我们需要确保只有一个用户正在使用该系统(否则,将考虑“额外”记录)。 即使是这种情况,我们不仅要考虑RUM,还要考虑表本身以及支持主键的索引的更新。 配置参数的值也会影响大小(此处使用“副本”日志级别,未压缩,此处使用)。 但是我们还是尝试一下。
fts=# select pg_current_wal_location() as start_lsn \gset
fts=# insert into mail_messages(parent_id, sent, subject, author, body_plain, tsv) select parent_id, sent, subject, author, body_plain, tsv from mail_messages where id % 100 = 0;
INSERT 0 3576
fts=# delete from mail_messages where id % 100 = 99;
DELETE 3590
fts=# vacuum mail_messages;
fts=# insert into mail_messages(parent_id, sent, subject, author, body_plain, tsv) select parent_id, sent, subject, author, body_plain, tsv from mail_messages where id % 100 = 1;
INSERT 0 3605
fts=# delete from mail_messages where id % 100 = 98;
DELETE 3637
fts=# vacuum mail_messages;
fts=# insert into mail_messages(parent_id, sent, subject, author, body_plain, tsv) select parent_id, sent, subject, author, body_plain, tsv from mail_messages where id % 100 = 2;
INSERT 0 3625
fts=# delete from mail_messages where id % 100 = 97;
DELETE 3668
fts=# vacuum mail_messages;
fts=# select pg_current_wal_location() as end_lsn \gset fts=# select pg_size_pretty(:'end_lsn'::pg_lsn - :'start_lsn'::pg_lsn);
pg_size_pretty ---------------- 3114 MB (1 row)
因此,我们得到了大约3 GB。 但是,如果我们对GIN索引重复相同的实验,则只会占用700 MB左右的空间。
因此,希望有一种不同的算法,该算法将找到可以将页面的一种状态转换为另一种状态的最小数量的插入和删除操作。 “ Diff”实用程序的工作方式与此类似。
Oleg Ivanov已经实现了这样的算法,并且他的
补丁正在讨论中。 在上面的示例中,此补丁使我们能够将日志记录的大小减少1.5倍,至1900 MB,但代价是速度有所降低。
不幸的是,补丁已卡住,并且周围没有活动。
物产
像往常一样,让我们看一下RUM访问方法的属性,注意与GIN的区别(
已经提供了查询)。
以下是访问方法的属性:
amname | name | pg_indexam_has_property --------+---------------+------------------------- rum | can_order | f rum | can_unique | f rum | can_multi_col | t rum | can_exclude | t -- f for gin
以下索引层属性可用:
name | pg_index_has_property ---------------+----------------------- clusterable | f index_scan | t -- f for gin bitmap_scan | t backward_scan | f
请注意,与GIN不同,RUM支持索引扫描-否则,不可能在带有“ limit”子句的查询中精确返回所需数目的结果。 因此,不需要与“ gin_fuzzy_search_limit”参数对应的对象。 因此,该索引可用于支持排除约束。
以下是列层属性:
name | pg_index_column_has_property --------------------+------------------------------ asc | f desc | f nulls_first | f nulls_last | f orderable | f distance_orderable | t -- f for gin returnable | f search_array | f search_nulls | f
此处的区别在于RUM支持排序运算符。 但是,并非对所有运算符类都是如此:例如,对于“ tsquery_ops”而言,它为false。
继续阅读 。