Ищем имена с опечатками в PostgreSQL

Все началось с того, что мне нужно было разработать поиск пациентов для одной внутренней медицинской системы. Логика работы была в том, что если мы не нашли человека в системе, то его нужно создать (а дубли пациентов плодить нельзя). В связи с этим одной из подзадач стала реализация поиска людей с учетом опечаток в их именах. Ну, а поскольку я люблю PostgreSQL (а когда в руках у тебя молоток, то все похоже на гвозди), не сложно угадать, на чем я решил реализовать поиск с опечатками…
sjrmsqmnovu_xaq--unhed2a3qs.jpeg

Обычно подобная задача решается двумя путями: с помощью нечеткого поиска или фонетическими алгоритмами. Будучи человеком ленивым и свято верящим, что все уже давно украдено придумано до нас, я обратился к документации PostgreSQL. На текущий момент в PostgreSQL есть два модуля, которые могут помочь в поиске с опечатками: pg_trgm и fuzzystrmatch.

  1. pg_trgm работает с триграммами, умеет поиск по подстроке и нечеткий поиск. В качестве индексов работает с gist и gin.
  2. fuzzystrmatch умеет считать расстояние Левенштейна между словами и три фонетических алгоритма: Soundex, Metaphone и Double Metaphone. Подводными камнями является, во-первых, то, что функция Левенштейна в данном модуле не позволяет создать индекс для произвольного поискового запроса. Во-вторых, все фонетические алгоритмы реализованы для латиницы.


В связи с этим, решение задачи я начал искать там, где светлее, а именно с модуля pg_trgm.

Триграммы


Для простоты модели рассмотрим таблицу info с идентификатором пациента, его фамилией, именем и отчеством. Так как мы хотим gist/gin индексы, то для начала нужно знать важный, но неприятный момент: один gist/gin индекс — одна колонка таблицы. Его нельзя создать, например, по конкатенации нескольких колонок. Поэтому ниже под катом будут созданы:

  • расширение pg_trgm
  • таблица пациентов, хранящая ФИО в виде jsonb (с проверками существования и заполнения ключей)
  • иммутабельная функция для построения индекса триграмм, преобразующая ФИО из jsonb в text


Скучный SQL код
create extension pg_trgm;

create table info (
  patid integer, fullname jsonb, 
  constraint info_pk primary key (patid),
  constraint fullname_exists check (
    fullname ? 'lname'::text and 
    fullname ? 'fname'::text and 
    fullname ? 'sname'::text
  ),
  constraint fullname_notnull check (
    (fullname ->> 'lname'::text) is not null and 
    (fullname ->> 'fname'::text) is not null
  )
);

create function fullname(in_fullname jsonb)
returns text
language plpgsql
immutable
as $$
begin
  return regexp_replace(
    lower(
      trim(
        coalesce(in_fullname->>'lname', '') || ' ' ||
        coalesce(in_fullname->>'fname', '') || ' ' ||
        coalesce(in_fullname->>'sname', '')
      )
    ),
    'ё',
    'е',
    'g'
   );
exception
  when others then raise exception '%', sqlerrm;
end;
$$;


Вставляем в таблицу около 300 тыс. записей ФИО и приступаем.

Триграммы и GIST


Итак, первым проверяем gist индекс для нечеткого поиска по триграммам.

Еще более скучный SQL код
create index info_gist_idx on info 
using gist (fullname(fullname) gist_trgm_ops);

CREATE INDEX
Time: 15054,102 ms 

explain (analyze, buffers) 
select patid, fullname(fullname) <-> 'смерно дени анато' as dist
from info order by dist limit 10; 
 
  Limit  (cost=0.28..4.35 rows=10 width=8) (actual time=157.378..157.688 rows=10 loops=1)
  Buffers: shared hit=5743
  ->  Index Scan using info_gist_idx on info  (cost=0.28..126822.96 rows=312084 width=8) (actual time=157.371..157.655 rows=10 loops=1)
        Order By: (fullname(fullname) <-> 'смерно дени анато'::text)
        Buffers: shared hit=5743
Planning time: 0.225 ms
Execution time: 158.223 ms
(7 rows)


