Поиск






Воскресенье, 24.11.2024, 19:42

| RSS

ОТДЕЛ
ИНФОРМАЦИОННЫХ
ТЕХНОЛОГИЙ
 
Каталог статей


Главная » Статьи » Интересные статьи

Полнотекстовый поиск в PostgreSQL

Введение

Полнотекстовый поиск (Full Text Search, FTS) - это не новая технология. Самые ранние патенты, связанные с поиском документов по заданной теме, были зарегистрированы в 1963 году, более чем 45 лет назад. Эти патенты включают "CONTENT ADDRESSABLE MEMORY APPARATUS" (US Pat. 3290659 - зарег. 30.12.1963), "SCAN CONTROL, AND NORMALIZATION FOR A CHARACTER RECOGNITION SYSTEM" (US Pat. 3295105 - зарег. 27.08.1964) и "INFORMATION RETRIEVAL SYSTEM AND METHOD" (US Pat. RE26429 - зарег. 08.12.1964).

До настоящего времени возможность структурировать то, что называется "неструктурированными данными", чтобы превратить этот "копну" в информационную систему, продолжает будоражить лучшие умы.

Длинный список выданных патентов наблюдается и в 2006 и 2007 годах: "Device and system for information management" (US Pat. 7376273 - зарег. 01.06.2007, Silverbrook Research Pty Ltd), "Metasearching by sending a plurality of queries to a plurality of servers" (US Pat. 7277918 - зарег. 16.01.2007) and "Distributed internet based speech recognition system with natural language ..." (US Pat. 7203646 - зарег. 22.05.2006, Phoenix Solutions, Inc.).

Конечно, сегодня мы знаем, что полнотекстовый поиск - это не только способ организации документов. Ближе всего к данному вопросу, хотя он и неспособен в точности воспроизвести функционал полнотекстового поиска, оказался поиск на основе алгоритма Байеса (Байесовый поиск) - статистическое средство индексации документов, определяющее вероятность того, что два документа подобны. Байесовый поиск обладает почти сверхъестественной способностью извлекать суть документа вместо того, чтобы полагаться на дословное соответствие текста. В наши дни Байесовый поиск наиболее широко используется в спам-фильтрах, хотя поисковые движки наподобие DeepDyve демонстрируют его более амбициозные возможности.

Некоторые СУБД уже имеют встроенную реализацию полнотекстового поиска, среди них наиболее заметны Oracle, SQL Server, а также свободные серверы MySQL и PostgreSQL.

В MySQL "родная" поддержка полнотекстового поиска в настоящее время доступна только для таблиц MyISAM, и всё ещё не реализована для более популярного формата InnoDB. В случае с PostgreSQL, самая ранняя реализация полнотекстового поиска была выполнена в виде модуля TSearch, в версии 7.4 заменённого на Tsearch2, и, наконец, в версии 8.3 включена в ядро PostgreSQL. [Авторами этих модулей, а также индексов GIST, являются наши соотечественники Фёдор Сигаев и Олег Бартунов - прим.перев.]

Существует также много реализаций "внешних" поисковых движков, которые могут использоваться как совместно с движками баз данных, так и отдельно. Среди них можно отметить Lucene, очень мощный и популярный движок, реализованный как часть проекта Apache; движки Swish-E и Sphynx, которые устойчиво набирают популярность.

Lucene представляет собой библиотеку, которую программисты могут использовать для разработки поисковых решений. На сайте проекта можно найти множество ссылок на продукты общего назначения, уже разработанные на базе этой библиотеки, включая интерфейсы для Java (оригинальный интерфейс), PHP и .NET. Sphynx особенно популярен среди разработчиков MySQL как движок, поддерживающий оба формата таблиц - MyISAM и InnoDB.

Полнотекстовый поиск в PostgreSQL

В двух словах, полнотекстовый поиск реализуется за счёт индексации слов, содержащихся в документе, и связывания этих проиндексированных слов со ссылками на документ. Поиск по запросу с поддержкой логических операций, используя операторы and, or, not и скобки, впоследствии может сопоставляться с индексом для определения документа, содержащего слова из этого запроса. PostgreSQL в настоящее время не поддерживает нечёткие логические операторы near, far или strip.

Устранение избыточности

