Защо това не работи
Типът индекс (т.е. клас оператор) gin_trgm_ops
се базира на %
оператор, който работи върху два text
аргументи:
CREATE OPERATOR trgm.%(
PROCEDURE = trgm.similarity_op,
LEFTARG = text,
RIGHTARG = text,
COMMUTATOR = %,
RESTRICT = contsel,
JOIN = contjoinsel);
Не можете да използвате gin_trgm_ops
за масиви. Индекс, дефиниран за колона от масив, никога няма да работи с any(array[...])
тъй като отделните елементи на масивите не са индексирани. Индексирането на масив би изисквало различен тип индекс, а именно индекс на gin масив.
За щастие, индексът gin_trgm_ops
е проектиран толкова умело, че работи с оператори like
и ilike
, което може да се използва като алтернативно решение (примерът е описан по-долу).
Тестова таблица
има две колони (id serial primary key, names text[])
и съдържа 100 000 латински изречения, разделени на елементи от масив.
select count(*), sum(cardinality(names))::int words from test;
count | words
--------+---------
100000 | 1799389
select * from test limit 1;
id | names
----+---------------------------------------------------------------------------------------------------------------
1 | {fugiat,odio,aut,quis,dolorem,exercitationem,fugiat,voluptates,facere,error,debitis,ut,nam,et,voluptatem,eum}
Търсене на дума фрагмент praesent
дава 7051 реда за 2400 ms:
explain analyse
select count(*)
from test
where 'praesent' % any(names);
QUERY PLAN
---------------------------------------------------------------------------------------------------------------
Aggregate (cost=5479.49..5479.50 rows=1 width=0) (actual time=2400.866..2400.866 rows=1 loops=1)
-> Seq Scan on test (cost=0.00..5477.00 rows=996 width=0) (actual time=1.464..2400.271 rows=7051 loops=1)
Filter: ('praesent'::text % ANY (names))
Rows Removed by Filter: 92949
Planning time: 1.038 ms
Execution time: 2400.916 ms
Материализиран изглед
Едно решение е да се нормализира моделът, включвайки създаването на нова таблица с едно име в един ред. Такова преструктуриране може да бъде трудно за изпълнение и понякога невъзможно поради съществуващи заявки, изгледи, функции или други зависимости. Подобен ефект може да се постигне без промяна на структурата на таблицата, като се използва материализиран изглед.
create materialized view test_names as
select id, name, name_id
from test
cross join unnest(names) with ordinality u(name, name_id)
with data;
With ordinality
не е необходимо, но може да бъде полезно, когато се агрегират имената в същия ред като в основната таблица. Запитване за test_names
дава същите резултати като основната таблица за същото време.
След създаването на индекса времето за изпълнение намалява многократно:
create index on test_names using gin (name gin_trgm_ops);
explain analyse
select count(distinct id)
from test_names
where 'praesent' % name
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=4888.89..4888.90 rows=1 width=4) (actual time=56.045..56.045 rows=1 loops=1)
-> Bitmap Heap Scan on test_names (cost=141.95..4884.39 rows=1799 width=4) (actual time=10.513..54.987 rows=7230 loops=1)
Recheck Cond: ('praesent'::text % name)
Rows Removed by Index Recheck: 7219
Heap Blocks: exact=8122
-> Bitmap Index Scan on test_names_name_idx (cost=0.00..141.50 rows=1799 width=0) (actual time=9.512..9.512 rows=14449 loops=1)
Index Cond: ('praesent'::text % name)
Planning time: 2.990 ms
Execution time: 56.521 ms
Решението има няколко недостатъка. Тъй като изгледът е материализиран, данните се съхраняват два пъти в базата данни. Трябва да запомните да опресните изгледа след промени в главната таблица. И заявките може да са по-сложни поради необходимостта от присъединяване на изгледа към главната таблица.
Използване на ilike
Можем да използваме ilike
върху масивите, представени като текст. Имаме нужда от неизменна функция, за да създадем индекса на масива като цяло:
create function text(text[])
returns text language sql immutable as
$$ select $1::text $$
create index on test using gin (text(names) gin_trgm_ops);
и използвайте функцията в заявки:
explain analyse
select count(*)
from test
where text(names) ilike '%praesent%'
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=117.06..117.07 rows=1 width=0) (actual time=60.585..60.585 rows=1 loops=1)
-> Bitmap Heap Scan on test (cost=76.08..117.03 rows=10 width=0) (actual time=2.560..60.161 rows=7051 loops=1)
Recheck Cond: (text(names) ~~* '%praesent%'::text)
Heap Blocks: exact=2899
-> Bitmap Index Scan on test_text_idx (cost=0.00..76.08 rows=10 width=0) (actual time=2.160..2.160 rows=7051 loops=1)
Index Cond: (text(names) ~~* '%praesent%'::text)
Planning time: 3.301 ms
Execution time: 60.876 ms
60 срещу 2400 ms, доста добър резултат без необходимост от създаване на допълнителни отношения.
Това решение изглежда по-просто и изисква по-малко работа, при условие обаче, че ilike
, който е по-малко прецизен инструмент от trgm %
оператор, е достатъчно.
Защо трябва да използваме ilike
вместо %
за цели масиви като текст? Сходството зависи до голяма степен от дължината на текстовете. Много е трудно да се избере подходяща граница за търсене на дума в дълги текстове с различна дължина. Напр. с limit = 0.3
имаме резултатите:
with data(txt) as (
values
('praesentium,distinctio,modi,nulla,commodi,tempore'),
('praesentium,distinctio,modi,nulla,commodi'),
('praesentium,distinctio,modi,nulla'),
('praesentium,distinctio,modi'),
('praesentium,distinctio'),
('praesentium')
)
select length(txt), similarity('praesent', txt), 'praesent' % txt "matched?"
from data;
length | similarity | matched?
--------+------------+----------
49 | 0.166667 | f <--!
41 | 0.2 | f <--!
33 | 0.228571 | f <--!
27 | 0.275862 | f <--!
22 | 0.333333 | t
11 | 0.615385 | t
(6 rows)