SQL HowTo: Prefixo FTS Search com Data Relevância

Em nosso VLSI , como em qualquer outro sistema de trabalho com documentos, à medida que os dados se acumulam, os usuários desejam " buscá -los".



Mas, como as pessoas não são computadores, elas procuram algo como " algo assim era de Ivanov ou de Ivanovsky ... não, isso não, antes, antes mesmo ... é isso! "



Ou seja, uma solução tecnicamente correta é uma pesquisa de texto completo de prefixo com resultados classificados por data .



Mas isso ameaça o desenvolvedor com problemas terríveis - afinal, para FTS-search em PostgreSQL , são usados ​​tipos "espaciais" de índices GIN e GiST , que não fornecem "deslizamento" de dados adicionais, exceto para um vetor de texto.



Resta apenas, infelizmente, ler todos os registros por correspondência de prefixo (há milhares deles!) E classificar ou, inversamente, ir pelo índice de data e filtrartodos os registros encontrados para o prefixo correspondem até encontrarmos os adequados (em quanto tempo haverá "rabiscos"? ..).



Ambos não são muito agradáveis ​​para o desempenho de consultas. Ou ainda consegue pensar em algo para uma pesquisa rápida?



Primeiro, vamos gerar nossos "textos atualizados":



CREATE TABLE corpus AS
SELECT
  id
, dt
, str
FROM
  (
    SELECT
      id::integer
    , now()::date - (random() * 1e3)::integer dt --  -   3 
    , (random() * 1e2 + 1)::integer len --  ""  100
    FROM
      generate_series(1, 1e6) id -- 1M 
  ) X
, LATERAL(
    SELECT
      string_agg(
        CASE
          WHEN random() < 1e-1 THEN ' ' -- 10%  
          ELSE chr((random() * 25 + ascii('a'))::integer)
        END
      , '') str
    FROM
      generate_series(1, len)
  ) Y;

      
      





Abordagem ingênua nº 1: gist + btree



Vamos tentar lançar o índice tanto para FTS quanto para classificação por data - e se eles ajudarem:



CREATE INDEX ON corpus(dt);
CREATE INDEX ON corpus USING gist(to_tsvector('simple', str));

      
      





Pesquisaremos todos os documentos que contenham palavras que comecem com 'abc...'



. E, primeiro, vamos verificar se não há muitos desses documentos, e o índice FTS é usado normalmente:



SELECT
  *
FROM
  corpus
WHERE
  to_tsvector('simple', str) @@ to_tsquery('simple', 'abc:*');

      
      









Bem ... é, claro, usado, mas leva mais de 8 segundos , o que claramente não é o que gostaríamos de gastar procurando por 126 registros.



Talvez se você adicionar classificação por data e pesquisar apenas os últimos 10 registros - será melhor?



SELECT
  *
FROM
  corpus
WHERE
  to_tsvector('simple', str) @@ to_tsquery('simple', 'abc:*')
ORDER BY
  dt DESC
LIMIT 10;
      
      









Mas não, apenas a classificação foi adicionada ao topo.



Abordagem ingênua nº 2: btree_gist



Mas há uma extensão excelente btree_gist



que permite "deslizar" um valor escalar em um índice GiST, o que deve nos permitir usar imediatamente a classificação de índice usando o operador de distância<->



, que pode ser usado para pesquisas kNN :



CREATE EXTENSION btree_gist;
CREATE INDEX ON corpus USING gist(to_tsvector('simple', str), dt);
      
      





SELECT
  *
FROM
  corpus
WHERE
  to_tsvector('simple', str) @@ to_tsquery('simple', 'abc:*')
ORDER BY
  dt <-> '2100-01-01'::date DESC --   ""     
LIMIT 10;

      
      









Infelizmente, isso não ajuda em nada.



Geometria para o resgate!



Mas é muito cedo para se desesperar! Vejamos a lista de classes de operadores integradas GiST - o operador de distância está <->



disponível apenas para "geométrico" circle_ops, point_ops, poly_ops



e, desde o PostgreSQL 13 - para box_ops



.



Então, vamos tentar traduzir nosso problema "para o plano" - atribuímos as coordenadas de alguns pontos aos nossos pares(, )



usados ​​para pesquisa de modo que as palavras "prefixadas" e as datas não distantes sejam o mais próximo possível:







Nós quebramos o texto em palavras



É claro que nossa pesquisa não será totalmente em texto completo, no sentido de que você não pode definir uma condição para várias palavras ao mesmo tempo. Mas com certeza será um prefixo!



