Статьи

Поиск реляционного контента с помощью Lucene’s BlockJoinQuery

В
выпуске Lucene 3.4.0 добавлена ​​новая функция, называемая
индексным соединением (иногда также называемая вложенными документами, вложенными документами или родительскими / дочерними документами), позволяющая эффективно индексировать и искать определенные типы
реляционного содержимого .

Большинство поисковых систем не могут напрямую индексировать реляционный контент, поскольку документы в индексе логически ведут себя как единая плоская таблица базы данных. Тем не менее, реляционный контент есть везде! Сайт со списком вакансий объединяет каждую компанию с конкретными списками для этой компании. Каждое резюме может иметь отдельный список навыков, образования и прошлого опыта работы. В музыкальном поисковике артист / группа присоединилась к альбомам, а затем присоединилась к песням. Поисковая система исходного кода объединяет проекты с модулями, а затем с файлами.

Возможно, документы PDF, которые вам нужно искать, огромны, поэтому вы разбиваете их и индексируете каждый раздел как отдельный документ Lucene; в этом случае у вас будут общие поля (заголовок, аннотация, автор, дата публикации и т. д.) для всего документа, присоединенного к вложенному документу (разделу) с собственными полями (текст, номер страницы и т. д.). XML-документы обычно содержат вложенные теги, представляющие присоединенные поддокументы; электронные письма имеют вложения; в офисные документы можно встраивать другие документы. Почти все поисковые домены имеют некоторую форму реляционного контента, часто требующего более одного объединения.

Если такой контент настолько распространен, то как поисковые приложения обрабатывают его сегодня?

Одно очевидное «решение» — просто использовать реляционную базу данных вместо поисковой системы! Если оценки релевантности менее важны, и вам необходимо выполнить значительное объединение, группировку, сортировку и т. Д., То использование базы данных может быть лучшим в целом. Большинство баз данных включают в себя некоторые формы текстового поиска, некоторые даже используют Lucene.

Если вы все еще хотите использовать поисковую систему, то одним из распространенных подходов является
денормализация контента заранее, во время индексации, путем объединения всех таблиц и индексации результирующих строк, дублирующих контент в процессе. Например, вы бы индексировали каждую песню как документ Lucene, копируя все поля из объединенного альбома песни и исполнителя / группы. Это работает правильно, но может быть ужасно расточительным, поскольку вы индексируете одинаковые поля, возможно, включая большие текстовые поля, снова и снова.

Другой подход заключается в том, чтобы объединить себя вне Lucene, указав песни, альбомы и исполнителя / группу как отдельные документы Lucene, возможно, даже в отдельных индексах. Во время поиска вы сначала запускаете запрос к одной коллекции, например, к песням. Затем вы перебираете
все хиты, собирая (объединяя) полный набор соответствующих альбомов, а затем запускаете второй запрос к альбомам с большим списком OR из первого запроса, повторяя этот процесс, если вам нужно присоединиться к артисту / группе, а также. Этот подход также будет работать, но он плохо масштабируется, поскольку вам, возможно, придется создавать, возможно, огромные последующие запросы.

Еще один подход заключается в использовании программного пакета, который уже реализовал один из этих подходов для вас!
эластичный поиск ,
Apache Solr ,
Apache Jackrabbit ,
Hibernate Search и многие другие все так или иначе обрабатывают реляционный контент.

С BlockJoinQuery вы теперь можете непосредственно искать реляционный контент самостоятельно!

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

Может быть, вам
повезло продать невероятную
футболку с коротким рукавом и луной с тремя волками , с этими артикулами (размер, цвет):

  • маленький, синий
  • маленький, черный
  • средний, черный
  • большой серый

Возможно, пользователь сначала ищет «рубашку волка», получает кучу хитов, а затем анализирует данные определенного размера и цвета, что приводит к следующему запросу:

   name:wolf AND size=small AND color=blue

который должен соответствовать этой рубашке. name — это поле рубашки, а размер и цвет — поля SKU.

Но если пользователь сверлит вместо этого на маленькой серой рубашке:

   name:wolf AND size=small AND color=gray

тогда эта рубашка не должна совпадать, потому что маленький размер только сине-черный.

Как вы можете выполнить эти запросы, используя BlockJoinQuery? Начните с индексации каждой рубашки (родительской) и всех ее SKU (дочерних) в качестве отдельных документов, используя новый API IndexWriter.addDocuments для добавления одной рубашки и всех ее SKU в качестве одного
блока документа . Этот метод атомарно добавляет блок документов в один сегмент как идентификаторы смежных документов, на которые опирается BlockJoinQuery. Вы также должны добавить поле маркера для каждого документа рубашки (например, type = shirt), так как для BlockJoinQuery требуется фильтр, идентифицирующий родительские документы.

Чтобы запустить BlockJoinQuery во время поиска, сначала нужно создать
родительский фильтр., соответствующие только рубашки. Обратите внимание, что фильтр должен использовать FixedBitSet под капотом, как CachingWrapperFilter:

  Filter shirts = new CachingWrapperFilter(
                    new QueryWrapperFilter(
                      new TermQuery(
                        new Term("type", "shirt"))));

