PostgreSQL中的索引(五) --GiST


在前幾篇文章中,我們討論了PostgreSQL索引引擎、訪問方法的接口以及兩種訪問方法:hash索引和B-tree。在本文中,我們將描述GiST索引。

GiST

GiST是廣義搜索樹«generalized search tree»的縮寫。這是一個平衡搜索樹,就像前面討論的«b-tree»。

有什么區別嗎?«btree»索引嚴格地與比較語義聯系在一起:支持«greater»、«less»和«equal»操作符是它唯一能做的(但是非常有能力!)然而,對於現代數據庫存儲的數據類型,這些操作符根本沒有意義:地理數據、文本文檔、圖像……

GiST索引方法可以幫助我們處理這些數據類型。它允許定義一個規則來跨平衡樹分發任意類型的數據,並定義一種使用此表示形式來供操作符訪問的方法。例如,GiST index可以«accommodate»R-tree用於支持相對位置操作符(位於左側、右側、contains等)的空間數據,或者RD-tree用於支持交集或包含操作符的集合。

由於可擴展性,可以在PostgreSQL中從頭開始創建一種全新的方法:為此,必須實現與索引引擎的接口。但這不僅需要對索引邏輯的預想,還需要對數據結構映射到頁,鎖的有效實現以及對WAL日志的支持。所有這些都需要具備較高的開發人員技能和巨大的人力資源。GiST通過接管低級問題並提供其自己的接口來簡化任務:幾種功能與技術無關,而與應用領域有關。從這個意義上講,我們可以將GiST視為構建新訪問方法的框架。

架構

GiST是由節點頁組成的高度平衡樹。 節點由索引行組成。

通常,葉節點的每一行(葉行)都包含一些謂詞(布爾表達式)和對表行(TID)的引用。 索引數據(鍵)必須符合此謂詞。

內部節點的每一行(內部行)還包含一個謂詞和對子節點的引用,並且子樹的所有索引數據都必須滿足此謂詞。 換句話說,內部行的謂詞包括所有子行的謂詞。GiST索引的這一重要特征取代了B樹的簡單排序。

GiST樹中的搜索使用專門的一致性函數(«consistent»)-接口定義的功能之一,並且以自己的方式為每個受支持的操作符家族實現。

為索引行調用一致性函數,並確定該行的謂詞是否與搜索謂詞(指定為“索引字段運算符表達式”)一致。對於內部行,此函數實際上確定是否需要下降到相應的子樹,而對於葉行,此函數確定索引的數據是否滿足謂詞。

搜索從根節點開始,就像普通的樹搜索一樣。一致性函數支持找出有意義的子節點(可能有多個)可以輸入,而哪些子節點不需要。然后對找到的每個子節點重復該算法。如果節點是葉子,則由一致性函數選擇的行將作為結果之一返回。

搜索是深度優先的:算法首先嘗試到達葉節點。這樣可以在可能的情況下盡快返回第一結果(如果用戶只對幾個結果感興趣,而不對所有結果感興趣,這可能很重要)。

一致性函數與 «greater», «less», 或 «equal»運算符無關。一致性函數的語義可能完全不同,因此,不要假定索引以特定順序返回值。

我們將不討論GiST中值的插入和刪除算法:更多接口函數執行這些操作。但是,有一點很重要。將新值插入索引后,將選擇該值在樹中的位置,以便其父行的謂詞盡可能少地擴展(理想情況下完全不擴展)。但是,當刪除一個值時,父行的謂詞不再減少。僅在以下情況下會發生這種情況:將頁分為兩部分(頁沒有足夠的空間來插入新的索引行時),或者從頭開始創建索引(使用REINDEX或VACUUM FULL命令)。因此,頻繁更改數據的GiST索引的效率會隨時間降低。

此外,我們將考慮一些針對各種數據類型和GiST有用屬性的索引示例:

  ·點(和其他幾何實體)和最近鄰居的搜索。
  ·間隔和排除約束。
  ·全文搜索。

R-tree用於points

我們將通過一個平面中的點的索引示例來說明上述內容(我們還可以為其他幾何實體建立類似的索引)。 常規的B樹不適合這種數據類型的數據,因為沒有為點定義比較運算符。

R-tree的想法是將平面划分為矩形,這些矩形總共覆蓋所有索引的點。 索引行存儲一個矩形,謂詞可以這樣定義:“所尋找的點位於給定的矩形之內”。

R樹的根將包含幾個最大的矩形(可能是相交的)。子節點將包含較小尺寸的矩形,這些矩形嵌入到父節點中,並且覆蓋所有基礎點。