Очевидно, что индексирование каждого отдельного слова в документе приведёт к образованию очень большого индекса. Но в этом нет необходимости, да и особой пользы тоже. Например, можно преобразовать весь текст к нижнему регистру символов, прежде чем индексировать слова, сделав поиск нечувствительным к регистру, и одновременно достигнув меньшего размера индекса. Далее, можно исключить из текста слова, не имеющие реального значения (такие как "и", "или", "для", артикли и т.п.), поскольку такие слова можно найти практически в любом документе [подобные слова принято называть стоп-словами]. Первый подход называется нормализацией, второй - исключением стоп-слов.

Наконец, можно ещё сильнее сократить размер индекса, заменив слова другими, имеющими идентичное или близкое значение. Скажем, слова "голодный", "оголодав" можно заменить словом "голод". Этот процесс называется заменой по словарю.

Последующие алгоритмические меры (см. Snowball) могут привести к дальнейшему сокращению слов до их основного значения перед индексацией статьи. Замена названий цветов их шестнадцатеричными эквивалентами и сокращение числа значений за счёт уменьшения точности - ещё один способ нормализации текста.

Все эти меры устраняют избыточность индексируемого текста, а следовательно, сокращают размер индекса и снижают требования к дисковому пространству, количеству операций чтения/записи, приводят к более быстрому построению индекса, и в итоге - к более быстрому поиску.

Словари PostgreSQL

Чтобы помочь с нормализацией и устранением избыточности текста, PostgreSQL предоставляет шаблоны для различных типов словарей, используемых в настройках текстового поиска. Это "простой словарь" (Simple Dictionary), "словарь синонимов" (Synonym Dictionary), "словарь тезаурусов" (Thesaurus Dictionary), "словарь iSpell" (iSpell Dictionary) и "словарь Snowball" (Snowball Dictionary).

Словарь Simple исключает стоп-слова и выполняет нормализацию регистра символов. Словарь Synonym заменяет одни слова другими, словарь Thesaurus обеспечивает распознавание фраз, специфических для различных отраслей промышленности, а словарь iSpell может быть использован для встраивания любого стандартного словаря iSpell, доступного для OpenOffice.org. Словари Snowball, которые выполняют алгоритмическое выделение основы слова и исключение стоп-слов, включены по умолчанию для различных языков в установку PostgreSQL.

В PostgreSQL все словари функционируют одинаково, с небольшим исключением для словарей типа Theraurus. По сути, они принимают на входе слово (называемое токеном) и возвращают массив лексем (нормализованных слов) или значение NULL. Когда словарь возвращает NULL, подразумевается, что токен не распознан. Это позволяет объединять словари в цепочки, размещая наиболее общий словарь в конце списка. В итоге, если один из первых в списке словарей возвращает слово, последующие словари игнорируются. Если же словарь, находящийся ближе к началу списка, возвращает NULL, токен обрабатывается последующими словарями.

Лексема - это токен, который преобразован к своей базовой форме. Прежде чем передавать текст документа словарю, PostgreSQL преобразовывает его в массив токенов, используя простой парсер. Этот парсер способен определять различные типы токенов в тексте, такие как теги XML или HTML, целые числа или числа с плавающей запятой, номера версий, веб-ссылки (URL), имена хостов и так далее. PostgreSQL предоставляет возможность обрабатывать различные типы токенов по-своему, сопоставляя заданному типу токена различные списки словарей.

Полнотекстовые конфигурации

Обратите внимание, что хотя PostgreSQL предлагает множество возможностей настройки, в базовой установке уже заложена вполне работоспособная конфигурация. Если на это нет особых причин, обычно нет нужды её менять. Тем не менее, ниже приведён пример поисковой конфигурации:

CREATE TEXT SEARCH CONFIGURATION public.my_config (COPY=pg_catalog.english );

Теперь, имея новую полнотекстовую конфигурацию, мы можем создать новый словарь:

CREATE TEXT SEARCH DICTIONARY english_ispell (
TEMPLATE = ispell,
DictFile = english,
AffFile = english,
StopWords = english
);

В примере выше мы создали словарь, основанный на шаблоне iSpell, где DictFile, AffFile и StopWords ссылаются на файлы в каталоге `pg_config_share`/tsearch_data/, которые называются english.dict, english.affix и english.stop соответственно.

Мы можем добавить этот словарь в конфигурацию следующим образом:

ALTER TEXT SEARCH CONFIGURATION my_config
ALTER MAPPING FOR asciiword, asciihword, hword_asciipart,
word, hword, hword_part
WITH english_ispell, english_stem;

Эта команда добавит наш словарь к нашей новой полнотекстовой конфигурации, перезаписав используемые по умолчанию типы лексем (asciiword и т.п.), чтобы гарантировать, что их обработка будет проходить как через словарь english_ispell, так и через english_stem. Теперь мы можем начать использовать нашу новую полнотекстовую конфигурацию, изменив следующий глобальный параметр:

SET default_text_search_config = 'public.my_config';

Этот параметр будет действовать на протяжении всего сеанса, или пока не будет изменён. Чтобы сделать конфигурацию постоянной, нужно установить этот параметр в файле data/postgresql.conf.

Поисковые векторы

Всё это, конечно, впечатляет, но как с помощью парсера и словарей содержимое документа преобразуется в массив лексем? Как проверяется соответствие текста критериям поиска? Для этого PostgreSQL предоставляет ряд функций. Первая из них, которую мы рассмотрим, - to_tsvector().

Существует встроенный тип данных - tsvector, содержащий массив лексем с информацией об их позиции. Позиции лексем используются в процессе поиска для ранжирования результатов на основе схожести и другой информации. Ранжированием можно управлять, помечая различные части, составляющие содержимое документа; например, заголовок, тело, реферат могут в процессе поиска получать различный вес за счёт различной маркировки этих секций. Метки секций, довольно простые - A, B, C и D, - ассоциируются с поисковым вектором во время его создания, но модификаторами веса, связанными с этими метками, можно управлять позже.

Мы можем создать поисковый вектор для текста следующим образом:

bob=# select to_tsvector('Free text seaRCh is a wonderful Thing');
to_tsvector
------------------------------------------------------
'free':1 'text':2 'thing':7 'search':3 'wonderful':6
(1 row)

Назначение весовых меток

Как видите, поисковый вектор - это просто список лексем с ассоциированными позициями. Стоп-слова, такие как 'a' и 'is', исключаются, а всё остальное приводится к нижнему регистру. Рассмотрим ещё один пример, добавив метки:

bob=# select setweight(to_tsvector('Free text seaRCh is a wonderful Thing'),'A');
setweight
-----------------------------------------------------------
'free':1A 'text':2A 'thing':7A 'search':3A 'wonderful':6A
(1 row)

По сути, можно создать новый вектор с лексемами, помеченными для различных секций текста, используя для каждой из секций функцию setweight:

bob=# select 
bob-# setweight(to_tsvector('All about search'), 'B') ||
bob-# setweight(to_tsvector('Free text seaRCh is a wonderful Thing'),'A');
?column?
---------------------------------------------------------------
'free':4A 'text':5A 'thing':10A 'search':3B,6A 'wonderful':9A
(1 row)

Считая, что "All about search" - это заголовок, а "Free text seaRCh is a wonderful Thing" - тело, теперь у нас есть лексемы, помеченные как принадлежащие заголовку, наряду с теми, которые принадлежат телу документа. Обратите внимание на то, что слова "All" и "About" в заголовке рассматриваются как стоп-слова. В последующем можно использовать эти метки для ранжирования результата в зависимости от веса, ассоциированного с заголовком и телом документа. Чуть позже мы вернёмся к этому вопросу.

Сопоставление с запросом с поддержкой логических операций

Каким же образом поисковый запрос сопоставляется с вектором? Это выполняется оператором @@. Например:

bob=# select to_tsvector('Free text seaRCh is a wonderful Thing') @@ 'wonderful';
?column?
----------
t
(1 row)

Хотя слово "wonderful" не является удивительным примером запроса с поддержкой логических операций, не так ли? Это просто обычный текст. Если этот текст будет иметь неправильный формат, например, содержать другие слова, запрос фактически приведёт к ошибке. PostgreSQL предоставляет две функции, которые можно использовать для преобразования текста в запрос, соответствующий типу данных tsquery, для последующего сопоставления с поисковым вектором. Эти функции - plainto_tsquery() и to_tsquery().

Довольно ограниченная по своим возможностям функция plainto_tsquery() просто преобразует текст в тип tsquery, объединяя все лексемы в тексте с помощью оператора '&' (или AND):

