Pull to refresh

Конвейер обработки текста в Sphinx

Reading time 10 min
Views 15K
Original author: Andrew Aksyonoff
Обработка текста в поисковом движке выглядит достаточно простой снаружи, однако на самом деле это сложный процесс. При индексации текст документов должен быть обработан стриппером HTML, токенайзером, фильтром стоп-слов, фильтром словоформ и морфологическим процессором. А ещё при этом нужно помнить про исключения (exceptions), слитные (blended) символы, N-граммы и границы предложений. При поиске всё становится ещё сложнее, поскольку помимо всего вышеупомянутого нужно вдобавок обрабатывать синтаксис запроса, который добавляет всевозможные спец. символы (операторы и маски). Сейчас мы расскажем, как всё это работает в Sphinx.

Картина в целом


Упрощённо конвейер обработки текста (в движке версий 2.х) выглядит примерно так:



Выглядит достаточно просто, однако дьявол кроется в деталях. Есть несколько очень разных фильтров (которые применяются в особом порядке); токенайзер занимается ещё чем-то помимо разбиения текста на слова; и наконец под «и т.д.» в блоке морфологии на самом деле находится ещё по меньшей мере три разных варианта.

Поэтому более точной будет следующая картина:





Фильтры регулярных выражений



Это необязательный шаг. По существу это набор регулярных выражений, которые применяются к документам и запросам, направляемым в Сфинкс, и ничего более! Таким образом это всего лишь «синтаксический сахар», однако достаточно удобный: с помощью регэкспов Sphinx обрабатывает всё подряд, а без них вам бы пришлось писать отдельный скрипт для загрузки данных в сфинкс, затем ещё один — чтобы исправлять запросы, и оба скрипта нужно было бы держать синхронными. А изнутри Сфинкса мы просто запускаем все фильтры над полями и запросами перед любой дальнейшей обработкой. Всё! Более детальное описание вы найдёте в разделе regexp_filter документации.

Стриппер HTML


Это тоже необязательный шаг. Данный обработчик подключается только если в конфигурации источника указана директива html_strip. Этот фильтр работает сразу после фильтра регулярных выражений. Стриппер устраняет из входящего текста все HTML-тэги. Кроме того, он умеет извлекать и индексировать отдельные атрибуты указанных тэгов (см. html_index_attrs), а также удалять текст между тэгами (см. html_remove_elements). Наконец, поскольку зоны в документах и абзацы используют ту же разметку SGML, стриппер выполняет определение границ зон и абзацев (см. index_sp и index_zones). (В противном случае пришлось бы делать ещё один точно такой же проход по документу ради решения этой задачи. Неэффективно!)

Токенизация


Этот шаг обязателен. Что бы ни случилось, нам необходимо разбить фразу «Mary had a little lamb» на отдельные ключевые слова. В этом и состоит суть токенизации: превратить текстовое поле во множество ключевых слов. Казалось бы, что может быть проще?

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

Во-первых, существуют хитрые символы, которые одновременно и «символы» и «не символы», и даже более того, одновременно могут быть «символом», «пробелом» и «знаком препинания» (который на первый взгляд тоже можно трактовать как пробел, но на самом деле нельзя). Для борьбы со всем этим хозяйством используются настройки charset_table, blend_chars, ignore_chars и ngram_chars.

По умолчанию токенайзер Сфинкса рассматривает все неизвестные символы как пробелы. Потому, какую бы сумасшедшую юникодную псевдографику вы бы ни «запилили» в свой документ, она будет проиндексирована просто как пробел. Все символы, упомянутые в charset_table, рассматриваются как обычные символы. Также charset_table позволяет отображать одни символы в другие: обычно это используется для приведения символов к одному регистру, для удаления диакритических знаков, либо для всего вместе. В большинстве случаев этого уже достаточно: сводим известные символы к содержимому charset_table; заменяем все неизвестные (включая пунктуацию) на пробелы — и всё, токенизация готова.

