blog-post

Формы слов против исключений

Исключения и формы слов — это два полезных инструмента, встроенных в Manticore Search, которые вы можете использовать для улучшения полноты и точности поиска. У них много общего, но также есть важные различия, которые я хотел бы рассмотреть в этой статье.

О токенизации

В чем разница между полнотекстовым поиском (также называемым свободным текстовым поиском) и поиском с подстановочными знаками, таким как:

  • общепринятый оператор LIKE в той или иной форме
  • или более сложные регулярные выражения

? Конечно, существует множество различий, но все начинается с того, что мы делаем с исходным текстом на каждом из подходов:

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

Когда вы хотите токенизировать текст, вам нужно решить, как это сделать, в частности:

  1. Что должно быть разделителями и символами слов. Обычно разделитель — это символ, который не встречается внутри слова, например, знаки препинания: ., ,, ?, !, - и т.д.
  2. Должен ли регистр букв токенов сохраняться или нет. Обычно нет, так как это плохо для поиска, если вы не находите Orange по ключевому слову orange.

Manticore делает это все автоматически. Например, текст "Что у меня есть? Список: кот, собака и попугай." токенизируется в:

mysql> drop table if exists t;
mysql> create table t(f text);
mysql> call keywords('What do I have? The list is: a cat, a dog and a parrot.', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | what      | what       |
| 2    | do        | do         |
| 3    | i         | i          |
| 4    | have      | have       |
| 5    | the       | the        |
| 6    | list      | list       |
| 7    | is        | is         |
| 8    | a         | a          |
| 9    | cat       | cat        |
| 10   | a         | a          |
| 11   | dog       | dog        |
| 12   | and       | and        |
| 13   | a         | a          |
| 14   | parrot    | parrot     |
+------+-----------+------------+
14 rows in set (0.00 sec)

Как вы можете видеть:

  • знаки препинания были удалены
  • и все слова были приведены к нижнему регистру

Проблема

Вот первая проблема: в некоторых случаях разделители рассматриваются как обычные символы слов, например, в "Является ли c++ самым мощным языком?" очевидно, что c++ — это отдельное слово. Это легко понять людям, но не алгоритмам полнотекстового поиска, так как он видит знак плюса, не находит его в своем списке символов слов и удаляет его из токена, так что вы получаете:

mysql> drop table if exists t;
mysql> create table t(f text);
mysql> call keywords('Is c++ the most powerful language?', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | is        | is         |
| 2    | c         | c          |
| 3    | the       | the        |
| 4    | most      | most       |
| 5    | powerful  | powerful   |
| 6    | language  | language   |
+------+-----------+------------+
6 rows in set (0.00 sec)

Хорошо, но в чем проблема?

Проблема в том, что после этой токенизации, если вы ищете c#, например, вы найдете вышеуказанное предложение:

mysql> drop table if exists t;
mysql> create table t(f text);
mysql> insert into t values(0,'Is c++ the most powerful language?');
mysql> select highlight() from t where match('c#');

+-------------------------------------------+
| highlight()                               |
+-------------------------------------------+
| Is <b>c</b>++ the most powerful language? |
+-------------------------------------------+
1 row in set (0.01 sec)

Это происходит потому, что c# также токенизируется в просто c, и затем c из поискового запроса совпадает с c из документа, и вы его получаете.

Какое решение? Есть несколько вариантов. Первый, который, вероятно, приходит на ум:

Хорошо, почему бы мне не добавить + и # в список символов слов?

Это хороший и справедливый вопрос. Давайте попробуем.

mysql> drop table if exists t;
mysql> create table t(f text) charset_table='non_cjk,+,#';
mysql> call keywords('Is c++ the most powerful language?', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | is        | is         |
| 2    | c++       | c++        |
| 3    | the       | the        |
| 4    | most      | most       |
| 5    | powerful  | powerful   |
| 6    | language  | language   |
+------+-----------+------------+
6 rows in set (0.00 sec)

Это работает, но + в списке сразу начинает влиять на другие слова и поиски, например:

mysql> drop table if exists t;
mysql> create table t(f text) charset_table='non_cjk,+,#';
mysql> call keywords('What is 2+2?', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | what      | what       |
| 2    | is        | is         |
| 3    | 2+2       | 2+2        |
+------+-----------+------------+
3 rows in set (0.00 sec)

Вы хотели, чтобы c++ было отдельным словом, но не 2+2, не так ли?

Верно, так что мы можем сделать?

Чтобы рассматривать c++ особым образом, вы можете сделать его исключением.

Исключения

Итак, исключения (также известные как синонимы) позволяют сопоставить один или несколько терминов (включая термины с символами, которые обычно исключаются) с одним ключевым словом.

Давайте сделаем c++ исключением, поместив его в файл исключений:

➜  ~ cat /tmp/exceptions
c++ => c++

и используя файл, когда мы создаем таблицу:

mysql> drop table if exists t;
mysql> create table t(f text) exceptions='/tmp/exceptions';
mysql> call keywords('Is c++ the most powerful language? What is 2+2?', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | is        | is         |
| 2    | c++       | c++        |
| 3    | the       | the        |
| 4    | most      | most       |
| 5    | powerful  | powerful   |
| 6    | language  | language   |
| 7    | what      | what       |
| 8    | is        | is         |
| 9    | 2         | 2          |
| 10   | 2         | 2          |
+------+-----------+------------+
10 rows in set (0.01 sec)

Ура, c++ теперь отдельное слово, и знаки плюса не потеряны, и все в порядке с 2+2 тоже.

Что вам нужно помнить об исключениях, так это то, что исключения очень глупы, совсем не умные, они делают ровно то, что вы им говорите, и ничего больше. В частности:

  • они не меняют регистр
  • если вы допустите ошибку и поставите двойной пробел, они не преобразуют его в один пробел

и так далее. Они буквально рассматривают ваш ввод как массив байтов.

Например, люди пишут c++ как в нижнем, так и в верхнем регистре. Давайте попробуем вышеуказанное исключение с верхним регистром?

mysql> drop table if exists t;
mysql> create table t(f text) exceptions='/tmp/exceptions';
mysql> call keywords('Is C++ the most powerful language? How about c++?', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | is        | is         |
| 2    | c         | c          |
| 3    | the       | the        |
| 4    | most      | most       |
| 5    | powerful  | powerful   |
| 6    | language  | language   |
| 7    | what      | what       |
| 8    | is        | is         |
| 9    | 2         | 2          |
| 10   | 2         | 2          |
+------+-----------+------------+
10 rows in set (0.00 sec)

Упс, C++ был токенизирован как просто c, потому что исключение — это c++ (нижний регистр), а не C++ (верхний регистр).

Но вы заметили, что исключение состоит из пары элементов, а не одного: c++ => c++. Левая часть — это то, что запускает алгоритм исключений в тексте, правая часть — это результирующий токен. Давайте попробуем добавить сопоставление C++ к c++?

  ~ cat /tmp/exceptions
c++ => c++
C++ => c++

mysql> drop table if exists t;
mysql> create table t(f text) exceptions='/tmp/exceptions';
mysql> call keywords('Is C++ the most powerful language? How about c++?', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | is        | is         |
| 2    | c++       | c++        |
| 3    | the       | the        |
| 4    | most      | most       |
| 5    | powerful  | powerful   |
| 6    | language  | language   |
| 7    | how       | how        |
| 8    | about     | about      |
| 9    | c++       | c++        |
+------+-----------+------------+
9 rows in set (0.00 sec)

Хорошо, теперь все снова в порядке, так как и C++, и c++ токенизируются в токен c++. Так удовлетворительно.

Какие еще хорошие примеры исключений:

  • AT&T => AT&T и at&t => AT&T.
  • M&M's => M&M's и m&m's => M&M's и M&m's => M&M's
  • U.S.A. => USA и US => USA

Какие плохие примеры?

  • us => USA, потому что мы не хотим, чтобы каждое us становилось USA.

Так что правило с исключениями таково:

Если термин включает специальные символы и именно так он обычно пишется в тексте и в поисковом запросе — сделайте его исключением.

Синонимы

Пользователи Manticore Search также часто называют исключения синонимами, потому что другой случай их использования — это не только сохранение специальных символов и регистра букв, но и сопоставление терминов, написанных совершенно по-разному, с одним и тем же токеном, например:

MS Windows => ms windows
Microsoft Windows => ms windows

Почему это важно? Потому что это позволяет легко находить документы с Microsoft Windows по MS Windows и наоборот.

Пример:

mysql> drop table if exists t;
mysql> create table t(f text) exceptions='/tmp/exceptions';
mysql> insert into t values(0, 'Microsoft Windows is one of the first operating systems');
mysql> select * from t where match('MS Windows');
+---------------------+---------------------------------------------------------+
| id                  | f                                                       |
+---------------------+---------------------------------------------------------+
| 1514841286668976139 | Microsoft Windows is one of the first operating systems |
+---------------------+---------------------------------------------------------+
1 row in set (0.00 sec)

На первый взгляд, это работает хорошо, но если подумать дальше и вспомнить, что исключения чувствительны к регистру и байтам, вы можете задать себе вопрос: "Не могут ли люди писать MicroSoft windows, MS WINDOWS, microsoft Windows и так далее?"

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

Это выглядит совсем не хорошо. Что мы можем с этим сделать?

Формы слов

Другой инструмент, который похож на исключения, — это формы слов. В отличие от исключений, формы слов применяются после токенизации входного текста. Поэтому они:

  • нечувствительны к регистру (если только ваша charset_table не включает чувствительность к регистру)
  • не заботятся о специальных символах

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

➜  ~ cat /tmp/wordforms
walks => walk
walked => walk
walking => walk
mysql> drop table if exists t;
mysql> create table t(f text) wordforms='/tmp/wordforms';
mysql> call keywords('walks _WaLkeD! walking', 't');

+------+-----------+------------+
| qpos | tokenized | normalized |
+------+-----------+------------+
| 1    | walks     | walk       |
| 2    | walked    | walk       |
| 3    | walking   | walk       |
+------+-----------+------------+
3 rows in set (0.00 sec)

As you can see all the 3 words were converted to just walk and, note, the 2nd word _WaLkeD! even being very deformed was also normalized fine. Do you see where I'm going with this? Yes, the MS Windows example. Let's test if the wordforms can be useful to solve that issue.

Let's put just 2 lines to the wordforms file:

➜  ~ cat /tmp/wordforms
ms windows => ms windows
microsoft windows => ms windows

and populate the table with a few documents:

mysql> drop table if exists t;
mysql> create table t(f text) wordforms='/tmp/wordforms';
mysql> insert into t values(0, 'Microsoft Windows is one of the first operating systems'), (0, 'porch windows'),(0, 'Windows are rolled down');

mysql> select * from t;
+---------------------+---------------------------------------------------------+
| id                  | f                                                       |
+---------------------+---------------------------------------------------------+
| 1514841286668976166 | Microsoft Windows is one of the first operating systems |
| 1514841286668976167 | porch windows                                           |
| 1514841286668976168 | Windows are rolled down                                 |
+---------------------+---------------------------------------------------------+
3 rows in set (0.00 sec)

Let's now try various queries:

mysql> select * from t where match('MS Windows');
+---------------------+---------------------------------------------------------+
| id                  | f                                                       |
+---------------------+---------------------------------------------------------+
| 1514841286668976166 | Microsoft Windows is one of the first operating systems |
+---------------------+---------------------------------------------------------+
1 row in set (0.00 sec)

MS Windows находит Microsoft Windows хорошо.

mysql> select * from t where match('ms WINDOWS');
+---------------------+---------------------------------------------------------+
| id                  | f                                                       |
+---------------------+---------------------------------------------------------+
| 1514841286668976166 | Microsoft Windows is one of the first operating systems |
+---------------------+---------------------------------------------------------+
1 row in set (0.01 sec)

ms WINDOWS тоже работает хорошо.

mysql> select * from t where match('mIcRoSoFt WiNdOwS');
+---------------------+---------------------------------------------------------+
| id                  | f                                                       |
+---------------------+---------------------------------------------------------+
| 1514841286668976166 | Microsoft Windows is one of the first operating systems |
+---------------------+---------------------------------------------------------+
1 row in set (0.00 sec)

✅ И даже mIcRoSoFt WiNdOwS находит тот же документ.

mysql> select * from t where match('windows');
+---------------------+---------------------------------------------------------+
| id                  | f                                                       |
+---------------------+---------------------------------------------------------+
| 1514841286668976166 | Microsoft Windows is one of the first operating systems |
| 1514841286668976167 | porch windows                                           |
| 1514841286668976168 | Windows are rolled down                                 |
+---------------------+---------------------------------------------------------+
3 rows in set (0.00 sec)

✅ Просто базовое windows находит все документы.

So indeed, wordforms helps to solve the issue.

The rule of thumb with the wordforms is:

Use wordforms for words and phrases that can be written in different forms and don't contain special characters.

Floor & Decor

Let's take a look at another example: we want to improve search for the brand name Floor & Decor. We can assume people can write this name in the following forms:

Floor & Decor
Floor & decor
floor & decor
Floor and Decor
floor and decor

and other letter capitalization combinations.

Also:

Floor & Decor Holdings
Floor & Decor Holdings, inc.

and, again, various combinations with different letter capitalized.

Now that we know how exceptions and wordforms work what do we do to cover this brand name?

First of all we can easily notice that the canonical brand name is Floor & Decor, i.e. it includes a special character which is normally considered a word separator, so should we use exceptions? But the name is long and can be written in many ways. If we use exceptions we can end up with a huge list of all the combinations. Moreover there are extended forms Floor & Decor Holdings and Floor & Decor Holdings, inc. which can make the list even longer.

The most optimal solution in this case is to just use wordforms like this:

➜  ~ cat /tmp/wordforms
floor & decor => fnd
floor and decor => fnd
floor & decor holdings => fnd
floor and decor holdings => fnd
floor & decor holdings inc => fnd
floor and decor holdings inc => fnd

Why does it include &? Actually you can skip it:

floor decor => fnd
floor and decor => fnd
floor decor holdings => fnd
floor and decor holdings => fnd
floor decor holdings inc => fnd
floor and decor holdings inc => fnd

because wordforms anyway ignores non-word characters, but just for the sake of ease of reading it was left.

As a result you'll get each combination tokenized as fnd which will be our shortkey for this brand name.

mysql> drop table if exists t; create table t(f text) wordforms='/tmp/wordforms';
mysql> call keywords('Floor & Decor', 't')
+------+-------------+------------+
| qpos | tokenized   | normalized |
+------+-------------+------------+
| 1    | floor decor | fnd        |
+------+-------------+------------+
1 row in set (0.00 sec)

mysql> call keywords('floor and Decor', 't')
+------+-----------------+------------+
| qpos | tokenized       | normalized |
+------+-----------------+------------+
| 1    | floor and decor | fnd        |
+------+-----------------+------------+
1 row in set (0.00 sec)

mysql> call keywords('Floor & Decor holdings', 't')
+------+----------------------+------------+
| qpos | tokenized            | normalized |
+------+----------------------+------------+
| 1    | floor decor holdings | fnd        |
+------+----------------------+------------+
1 row in set (0.00 sec)

mysql> call keywords('Floor & Decor HOLDINGS INC.', 't')
+------+--------------------------+------------+
| qpos | tokenized                | normalized |
+------+--------------------------+------------+
| 1    | floor decor holdings inc | fnd        |
+------+--------------------------+------------+
1 row in set (0.00 sec)

Is this the perfect ultimate solution? Unfortunately not as many other things in the area of full-text search. There are always rare cases and in this case too. For example:

mysql> drop table if exists t; create table t(f text) wordforms='/tmp/wordforms';
mysql> insert into t values(0,'It\'s located on the 2nd floor. Decor is also nice');
mysql> select * from t where match('Floor & Decor Holdings');

+---------------------+---------------------------------------------------+
| id                  | f                                                 |
+---------------------+---------------------------------------------------+
| 1514841286668976231 | It's located on the 2nd floor. Decor is also nice |
+---------------------+---------------------------------------------------+
1 row in set (0.00 sec)

We can see here that Floor & Decor Holdings finds the document which has floor in the end of the first sentence and the following one starts with Decor. This happens because floor. Decor also gets tokenized to fnd since we use just wordforms that are insensitive to letter case and special characters:

mysql> call keywords('floor. Decor', 't');
+------+-------------+------------+
| qpos | tokenized   | normalized |
+------+-------------+------------+
| 1    | floor decor | fnd        |
+------+-------------+------------+
1 row in set (0.00 sec)

The false match is not good. To solve this particular problem we can use Manticore's functionality to detect sentences and paragraphs .

Now if we enable it we can see that the document is not a match for the keyword any more:

mysql> drop table if exists t; create table t(f text) wordforms='/tmp/wordforms' index_sp='1';
mysql> insert into t values(0,'It\'s located on the 2nd floor. Decor is also nice');
mysql> select * from t where match('Floor & Decor Holdings');

Empty set (0.00 sec)

because:

  1. Floor & Decor, as we remember is converted into fnd by wordforms
  2. index_sp='1' splits text into sentences
  3. after splitting floor. and Decor end up in different sentences
  4. and do not match fnd and therefore all the original forms of it anymore

Conclusion

Manticore's exceptions and wordforms are powerful tools that can help you fine-tune your search, in particular improve recall and precision when it comes to short terms with special characters that should be retained and longer terms that should be aliased one to another. But you need to help Manticore do it, since it can't decide what the names should be for you.

Thank you for reading this article!

References:

Установить Manticore Search

Установить Manticore Search