Создайте этот фильтр один раз, сразу и повторно используйте его каждый раз, когда вам нужно выполнить это объединение.

Затем для каждого запроса, который требует объединения, поскольку он включает поля SKU и рубашку, начните с дочернего запроса, соответствующего только полям SKU:

  BooleanQuery skuQuery = new BooleanQuery();
  skuQuery.add(new TermQuery(new Term("size", "small")), Occur.MUST);
  skuQuery.add(new TermQuery(new Term("color", "blue")), Occur.MUST);

Затем используйте BlockJoinQuery для перевода попаданий из пространства документов SKU в пространство документов рубашки:

  BlockJoinQuery skuJoinQuery = new BlockJoinQuery(
    skuQuery, 
    shirts,
    ScoreMode.None);

Перечисление ScoreMode определяет, как баллы за несколько попаданий SKU должны быть объединены с баллами за соответствующий попадание в рубашку. В этом запросе вам не нужны результаты матчей SKU, но если вы это сделали, вы можете агрегировать с Avg, Max или Total.

Наконец, теперь вы можете создать произвольный запрос рубашки, используя skuJoinQuery в качестве предложения:

  BooleanQuery query = new BooleanQuery();
  query.add(new TermQuery(new Term("name", "wolf")), Occur.MUST);
  query.add(skuJoinQuery, Occur.MUST);

Вы также можете просто запустить skuJoinQuery как есть, если у запроса нет полей рубашки.

Наконец, просто запустите этот запрос, как обычно! Возвращенные хиты будут только документами рубашки; если вы также хотите увидеть, какие SKU соответствуют каждой рубашке, используйте BlockJoinCollector:

  BlockJoinCollector c = new BlockJoinCollector(
    Sort.RELEVANCE, // sort
    10,             // numHits
    true,           // trackScores
    false           // trackMaxScore
    );
  searcher.search(query, c);

Предоставленная сортировка должна использовать только поля рубашки (вы не можете сортировать по полям SKU). Когда каждый удар (рубашка) является конкурентоспособным, этот сборщик также запишет все SKU, которые соответствуют этой рубашке, которые вы можете получить следующим образом:

  TopGroups hits = c.getTopGroups(
    skuJoinQuery,
    skuSort,
    0,   // offset
    10,  // maxDocsPerGroup
    0,   // withinGroupOffset
    true // fillSortFields
  );

Установите skuSort в порядке сортировки для SKU в каждой рубашке. Первые смещенные попадания пропускаются (используйте это для перелистывания показов рубашки). Под каждой рубашкой будет возвращено максимум SKU maxDocsPerGroup. Используйте WithinGroupOffset, если вы хотите, чтобы страница в пределах SKU. Если fillSortFields равно true, то каждое попадание SKU будет иметь значения для полей из skuSort.

Хиты, возвращенные BlockJoinCollector.getTopGroups, являются хитами SKU, сгруппированными по рубашке. Вы бы получили точно такие же результаты, если бы вы денормализовали заранее, а затем использовали группирование для группировки результатов по рубашке.

Вы также можете сделать более одного объединения в одном запросе; объединения могут быть вложенными (родительский элемент дочернему элементу внука) или параллельными (родительский элемент дочернему элементу 1 и родительский элемент дочернему элементу 2).

Однако существуют некоторые важные ограничения для соединений с индексом и временем:

  • Объединение должно быть вычислено во время индекса и «скомпилировано» в индекс, так как все объединенные дочерние документы должны быть проиндексированы вместе с родительским документом как один блок документа.
  • Разные типы документов (например, рубашки и SKU) должны совместно использовать один индекс, что расточительно, так как означает, что не разреженные структуры данных, такие как записи FieldCache, потребляют больше памяти, чем если бы у вас были отдельные индексы.
  • Если вам необходимо переиндексировать родительский документ или любой из его дочерних документов, или удалить или добавить дочерний документ, тогда весь блок должен быть переиндексирован. В некоторых случаях это является большой проблемой, например, если вы индексируете «отзывы пользователей» как дочерние документы, то всякий раз, когда пользователь добавляет рецензию, вам придется переиндексировать эту рубашку, а также все ее SKU и отзывы пользователей.
  • Поддержка QueryParser отсутствует, поэтому вам необходимо программно создавать родительский и дочерний запросы, разделяя их по родительским и дочерним полям.
  • В настоящее время объединение может идти только в одном направлении (сопоставление дочерних docID с родительскими docID), но в некоторых случаях вам необходимо сопоставить родительские docID с дочерними docID. Например, при поиске песен, возможно, вы хотите, чтобы все подходящие песни были отсортированы по названию. Вы не можете легко сделать это сегодня, потому что единственный способ получить хиты песни — это сгруппировать по альбому или группе / исполнителю.
  • Объединение является внутренним объединением «один (родитель) ко многим (детям)».

Как обычно, патчи приветствуются!

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

Источник: 
http://blog.mikemccandless.com/2012/01/searching-relational-content-with.html