bob=# select plainto_tsquery('wonderful text');
plainto_tsquery
----------------------
'wonderful' & 'text'
bob=# select to_tsvector('Free text seaRCh is a wonderful Thing') @@ plainto_tsquery('wonderful text');
?column?
----------
t
(1 row)

Хотя это может быть полезным, plainto_tsquery() не поддерживает другие логические операции кроме '&'. Функция to_tsquery() является более продвинутой, обеспечивая обработку серьёзных логических выражений. К сожалению, она излишне придирчива ко входным параметрам. Она не принимает текст, не разделённый операторами '&', '|' и '!'. Она понимает скобки, которые используются для расстановки приоритета операторов, но два токена, которые преобразуются в различные лексемы непосредственно один после другого, вызовут ошибку:

bob=# select to_tsquery('wonderful text');
ERROR: syntax error in tsquery: "wonderful text"
bob=# select to_tsquery('wonderful | text');
to_tsquery
----------------------
'wonderful' | 'text'

Ранжирование результатов поиска

Тем не менее, мы можем использовать поисковые запросы с поддержкой логических операций для сопоставления с поисковым вектором, или использовать запрос и вектор для выполнения ранжирования, используя одну из функций: ts_rank() или ts_rank_cd(). Работа этих функций слегка отличается. ts_rank() рассматривается как "стандартная" функция ранжирования, в то время как ts_rank_cd() использует алгоритм Cover Density Ranking (CDR), который более интересен для ранжирования фраз, чем для элементов самих запросов.

Для ранжирования результатов можно использовать следующее:

bob=# select ts_rank(to_tsvector('Free text seaRCh is a wonderful Thing'), to_tsquery('wonderful | thing'));
ts_rank
-----------
0.0607927
(1 row)

bob=# select ts_rank(to_tsvector('Free text seaRCh is a wonderful Thing'), to_tsquery('wonderful & thing'));
ts_rank
-----------
0.0991032
(1 row)

bob=# select ts_rank_cd(to_tsvector('Free text seaRCh is a wonderful Thing'), to_tsquery('wonderful & thing'));
ts_rank_cd
------------
0.1
(1 row)

Возвращаясь к разговору о возможности ранжирования в зависимости от весов вектора, вы можете изменить запрос таким образом:

bob=# select 
bob-# setweight(to_tsvector('All about search'), 'B') ||
bob-# setweight(to_tsvector('Free text seaRCh is a wonderful Thing'),'A');
?column?
---------------------------------------------------------------
'free':4A 'text':5A 'thing':10A 'search':3B,6A 'wonderful':9A
(1 row)

Здесь мы пометили различные секции нашего документа. Теперь мы можем сделать следующее:

bob=# select ts_rank(
bob-# array[0.1,0.1,0.9,0.1],
bob-# setweight(to_tsvector('All about search'), 'B') ||
bob-# setweight(to_tsvector('Free text seaRCh is a wonderful Thing'),'A'),
bob-# to_tsquery('wonderful & search'));
ts_rank
----------
0.328337
(1 row)

bob=# select ts_rank(
bob-# array[0.1,0.1,0.1,0.9],
bob-# setweight(to_tsvector('All about search'), 'B') ||
bob-# setweight(to_tsvector('Free text seaRCh is a wonderful Thing'),'A'),
bob-# to_tsquery('wonderful & search'));
ts_rank
----------
0.907899
(1 row)

Строка array[0.1,0.1,0.9,0.1] передаётся в качестве аргумента функции ts_rank(), принимающей аргументы в порядке {D,C,B,A}. Поскольку мы пометили секцию тела документа как A, а заголовок как B, в вышеприведённых выражениях мы сперва присваиваем B=0.9, A=0.1, а потом B=0.1, A=0.9. Результаты ранжирующей функции соответствующим образом меняются. Если не определено иное, то по умолчанию необязательный массив весов определяется как {0.1, 0.2, 0.4, 1.0}.

Индексирование поисковых векторов: GIST и GIN

До сих пор в наших примерах для демонстрации соответствия между поисковыми векторами (tsvector) и запросами (tsquery) мы использовали только оператор select, а также возможности ранжирования PostgreSQL. Двигаясь дальше, мы рассмотрим использование индексов для ускорения поиска, а позже затронем более впечатляющий вопрос, о котором мы до сих пор умалчивали: почему to_tsquery столь педантична и как с этим быть?

