12.3. Управление текстовым поиском

Для реализации полнотекстового поиска необходимы функции, позволяющие создать tsvector из документа и tsquery из запроса пользователя. Кроме того, результаты нужно выдавать в удобном порядке, так что нам потребуется функция, оценивающая релевантность документа для данного запроса. Важно также иметь возможность выводить найденный текст подходящим образом. В Postgres Pro есть все необходимые для этого функции.

12.3.1. Разбор документов

Для преобразования документа в тип tsvector Postgres Pro предоставляет функцию to_tsvector.

to_tsvector([конфигурация regconfig,] документ text) returns tsvector

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

SELECT to_tsvector('english', 'a fat  cat sat on a mat - it ate a fat rats');
                  to_tsvector
-----------------------------------------------------
 'ate':9 'cat':3 'fat':2,11 'mat':7 'rat':12 'sat':4

В этом примере мы видим, что результирующий tsvector не содержит слова a, on и it, слово rats превратилось rat, а знак препинания «-» был проигнорирован.

Функция to_tsvector внутри вызывает анализатор, который разбивает текст документа на фрагменты и классифицирует их. Для каждого фрагмента она проверяет список словарей (Раздел 12.6), определяемый типом фрагмента. Первый же словарь, распознавший фрагмент, выдаёт одну или несколько представляющих его лексем. Например, rats превращается в rat, так как один из словарей понимает, что слово rats — это слово rat во множественном числе. Некоторое слова распознаются как стоп-слова (Подраздел 12.6.1) и игнорируются как слова, фигурирующие в тексте настолько часто, что искать их бессмысленно. В нашем примере это a, on и it. Если фрагмент не воспринимается ни одним словарём из списка, он так же игнорируется. В данном примере это происходит со знаком препинания -, так как с таким типом фрагмента (символы-разделители) не связан никакой словарь и значит такие фрагменты никогда не будут индексироваться. Выбор анализатора, словарей и индексируемых типов фрагментов определяется конфигурацией текстового поиска (Раздел 12.7). В одной базе данных можно использовать разные конфигурации, в том числе, предопределённые конфигурации для разных языков. В нашем примере мы использовали конфигурацию по умолчанию для английского языка — english.

Для назначения элементам tsvector разных весов используется функция setweight. Вес элемента задаётся буквой A, B, C или D. Обычно это применяется для обозначения важности слов в разных частях документа, например в заголовке или в теле документа. Затем эта информация может использоваться при ранжировании результатов поиска.

Так как to_tsvector(NULL) вернёт NULL, мы советуем использовать coalesce везде, где соответствующее поле может быть NULL. Создавать tsvector из структурированного документа рекомендуется так:

UPDATE tt SET ti =
    setweight(to_tsvector(coalesce(title,'')), 'A')    ||
    setweight(to_tsvector(coalesce(keyword,'')), 'B')  ||
    setweight(to_tsvector(coalesce(abstract,'')), 'C') ||
    setweight(to_tsvector(coalesce(body,'')), 'D');

Здесь мы использовали setweight для пометки происхождения каждой лексемы в сформированных значениях tsvector и объединили помеченные значения с помощью оператора конкатенации типов tsvector ||. (Подробнее эти операции рассматриваются в Подразделе 12.4.1.)

12.3.2. Разбор запросов

Postgres Pro предоставляет функции to_tsquery, plainto_tsquery и phraseto_tsquery для приведения запроса к типу данных tsquery. Функция to_tsquery даёт больше возможностей, чем plainto_tsquery и phraseto_tsquery, но более строга к входному запросу.

to_tsquery([конфигурация regconfig,] текст_запроса text) returns tsquery

to_tsquery создаёт значение tsquery из текста_запроса, который может состоять из простых фрагментов, разделённых логическими операторами & (И), | (ИЛИ), ! (НЕ), а также операторами поиска фраз <-> (ПРЕДШЕСТВУЕТ). Эти операторы могут быть заключены в скобки. Другими словами, входное значение для to_tsquery должно уже соответствовать общим правилам для значений tsquery, описанным в Разделе 8.11. Различие их состоит в том, что во вводимом в tsquery значении фрагменты воспринимаются буквально, тогда как to_tsquery нормализует фрагменты, приводя их к лексемам, используя явно указанную или подразумеваемую конфигурацию, и отбрасывая стоп-слова. Например:

SELECT to_tsquery('english', 'The & Fat & Rats');
  to_tsquery   
---------------
 'fat' & 'rat'

Как и при вводе значения tsquery, для каждой лексемы можно задать вес, чтобы при поиске можно было выбрать из tsvector только лексемы с заданными весами. Например:

SELECT to_tsquery('english', 'Fat | Rats:AB');
    to_tsquery    
------------------
 'fat' | 'rat':AB

К лексеме также можно добавить *, определив таким образом условие поиска по префиксу:

SELECT to_tsquery('supern:*A & star:A*B');
        to_tsquery        
--------------------------
 'supern':*A & 'star':*AB

Такая лексема будет соответствовать любому слову в tsvector, начинающемуся с данной подстроки.

to_tsquery может также принимать фразы в апострофах. Это полезно в основном когда конфигурация включает тезаурус, который может обрабатывать такие фразы. В показанном ниже примере предполагается, что тезаурус содержит правило supernovae stars : sn:

SELECT to_tsquery('''supernovae stars'' & !crab');
  to_tsquery
---------------
 'sn' & !'crab'

Если убрать эти апострофы, to_tsquery не примет фрагменты, не разделённые операторами AND и OR, и выдаст синтаксическую ошибку.

plainto_tsquery([конфигурация regconfig,] текст_запроса text) returns tsquery

plainto_tsquery преобразует неформатированный текст_запроса в значение tsquery. Текст разбирается и нормализуется подобно тому, как это делает to_tsvector, а затем между оставшимися словами вставляются логические операторы & (AND).

Пример:

SELECT plainto_tsquery('english', 'The Fat Rats');
 plainto_tsquery 
-----------------
 'fat' & 'rat'

Заметьте, что plainto_tsquery не распознаёт во входной строке логические операторы, операторы поиска фраз, метки весов или обозначения префиксов:

SELECT plainto_tsquery('english', 'The Fat & Rats:C');
   plainto_tsquery   
---------------------
 'fat' & 'rat' & 'c'

В данном случае все знаки пунктуации были отброшены как пробельные символы.

phraseto_tsquery([конфигурация regconfig,] текст_запроса text) returns tsquery

phraseto_tsquery ведёт себя подобно plainto_tsquery, за исключением того, что она использует оператор <-> (ПРЕДШЕСТВУЕТ) вместо булевского оператора & (И). Это особенно полезно при поиске точных последовательностей лексем, так как оператор фразового поиска поддерживает порядок лексем.

Например:

SELECT phraseto_tsquery('english', 'The Fat Rats');
 phraseto_tsquery
------------------
 'fat' <-> 'rat'

Как и plainto_tsquery, phraseto_tsquery не распознаёт логические и фразовые поисковые операторы, метки веса или метки префикса в своих аргументах:

SELECT phraseto_tsquery('english', 'The Fat & Rats:C');
      phraseto_tsquery
-----------------------------
 'fat' <-> 'rat' <-> 'c'

Можно указать конфигурацию, используемую для разбора документа, например, мы можем создать новую конфигурацию, используя словарь hunspell (назовём её eng_hunspell) для того, чтобы находить слова в различных формах:

SELECT phraseto_tsquery('eng_hunspell', 'developer of the building which collapsed');
                                      phraseto_tsquery
--------------------------------------------------------------------------------------------
 ( 'developer' <3> 'building' ) <2> 'collapse' | ( 'developer' <3> 'build' ) <2> 'collapse'

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

Ранжирование документов можно представить как попытку оценить, насколько они релевантны заданному запросу и отсортировать их так, чтобы наиболее релевантные выводились первыми. В Postgres Pro встроены две функции ранжирования, принимающие во внимание лексическую, позиционную и структурную информацию; то есть, они учитывают, насколько часто и насколько близко встречаются в документе ключевые слова и какова важность содержащей их части документа. Однако само понятие релевантности довольно размытое и во многом определяется приложением. Приложения могут использовать для ранжирования и другую информацию, например, время изменения документа. Встроенные функции ранжирования можно рассматривать лишь как примеры реализации. Для своих конкретных задач вы можете разработать собственные функции ранжирования и/или учесть при обработке их результатов дополнительные факторы.

Ниже описаны две встроенные функции ранжирования:

ts_rank([веса float4[],] вектор tsvector, запрос tsquery [, нормализация integer]) returns float4

Ранжирует векторы по частоте найденных лексем.

ts_rank_cd([веса float4[],] вектор tsvector, запрос tsquery [, нормализация integer]) returns float4

Эта функция вычисляет плотность покрытия для данного вектора документа и запроса, используя метод, разработанный Кларком, Кормаком и Тадхоуп и описанный в статье "Relevance Ranking for One to Three Term Queries" в журнале "Information Processing and Management" в 1999 г. Плотность покрытия вычисляется подобно рангу ts_rank, но в расчёт берётся ещё и близость соответствующих лексем друг к другу.

Для вычисления результата этой функции требуется информация о позиции лексем. Поэтому она игнорируют «очищенные» от этой информации лексемы в tsvector. Если во входных данных нет неочищенных лексем, результат будет равен нулю. (За дополнительными сведениями о функции strip и позиционной информации в данных tsvector обратитесь к Подразделу 12.4.1.)

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

{вес D, вес C, вес B, вес A}

Если этот аргумент опускается, подразумеваются следующие значения:

{0.1, 0.2, 0.4, 1.0}

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

Так как вероятность найти ключевые слова увеличивается с размером документа, при ранжировании имеет смысл учитывать его, чтобы, например, документ с сотней слов, содержащий пять вхождений искомых слов, считался более релевантным, чем документ с тысячей слов и теми же пятью вхождениями. Обе функции ранжирования принимают целочисленный параметр нормализации, определяющий, как ранг документа будет зависеть от его размера. Этот параметр представляет собой битовую маску и управляет несколькими режимами: вы можете включить сразу несколько режимов, объединив значения оператором | (например так: 2|4).

  • 0 (по умолчанию): длина документа не учитывается

  • 1: ранг документа делится на 1 + логарифм длины документа

  • 2: ранг документа делится на его длину

  • 4: ранг документа делится на среднее гармоническое расстояние между блоками (это реализовано только в ts_rank_cd)

  • 8: ранг документа делится на число уникальных слов в документе

  • 16: ранг документа делится на 1 + логарифм числа уникальных слов в документе

  • 32: ранг делится своё же значение + 1

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

Важно заметить, что функции ранжирования не используют никакую внешнюю информацию, так что добиться нормализации до 1% или 100% невозможно, хотя иногда это желательно. Применив параметр 32 (rank/(rank+1)), можно свести все ранги к диапазону 0..1, но это изменение будет лишь косметическим, на порядке сортировки результатов это не отразится.

В данном примере выбираются десять найденных документов с максимальным рангом:

SELECT title, ts_rank_cd(textsearch, query) AS rank
FROM apod, to_tsquery('neutrino|(dark & matter)') query
WHERE query @@ textsearch
ORDER BY rank DESC
LIMIT 10;
                     title                     |   rank
-----------------------------------------------+----------
 Neutrinos in the Sun                          |      3.1
 The Sudbury Neutrino Detector                 |      2.4
 A MACHO View of Galactic Dark Matter          |  2.01317
 Hot Gas and Dark Matter                       |  1.91171
 The Virgo Cluster: Hot Plasma and Dark Matter |  1.90953
 Rafting for Solar Neutrinos                   |      1.9
 NGC 4650A: Strange Galaxy and Dark Matter     |  1.85774
 Hot Gas and Dark Matter                       |   1.6123
 Ice Fishing for Cosmic Neutrinos              |      1.6
 Weak Lensing Distorts the Universe            | 0.818218

Тот же пример с нормализованным рангом:

SELECT title, ts_rank_cd(textsearch, query, 32 /* rank/(rank+1) */ ) AS rank
FROM apod, to_tsquery('neutrino|(dark & matter)') query
WHERE  query @@ textsearch
ORDER BY rank DESC
LIMIT 10;
                     title                     |        rank
-----------------------------------------------+-------------------
 Neutrinos in the Sun                          | 0.756097569485493
 The Sudbury Neutrino Detector                 | 0.705882361190954
 A MACHO View of Galactic Dark Matter          | 0.668123210574724
 Hot Gas and Dark Matter                       |  0.65655958650282
 The Virgo Cluster: Hot Plasma and Dark Matter | 0.656301290640973
 Rafting for Solar Neutrinos                   | 0.655172410958162
 NGC 4650A: Strange Galaxy and Dark Matter     | 0.650072921219637
 Hot Gas and Dark Matter                       | 0.617195790024749
 Ice Fishing for Cosmic Neutrinos              | 0.615384618911517
 Weak Lensing Distorts the Universe            | 0.450010798361481

Ранжирование может быть довольно дорогостоящей операцией, так как для вычисления ранга необходимо прочитать tsvector каждого подходящего документа и это займёт значительное время, если придётся обращаться к диску. К сожалению, избежать этого вряд ли возможно, так как на практике по многим запросам выдаётся большое количество результатов.

12.3.4. Выделение результатов

Представляя результаты поиска, в идеале нужно выделять часть документа и показывать, как он связан с запросом. Обычно поисковые системы показывают фрагменты документа с отмеченными искомыми словами. В Postgres Pro для реализации этой возможности представлена функция ts_headline.

ts_headline([конфигурация regconfig,] документ text, запрос tsquery [, параметры text])
  returns text

ts_headline принимает документ вместе с запросом и возвращает выдержку из документа, в которой выделяются слова из запроса. Применяемую для разбора документа конфигурацию можно указать в параметре config; если этот параметр опущен, применяется конфигурация default_text_search_config.

Если в параметрах передаётся строка options, она должна состоять из списка разделённых запятыми пар параметр=значение. Параметры могут быть следующими:

  • StartSel, StopSel: строки, которые будут разграничивать слова запроса в документе, выделяя их среди остальных. Если эти строки содержат пробелы или запятые, их нужно заключить в кавычки.

  • MaxWords, MinWords: эти числа определяет нижний и верхний предел размера выдержки.

  • ShortWord: слова такой длины или короче в начале и конце выдержки будут отбрасываться. Значение по умолчанию, равное 3, исключает распространённые английские артикли.

  • HighlightAll: логический флаг; если он равен true, выдержкой будет весь документ и три предыдущие параметра игнорируются.

  • MaxFragments: максимальное число выводимых текстовых выдержек или фрагментов. Значение по умолчанию, равное 0, выбирает метод создания выдержки без фрагментов. При значении большем 0 выбирается метод с фрагментами, когда находятся все фрагменты, содержащие как можно больше слов запроса, а затем они сжимаются до слов запроса. Такие фрагменты могут содержать какие-то ключевые слова в середине и ограничиваются двумя искомыми словами. При этом фрагменты могут содержать не больше MaxWords слов, а в начале и конце они будут очищены от слов длины ShortWord и меньше. Если в документе найдены не все слова запроса, выводится один фрагмент, включающий первые MinWords слов в документе.

  • FragmentDelimiter: Когда выводятся несколько фрагментов, они будут разделяться этой строкой.

Все явно не определённые параметры получают такие значения по умолчанию:

StartSel=<b>, StopSel=</b>,
MaxWords=35, MinWords=15, ShortWord=3, HighlightAll=FALSE,
MaxFragments=0, FragmentDelimiter=" ... "

Пример использования:

SELECT ts_headline('english',
  'The most common type of search
is to find all documents containing given query terms
and return them in order of their similarity to the
query.',
  to_tsquery('query & similarity'));
                        ts_headline                         
------------------------------------------------------------
 containing given <b>query</b> terms
 and return them in order of their <b>similarity</b> to the
 <b>query</b>.

SELECT ts_headline('english',
  'The most common type of search
is to find all documents containing given query terms
and return them in order of their similarity to the
query.',
  to_tsquery('query & similarity'),
  'StartSel = <, StopSel = >');
                      ts_headline                      
-------------------------------------------------------
 containing given <query> terms
 and return them in order of their <similarity> to the
 <query>.

Функция ts_headline работает с оригинальным документом, а не с его сжатым представлением tsvector, так что она может быть медленной и использовать её следует осмотрительно. Типичная ошибка — вызывать ts_headline для всех подходящих документов, когда показываются только десять. Правильный подход можно реализовать, применив подзапросы SQL, например так:

SELECT id, ts_headline(body, q), rank
FROM (SELECT id, body, q, ts_rank_cd(ti, q) AS rank
      FROM apod, to_tsquery('stars') q
      WHERE ti @@ q
      ORDER BY rank DESC
      LIMIT 10) AS foo;