從理論上講,葉節點必須包含要索引的點,但是所有索引行中的數據類型必須相同,因此,再次存儲了矩形,但是將“折疊”成點。

為了可視化這種結構,我們提供了R樹的三層的圖像。點是機場的坐標(類似於演示數據庫的“ airports”表中的坐標,但提供了來自openflights.org的更多數據)。

 

第一層:兩個大的有交集的矩形

第二層:大的矩形被分為小的矩形

第三層:每個矩形都包含足夠多的點以容納一個索引頁

現在讓我們考慮一個非常簡單的«一層»示例:

postgres=# create table points(p point);

postgres=# insert into points(p) values
  (point '(1,1)'), (point '(3,2)'), (point '(6,3)'),
  (point '(5,5)'), (point '(7,8)'), (point '(8,6)');

postgres=# create index on points using gist(p);

通過這種拆分,索引結構將如下所示:

創建的索引可用於加速以下查詢,例如:«查找給定矩形中包含的所有點»。這個條件可以形式化為:p <@ box '(2,1),(6,3)'(來自«points_ops»家族的操作符<@表示«包含在»中):

postgres=# set enable_seqscan = off;

postgres=# explain(costs off) select * from points where p <@ box '(2,1),(7,4)';
                  QUERY PLAN                  
----------------------------------------------
 Index Only Scan using points_p_idx on points
   Index Cond: (p <@ '(7,4),(2,1)'::box)
(2 rows)

操作符(“indexed-field <@ expression”,其中indexed-field是一個點,表達式是一個矩形)的一致性函數定義如下。對於內部行,如果其矩形與表達式定義的矩形相交,則返回«yes»。對於葉子行,如果其點(«collapsed»矩形)包含在表達式定義的矩形中,則函數返回«yes»。

搜索從根節點開始。矩形(2,1)-(7,4)與(1,1)-(6,3)相交,但與(5,5)-(8,8)不相交,因此無需下降到第二個子樹。

到達一個葉子節點,我們經歷所包含的三個點,返回兩個結果:(3,2)и(6,3)。

postgres=# select * from points where p <@ box '(2,1),(7,4)';
   p  
-------
 (3,2)
 (6,3)
(2 rows)

內部原理

不幸的是,«pageinspect»不允許查看GiST索引。但是還有另一種方法可用:«gevel»擴展。但是它不包括在標准交付中。可以自己嘗試安裝。

如果一切順利,您將可以使用三個功能。首先,我們可以得到一些統計數據:

postgres=# select * from gist_stat('airports_coordinates_idx');
                gist_stat                
------------------------------------------
 Number of levels:          4            +
 Number of pages:           690          +
 Number of leaf pages:      625          +
 Number of tuples:          7873         +
 Number of invalid tuples:  0            +
 Number of leaf tuples:     7184         +
 Total size of tuples:      354692 bytes +
 Total size of leaf tuples: 323596 bytes +
 Total size of index:       5652480 bytes+
 
(1 row)

很明顯,機場坐標上的索引的大小是690頁,索引由四個層組成:根層和兩個內部層如上圖所示,第四個層次是leaf。

實際上,8000個點的索引看起來要小得多:這里為清晰起見,它的填充系數為10%。

第二,我們可以輸出索引樹:

postgres=# select * from gist_tree('airports_coordinates_idx');
                                       gist_tree                                              
-----------------------------------------------------------------------------------------
 0(l:0) blk: 0 numTuple: 5 free: 7928b(2.84%) rightlink:4294967295 (InvalidBlockNumber) +
     1(l:1) blk: 335 numTuple: 15 free: 7488b(8.24%) rightlink:220 (OK)                 +
         1(l:2) blk: 128 numTuple: 9 free: 7752b(5.00%) rightlink:49 (OK)               +
             1(l:3) blk: 57 numTuple: 12 free: 7620b(6.62%) rightlink:35 (OK)           +
             2(l:3) blk: 62 numTuple: 9 free: 7752b(5.00%) rightlink:57 (OK)            +
             3(l:3) blk: 72 numTuple: 7 free: 7840b(3.92%) rightlink:23 (OK)            +
             4(l:3) blk: 115 numTuple: 17 free: 7400b(9.31%) rightlink:33 (OK)          +
 ...

第三,我們可以輸出存儲在索引行的數據。注意以下細微差別:函數的結果必須轉換為所需的數據類型。在我們的情況下,這個類型是«box»(一個邊界矩形)。例如,注意頂層的5行:

postgres=# select level, a from gist_print('airports_coordinates_idx')
  as t(level int, valid bool, a box) where level = 1;
 level |                                   a                                  