PostgreSQL поддерживает для полнотекстового поиска два типа индексов - GIST, основанный на хэш-таблицах, и GIN на основе двоичных деревьев (Btree).

GIST создаёт индексы действительно очень быстро. Он сохраняет хэш-таблицу элементов в tsvector, и использует хэш элементов в tsquery для поиска ассоциированных документов. К сожалению, поскольку результаты поиска по хэшу недетерминированы (неоднозначны), PostgreSQL вынужден проверять результаты поиска, просматривая все найденные статьи, и дважды проверять соответствие, прежде чем вернёт результат. Для небольших наборов лексем и небольших баз данных это работает довольно неплохо. Однако для действительно больших баз данных затраты ресурсов на повторное чтение данных (или индекса) приводят к тому, что этот подход работает очень медленно.

Индексы GIN отличаются однозначностью и не подразумевают накладных расходов на поиск при использовании этого типа индексов, но с другой стороны, создание этого индекса замедляется логарифмически (хорошо хоть не экспоненциально) по мере роста числа записей. При решении вопроса, какой из типов индекса выбрать, следует принимать во внимание природу базы данных и её размер.

При использовании индексов существует два подхода. Первый заключается в индексации некоторой функции по полю данных, а второй - в сохранении поискового вектора в дополнительном поле таблицы с последующей индексацией этого поля. Например,

CREATE INDEX pgweb_idx ON pgweb 
USING gin(to_tsvector('english', title || ' ' || body));

создаст GIN-индекс, используя комбинацию полей заголовка и тела документа. Этот метод весьма прост, но связан с накладными расходами: создание индекса требует дополнительной работы, и алгоритм поиска усложняется. Второй подход использует триггер для заполнения выделенного поля вектора во время добавления записи в таблицу или её удаления:

ALTER TABLE pgweb ADD COLUMN tsv tsvector;
UPDATE pgweb SET tsv =
to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''));
CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE
ON pgweb FOR EACH ROW EXECUTE PROCEDURE
tsvector_update_trigger(tsv, 'pg_catalog.english', title, body);

Теперь мы просто добавляем, изменяем или удаляем записи таблицы как обычно, а триггер заботится о заполнении поля tsv в соответствующей строке таблицы, что, в свою очередь, приводит к обновлению индекса. В последующем запросы могут выполняться сразу к полю tsv, и это упрощает запрос.

Выделение результатов поиска

Прежде чем двинуться дальше, нужно упомянуть довольно полезную особенность, связанную с поиском текста. PostgreSQL предоставляет возможность выделять текст на основе результатов поиска. Это реализуется функцией pg_headline(). Данная функция возвращает в качестве результата текст, в котором все слова, соответствующие запросу, заключены в HTML-теги <b></b>. Использовать её очень просто:

bob=# select ts_headline('Free text seaRCh is a wonderful Thing',
to_tsquery('wonderful & thing'));
ts_headline
-----------------------------------------------------
Free text seaRCh is a <b>wonderful</b> <b>Thing</b>
(1 row)

Чтобы применить выделение к соответствующему реферату (abstract), можно выполнить следующую команду:

select ts_headline(abstract, query)
from pgweb, to_tsquery('wonderful & thing') query,
where query @@ tsv;
[Функция ts_headline() принимает ряд параметров, позволяющих, в частности, изменять символы, окружающие выделяемое слово - прим.перев.]

Фразовый поиск

Говоря об особенностях, нужно упомянуть одну якобы недостающую функцию, на отсутствие которой в PostgreSQL жалуются многие люди, особенно пользователи внешних текстовых движков - фразовый поиск. Здесь мы имеем в виду очевидно несуществующую возможность движка полнотекстового поиска, используемого в PostgreSQL, поддерживать поиск в тексте проиндексированных точных (фиксированных) фраз.

Но давайте просто забудем об этом на мгновение. Что конкретно мы здесь ожидаем? Как много точных фраз существует в документе, скажем, из десяти слов? Давайте предположим, что эти слова представляют из себя отдельные символы: a, b, c, ..., j. Все возможные фразы в этом документе следующие:

a,ab,abc,abcd... (10 фраз)
b,bc,bcd,bdce... (9 фраз)
и т.д.

