# Wordforms vs exceptions

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

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

В чём разница между полнотекстовым поиском (также называемым свободным текстовым поиском) и поиском с подстановочными знаками, таким как:
* широко известный оператор `LIKE` в той или иной форме
* или более сложные регулярные выражения

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

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

Manticore делает всё это автоматически. Например, текст "`What do I have? The list is: a cat, a dog and a parrot.`" токенизируется в:

```sql
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)
```

Как видите:
* знаки пунктуации были удалены
* и все слова приведены к нижнему регистру

## Проблема

Вот первая проблема: в некоторых случаях разделители рассматриваются как обычные символы слов, например в "`Is c++ the most powerful language?`" очевидно, что `c++` — отдельное слово. Это легко понять людям, но не алгоритмам полнотекстового поиска, поскольку они видят знак плюса, не находят его в списке символов слов и удаляют из токена, в результате вы получаете:

```sql
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#`, например, вы найдёте вышеуказанное предложение:

```sql
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` из документа, и вы получаете результат.

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

> ОК, почему бы мне не добавить + и # в список символов слов?

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

```sql
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)
```

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

```sql
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++` особым образом, вы можете сделать его исключением.

## Исключения

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

Давайте сделаем `c++` исключением, поместив его в файл исключений:
```bash
➜  ~ cat /tmp/exceptions
c++ => c++
```

и используя этот файл при создании таблицы:

```sql
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++` как в нижнем, так и в верхнем регистре. Давайте попробуем вышеуказанное исключение в верхнем регистре?

```sql
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++`?

```sql
➜  ~ 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`.

Итак, правило большого пальца для исключений таково:

{{< notice "tip" >}}
Если термин содержит специальные символы и обычно пишется так в тексте и в поисковом запросе — сделайте его исключением.
{{< /notice >}}

# Синонимы

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

```bash
MS Windows => ms windows
Microsoft Windows => ms windows
```

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

Пример:

```sql
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` и так далее?»

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

> Выглядит совсем неплохо. Что мы можем с этим сделать?

## Словоформы

Другой инструмент, похожий на исключения, — это `wordforms`. В отличие от исключений, словоформы применяются после токенизации входящего текста. Поэтому они:
* регистронезависимы (если только ваш `charset_table` не включает чувствительность к регистру)
* не учитывают специальные символы

По сути они позволяют заменить одно слово другим. Обычно это используется для приведения разных форм слова к одной нормальной форме. Например, нормализовать все варианты, такие как «walks», «walked», «walking», до нормальной формы «walk»:

```bash
➜  ~ cat /tmp/wordforms
walks => walk
walked => walk
walking => walk
```

```sql
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)
```

Как вы можете видеть, все 3 слова были преобразованы в просто `walk`, и обратите внимание, что второе слово `_WaLkeD!`, даже будучи сильно деформированным, также было нормально преобразовано. Вы понимаете, к чему я веду? Да, пример с `MS Windows`. Давайте проверим, могут ли формы слов быть полезны для решения этой проблемы.

Давайте добавим всего 2 строки в файл форм слов:

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

и заполним таблицу несколькими документами:

```sql
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)
```

Теперь давайте попробуем различные запросы:
```sql
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` нормально.

```sql
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` тоже работает нормально.


```sql
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` находит тот же документ.

```sql
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` находит все документы.

Таким образом, `wordforms` действительно помогает решить проблему.

Общее правило для форм слов:

{{< notice "tip" >}}
Используйте формы слов для слов и фраз, которые могут быть написаны в разных формах и не содержат специальных символов.
{{< /notice >}}

## Floor & Decor

Давайте рассмотрим еще один пример: мы хотим улучшить поиск по названию бренда `Floor & Decor`. Мы можем предположить, что люди могут писать это название в следующих формах:

```bash
Floor & Decor
Floor & decor
floor & decor
Floor and Decor
floor and decor
```
и других комбинациях с разным регистром букв.

Также:
```bash
Floor & Decor Holdings
Floor & Decor Holdings, inc.
```
и, снова, различные комбинации с разными заглавными буквами.

Теперь, когда мы знаем, как работают `exceptions` и `wordforms`, что мы можем сделать, чтобы охватить это название бренда?

Прежде всего, мы можем легко заметить, что каноническое название бренда — это `Floor & Decor`, т.е. оно включает специальный символ, который обычно считается разделителем слов, так что нам следует использовать `exceptions`? Но название длинное и может быть написано многими способами. Если мы используем `exceptions`, мы можем получить огромный список всех комбинаций. Более того, есть расширенные формы `Floor & Decor Holdings` и `Floor & Decor Holdings, inc.`, которые могут сделать список еще длиннее.

Наиболее **оптимальное** решение в этом случае — просто использовать `wordforms` вот так:

```bash
➜  ~ 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
```

Почему оно включает `&`? На самом деле, вы можете его пропустить:
```bash
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
```
потому что `wordforms` все равно игнорирует несловесные символы, но просто для удобства чтения он был оставлен.

В результате вы получите каждую комбинацию, токенизированную как `fnd`, что будет нашим коротким ключом для этого названия бренда.

```sql
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)
```


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

```sql
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)
```

Мы видим здесь, что `Floor & Decor Holdings` находит документ, в котором `floor` находится в конце первого предложения, а следующее начинается с `Decor`. Это происходит потому, что `floor. Decor` также токенизируется в `fnd`, так как мы используем только `wordforms`, которые нечувствительны к регистру букв и специальным символам:

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

Ложное совпадение — это плохо. Чтобы решить эту конкретную проблему, мы можем использовать функциональность Manticore для [обнаружения предложений и абзацев](https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Advanced_HTML_tokenization#index_sp).

Теперь, если мы включим это, мы увидим, что документ больше не соответствует ключевому слову:

```sql
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)
```

потому что:
1. `Floor & Decor`, как мы помним, преобразуется в `fnd` с помощью `wordforms`
2. `index_sp='1'` разбивает текст на предложения
3. после разбиения `floor.` и `Decor` оказываются в разных предложениях
4. и больше не соответствуют `fnd`, и, следовательно, всем оригинальным формам этого слова


# Заключение

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

Спасибо за чтение этой статьи!

#### Ссылки:

* [Документация по `exceptions`](https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Exceptions)
* [Документация по `wordforms`](https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Wordforms)