-------+-----------------------------------------------------------------------
     1 | (47.663586,80.803207),(-39.2938003540039,-90)
     1 | (179.951004028,15.6700000762939),(15.2428998947144,-77.9634017944336)
     1 | (177.740997314453,73.5178070068359),(15.0664,10.57970047)
     1 | (-77.3191986083984,79.9946975708),(-179.876998901,-43.810001373291)
     1 | (-39.864200592041,82.5177993774),(-81.254096984863,-64.2382965088)
(5 rows)

實際上,上面提供的數據就是根據這些數據創建的。

檢索和排序的操作符

到目前為止討論的操作符(例如謂詞p <@ box '(2,1),(7,4)'中的<@)可以稱為搜索操作符,因為它們指定查詢中的搜索條件。

還有另一種操作符類型:排序操作符。它們用於按order BY子句的排序順序的規范,而不是列名的常規規范。下面是這種查詢的一個例子:

postgres=# select * from points order by p <-> point '(4,7)' limit 2;
   p  
-------
 (5,5)
 (7,8)
(2 rows)

p <->point'(4,7)'這是一個使用排序運算符<->的表達式,它表示從一個參數到另一個參數的距離。查詢的意義是返回最接近點(4,7)的兩個點。這樣的搜索被稱為k-NN ---- k最近鄰居搜索。

為了支持這類查詢,訪問方法必須定義一個額外的距離函數,並且排序操作符必須包含在適當的操作符類(例如,用於點的«points_ops»類)。下面的查詢顯示了操作符及其類型(«s»-搜索和«o»-排序):

postgres=# select amop.amopopr::regoperator, amop.amoppurpose, amop.amopstrategy
from pg_opclass opc, pg_opfamily opf, pg_am am, pg_amop amop
where opc.opcname = 'point_ops'
and opf.oid = opc.opcfamily
and am.oid = opf.opfmethod
and amop.amopfamily = opc.opcfamily
and am.amname = 'gist'
and amop.amoplefttype = opc.opcintype;
      amopopr      | amoppurpose | amopstrategy
-------------------+-------------+--------------
 <<(point,point)   | s           |            1  strictly left
 >>(point,point)   | s           |            5  strictly right
 ~=(point,point)   | s           |            6  coincides
 <^(point,point)   | s           |           10  strictly below
 >^(point,point)   | s           |           11  strictly above
 <->(point,point)  | o           |           15  distance
 <@(point,box)     | s           |           28  contained in rectangle
 <@(point,polygon) | s           |           48  contained in polygon
 <@(point,circle)  | s           |           68  contained in circle
(9 rows)

策略的數量也被顯示,並解釋其含義。很明顯,有比«btree»更多的策略,其中只有一些只是支持點。可以為其他數據類型定義不同的策略。

距離函數是為索引元素調用的,它必須計算從表達式(“indexed-field ordering-operator expression”)定義的值到給定元素的距離。對於葉元素,這只是到索引值的距離。對於內部元素,函數必須返回到子葉元素的最小距離。由於遍歷所有子行代價很高,因此允許該函數樂觀地低估距離,但以降低搜索效率為代價。但是,決不允許函數高估距離,因為這將破壞索引的工作。

distance函數可以返回任何可排序類型的值(對於排序值,PostgreSQL將使用相應的«btree»訪問方法操作符家族中的比較語義,如前面所述)。

對於平面上的點,距離以通常意義上的方式解釋:(x1,y1) <-> (x2,y2)的值等於橫坐標和縱坐標的差平方和的平方根。點到邊界矩形的距離取該點到該矩形的最小距離,如果該點位於矩形內,則取零。不需要遍歷子點就可以很容易地計算出這個值,而且這個值肯定不會大於到任何子點的距離。

讓我們考慮一下上述查詢的搜索算法。

搜索從根節點開始。節點包含兩個邊界矩形。到(1,1)-(6,3)的距離是4.0,到(5,5)-(8,8)的距離是1.0。

子節點按照距離增加的順序遍歷。這樣,我們首先下降到最近的子節點,計算到點的距離(為了便於觀察,我們將在圖中顯示數字):

該信息足以先返回兩個點(5,5)和(7,8)。由於我們知道到位於矩形(1,1)-(6,3)內的點的距離是4.0或更大,所以我們不需要下降到第一個子節點。

但如果我們需要找出前三個點呢?

postgres=# select * from points order by p <-> point '(4,7)' limit 3;
   p  
-------
 (5,5)
 (7,8)
 (8,6)
(3 rows)