Индекс создавался 15 сек, размер 45 Мб, поиск по неполному имени с опечатками — 158 мс.

Триграммы и GIN


Далее рассмотрим gin индекс для нечеткого поиска по триграммам.

Думаете, передыдущие спойлеры с SQL были скучными?
create index info_trgm_idx on info 
using gin(fullname(fullname) gin_trgm_ops);

CREATE INDEX
Time: 10163,401 ms

explain (analyze, buffers) 
select patid, similarity(fullname(fullname), 'смерно дени анато' ) as sml
from info where true
and fullname(fullname) % 'смерно дени анато'
order by sml desc limit 10;

 Limit  (cost=1180.22..1180.25 rows=10 width=8) (actual time=133.086..133.117 rows=8 loops=1)
  Buffers: shared hit=5741
  ->  Sort  (cost=1180.22..1181.00 rows=312 width=8) (actual time=133.080..133.090 rows=8 loops=1)
        Sort Key: (similarity(fullname(fullname), 'смерно дени анато'::text)) DESC
        Sort Method: quicksort  Memory: 25kB
        Buffers: shared hit=5741
        ->  Bitmap Heap Scan on info  (cost=26.70..1173.48 rows=312 width=8) (actual time=132.828..133.048 rows=8 loops=1)
              Recheck Cond: (fullname(fullname) % 'смерно дени анато'::text)
              Heap Blocks: exact=7
              Buffers: shared hit=5741
              ->  Bitmap Index Scan on info_gist_idx  (cost=0.00..26.62 rows=312 width=0) (actual time=132.699..132.699 rows=8 loops=1)
                    Index Cond: (fullname(fullname) % 'смерно дени анато'::text)
                    Buffers: shared hit=5734
Planning time: 0.573 ms
Execution time: 133.225 ms
(15 rows)


Создание индекса 10 сек, размер 18 Мб, поиск по неполному имени с опечатками — 133 мс.

Если честно, то результаты так себе — ведь у меня на ноутбуке стоит китайский ssd диск из славного города Шэньчжэня. Поэтому, попробуем ускорить процесс, совместив нечеткий и полнотекстовый поиск.

Триграммы и полнотекстовый поиск


Идея крайне простая — собрать из всех написаний фамилий, имен и отчеств отдельную таблицу-словарь. Вначале входную строку поиска мы разрежем на лексемы, каждую из лексем поищем в таблице-словаре через нечеткий поиск, выберем оттуда все возможные варианты написаний каждой лексемы, положим их в tsquery и сделаем полнотекстовый поиск по tsvector индексу таблицы info. Выгода от этого плана в том, что скорость нечеткого поиска по триграммам зависит от ширины строки и их количества в колонке с текстом. Очевидно, что словарь ФИО будет компактнее, чем оригинальная колонка в таблице info, а значит — поиск будет быстрее. Недостаток у метода лишь один — при добавлении каждого нового пациента придется обновлять словарь, если лексемы из ФИО в нем не встречались. Для проверки нам потребуется собрать из исходников rum индекс для построения tsvector индекса по ФИО в таблице info. Rum является модифицированной версией gin индекса, хранящем в листьях дополнительную информацию. В нашем случае воспользуемся классом операторов rum_tsvector_ops, который хранит позиционную информацию о лексеме в индексе. Поэтому, в отличие от gin, мы сможем использовать index-only запрос tsquery вида

(смернов|смирнов|чернов)<->(денис)<->(анатольевич) 

без обращения к таблице за дополнительной информацией о порядке лексемы в кортеже. Более того, рекомендацией для gin является физическое существование колонки tsvector, так как все найденные указатели на кортежи придется перепроверять в таблице. А если физически колонки tsvector нет (вы его построили функцией для индекса), то для каждого кортежа придется производить дополнительное вычисление tsvector. В общем, rum в данной истории будет куда производительнее.

Эверест в мире скучного SQL
create extension rum;

create index info_rum_idx on info 
using rum (
  to_tsvector('simple'::regconfig, fullname(fullname)) rum_tsvector_ops
);

CREATE INDEX
Time: 7.545s (7 seconds)