Vamos formar uma tabela de dicionário auxiliar:



CREATE TABLE corpus_kw AS
SELECT
  id
, dt
, kw
FROM
  corpus
, LATERAL (
    SELECT
      kw
    FROM
      regexp_split_to_table(lower(str), E'[^\\-a-z-0-9]+', 'i') kw
    WHERE
      length(kw) > 1
  ) T;

      
      





Em nosso exemplo, havia 4,8 milhões de “palavras” por 1 milhão de “textos”.



Escrevendo as palavras



Para traduzir uma palavra em sua "coordenada", vamos imaginar que este seja um número escrito em notação de base2^16



(afinal, também queremos oferecer suporte a caracteres UNICODE). Vamos apenas anotá-lo a partir da 47ª posição fixa:







Seria possível começar da 63ª posição, isso nos dará valores um pouco menores do que os valores 1E+308



limite para double precision



, mas então ocorrerá um estouro ao construir o índice.



Acontece que no eixo das coordenadas todas as palavras serão ordenadas:







ALTER TABLE corpus_kw ADD COLUMN p point;

UPDATE
  corpus_kw
SET
  p = point(
    (
      SELECT
        sum((2 ^ 16) ^ (48 - i) * ascii(substr(kw, i, 1)))
      FROM
        generate_series(1, length(kw)) i
    )
  , extract('epoch' from dt)
  );

CREATE INDEX ON corpus_kw USING gist(p);

      
      





Nós formamos uma consulta de pesquisa



WITH src AS (
  SELECT
    point(
      ( --     
        SELECT
          sum((2 ^ 16) ^ (48 - i) * ascii(substr(kw, i, 1)))
        FROM
          generate_series(1, length(kw)) i
      )
    , extract('epoch' from dt)
    ) ps
  FROM
    (VALUES('abc', '2100-01-01'::date)) T(kw, dt) --  
)
SELECT
  *
, src.ps <-> kw.p d
FROM
  corpus_kw kw
, src
ORDER BY
  d
LIMIT 10;
      
      









Agora temos os id



documentos que procurávamos, classificados na ordem certa - e demorou menos de 2 ms, 4.000 vezes mais rápido !



Uma pequena mosca na pomada



O operador <->



não sabe nada sobre a nossa ordenação ao longo de dois eixos, portanto, nossos dados necessários estão localizados apenas em um dos trimestres corretos, dependendo da classificação exigida por data:







Bem, ainda queríamos selecionar os próprios textos-documentos, e não suas palavras-chave, então vamos precisar de um índice há muito esquecido:



CREATE UNIQUE INDEX ON corpus(id);
      
      





Vamos finalizar a solicitação:



WITH src AS (
  SELECT
    point(
      (
        SELECT
          sum((2 ^ 16) ^ (48 - i) * ascii(substr(kw, i, 1)))
        FROM
          generate_series(1, length(kw)) i
      )
    , extract('epoch' from dt)
    ) ps
  FROM
    (VALUES('abc', '2100-01-01'::date)) T(kw, dt) --  
)
, dc AS (
  SELECT
    (
      SELECT
        dc
      FROM
        corpus dc
      WHERE
        id = kw.id
    )
  FROM
    corpus_kw kw
  , src
  WHERE
    p[0] >= ps[0] AND -- kw >= ...
    p[1] <= ps[1]     -- dt DESC
  ORDER BY
    src.ps <-> kw.p
  LIMIT 10
)
SELECT
  (dc).*
FROM
  dc;
      
      









Eles nos acrescentaram um pouco InitPlan



com o cálculo da constante x / y, mas ainda assim mantivemos dentro dos mesmos 2 ms !



Mosca na pomada # 2



Nada é gratuito:



SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) FROM pg_class WHERE relname LIKE 'corpus%';
      
      





corpus          | 242 MB --   
corpus_id_idx   |  21 MB --   PK
corpus_kw       | 705 MB --    
corpus_kw_p_idx | 403 MB -- GiST-

      
      





242 MB de "textos" tornaram-se 1,1 GB de "índice de pesquisa".



Mas, afinal, corpus_kw



a data e a própria palavra estão, que não usamos na própria pesquisa - então, vamos excluí-las:



ALTER TABLE corpus_kw
  DROP COLUMN kw
, DROP COLUMN dt;

VACUUM FULL corpus_kw;
      
      





corpus_kw       | 641 MB --  id  point

      
      





Um pouco - mas agradável. Não ajudou muito, mas ainda assim 10% do volume foi recuperado.



All Articles