盡管第二個子節點包含所有這些點,但是如果不查看第一個子節點,我們就不能返回(8,6),因為這個節點可以包含更近的點(因為4.0 < 4.1)。

 

對於內部行,這個例子說明了距離函數的需求。通過為第二行選擇更小的距離(4.0而不是實際的4.5),我們降低了效率(算法不必要地開始檢查額外的節點),但沒有破壞算法的正確性。

直到最近,GiST還是唯一能夠處理排序操作符的訪問方法。但是情況已經發生了變化:RUM訪問方法(待進一步討論)已經加入了這一組方法,而且老的B-tree也不太可能加入其中:我們的同事Nikita Glukhov開發的一個補丁正在社區討論中。

從2019年3月開始,在即將發布的PostgreSQL 12(也由Nikita編寫)中,為SP-GiST增加了k-NN支持。b樹補丁仍在進行中。

R-trees用於intervals

使用GiST訪問方法的另一個例子是對間隔進行索引,例如,時間間隔(«tsrange»類型)。所有的區別是內部節點將包含邊界間隔,而不是邊界矩形。

讓我們考慮一個簡單的例子。我們將出租一間小屋,並將預訂間隔存儲在一張表中:

postgres=# create table reservations(during tsrange);

postgres=# insert into reservations(during) values
('[2016-12-30, 2017-01-09)'),
('[2017-02-23, 2017-02-27)'),
('[2017-04-29, 2017-05-02)');

postgres=# create index on reservations using gist(during);

該索引可用於加速以下查詢,例如:

postgres=# select * from reservations where during && '[2017-01-01, 2017-04-01)';
                    during                    
-----------------------------------------------
 ["2016-12-30 00:00:00","2017-01-08 00:00:00")
 ["2017-02-23 00:00:00","2017-02-26 00:00:00")
(2 rows)

postgres=# explain (costs off) select * from reservations where during && '[2017-01-01, 2017-04-01)';
                                     QUERY PLAN                                    
------------------------------------------------------------------------------------
 Index Only Scan using reservations_during_idx on reservations
   Index Cond: (during && '["2017-01-01 00:00:00","2017-04-01 00:00:00")'::tsrange)
(2 rows)

區間&&運算符表示相交;因此,查詢必須返回與給定區間相交的所有區間。對於這樣的操作符,一致性函數確定給定的區間是否與內部行或葉子頁行中的值相交。

注意,這不是關於以某種順序獲取區間,盡管為區間定義了比較運算符。我們可以對區間使用«btree»索引,但在這種情況下,我們將不得不在不支持以下操作的情況下做:

postgres=# select amop.amopopr::regoperator, amop.amoppurpose, amop.amopstrategy
from pg_opclass opc, pg_opfamily opf, pg_am am, pg_amop amop
where opc.opcname = 'range_ops'
and opf.oid = opc.opcfamily
and am.oid = opf.opfmethod
and amop.amopfamily = opc.opcfamily
and am.amname = 'gist'
and amop.amoplefttype = opc.opcintype;
         amopopr         | amoppurpose | amopstrategy
-------------------------+-------------+-------------- 
 @>(anyrange,anyelement) | s           |           16  contains element
 <<(anyrange,anyrange)   | s           |            1  strictly left
 &<(anyrange,anyrange)   | s           |            2  not beyond right boundary
 &&(anyrange,anyrange)   | s           |            3  intersects
 &>(anyrange,anyrange)   | s           |            4  not beyond left boundary
 >>(anyrange,anyrange)   | s           |            5  strictly right
 -|-(anyrange,anyrange)  | s           |            6  adjacent
 @>(anyrange,anyrange)   | s           |            7  contains interval
 <@(anyrange,anyrange)   | s           |            8  contained in interval
 =(anyrange,anyrange)    | s           |           18  equals
(10 rows)

(除了等於,它包含在«btree»訪問方法的操作符類中)

內部原理

我們可以使用相同的«gevel»擴展查看內部。我們只需要記住在調用gist_print時改變數據類型:

postgres=# select level, a from gist_print('reservations_during_idx')
as t(level int, valid bool, a tsrange);
 level |                       a                      
-------+-----------------------------------------------
     1 | ["2016-12-30 00:00:00","2017-01-09 00:00:00")
     1 | ["2017-02-23 00:00:00","2017-02-27 00:00:00")
     1 | ["2017-04-29 00:00:00","2017-05-02 00:00:00")
(3 rows)

R-tree用於Exclusion constraint(排除約束)

GiST索引可用於支持排除約束(EXCLUDE)。

排除約束確保任意兩個表行的給定字段在某些操作符方面不會相互“對應”。如果選擇«equals»操作符,我們就精確地得到了唯一的約束:給定的任意兩行的字段互不相等。