create table patname (
  lex text,
  constraint patname_uniq_idx unique (lex)
);

create index patname_fuzzy_idx on patname using gin (lex gin_trgm_ops);
CREATE INDEX
Time: 0.596s

insert into patname (lex)
select word from ts_stat($$
  select to_tsvector('simple', fullname(fullname)) from info
$$);

explain (analyze, buffers)
with fio as (
  select lexeme as lex, positions[1] as pos 
  from unnest(to_tsvector('simple','смернов дин онатол'))
),
query as(
  select to_tsquery('simple', string_agg(q.tq,'&')) as q from (
    select f.pos,  '('||string_agg(p.lex,'|')||')' as tq from fio as f
    join patname as p on p.lex % f.lex
    group by f.pos
  ) as q
)
select to_tsvector('simple'::regconfig, fullname(fullname)) 
  <=> (select q from query) as rank, * from info
where to_tsvector('simple'::regconfig, fullname(fullname)) 
  @@ (select q from query)
order by to_tsvector('simple'::regconfig, fullname(fullname)) 
  <=> (select q from query);

 Sort  (cost=6453.71..6457.61 rows=1560 width=100) (actual time=68.201..68.202 rows=1 loops=1)
  Sort Key: ((to_tsvector('simple'::regconfig, fullname(info.fullname)) <=> $3))
  Sort Method: quicksort  Memory: 25kB
  Buffers: shared hit=536
  CTE fio
    ->  Function Scan on unnest  (cost=0.00..0.10 rows=10 width=34) (actual time=0.023..0.034 rows=3 loops=1)
  CTE query
    ->  Aggregate  (cost=1484.60..1484.86 rows=1 width=32) (actual time=11.829..11.830 rows=1 loops=1)
          Buffers: shared hit=325
          ->  HashAggregate  (cost=1484.30..1484.48 rows=10 width=34) (actual time=11.640..11.644 rows=2 loops=1)
                Group Key: f.pos
                Buffers: shared hit=325
                ->  Nested Loop  (cost=16.58..1480.53 rows=755 width=19) (actual time=2.940..11.442 rows=62 loops=1)
                      Buffers: shared hit=325
                      ->  CTE Scan on fio f  (cost=0.00..0.20 rows=10 width=34) (actual time=0.028..0.053 rows=3 loops=1)
                      ->  Bitmap Heap Scan on patname p  (cost=16.58..147.28 rows=75 width=17) (actual time=1.905..3.717 rows=21 loops=3)
                            Recheck Cond: (lex % f.lex)
                            Rows Removed by Index Recheck: 321
                            Heap Blocks: exact=275
                            Buffers: shared hit=325
                            ->  Bitmap Index Scan on patname_fuzzy_idx  (cost=0.00..16.57 rows=75 width=0) (actual time=1.277..1.277 rows=342 loops=3)
                                  Index Cond: (lex % f.lex)
                                  Buffers: shared hit=50
  InitPlan 3 (returns $3)
    ->  CTE Scan on query  (cost=0.00..0.02 rows=1 width=32) (actual time=0.004..0.006 rows=1 loops=1)
  InitPlan 4 (returns $4)
    ->  CTE Scan on query query_1  (cost=0.00..0.02 rows=1 width=32) (actual time=11.834..11.839 rows=1 loops=1)
          Buffers: shared hit=325
  ->  Bitmap Heap Scan on info  (cost=31.99..4885.97 rows=1560 width=100) (actual time=68.184..68.187 rows=1 loops=1)
        Recheck Cond: (to_tsvector('simple'::regconfig, fullname(fullname)) @@ $4)
        Heap Blocks: exact=1
        Buffers: shared hit=536
        ->  Bitmap Index Scan on info_rum_idx  (cost=0.00..31.60 rows=1560 width=0) (actual time=67.847..67.847 rows=1 loops=1)
              Index Cond: (to_tsvector('simple'::regconfig, fullname(fullname)) @@ $4)
              Buffers: shared hit=517
Planning time: 5.012 ms
Execution time: 68.583 ms
(37 rows)


