Wystąpienie

Wyszukiwarka tekstowa w Django oparta na PostgreSQL

Od __icontains do indeksu trigramowego GIN. Jak w Solvbocie zbudowaliśmy wyszukiwarkę, która na „rej krow gosp” znajduje Sąd Rejonowy dla Krakowa-Krowodrzy, XV Wydział Gospodarczy.

Slajd 1Slajd 2Slajd 3Slajd 4Slajd 5Slajd 6Slajd 7Slajd 8Slajd 9Slajd 10Slajd 11Slajd 12Slajd 13Slajd 14Slajd 15Slajd 16Slajd 17Slajd 18Slajd 19Slajd 20Slajd 21Slajd 22Slajd 23Slajd 24Slajd 25Slajd 26Slajd 27Slajd 28Slajd 29Slajd 30Slajd 31Slajd 32Slajd 33Slajd 34Slajd 35Slajd 36Slajd 37Slajd 38Slajd 39Slajd 40Slajd 41Slajd 42Slajd 43Slajd 44Slajd 45Slajd 46
Pobierz slajdy (PDF)
1 / 46

W Solvbocie (dziś Infino Legal) poza treścią dokumentów użytkownicy szukają nazw: sądów, instytucji, osób, tytułów dokumentów i zadań. Sądów jest około 300, po około 10 wydziałów, a nikt nie chce wpisywać „Sąd Rejonowy dla Łodzi-Śródmieścia w Łodzi, XV Wydział Gospodarczy”. Chcą wpisać „rej lodz gosp”. Do tego wyszukiwarka podpowiada wyniki praktycznie w każdym polu wyszukiwania i odpala się przy każdym naciśnięciu klawisza, więc musi być szybka.

Dlaczego nie wystarczy __icontains

.filter(text__icontains="ala kot") nie znajdzie „Ala ma kota”, bo szuka dokładnie tego ciągu znaków, a słowa w tekście nie stoją obok siebie. Rozbicie zapytania na słowa pomaga, ale pojawiają się kolejne problemy:

  • polskie znaki: „lukasz” nie znajdzie „Łukasza” bez unaccent,
  • fragmenty słów: „ala” znajdzie „malamuta”,
  • przecinki i myślniki, które trzeba sprytnie odciąć przy dzieleniu na słowa,
  • wydajność, bo każde z tych obejść to pełny skan tabeli.

Wyszukiwanie pełnotekstowe? Nie tym razem

Prawdziwa wyszukiwarka to stemming i lematyzacja („Łukasz ma psa” → łukasz, mieć, pies), ranking wyników (TF-IDF), poprawianie literówek i narzędzia takie jak Lucene czy Elasticsearch. Do nazw sądów i tytułów gramatyka nie jest jednak potrzebna. Wystarczy szukać po początkach słów, bez polskich znaków i bez rozróżniania wielkości liter, byle szybko.

Trigramy i GIN w PostgreSQL

Rozszerzenie pg_trgm dzieli tekst na trójki znaków i pozwala indeksować zapytania ILIKE '%...%'. Haczyk: unaccent nie jest funkcją IMMUTABLE, więc nie da się go użyć w indeksie. Trzeba go opakować:

sql
CREATE OR REPLACE FUNCTION imm_unaccent(text) RETURNS text AS $$
    SELECT public.unaccent('public.unaccent', $1)
$$ LANGUAGE sql IMMUTABLE;

CREATE INDEX idx_gin_unaccent ON t_random
    USING gin (imm_unaccent(text) gin_trgm_ops);

Benchmark na 5 mln losowych wierszy (po 5 słów, ok. 1 GB na dysku). Zapytanie docelowe to „łódź” i „gosp” jako początki słów:

Wariant Czas
Zapytanie docelowe, bez indeksu 2932 ms
Zapytanie docelowe, indeks GIN na imm_unaccent(text) 81 ms
Proste text ILIKE '%łódź%' bez imm_unaccent, ten sam indeks 641 ms

Ostatni wiersz to pułapka: indeks działa tylko wtedy, gdy zapytanie używa dokładnie tego samego wyrażenia co indeks. Inaczej Postgres po cichu wraca do pełnego skanu.

Po stronie Django zostaje migracja, która włącza rozszerzenia pg_trgm i unaccent i tworzy funkcję imm_unaccent, oraz filtr budujący to zapytanie. To rozwiązanie działało u nas na produkcji od początku istnienia systemu.

Co dalej

Setki tysięcy dokumentów po kilka stron, w dużej części skanów po OCR. Tam proste dopasowanie już nie wystarczy i wracają stemming, ranking oraz literówki w nazwach własnych.

To samo wystąpienie powtórzyłem 17 maja 2022 na Pytech Summit (online).