索引支持排除約束以及唯一約束。我們可以選擇任意的操作符:

  1.它由索引方法-«can_exclude»屬性(例如«btree»、GiST或spi -GiST,但不包括GIN)支持。

  2.它是可交換的,即滿足條件:a operator b = b operator a。

 

下面列出了一些合適的策略和操作符示例(操作符可以有不同的名稱並不是對所有數據類型都可用):

· For «btree»:
  - «equals» `=`
·For GiST and SP-GiST:
  - «intersection» `&&`
  - «coincidence» `~=`
  - «adjacency» `-|-`

 

我們可以在排除約束中使用相等運算符,但這是不實用:唯一的約束會更有效。這就是為什么我們在討論b-樹時沒有涉及排除約束。

讓我們看一個使用排除約束的示例。不允許保留相交區間是合理的。

postgres=# alter table reservations add exclude using gist(during with &&);

一旦我們創建了排除約束,我們可以添加行:

postgres=# insert into reservations(during) values ('[2017-06-10, 2017-06-13)');

但是試圖插入一個相交的間隔到表中會導致錯誤:

postgres=# insert into reservations(during) values ('[2017-05-15, 2017-06-15)');
ERROR: conflicting key value violates exclusion constraint "reservations_during_excl"
DETAIL: Key (during)=(["2017-05-15 00:00:00","2017-06-15 00:00:00")) conflicts with existing key (during)=(["2017-06-10 00:00:00","2017-06-13 00:00:00")).

«btree_gist» extension

讓我們把問題復雜化。我們擴大我們的小生意,我們打算租幾間小別墅:

postgres=# alter table reservations add house_no integer default 1;

我們需要修改排除約束,以便把門牌號也考慮進去。但是,GiST不支持整數的相等運算:

postgres=# alter table reservations drop constraint reservations_during_excl;

postgres=# alter table reservations add exclude using gist(during with &&, house_no with =);
ERROR: data type integer has no default operator class for access method "gist"
HINT: You must specify an operator class for the index or define a default operator class for the data type.

在這種情況下,“btree_gist”擴展將提供幫助,它為b-樹固有的操作增加了GiST支持。最終,GiST可以支持任何操作符,那么為什么我們不應該教它支持«greater»、«less»和«equal»操作符呢?

postgres=# create extension btree_gist;

postgres=# alter table reservations add exclude using gist(during with &&, house_no with =);

現在我們仍然無法預定同一日期的第一個小屋:

postgres=# insert into reservations(during, house_no) values ('[2017-05-15, 2017-06-15)', 1);
ERROR: conflicting key value violates exclusion constraint "reservations_during_house_no_excl"

但是我們可以預定第二個:

postgres=# insert into reservations(during, house_no) values ('[2017-05-15, 2017-06-15)', 2);

但是要注意,雖然GiST可以以某種方式支持«greater»、«less»和«equal»操作符,但是B-tree在這方面做得更好。因此,只有在本質上需要GiST索引時,才值得使用這種技術,就像我們的示例中那樣。

RD-tree用於全文檢索

全文檢索

讓我們從簡單介紹PostgreSQL全文搜索開始(如果你已經知道,你可以跳過這一節)。

全文搜索的任務是從文檔集中選擇那些與搜索查詢匹配的文檔。(如果有很多匹配文檔,那么找到最佳匹配非常重要,但在這里我們將不討論它)

出於搜索的目的,文檔被轉換為專門化的類型«tsvector»,它包含lexemes及其在文檔中的位置。詞素(lexemes)是被轉換成適合搜索的形式的單詞。例如,單詞通常被轉換為小寫,變量結尾被切斷:

postgres=# select to_tsvector('There was a crooked man, and he walked a crooked mile');
               to_tsvector               
-----------------------------------------
 'crook':4,10 'man':5 'mile':11 'walk':8
(1 row)

我們還可以看到一些單詞(叫做stop words)被完全刪除了(«there»,«was»,«a»,«and»,«he»),因為它們可能出現得太頻繁了,搜索起來沒有意義。當然,所有這些轉換都可以配置,但那就是另一回事了。

搜索查詢用另一種類型表示:«tsquery»。粗略地說,一個查詢由一個或幾個由連接詞連接的詞素(lexemes)組成:«and»&,«or»|,«not»!..我們還可以使用括號來明確操作優先級。

postgres=# select to_tsquery('man & (walking | running)');
         to_tsquery         
----------------------------
 'man' & ( 'walk' | 'run' )
(1 row)

只有一個匹配操作符@@用於全文搜索。