Однако есть три существенных исключения.

  • Иногда текстовый редактор (например, Word) вставляет символы мягкого переноса прямо в текст! И если вы не проигнорируете их целиком (вместо того, чтобы просто заменить на пробелы), то текст будет проиндексирован как «ma ry had a lit t le lamb». Для решения этой проблемы используйте ignore_chars.
  • Восточные языки с иероглифами. Им не существенны пробелы! Поэтому для ограниченной поддержки текстов CJK (Chinese, Japanese, Korean) в ядре вы можете указать директиву ngram_chars, и тогда каждый такой символ будет рассмотрен как отдельное ключевое слово, как будто он окружен пробелами (даже если на самом деле это не так).
  • Для хитрых символов вроде & или. мы на самом деле НЕ ЗНАЕМ в процессе разбиения на слова, хотим ли мы их проиндексировать, либо удалить. Например, во фразе «Jeeves & Wooster» знак & вполне можно убрать. А вот в AT&T — никак! Также нельзя портить «Marwel's Agents of S.H.I.E.L.D». Для этого Сфинксу можно указать список символов директивой blend_chars. Символы из этого списка будут обработаны сразу двумя способами: как обычные символы, и как пробелы. Заметьте, как простой оборот из обычных символов может привести к генерации множества токенов, когда в игру вступает список blend_chars: скажем, поле «Back to U.S.S.R» будет, как обычно, разбито на токены back to u s s r, как обычно, но кроме того ещё один токен «u.s.s.r» будет проиндексирован в той же позиции, что и «u» в базовом разделении.


И всё это происходит уже с самыми базовыми элементами текста — символами! Испугались?!

