Статьи

Поиск по иерархическим полям с использованием Solr

В наших недавних и постоянных усилиях сделать мир лучше, мы работали с прославленным  Уолдо Джакитом  над проектом под названием StateDecoded. По сути, мы делаем законы легко доступными для поиска и доступными для непрофессионала. Здесь, посмотрите предшествующий проект,  Virginia Decoded . StateDecoded будет похож на предшественника, но с расширенными и развитыми возможностями поиска. И вместо только закона штата Вирджиния, с StateDecoded, любой муниципалитет сможет  загрузить  индекс проекта с открытым исходным кодом свои собственные законы и дать своим гражданам лучшую видимость в правилах, которые ими управляют.

Однако в этом посте я хочу остановиться на одной из хороших загадок Solr, с которыми мы столкнулись, связанных с иерархической природой индексируемых документов. Законы разделены на разделы, главы и параграфы, и у нас есть документы на каждом уровне. В нашем Solr эта иерархия отражена в поле, помеченном как «раздел». Так, например, вот 3 примера этого поля раздела:

  • <field name="section">30</field> — Документ, который содержит информацию, относящуюся к разделу 30.
  • <field name="section">30.4</field> — Документ, содержащий информацию, относящуюся к разделу 30 главы 4.
  • <field name="section">30.4.15</field> — Документ, содержащий информацию, относящуюся к разделу 30 главы 4 пункта 15.

И наша цель в этой области состоит в том, что если кто-то ищет определенный раздел закона, ему будет предоставлен закон, наиболее специфичный для их запроса, и законы, которые менее конкретны. Например, если пользователь ищет «30,4», то результаты должны содержать документы для раздела 30, раздела 30.4, раздела 30.4.15, раздела 30.4.16 и т. Д., А первый результат должен быть для 30,4. Другие документы, такие как 40.4 не должны быть возвращены.

Изначально мы решили проблему с PathHierarchyTokenizerFactory.

<fieldname="section"type="path"indexed="true"stored="true"/>

и

<fieldTypename="path"class="solr.TextField"><analyzer><tokenizerclass="solr.PathHierarchyTokenizerFactory"delimiter="/"/></analyzer></fieldType>

Используя этот анализатор, вот как несколько примеров документов токенизируются:

30--><30>30.4--><30><30.4>30.4.15--><30><30.4><30.4.15>30.4.16--><30><30.4><30.4.16>30.5--><30><30.5>40.5--><40><40.5>

И если кто-то ищет закон, то же самое происходит. Так что, если кто-то хочет взглянуть на закон «30,4», то это будет размечено как <30> и <30,4>. В этом случае первые 5 документов совпадают. В частности, документы 30.4, 30.4.15 и 30.4.16 имеют два совпадающих токена, в то время как 30 и 30.5 имеют один совпадающий токен. Кроме того, из-за нормализации длины поля совпадение в более коротком поле, скажем, 30,4 с 2 токенами, повышается выше, чем совпадение в поле с 3 токенами, такими как 30.4.15 или 30.4.16.

Учитывая все обстоятельства, полученные документы должны прийти в следующем порядке:

  • 30,4
  • 30.4.15
  • 30.4.16
  • 30
  • 30,5

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

  • 30.4.15
  • 30,4
  • 30.4.16
  • 30
  • 30,5

Так в чем дело?! (Эксперты Solr читают это, вы знаете, где рушится наша теория?)

Наша теория действительно обоснована, но проблема заключается в практическом хранении полевой нормы. Каждое поле каждого документа хранится с дополнительной информацией, которая называется нормой поля. В идеале, полевая норма могла бы содержать очень конкретную информацию о том, как долго документ, но чем точнее полевая норма, тем больше места вам нужно для хранения этой информации. В итоге разработчики Lucene решили хранить полевую норму в виде одного байта. Это означает, что поле norm может хранить ровно 256 различных значений. И если вы посмотрите на класс Lucene TFIDFS Similarity (класс, отвечающий за работу с полевыми нормами и оценкой документов),  вы увидите, где именно эти байтовые значения переводятся в значения с плавающей запятой., Есть даже комментарий, который дает подсказку, в чем может быть наша проблема:

Кодирование использует трехбитную мантиссу, пятибитовую экспоненту и точку нулевой экспоненты в 15, таким образом, представляя значения от 7 × 10 ^ 9 до 2 × 10 ^ -9 с примерно одной значимой десятичной цифрой точности. Ноль также представлен. Отрицательные числа округляются до нуля. Значения, слишком большие для представления, округляются до наибольшего представимого значения. Положительные значения, слишком малые для представления, округляются до наименьшего положительного представимого значения.

Из-за отсутствия точности норма поля для полей длины 2 и длины 3 одинакова! … И вот почему мы получаем странный заказ. Первые три документа имеют одинаковую оценку и поэтому возвращаются в порядке индекса.

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

<field name="section_descendent" type="descendent_path" indexed="true" stored="true"/>
<field name="section_ancestor" type="ancestor_path" indexed="true" stored="true"/>

и

<fieldType name="descendent_path" class="solr.TextField">
  <analyzer type="index">
    <tokenizer class="solr.PathHierarchyTokenizerFactory" delimiter="/" />
  </analyzer>
  <analyzer type="query">
    <tokenizer class="solr.KeywordTokenizerFactory" />
  </analyzer>
</fieldType>

<fieldType name="ancestor_path" class="solr.TextField">
  <analyzer type="index">
    <tokenizer class="solr.KeywordTokenizerFactory" />
  </analyzer>
  <analyzer type="query">
    <tokenizer class="solr.PathHierarchyTokenizerFactory" delimiter="/" />
  </analyzer>
</fieldType>

В этом случае мы разделяем раздел на два поля: section_descendent и section_ancestor, которые разбиты на токены немного по-другому, чем раньше. Разница в том, что в предыдущем примере PathHierarchyTokenizer использовался как для индекса, так и для запроса. Но в этом случае descendent_path использует PathHierarchyTokenizer только во время индекса, а ancestor_path использует PathHierarchyTokenizer только во время запроса. (KeywordTokenizer маркирует все поле как один токен.) В результате section_descendent будет сопоставлять запросы только для разделов, являющихся потомками запроса — поэтому 30.4 будут соответствовать 30.4 и 30.4.15 и 30.4.16, но не 30. И аналогично section_ancestor будет сопоставлять запросы только для тех разделов, которые являются предками запроса, поэтому 30.4 будет соответствовать 30.4 и 30, но не 30.4.15.

Последний кусок головоломки — обработчик запросов. Здесь мы просто используем синтаксический анализатор запросов edismax с установленным qf (полями запроса) для включения обоих descendent_path ancestor_path. Теперь, когда кто-то запрашивает 30.4, тогда в descendent_path он получает совпадения 30.4, 30.4.15 и 30.4.16; и в ancestor_path они получают совпадения 30,4 и 30. Единственный документ, который совпадает в обоих полях, — это 30,4, таким образом, он получает дополнительное повышение и появляется в верхней части результатов поиска, за которым следуют другие документы. Задача решена!

Теперь интересно отметить, что мы потеряли одноуровневые документы (например, 30,5 в этом примере). Лично мне кажется, что это нормально, но если мы действительно этого хотим, все, что нам нужно сделать, это снова включить исходный путь к разделу поля токена в запросе и в индексе.