postgres=# select to_tsvector('There was a crooked man, and he walked a crooked mile') @@ to_tsquery('man & (walking | running)');
 ?column?
----------
 t
(1 row)

postgres=# select to_tsvector('There was a crooked man, and he walked a crooked mile') @@ to_tsquery('man & (going | running)');
 ?column?
----------
 f
(1 row)

寫到這,這些信息已經足夠了。在下一篇以GIN索引為特性的文章中,我們將更深入地研究全文搜索。

RD-trees

要想快速的全文搜索,首先,表需要存儲一個類型為«tsvector»的列(以避免每次搜索時執行代價高昂的轉換),其次,必須在該列上構建索引。其中一種可能的索引是GiST。

postgres=# create table ts(doc text, doc_tsv tsvector);

postgres=# create index on ts using gist(doc_tsv);

postgres=# insert into ts(doc) values
  ('Can a sheet slitter slit sheets?'), 
  ('How many sheets could a sheet slitter slit?'),
  ('I slit a sheet, a sheet I slit.'),
  ('Upon a slitted sheet I sit.'), 
  ('Whoever slit the sheets is a good sheet slitter.'), 
  ('I am a sheet slitter.'),
  ('I slit sheets.'),
  ('I am the sleekest sheet slitter that ever slit sheets.'),
  ('She slits the sheet she sits on.');

postgres=# update ts set doc_tsv = to_tsvector(doc);

當然,將最后一步(將文檔轉換為«tsvector»)委托給觸發器是很方便的。

postgres=# select * from ts;
-[ RECORD 1 ]----------------------------------------------------
doc     | Can a sheet slitter slit sheets?
doc_tsv | 'sheet':3,6 'slit':5 'slitter':4
-[ RECORD 2 ]----------------------------------------------------
doc     | How many sheets could a sheet slitter slit?
doc_tsv | 'could':4 'mani':2 'sheet':3,6 'slit':8 'slitter':7
-[ RECORD 3 ]----------------------------------------------------
doc     | I slit a sheet, a sheet I slit.
doc_tsv | 'sheet':4,6 'slit':2,8
-[ RECORD 4 ]----------------------------------------------------
doc     | Upon a slitted sheet I sit.
doc_tsv | 'sheet':4 'sit':6 'slit':3 'upon':1
-[ RECORD 5 ]----------------------------------------------------
doc     | Whoever slit the sheets is a good sheet slitter.
doc_tsv | 'good':7 'sheet':4,8 'slit':2 'slitter':9 'whoever':1
-[ RECORD 6 ]----------------------------------------------------
doc     | I am a sheet slitter.
doc_tsv | 'sheet':4 'slitter':5
-[ RECORD 7 ]----------------------------------------------------
doc     | I slit sheets.
doc_tsv | 'sheet':3 'slit':2
-[ RECORD 8 ]----------------------------------------------------
doc     | I am the sleekest sheet slitter that ever slit sheets.
doc_tsv | 'ever':8 'sheet':5,10 'sleekest':4 'slit':9 'slitter':6
-[ RECORD 9 ]----------------------------------------------------
doc     | She slits the sheet she sits on.
doc_tsv | 'sheet':4 'sit':6 'slit':2

索引應該如何構造?不能直接使用R-tree,因為不清楚如何為文檔定義一個«邊界矩形»。但是我們可以對訪問集合(sets)方法進行一些修改,即所謂的RD-樹(RD代表«Russian Doll»)。在這種情況下,集合被理解為詞素(lexemes)的集合,但一般來說,集合可以是任意的。

rd-tree的一個思想是用一個邊界集替換一個邊界矩形,也就是一個包含子集所有元素的集合。

一個重要的問題是如何在索引行中表示集合。最簡單的方法就是枚舉集合中的所有元素。比如:

然后,例如,對於通過條件doc_tsv @@ to_tsquery('sit')訪問,我們只能下降到那些包含«sit»詞素的節點:

這種表述有明顯的問題。文檔中lexemes的數量可能非常大,因此索引行將具有較大的大小並進入TOAST,從而大大降低了索引的效率。即使每個文檔只有很少的唯一lexemes,集合的並集仍然可能非常大:根的值越高,索引行越大。

有時會使用這樣的表示,但用於其他數據類型。全文搜索使用另一種更緊湊的解決方案——所謂的簽名樹。它的想法對所有使用過Bloom filter的人來說都很熟悉。

每個詞素可以用它的簽名表示:一個特定長度的bit串,其中除一個bit之外的所有位都為零。此bit的位置由詞位的哈希函數的值決定(我們前面討論了哈希函數的內部內容)。