Вдобавок, токенайзер (как ни странно) умеет работать с исключениями (exceptions) (такими как C++ или C# — где спец. символы имеют смысл только в этих ключевых слова и могут быть полностью проигнорированы во всех других случаях), и кроме того он умеет определять границы предложений (если задана директива index_sp). Данная задача не может быть решена позже, поскольку после токенизации у нас больше не будет ни спец. символов, ни пунктуации. Также этим не стóит заниматься на более ранних стадиях, поскольку, опять же, 3 прохода по одному и тому же тексту, чтобы сделать над ним 4 операции это хуже, чем один-единственный, который сразу всё расставит по своим местам.

Внутри токенайзер устроен так, что исключения срабатывают раньше всего остального. В этом смысле они очень похожи на фильтры регулярных выражений (и более того, их вполне можно эмулировать с помощью регулярных выражений. Мы тут говорим «вполне можно», однако сами никогда не пробовали: на самом деле с исключениями работать гораздо проще и быстрее. Хотите добавить ещё один регэксп? Ок, это приведёт к ещё одному проходу по тексту поля. А вот все исключения сразу применяются на единственном проходе токенайзера и занимают 15-20% от времени токенизации (что в целом составит 2-5% от общего времени индексации).

Определение границ предложений определено в коде токенайзера и там ничего настраивать нельзя (да и не нужно). Просто включайте и надейтесь, что всё заработает (обычно так и бывает; хотя кто знает, может быть найдутся какие-нибудь странные краевые случаи).

Итак, если вы возьмёте сравнительно безобидную точку, и впишете её сперва в одном из exception, а также в blend_chars, а также поставите index_sp=1 — вы рискуете разворошить целое осиное гнездо (к счастью, не выходящее за границы токенайзера). Опять же, снаружи всё «просто работает» (хотя если вы включите ВСЕ вышеупомянутые опции, а потом ещё и попробуете проиндексировать какой-нибудь странный текст, который вызовет срабатывание всех одновременно условий и тем самым пробудит Ктулху — что ж, сами виноваты!)

С этого момента у нас есть токены! И все последующие фазы обработки имеют дело именно с отдельными токенами. Вперёд!

Словоформы и морфология


Оба шага необязательны; оба по умолчанию отключены. Более интересно то, что словоформы и морфологические процессоры (стеммеры и лемматизаторы) в некотором роде взаимозаменяемы, и потому мы рассматриваем их вместе.

Каждое слово, созданное токенайзером, обрабатывается отдельно. Доступно несколько различных обработчиков: от тупых, но всё ещё кое-где популярных Soundex и Metaphone до классических стеммеров Портера, включая библиотеку libstemmer, а также полноценных словарных лемматизаторов. Все обработчики в целом берут одно слово и заменяют его заданной нормализованной формой. Пока неплохо.

А теперь детали: морфологические обработчики применяются в точности в том порядке, как упомянуты в конфиге, до тех пор, пока слово не будет обработано. То есть как только слово оказалось изменено одним из обработчиков — всё, цепочка обработки заканчивается, и все последующие обработчики даже не будут вызваны. Например, в цепочке morphology = stem_en, stem_fr английский стеммер будет иметь преимущество; а в цепочке morphology = stem_fr,stem_en — французский. А в цепочке morphology = soundex, stem_en упоминание английского стеммера по существу бесполезно, поскольку soundex преобразует все английские слова ещё до того, как стеммер до них доберётся. Важный краевой эффект этого поведения состоит в том, что если слово уже находится в нормальной форме и это обнаружил один из стеммеров (но, при этом, разумеется, ничего не стал менять), то оно будет обработано последующими стеммерами.

Далее. Обычные словоформы — это неявный морфологический обработчик наивысшего приоритета. Если заданы словоформы, то слова в первую очередь обрабатываются ими, и попадают в обработчики морфологии только если никаких преобразований не случилось. Таким образом любую неприятную ошибку стеммера или лемматизатора можно исправить с помощью словоформ. Например, английский стеммер приводит слова «business» и «busy» к одинаковой основе «busi». И это легко исправляется добавлением одной строчки «business => business» в словоформы. (и да, заметьте — словоформы даже больше, чем морфология, поскольку в этом случае достаточно факта замены слова, и неважно, что само оно, по сути, не изменилось).

Выше были упомянуты «обычные словоформы». И вот, почему: всего есть три разных типа словоформ.
  1. Обычные словоформы. Они отображают токены 1:1 и в некотором роде заменяют морфологию (мы только что об этом упоминали)
  2. Морфологические словоформы. Можно заменить всех бегающих на гуляющих единственной строчкой "~бегать => гулять" вместо множества правил про «бег», «бежать», «бегал», «убежал» и т.д. И если в английском языке таких вариантов может быть не так много, то в некоторых других, вроде нашего русского, у одной основы могут быть десятки или даже сотни разных флексий. Морфологические словоформы применяются после обработчиков морфологии. И они по-прежнему отображают слова 1:1
  3. Мультиформы. Они отображают слова M:N. В целом они работают как обычная подстановка и выполняются на как можно более ранней стадии. Наиболее просто представить мультиформы как некие ранние замены. В этом смысле они являются своего рода регэкспом или исключением, однако применяются на другой стадии и потому игнорируют пунктуацию. Заметьте, что после применения мультиформ получившиеся токены подвергаются всем остальным морфологическим обработкам, включая обычные 1:1 словоформы!


Рассмотрим пример:

morphology = stem_en
wordforms = myforms.txt
 
myforms.txt:
walking => crawling
running shoes => walking pants
~pant => shoes


Допустим, мы индексируем документ «my running shoes» с этими странными настройками. Что же будет в результате в индексе?

  • Сперва мы получим три токена — «my» «running» «shoes».
  • Затем применится мультиформа и преобразует это в «my» «walking» «pants».
  • Обычная словоформа отобразит «walking» в «crawling» (получится «my» «crawling» «pants»)
  • Морфологический процессор (английский стеммер) обработает «my» и «pants» (поскольку «walking» уже обработана обычной словоформой) и выдаст «my» «crawling» «pant»
  • Наконец, морфологическая словоформа отобразит все формы слова pant в shoes. Получившиеся в конечном результате токены «my» «crawling» «shoes» и будут сохранены в индексе.


Звучит солидно. Однако как простому смертному, который не занимается разработкой Sphinx и совсем не привык отлаживать код на C++, догадаться до всего этого? Очень просто: для этого есть специальная команда:

mysql> call keywords('my running shoes', 'test1');
+------+---------------+------------+
| qpos | tokenized     | normalized |
+------+---------------+------------+
| 1    | my            | my         |
| 2    | running shoes | crawling   |
| 3    | running shoes | shoes      |
+------+---------------+------------+
3 rows in set (0.00 sec)


и в завершение этого раздела проиллюстрируем, как морфология и три разных типа словоформ взаимодействуют вместе:



Слова и позиции


После всех обработок токены имеют определённые позиции. Обычно они просто пронумерованы последовательно, начиная с единицы. Однако каждая позиция в документе может принадлежать одновременно нескольким токенам! Обычно это происходит когда один «сырой» токен генерирует несколько версий окончательного слова либо с помощью слитных символов, либо лемматизацией, либо ещё несколькими путями.

Магия слитных символов


Например, «AT&T» в случае слитного символа "&" будет разбито на «at» в позиции 1, «t» в позиции 2, а также «at&t» в позиции 1.

Лемматизация


Это уже интереснее. Например, имеем документ «White dove flew away. I dove into the pool.» Первое вхождение слова «dove» — это существительное. Второе — глагол «dive» в прошедшем времени. Но разбирая эти слова как отдельные токены, мы никак не можем об этом сказать (и даже если будем смотреть на несколько токенов сразу — бывает достаточно трудно принять правильное решение). В этом случае morphology = lemmatize_en_all приведёт к индексации всех возможных вариантов. В данном примере в позициях 2 и 6 будут проиндексировано по два разных токена, так что и «dove» и «dive» будут сохранены.

Позиции влияют на поиск с помощью фраз (phrase) и неточных фраз (proximity); также они влияют на ранжирование. И в результате любой из четырёх запросов — «white dove», «white dive», «dove into» «dive into» приведёт к нахождению документа в режиме фразы.

Стопслова


Шаг удаления стоп-слов очень прост: мы просто выбрасываем их из текста. Однако пару вещей всё же нужно иметь в виду:
1. Как можно полностью игнорировать стоп-слова (вместо того чтобы просто затереть их пробелами). Даже хотя стоп-слова и выбрасываются, позиции остальных слов остаются неизменными. Это значит, что «microsoft office» и «microsoft in the office» в случае игнорирования «in» и «the» как стоп-слов, произведут разные индексы. В первом документе слово «office» находится в позиции 2. Во втором — в позиции 4. Если же вы хотите полностью убрать стоп-слова, вы можете задействовать директиву stopword_step и установить её в 0. Это повлияет на поиск фраз и ранжирование.
2. Как добавить в стоп-слова отдельные формы или же полные леммы. Эта настройка называется stopwords_unstemmed и определяется, применяется ли удаление стоп-слов до или после морфологии.

Что ещё осталось?


Ну вот, мы практически покрыли все типовые задачи повседневной обработки текста. Теперь вам должно быть понятно, что там происходит внутри, как это всё работает вместе, и как настроить Сфинкс, чтоб добиться нужного вам результата. Ура!

Но есть ещё кое-что. Вкратце упомянем, что есть также опция index_exact_words, которая предписывает проиндексировать первоначальный токен (до применения морфологии) вдобавок к морфологию. Также есть опция bigram_index, которая заставит сфинкс индексировать пары слов («a brown fox» станет токенами «a brown», «brown fox») и затем использовать их для сверхбыстрого поиска по фразам. Также можно задействовать плагины индексации и запросов, которые позволят вам реализовать практически любую нужную обработку токенов.

И наконец, в грядущем релизе Sphinx 3.0 есть планы унифицировать все эти настройки, чтобы вместо общих директив, которые действуют на весь документ целиком, дать возможность строить отдельные цепочки фильтров для обработки отдельных полей. Так чтобы можно было, например, сперва удалить какие-то стоп-слова, затем применить словоформы, затем морфологию, затем ещё один фильтр словоформ и т.д.
Tags:
Hubs:
+16
Comments 8
Comments Comments 8

Articles