Иначе говоря, для документа, состоящего из (n) слов, существует (n) фраз, начинающихся с первого слова, (n - 1), начинающихся со второго слова, (n - 2), начинающихся с третьего, и т.д. Это преобразуется в число фраз = n/2 * (n + 1) = 10/2*(10+1) = 55 фраз в документе из 10 слов. Для документа из 100 слов мы получим 5050 фраз! Вы всё ещё считаете, что индекс по всем возможным фразам - хорошая вещь? Вспомните, что такой индекс должен будет содержать все слова, и не позволит устранить избыточность из текста (например, стоп-слова). Действительно, не слишком практичный подход.

Далее, как внешними поисковыми движками поддерживается индекс, помогающий выполнять поиск точных фраз? Да тем же самым способом, как это делает PostgreSQL! Слегка упрощённо, используется следующий алгоритм:

  1. Текстовая фраза преобразуется в поисковый запрос с поддержкой логических операций, используя оператор '&';
  2. Используется движок текстового поиска для поиска соответствующих запросу документов;
  3. Для найденных документов проверяется соответствие без учёта регистра символов первоначальной текстовой фразе, чтобы исключить ложные срабатывания.

Для PostgreSQL поддерживается чувствительная к регистру символов проверка фраз на соответствие с использованием оператора LIKE, а также нечувствительная к регистру проверка с помощью оператора ILIKE. Для поиска текста "free text and 'Postgres text search'" запрос может выглядеть следующим образом:

select headline, ts_rank(tsv, query) 
from pgweb, to_tsquery('free & text & postgres & search') query
where tsv @@ query and body ilike '%Postgres text search%';

... который использует полнотекстовый индекс для поиска статей, текст которых соответствует запросу, а затем для найденных элементов ищутся пересечения, дословно соответствующие тексту.

Просто, не правда ли?

Разбор запросов на естественном языке

Ну, не так уж и просто. Как, скажите на милость, несчастный разработчик должен преобразовать "человеческую" фразу, приведённую выше, в последующий оператор SQL? Это нетривиальная задача и, к тому же, выходящая далеко за пределы возможностей некоторых разработчиков. В самом деле, многие рассматривают это как нечто невообразимое. Эта работа включает в себя разработку пре-парсера для разбора вводимой поисковой фразы, который будет транслировать поисковую фразу на естественном языке в оператор SQL, подобный показанному выше.

В конце этой статьи мы включили, с извинениями перед приверженцами компилятора YACC, листинг пре-парсера, который именно это и делает. Логика в целом достаточно обобщённая, чтобы можно было реализовать её почти на любом языке программирования, но показанный здесь листинг написан на стандартном C++. Это парсер, реализующий алгоритм рекурсивного спуска со смещением приоритета, без внешних зависимостей. Вы можете использовать этот код совершенно свободно любым способом (он распространяется под лицензией BSD).

['parser.cpp' для правильной компиляции требует либо 'g++', либо 'gcc -lstdc++'. - прим.ред.]

Ссылки

  1. http://en.wikipedia.org/wiki/Full_text_search
  2. http://en.wikipedia.org/wiki/Search_engines
  3. http://www.postgresql.org/docs/8.4/static/textsearch.html
  4. http://snowball.tartarus.org/
  5. http://www.google.com/patents
  6. Web Communities By Yanchun Zhang, Jeffrey Xu Yu, Jingyu Hou

Об авторе

Пол работает архитектором программного обеспечения в компании-поставщике решений по обработке финансовой информации. Отказавшись от карьеры в области ядерной химии (во время работы в этой области он и заинтересовался аппаратным и программным обеспечением), он устроился в качестве разработчика в брокерскую фирму. Использовать Linux он начал в 1994 году - то была Slackware 1.18. Его первой страстью навсегда стала разработка программ. Среди других интересов - музыка, компьютерные игры, нейронные сети и чтение.

Источник http://rus-linux.net/lib.php?name=/MyLDP/subd/pgfts.html

Категория: Интересные статьи | Добавил: sashacd (30.07.2009)
Просмотров: 4772 | Рейтинг: 0.0/0 |
Всего комментариев: 0
Имя *:
Email *:
Код *:

Copyright ООО "Отдел Информационных Технологий" © 2024