文檔簽名是所有文檔詞典的按位或簽名。

假設詞匯的簽名如下:

could    1000000
ever     0001000
good     0000010
mani     0000100
sheet    0000100
sleekest 0100000
sit      0010000
slit     0001000
slitter  0000001
upon     0000010
whoever  0010000

文檔的簽名如下:

Can a sheet slitter slit sheets?                       0001101
How many sheets could a sheet slitter slit?            1001101
I slit a sheet, a sheet I slit.                        0001100
Upon a slitted sheet I sit.                            0011110
Whoever slit the sheets is a good sheet slitter.       0011111
I am a sheet slitter.                                  0000101
I slit sheets.                                         0001100
I am the sleekest sheet slitter that ever slit sheets. 0101101
She slits the sheet she sits on.                       0011100

索引樹可以表示為:

這種方法的優點是顯而易見的:索引行大小相同,而且這樣的索引很緊湊。但它也有一個明顯的缺點:為了緊湊性,犧牲了准確性。

讓我們考慮相同的條件doc_tsv @@ to_tsquery('sit')。讓我們以與文檔相同的方式計算搜索查詢的簽名:在本例中為0010000。一致性函數必須返回所有其簽名至少包含查詢簽名中的一位的子節點:

與上圖相比較:我們可以看到樹變成了黃色,這意味着在搜索過程中出現了誤報和過多的節點。在這里,我們選擇了«whoever»詞匯素,不幸的是,它的簽名與«sit»詞匯素的簽名相同。重要的是在模式中不能出現錯誤的否定,也就是說,我們肯定不會錯過需要的值。

此外,不同的文檔也可能會得到相同的簽名:在我們的例子中,文檔«I slit a sheet, a sheet I slit»、«I slit sheets»(兩者的簽名都是0001100)。如果葉索引行沒有存儲«tsvector»的值,索引本身將給出false positives。當然,在這種情況下,該方法將要求索引引擎用表重新檢查結果,這樣用戶就不會看到這些誤報。但搜索效率可能會受到影響。

實際上,簽名在當前實現中是124個bytes大,而在圖中是7bit,因此上述問題比示例中出現的可能性要小得多。但實際上,還有更多的文檔被編入了索引。想辦法減少false positives的數量的實現變得有點棘手:«tsvector»索引存儲在一片葉子索引行,但前提是其規模不大(略小於1/16的一個頁面,這是大約一半的千字節8 kb頁面)。

示例

為了了解索引是如何在實際數據上工作的,讓我們以«pgsql-hacker»電子郵件的歸檔為例。示例中使用的版本包含356125條消息,其中包含發送日期、主題、作者和文本:

fts=# select * from mail_messages order by sent limit 1;
-[ RECORD 1 ]------------------------------------------------------------------------
id         | 1572389
parent_id  | 1562808
sent       | 1997-06-24 11:31:09
subject    | Re: [HACKERS] Array bug is still there....
author     | "Thomas G. Lockhart" <Thomas.Lockhart@jpl.nasa.gov>
body_plain | Andrew Martin wrote:                                                    +
           | > Just run the regression tests on 6.1 and as I suspected the array bug +
           | > is still there. The regression test passes because the expected output+
           | > has been fixed to the *wrong* output.                                 +
           |                                                                         +
           | OK, I think I understand the current array behavior, which is apparently+
           | different than the behavior for v1.0x.                                  +
             ...

添加和填充«tsvector»類型的列並構建索引。在這里,我們將在一個矢量中連接三個值(subject, author, 和 message text),以表明文檔不需要是一個字段,可以由完全不同的任意部分組成。

fts=# alter table mail_messages add column tsv tsvector;

fts=# update mail_messages
set tsv = to_tsvector(subject||' '||author||' '||body_plain);
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
...
UPDATE 356125
fts=# create index on mail_messages using gist(tsv); 

正如我們所看到的,一定數量的單詞因為太大而被刪除。但是最終創建的索引可以支持搜索查詢:

fts=# explain (analyze, costs off)
select * from mail_messages where tsv @@ to_tsquery('magic & value');
                        QUERY PLAN
----------------------------------------------------------
 Index Scan using mail_messages_tsv_idx on mail_messages
 (actual time=0.998..416.335 rows=898 loops=1)
   Index Cond: (tsv @@ to_tsquery('magic & value'::text))
   Rows Removed by Index Recheck: 7859
 Planning time: 0.203 ms
 Execution time: 416.492 ms
(5 rows)

我們可以看到,在898行匹配條件的情況下,access方法返回7859多行,這些行是通過重新檢查表過濾掉的。這說明了准確性的損失對效率的負面影響。

