В этой публикации описывается реализация упрощенной но вполне прикладной задачи полнотекстового поиска по базе исходных текстов. Под прикладной задачей здесь понимается конечно не поиск максимально похожей последовательности символов, составляющей слова и фрагменты текста. Такой поиск малоинформативен и вполне может быть реализован без инструментов fts. Мы будем искать в базе текстов некоторые сущности и извлекать из текстов полезную информацию.
Сформулируем следующее допущение
Каждый текст в базе данных может содержать объект, выраженный одущевленным существительным и событие, выраженное глаголом.
Это очень упрощенное допущение тем не менее позволяет нам а) сформулировать практическую задачу поиска полезной информации в большом массиве текстов, и б) разобраться в настройках полнотекстрового поиска в postgresql
Задача (см. допущение выше)
По пользовательскому запросу в базе исходнфх текстов найти наиболее релевантные записи, содержащие искомый объект(-ы) и (или) связанное с ним событие
Исходные данные
В качестве исходных данных возьмем новостную тематику текстов. Тестовую выборку по данной тематике на различных языках можно взять здесь. В нашем случае тестовая выборка состоит из 1 000 000 записей. Каждая запись представляет одно предложение (здесь это не принципиально, текст (запись) может состоять из нескольких предложений).
Данную выборку будем считать репрезентативной, т.е. объективно описывающей характеристики и особенности текстов похожей тематики (новостей на русском языке).
Решение
- Импорт данных в БД
Для импорта скачанных из указанного источника данных можно использовать такой скрипт
# -*- coding: utf-8 -*-
import codecs
import psycopg2
if __name__=='__main__':
dbname="{{dbname}}"
conn = psycopg2.connect("dbname='%s' user='{{username}}' password='{{userpassword}}'" %(dbname,))
conn.autocommit=True
cur = conn.cursor()
with codecs.open('{{filename}}', 'r', 'utf-8') as fr:
for line in fr:
q="INSERT INTO {{schema}}.news (sent) VALUES (%s);"
cur.execute(q, (line,))
conn.close()
1. Создание словарей
Поскольку в нашей задаче важно выделять объекты (одущевленные существительные) и события (глаголы), сгенерируем соответсвующие словари. Для генерации ispell-словарей используем материалы этой статьи. В итоге получим 5 файлов
- словарь людей и фамилий
- словарь одушивленных предметов
- словарь глаголов (совершенных и несовершенных)
Cоздаем в бд postgresql необходимые словари.
-- словарь имем
CREATE TEXT SEARCH DICTIONARY {{schema_name}}.noun_name (
TEMPLATE = ispell,
dictfile = 'noun_name',
afffile = 'ru',
stopwords='russian'
);
-- словарь фамилий
CREATE TEXT SEARCH DICTIONARY {{schema_name}}.noun_surn (
TEMPLATE = ispell,
dictfile = 'noun_surn',
afffile = 'ru',
stopwords='russian'
);
-- словарь одушевленных существительных
CREATE TEXT SEARCH DICTIONARY {{schema_name}}.noun_anim (
TEMPLATE = ispell,
dictfile = 'noun_anim',
afffile = 'ru',
stopwords='russian'
);
-- словарь глаголов (совершенных)
CREATE TEXT SEARCH DICTIONARY {{schema_name}}.verb_perf (
TEMPLATE = ispell,
dictfile = 'verb_perf',
afffile = 'ru',
stopwords='russian'
);
-- словарь глаголов (несовершенных)
CREATE TEXT SEARCH DICTIONARY {{schema_name}}.verb_impf (
TEMPLATE = ispell,
dictfile = 'verb_impf',
afffile = 'ru',
stopwords='russian'
);
Этап 3. Создаем конфигурацию.
CREATE TEXT SEARCH CONFIGURATION {{schema_name}}.noun_verb (
PARSER = default
);
ALTER TEXT SEARCH CONFIGURATION {{schema_name}}.noun_verb
ADD MAPPING FOR word WITH {{schema_name}}.noun_name, {{schema_name}}.noun_surn, {{schema_name}}.verb_perf, {{schema_name}}.verb_impf, {{schema_name}}.noun_anim
Обратите внимание - словарь одушивленных существительных мы поместили в самом конце цепочки. Это сделано для того, чтобы наша конфигурация распознавала одушивленные объекты только в том случае если они не распознаны словарями имен и фамилий. Поскольку по смыслу формулировки задачи и тематике текстов запросы типа "Обама встретился" будут преобладать над "ежик прибежал", большинство искомых объектов будут распознаваться словарями имен и фамилий, а такая конфигурация сократит время поиска.
Тестируем
select * from to_tsvector('{{schema_name}}.noun_verb', 'Встретились с ОБАМОЙ вчера ранним вечером в 19')
Тест показывает преимущества такой конфигурации - в tsvector попадают только существительные (одушивленные!!!) и глаголы - остальные слова исходного текста игнорируются. Это позволяет сократить длину вектора и тем самым повысить скорость поиска.
Есть и еще одно преимущество - исключение из tsvector незначимых для поиска (в данной постановке задачи) слов оказывает положительный эффект при оценке релевантности поиска. Более подробно данный вопрос раскрыт далее.
Теперь можем выполнять поиск
select * from {{schema_name}}.news where to_tsvector('{{schema_name}}.noun_infn', sent) @@ plainto_tsquery('{{schema_name}}.noun_infn', 'МЕДВЕДЕВ посетил')
Этап 4. Настройка производительности.
Конечно такой поиск малоэффективный. Потому что медленный. Чтобы сократить время поиска прежде всего создадим индекс.
CREATE INDEX gin_noun_verb ON {{schema_name}}.news USING GIN (to_tsvector('{{schema_name}}.noun_verb', sent));
Совсем другое дело. Скорость поиска по запросу выше увеличилась в десятки раз.
Этап 5. Настраиваем релевантность результатов запроса
Релевантность - это мера близости по смыслу результатов поиска поисковому запросу. Формулировка такая нечеткая, потому что конкретное значение этой меры опрелеляется разработчиком для каждого прикладного проекта отдельно. При постановке нашей задачи мы только определили, что результаты поиска должны быть отсортированы по релевантности, но саму меру релевантности не определили. Пора это исправить.
Применительно к нашей задаче будем полагать, что наиболее релевантными являются те тексты, в которых упоминается объект(-ы) и действия (события), связанные именно с этим объектом.
Например, во фразе "Обама встретился с Медведевым, который вчера общался с Меркель." имеем следующие связки объект-событие (Обама-встретился), (Меркель-общалась), (Медведев-встретился), (Медведев-общался).
Если в поисковом запросе будет указано "Обама встречался", то фраза выше должна иметь высокую релевантность, потому что соответсвует смыслу запроса. Но если поисковый запрос будет составлен как "Обама общался" то он будет отражаться в результатах поиска (во фразе присутсвует объект "Обама" и событие "общался"), но релевантность в данном случае должна быть существенно ниже, поскольку событие "общался" не относятся к объекту "Обама".
Чтобы сопоставить объекты и относящиеся к ним события, по-хорошему, необходимо предварительно выполнить синтаксический разбор исходной фразы. Но данная задача не тривиальна и ее описание выходит далеко за рамки статьи. Поэтому, далее мы примем одно интуитивное допушение, которое для некоторых типов текстов может быть весьма эффективным (его обоснование или опровержение оставим для других статей).
Связанные по смыслу сущности (объекты и события) в тексте стремятся располагаться как можно ближе друг к другу.
Приняв такое допущение мы можем весьма эффективно использовать функцию ts_rank_cd() для оценки релевантности каждого результата запроса. И здесь наша конфигудация noun_verb играет не последнюю роль. Рассмотрим следующий пример
select ts_rank_cd(to_tsvector('corpora.noun_verb', 'Обама встретился с Медведевым'), plainto_tsquery('corpora.noun_verb', 'обама встретился'))
Оценка релевантности = 0.1. Обратим внимание, что объект и событие располагаются рядом. Поэтому оценка имеет максимальное значение (в шкале по-умолчанию. О том, как рассчитывается оценка и диапазоне изменения ее значений можно прочитать в статье). Теперь немного изменим исходный текст
select ts_rank_cd(to_tsvector('corpora.noun_verb', 'Обама вчера встретился с Медведевым'), plainto_tsquery('corpora.noun_verb', 'обама встретился'))
Оценка релевантности не изменится = 0.1. Это вполне соответсвует условиям нашей задачи. Нас интересуют только объекты и связанные с ними события, другая уточняющая информация (время наступления события) для нас несущественна. Поэтому с точки зрения созданной нами конфигурации исходные фразыв первом и втором запросах являются равнозначными, а результат запроса по ним имеет одинаковую релевантность.
И, наконец, выполним такой запрос
select ts_rank_cd(to_tsvector('corpora.noun_verb', 'Обама и Меркель встретились с Медведевым'), plainto_tsquery('corpora.noun_verb', 'обама встретился'))
Оценка релевантности уменьшилась = 0.05. Потому что слово "Меркель" было включено в tsvector и разделило искомые сущности "Обама" и "встретился". Такое изменением оценки релевантности вполне соответствует смыслу - сначала нас интересуют события "Обама встретился с ..." и только потом "Обама и ... встретились с ..."
Функция ts_rank_cd() имеет еше дополнительные инструменты для более тонкой настройки расчета релевантности. Рассмотрим такой пример. Опрелелены две исходные фразы
- "Обама встретился с Медведевым"
- "Обама встретился с Медведевым и Меркель"
И задан поисковый запрос "Обама встретился с Медведевым".
Понятно, что первая фраза будет более релевантной поисковому запросу, чем вторая. Но вычисление оценки релевантности для обоих фраз способом выше даст одинаковый результат - фразы будут отценены как эквивалентные. Вместе с тем, Функция ts_rank_cd() имеет дополнительный необязательный параметр, учитывающей при расчете релевантности особенности построения исхолной фразы, а точнее - особенности вектора tsvector, составленного конфигурацией из исходной фразы. Эти особенности учитываются следующим образом: расчетная оценка релевантности делится на коэффициент, в качестве которого может выступать длина tsvector, логарифм длины, количество уникальных лексеем в векторе и др. коэффициент влияет только степень назличия, подробнее можно прочитать в этой статье. Поэтому, выполнив такой запрос
select ts_rank_cd(to_tsvector('corpora.noun_verb', 'Обама встретился с Медведевым и Меркель'),
plainto_tsquery('corpora.noun_verb', 'обама встретился с Медведевым '), 2);
мы получим различные оценки релевантности для наших исходных фраз. Что и требуется для решения поставленной задачи
Итог
После всех экспериментов с настройками, запрос поиска для сформулированной задачи будет выглядеть следующим образом.
select sent, ts_rank_cd(to_tsvector('{{schema_name}}.noun_verb', sent), plainto_tsquery('{{schema_name}}.noun_verb', 'обама встретилcя'),2) as rank
from {{schema_name}}.news where to_tsvector('{{schema_name}}.noun_verb', sent) @@ plainto_tsquery('{{schema_name}}.noun_verb', 'обама встретилcя')
order by rank desc
Послесловие
За пределами данной статьи осталось рассмотрение случая назначения категорий для отдельных частей исходных текстов и некоторые полезные функции полнотекстрового поиска. Однакомиться с ними можно в этой публикации.
Дополнение
Если в какой-то момент изменится форимулировка прикладной задачи, например станет актуальным поиск не связки объект-событие, а нужно будет находить в тексте описательные характеристики объектов, достаточно будет создать новую конфигурацию по методике, приведенной в данной статье.
Вопросы администрирования
Как правило, ресурсы, выделяемые по базу ограничены. Поэтому полезно иметь несколько инструментов для их контроля и управления. Прочитать о них можно в этой статье