Итого, индекс полнотекстового поиска создавался 7 секунд (размер 13 Мб), индекс словаря лексем создался за 0,6 секунд (размер 5,8 Мб), поиск — 68 мс. Из недостатков — селективность хуже, чем у предыдущих вариантов.

Фонетические алгоритмы


Перепробовав варианты нечеткого поиска из модуля pg_trmg я решил посмотреть еще раз на fuzzystrmatch. Как проиндексировать функцию Левенштейна я не придумал, а вот фонетические алгоритмы меня крайне заинтересовали. Как говорилось выше, из коробки в PostgreSQL фонетические функции реализованы только для латиницы и заточены под английские имена. Поиск в интернете их русских реализаций привел меня на замечательную статью на Хабре, где описывался рабочий алгоритм Metaphone для русских имен (состоящих из русских букв). Печалило лишь одно — хоть он и был простым, но реализовывать эту логику на plpgsql было как-то совсем грустно, то ли дело на каком-нибудь Python… И тут я вспомнил, что plpython3u — является небезопасным (функции на нем могут получить доступ к файловой системе с правами процесса postgres), но отлично работающим языком в PostgreSQL. И грех бы им не воспользоваться. Поэтому, я написал две иммутабельные функции:

  • phoneme на plpython3u, которая превращает лексему в фонему («смирнов» в «смирнаф») по алгоритму из статьи на Хабре
  • metaphone на plpgsql, которая превращает уже не одну лексему в фонему, а целый текст в набор фонем. По факту, это просто обвязка над функцией phoneme.

Metaphone и btree


Далее попробуем создать обычный btree индекс по фукнции metaphone и оценим скорость.

Проходите мимо, здесь нет ничего интересного
create or replace function phoneme (in_lexeme text)
returns text
language plpython3u
immutable
as $$
import re


class Lexeme:
    def __init__(self, body):
        """

        :type body: str
        """
        self.body = body.lower().strip()
        # Правила замены гласных
        self._vowels = {"(?:йо|ио|йе|ие)": "и", "[оыя]": "а", "[еёэ]": "и", "[ю]": "у"}
        # Правила оглушения согласных
        self._consonants = {"б": "п", "з": "с", "д": "т", "в": "ф"}
        # Согласная должна быть оглушена, если за ней следует один из символов списка _deafening_chars
        self._deafening_chars = ["б", "в", "г", "д", "ж", "з", "й"]
        # Удаляемые символы
        self._removable_chars = {"[ъь]": ""}

    def _remove_double_chars(self):
        return Lexeme("".join((char for num, char in enumerate(self.body) if char != self.body[num - 1])))

    def _deafen_consonants(self):
        modified_body = ""
        for num, char in enumerate(self.body):
            if char in self._consonants and (
                num < len(self.body) - 1 and self.body[num + 1] in self._deafening_chars
                or num == len(self.body) - 1
            ):
                modified_body += self._consonants[char]
            else:
                modified_body += char
        return Lexeme(modified_body)

    @staticmethod
    def _regexp_replace(text, char_dict):
        modified_body = text
        for item in char_dict:
            modified_body = re.sub(item, char_dict[item], modified_body)
        return Lexeme(modified_body)

    def _replace_vowels(self):
        return self._regexp_replace(self.body, self._vowels)

    def _remove_chars(self):
        return self._regexp_replace(self.body, self._removable_chars)

    def metaphone(self):
        return self._remove_chars()._replace_vowels()._deafen_consonants()._remove_double_chars().body

return Lexeme(in_lexeme).metaphone()
$$;

create or replace function metaphone (in_phonemes text)
returns text
language plpgsql
immutable
as $$
begin
  return (
    select string_agg(q.lex,' ') from (
      select phoneme(lexeme) as lex from unnest(to_tsvector('simple', in_phonemes))
      order by positions
    ) as q
  );
exception when others then
  raise '%', SQLERRM using errcode = SQLSTATE;
end;
$$;

create index info_metaphone_idx on info (
  metaphone(fullname(fullname)) text_pattern_ops
);

CREATE INDEX
Time: 114.757s (a minute)

explain (analyze, buffers)
select patid, fullname from info 
where metaphone(fullname(fullname)) like 
regexp_replace(metaphone('смернов дин онатол'),'\s','%','g')||'%' 
limit 10;

 Limit  (cost=76.03..1388.96 rows=10 width=96) (actual time=22.452..129.944 rows=3 loops=1)
  Buffers: shared hit=239
  ->  Bitmap Heap Scan on info  (cost=76.03..4146.10 rows=31 width=96) 
         (actual time=22.447..129.927 rows=3 loops=1) 
        Filter: (metaphone(fullname(fullname)) ~~ 'смирнаф%дин%анатал%'::text)
        Rows Removed by Filter: 244
        Heap Blocks: exact=234
        Buffers: shared hit=239
        ->  Bitmap Index Scan on info_metaphone_idx  (cost=0.00..76.02 rows=1560 width=0) 
               (actual time=0.061..0.061 rows=247 loops=1) 
              Index Cond: ((metaphone(fullname(fullname)) ~>=~ 'смирнаф'::text) AND 
                (metaphone(fullname(fullname)) ~<~ 'смирнах'::text))
              Buffers: shared hit=5
Planning time: 1.012 ms
Execution time: 129.977 ms
(12 rows)

Time: 131,802 ms


Индекс создавался 114 сек, размер 22 Мб (кажется, я написал не самую оптимальную функцию на питоне по производительности), запрос 131 мс. Индекс срабатывает лишь по малой части подстроки, а дальше работает фильтр из-за »%». Плохо.

Metaphone и триграммы


Попробуем на базе созданной на plpython3u функции metaphone построить индекс триграмм. Но будем использовать его теперь не для нечеткого поиска, а для поиска подстроки.

Как уменьшить время запроса народными средствами с помощью…
create index info_metaphone_trgm_idx on info 
using gin (metaphone(fullname(fullname)) gin_trgm_ops);

CREATE INDEX
Time: 124.713s (2 minutes)

explain (analyze, buffers)
select patid, fullname from info 
where metaphone(fullname(fullname)) like 
'%'||regexp_replace(metaphone('смернов дин онатол'),'\s','%','g')||'%' limit 10;

 Limit  (cost=92.24..134.98 rows=10 width=96) (actual time=9.562..10.638 rows=3 loops=1)
  Buffers: shared hit=103
  ->  Bitmap Heap Scan on info  (cost=92.24..224.74 rows=31 width=96) (actual time=9.554..10.617 rows=3 loops=1)
        Recheck Cond: (metaphone(fullname(fullname)) ~~ '%смирнаф%дин%анатал%'::text)
        Heap Blocks: exact=2
        Buffers: shared hit=103
        ->  Bitmap Index Scan on info_metaphone_trgm_idx  (cost=0.00..92.23 rows=31 width=0) (actual time=8.354..8.354 rows=3 loops=1)
              Index Cond: (metaphone(fullname(fullname)) ~~ '%смирнаф%дин%анатал%'::text)
              Buffers: shared hit=101
Planning time: 2.029 ms
Execution time: 10.726 ms
(11 rows)

Time: 14,480 ms


Время создания индекса — 124 сек, размер 15 Мб (привет мои кривые руки и plpython3u), поиск — 14 мс.

Итоги


Подведем сводную таблицу различных вариантов поиска с опечатками

Тип поиска
Время создания индекса
Размер индекса
Скорость поиска с опечатками
Замечания
Триграммы gist
15 сек
45 Мб
158 мс
Триграммы gin
10 сек
18 Мб
133 мс
Триграммы и полнотекстовый поиск
7,6 сек
18,8 Мб
68 мс
Хуже селективность, нужно поддерживать словарь лексем
Metaphone btree
114 сек
22 Мб
131 мс
Небезопасный язык plpython3u
Metaphone триграммы
124 сек
15 Мб
14 мс
Небезопасный язык plpython3u


Так как в моем случае система будет ориентирована на чтение, а не на запись (максимум добавление пары пациентов в минуту), то мой вариант — Metaphone с триграммами. Если у кого-то есть идеи, как можно оптимизировать функцию на Python по скорости, то отпишитесь в комментариях, я добавлю данные в тесты.

© Habrahabr.ru