內部原理

為了分析索引的內容,我們將再次使用«gevel»擴展:

fts=# select level, a
from gist_print('mail_messages_tsv_idx') as t(level int, valid bool, a gtsvector)
where a is not null;
 level |               a              
-------+-------------------------------
     1 | 992 true bits, 0 false bits
     2 | 988 true bits, 4 false bits
     3 | 573 true bits, 419 false bits
     4 | 65 unique words
     4 | 107 unique words
     4 | 64 unique words
     4 | 42 unique words
...

存儲在索引行中的特殊類型«gtsvector»的值實際上是簽名加上源«tsvector»。如果向量可用,輸出將包含詞素(唯一單詞)的數量,否則將包含簽名中的true或false位數。

很明顯,在根節點中,簽名退化為«all ones»,也就是說,一個索引級別變得毫無用處(還有一個幾乎毫無用處,只有四個false bits)。

屬性

讓我們看看GiST訪問方法的屬性(查詢已經在前面提供):

postgres=# select a.amname, p.name, pg_indexam_has_property(a.oid,p.name)
postgres-# from pg_am a,
postgres-#      unnest(array['can_order','can_unique','can_multi_col','can_exclude']) p(name)
postgres-# where a.amname = 'gist'
postgres-# order by a.amname;
 amname |     name      | pg_indexam_has_property
--------+---------------+-------------------------
 gist   | can_order     | f
 gist   | can_unique    | f
 gist   | can_multi_col | t
 gist   | can_exclude   | t
(4 rows)

postgres=# 

不支持值排序和惟一約束。正如我們所看到的,索引可以建立在幾個列上,並在排除約束中使用。

以下索引層屬性可用:

     name      | pg_index_has_property
---------------+-----------------------
 clusterable   | t
 index_scan    | t
 bitmap_scan   | t
 backward_scan | f

最有趣的屬性是列層。一些屬性獨立於操作符類:

        name        | pg_index_column_has_property
--------------------+------------------------------
 asc                | f
 desc               | f
 nulls_first        | f
 nulls_last         | f
 orderable          | f
 search_array       | f
 search_nulls       | t

(不支持排序;索引不能用於搜索數組;支持null)。

但是剩下的兩個屬性«distance_orderable»和«returnable»將取決於所使用的操作符類。例如,對於points,我們將得到:

        name        | pg_index_column_has_property
--------------------+------------------------------
 distance_orderable | t
 returnable         | t

第一個屬性表示距離操作符可用於搜索最近的鄰居點。第二個說明該索引可以用於僅索引掃描。盡管葉子索引行存儲的是矩形而不是點,但是access方法可以返回所需的內容。

以下是interval的屬性:

        name        | pg_index_column_has_property
--------------------+------------------------------
 distance_orderable | f
 returnable         | t

對於interval,距離函數沒有定義,因此,搜索最近的鄰居點是不可能的。

對於全文搜索,我們得到:

        name        | pg_index_column_has_property
--------------------+------------------------------
 distance_orderable | f
 returnable         | f

由於葉子行只能包含簽名而不包含數據本身,因此已經失去了對僅索引掃描的支持。然而,這只是一個小小的損失,因為沒有人對type«tsvector»的值感興趣:這個值用於選擇行,而需要顯示的是源文本,但無論如何索引中都沒有。

其他數據類型

最后,除了已經討論過的幾何類型(通過點的例子)、間隔和全文搜索類型之外,我們還將提到目前由GiST訪問方法支持的其他一些類型。

在標准類型中,這是ip地址的類型“inet”。其余的都是通過擴展添加的:

·cube為多維數據集提供«cube»數據類型。對於這種類型,就像對於平面中的幾何類型一樣,定義了GiST操作符類:R-tree,支持搜索最近鄰。

·seg為帶有邊界的interval提供«seg»數據類型,並為該數據類型(R-tree)添加了對GiST索引的支持。

·intarray擴展了整數數組的功能,並為它們添加了GiST支持。實現了兩個操作符類:«gist_int_ops»(RD-tree,具有索引行中鍵的完整表示)和«gist_bigint_ops»(簽名RD-tree)。第一個類可用於小數組,第二個類可用於大數組。

·ltree為樹狀結構添加了«ltree»數據類型,並為該數據類型(RD-tree)提供了GiST支持。
·pg_trgm添加了一個專門的操作符類«gist_trgm_ops»,用於在全文搜索中使用三元組合。但這將與GIN索引一起進一步討論。

 

原文地址:https://habr.com/en/company/postgrespro/blog/444742/

 

  

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM