Полнотекстовый поиск диалогов с Apache Lucene: руководство
Опубликовано: 2022-03-11Apache Lucene — это библиотека Java, используемая для полнотекстового поиска документов и являющаяся ядром поисковых серверов, таких как Solr и Elasticsearch. Он также может быть встроен в приложения Java, такие как приложения для Android или веб-серверы.
Хотя параметры конфигурации Lucene обширны, они предназначены для использования разработчиками баз данных на общем корпусе текстов. Если ваши документы имеют определенную структуру или тип содержимого, вы можете воспользоваться преимуществами любого из них, чтобы улучшить качество поиска и возможности запросов.
В качестве примера такой настройки в этом руководстве по Lucene мы проиндексируем корпус Project Gutenberg, который предлагает тысячи бесплатных электронных книг. Мы знаем, что многие из этих книг — романы. Предположим, нас особенно интересуют диалоги в этих романах. Ни Lucene, ни Elasticsearch, ни Solr не предоставляют готовых инструментов для идентификации контента как диалога. На самом деле, они будут отбрасывать знаки препинания на самых ранних этапах анализа текста, что противоречит возможности идентифицировать части текста, которые являются диалогами. Таким образом, именно на этих ранних этапах наша настройка должна начинаться.
Части конвейера анализа Apache Lucene
Анализ JavaDoc Lucene обеспечивает хороший обзор всех движущихся частей в конвейере анализа текста.
На высоком уровне вы можете думать о конвейере анализа как о потреблении необработанного потока символов в начале и создании «терминов», примерно соответствующих словам, в конце.
Стандартный конвейер анализа можно представить следующим образом:
Мы увидим, как настроить этот конвейер для распознавания областей текста, помеченных двойными кавычками, которые я буду называть диалогом, а затем находить совпадения, возникающие при поиске в этих областях.
Чтение символов
Когда документы изначально добавляются в индекс, символы считываются из Java InputStream, поэтому они могут поступать из файлов, баз данных, вызовов веб-служб и т. д. Чтобы создать индекс для Project Gutenberg, мы загружаем электронные книги и создайте небольшое приложение для чтения этих файлов и записи их в индекс. Создание индекса Lucene и чтение файлов — это хорошо пройденные пути, поэтому мы не будем их подробно изучать. Основной код для создания индекса:
IndexWriter writer = ...; BufferedReader reader = new BufferedReader(new InputStreamReader(... fileInputStream ...)); Document document = new Document(); document.add(new StringField("title", fileName, Store.YES)); document.add(new TextField("body", reader)); writer.addDocument(document);
Мы видим, что каждая электронная книга будет соответствовать одному Document
Lucene, поэтому позже результаты нашего поиска будут представлять собой список соответствующих книг. Store.YES
указывает, что мы сохраняем поле заголовка , которое является просто именем файла. Однако мы не хотим сохранять тело книги, так как оно не нужно при поиске и только займет место на диске.
Фактическое чтение потока начинается с addDocument
. IndexWriter
извлекает токены с конца конвейера. Это получение происходит обратно по каналу до тех пор, пока первая стадия, Tokenizer
, не прочитает из InputStream
.
Также обратите внимание, что мы не закрываем поток, так как Lucene делает это за нас.
Токенизация персонажей
Lucene StandardTokenizer отбрасывает знаки препинания, поэтому наша настройка начнется здесь, так как нам нужно сохранить кавычки.
Документация для StandardTokenizer
предлагает вам скопировать исходный код и адаптировать его к вашим потребностям, но это решение было бы излишне сложным. Вместо этого мы расширим CharTokenizer
, который позволит вам указать символы для «принятия», где те, которые «не приняты», будут рассматриваться как разделители между токенами и отбрасываться. Поскольку нас интересуют слова и цитаты вокруг них, наш пользовательский токенизатор выглядит просто:
public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }
Учитывая входной поток [He said, "Good day".]
, произведенными токенами будут [He]
, [said]
, ["Good]
, [day"]
Обратите внимание, как кавычки перемежаются внутри токенов. Можно написать Tokenizer
, который создает отдельные токены для каждой котировки, но Tokenizer
также занимается кропотливыми, легко испорченными деталями, такими как буферизация и сканирование, поэтому лучше всего сделать ваш Tokenizer
простым и очистить поток токенов дальше по конвейеру.
Разделение токенов с помощью фильтров
После токенизатора идет ряд объектов TokenFilter
. Кстати, обратите внимание, что этот фильтр немного неверен, поскольку TokenFilter
может добавлять, удалять или изменять токены.
Многие классы фильтров, предоставляемые Lucene, ожидают одиночные слова, поэтому в них не будут входить наши смешанные токены слов и кавычек. Таким образом, следующей настройкой нашего учебника Lucene должно быть введение фильтра, который очистит вывод QuotationTokenizer
.
Эта очистка будет включать создание дополнительного маркера начальной цитаты , если цитата появляется в начале слова, или маркера конечной цитаты , если цитата появляется в конце. Мы отложим обработку слов в одинарных кавычках для простоты.
Создание подкласса TokenFilter
включает реализацию одного метода: incrementToken
. Этот метод должен вызвать incrementToken
для предыдущего фильтра в канале, а затем манипулировать результатами этого вызова, чтобы выполнить любую работу, за которую отвечает фильтр. Результаты incrementToken
доступны через объекты Attribute
, которые описывают текущее состояние обработки токена. После того, как наша реализация incrementToken
вернется, ожидается, что атрибуты были изменены для установки маркера для следующего фильтра (или индекса, если мы находимся в конце конвейера).
Атрибуты, которые нас интересуют на данном этапе конвейера:
CharTermAttribute
: содержит буферchar[]
, содержащий символы текущего токена. Нам нужно будет манипулировать этим, чтобы удалить цитату или создать токен цитаты.TypeAttribute
: содержит «тип» текущего токена. Поскольку мы добавляем начальные и конечные кавычки в поток токенов, мы введем два новых типа, используя наш фильтр.OffsetAttribute
: Lucene может дополнительно сохранять ссылки на расположение терминов в исходном документе. Эти ссылки называются «смещениями», которые являются просто начальным и конечным индексами в исходном потоке символов. Если мы изменим буфер вCharTermAttribute
, чтобы он указывал только на подстроку маркера, мы должны соответствующим образом настроить эти смещения.
Вам может быть интересно, почему API для управления потоками токенов такой запутанный и, в частности, почему мы не можем просто сделать что-то вроде String#split
для входящих токенов. Это связано с тем, что Lucene разработана для высокоскоростной индексации с низкими издержками, благодаря чему встроенные токенизаторы и фильтры могут быстро обрабатывать гигабайты текста, используя только мегабайты памяти. Чтобы достичь этого, во время токенизации и фильтрации выполняется мало или совсем не выделяется, поэтому упомянутые выше экземпляры Attribute
предназначены для однократного выделения и повторного использования. Если ваши токенизаторы и фильтры написаны таким образом и минимизируют свои собственные выделения, вы можете настроить Lucene без ущерба для производительности.
Имея все это в виду, давайте посмотрим, как реализовать фильтр, который принимает токен, такой как ["Hello]
, и создает два токена, ["]
и [Hello]
:
public class QuotationTokenFilter extends TokenFilter { private static final char QUOTE = '"'; public static final String QUOTE_START_TYPE = "start_quote"; public static final String QUOTE_END_TYPE = "end_quote"; private final OffsetAttribute offsetAttr = addAttribute(OffsetAttribute.class); private final TypeAttribute typeAttr = addAttribute(TypeAttribute.class); private final CharTermAttribute termBufferAttr = addAttribute(CharTermAttribute.class);
Начнем с получения ссылок на некоторые атрибуты, которые мы видели ранее. Мы добавляем к именам полей суффикс «Атрибут», чтобы было понятно позже, когда мы будем ссылаться на них. Возможно, некоторые реализации Tokenizer
не предоставляют эти атрибуты, поэтому мы используем addAttribute
для получения наших ссылок. addAttribute
создаст экземпляр атрибута, если он отсутствует, в противном случае получит общую ссылку на атрибут этого типа. Обратите внимание, что Lucene не допускает одновременное использование нескольких экземпляров одного и того же типа атрибута.
private boolean emitExtraToken; private int extraTokenStartOffset, extraTokenEndOffset; private String extraTokenType;
Поскольку наш фильтр вводит новый токен, которого не было в исходном потоке, нам нужно место для сохранения состояния этого токена между вызовами incrementToken
. Поскольку мы разделяем существующий токен на два, достаточно знать только смещения и тип нового токена. У нас также есть флаг, который сообщает нам, будет ли следующий вызов incrementToken
выдавать этот дополнительный токен. На самом деле Lucene предоставляет пару методов, captureState
и restoreState
, которые сделают это за вас. Но эти методы подразумевают выделение объекта State
и на самом деле могут оказаться более сложными, чем простое самостоятельное управление этим состоянием, поэтому мы не будем их использовать.
@Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }
В рамках агрессивного предотвращения выделения ресурсов Lucene может повторно использовать экземпляры фильтра. В этой ситуации ожидается, что вызов reset
вернет фильтр в исходное состояние. Итак, здесь мы просто сбрасываем наши дополнительные поля токенов.
@Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...
Теперь мы подходим к интересным моментам. Когда вызывается наша реализация incrementToken
, у нас есть возможность не вызывать incrementToken
на более ранней стадии конвейера. Поступая таким образом, мы эффективно вводим новый токен, потому что мы не извлекаем токен из Tokenizer
.
Вместо этого мы вызываем advanceToExtraToken
, чтобы настроить атрибуты для нашего дополнительного токена, устанавливаем для emitExtraToken
значение false, чтобы избежать этой ветки при следующем вызове, а затем возвращаем true
, что указывает на то, что доступен другой токен.
@Override public boolean incrementToken() throws IOException { ... (emit extra token) ... boolean hasNext = input.incrementToken(); if (hasNext) { char[] buffer = termBufferAttr.buffer(); if (termBuffer.length() > 1) { if (buffer[0] == QUOTE) { splitTermQuoteFirst(); } else if (buffer[termBuffer.length() - 1] == QUOTE) { splitTermWordFirst(); } } else if (termBuffer.length() == 1) { if (buffer[0] == QUOTE) { typeAttr.setType(QUOTE_END_TYPE); } } } return hasNext; }
Оставшаяся часть incrementToken
будет выполнять одно из трех разных действий. Напомним, что termBufferAttr
используется для проверки содержимого токена, проходящего через канал:
Если мы достигли конца потока маркеров (т. е
hasNext
имеет значение false), мы закончили и просто возвращаемся.Если у нас есть токен из более чем одного символа, и один из этих символов является кавычкой, мы разделяем токен.
Если токен представляет собой одиночную кавычку, мы предполагаем, что это конечная кавычка. Чтобы понять почему, обратите внимание, что начальные кавычки всегда появляются слева от слова (т. е. без промежуточных знаков препинания), тогда как конечные кавычки могут следовать за знаками препинания (например, в предложении
[He told us to "go back the way we came."]
). В этих случаях конечная кавычка уже будет отдельным токеном, поэтому нам нужно только задать ее тип.
splitTermQuoteFirst
и splitTermWordFirst
установят атрибуты, чтобы сделать текущий токен либо словом, либо кавычкой, и настроят «дополнительные» поля, чтобы вторая половина могла быть использована позже. Эти два метода похожи, поэтому мы рассмотрим только splitTermQuoteFirst
:
private void splitTermQuoteFirst() { int origStart = offsetAttr.startOffset(); int origEnd = offsetAttr.endOffset(); offsetAttr.setOffset(origStart, origStart + 1); typeAttr.setType(QUOTE_START_TYPE); termBufferAttr.setLength(1); prepareExtraTerm(origStart + 1, origEnd, TypeAttribute.DEFAULT_TYPE); }
Поскольку мы хотим разделить этот токен так, чтобы цитата появлялась в потоке первой, мы усекаем буфер, устанавливая длину в единицу (т. е. один символ, а именно цитату). Мы корректируем смещения соответствующим образом (т. е. указывая на цитату в исходном документе), а также устанавливаем тип начальной цитаты.
prepareExtraTerm
установит extra*
поля и установит для emitExtraToken
значение true. Он вызывается со смещениями, указывающими на «лишнюю» лексему (т. е. слово, следующее за кавычкой).
Весь QuotationTokenFilter
доступен на GitHub.
Кроме того, хотя этот фильтр создает только один дополнительный токен, этот подход можно расширить, чтобы ввести произвольное количество дополнительных токенов. Просто замените extra*
поля коллекцией или, что еще лучше, массивом фиксированной длины, если существует ограничение на количество дополнительных токенов, которые могут быть созданы. См. SynonymFilter
и его внутренний класс PendingInput
.

Использование токенов котировок и диалог маркировки
Теперь, когда мы приложили все усилия, чтобы добавить эти кавычки в поток токенов, мы можем использовать их для разграничения разделов диалога в тексте.
Поскольку нашей конечной целью является корректировка результатов поиска в зависимости от того, являются ли термины частью диалога или нет, нам необходимо прикрепить метаданные к этим терминам. Lucene предоставляет PayloadAttribute
для этой цели. Полезные данные — это массивы байтов, которые хранятся вместе с терминами в индексе и могут быть прочитаны позже во время поиска. Это означает, что наш флаг будет расточительно занимать целый байт, поэтому дополнительные полезные нагрузки могут быть реализованы в виде битовых флагов для экономии места.
Ниже представлен новый фильтр DialoguePayloadTokenFilter
, который добавляется в самый конец конвейера анализа. Он прикрепляет полезную нагрузку, указывающую, является ли токен частью диалога.
public class DialoguePayloadTokenFilter extends TokenFilter { private final TypeAttribute typeAttr = getAttribute(TypeAttribute.class); private final PayloadAttribute payloadAttr = addAttribute(PayloadAttribute.class); private static final BytesRef PAYLOAD_DIALOGUE = new BytesRef(new byte[] { 1 }); private static final BytesRef PAYLOAD_NOT_DIALOGUE = new BytesRef(new byte[] { 0 }); private boolean withinDialogue; protected DialoguePayloadTokenFilter(TokenStream input) { super(input); } @Override public void reset() throws IOException { this.withinDialogue = false; super.reset(); } @Override public boolean incrementToken() throws IOException { boolean hasNext = input.incrementToken(); while(hasNext) { boolean isStartQuote = QuotationTokenFilter .QUOTE_START_TYPE.equals(typeAttr.type()); boolean isEndQuote = QuotationTokenFilter .QUOTE_END_TYPE.equals(typeAttr.type()); if (isStartQuote) { withinDialogue = true; hasNext = input.incrementToken(); } else if (isEndQuote) { withinDialogue = false; hasNext = input.incrementToken(); } else { break; } } if (hasNext) { payloadAttr.setPayload(withinDialogue ? PAYLOAD_DIALOGUE : PAYLOAD_NOT_DIALOGUE); } return hasNext; } }
Так как этому фильтру нужно поддерживать только одну часть состояния withinDialogue
, это намного проще. Начальная кавычка указывает на то, что мы сейчас находимся в разделе диалога, а конечная кавычка указывает на то, что раздел диалога закончился. В любом случае токен цитаты отбрасывается путем второго вызова incrementToken
, поэтому, по сути, маркеры начала или конца цитаты никогда не проходят дальше этого этапа в конвейере.
Например, DialoguePayloadTokenFilter
преобразует поток токенов:
[the], [program], [printed], ["], [hello], [world], ["]`
в этот новый поток:
[the][0], [program][0], [printed][0], [hello][1], [world][1]
Связывание токенизаторов и фильтров вместе
Analyzer
отвечает за сборку конвейера анализа, обычно путем объединения Tokenizer
с серией TokenFilter
s. Analyzer
также могут определять, как этот конвейер повторно используется между анализами. Нам не нужно об этом беспокоиться, так как наши компоненты не требуют ничего, кроме вызова reset()
между использованиями, что всегда будет делать Lucene. Нам просто нужно выполнить сборку, реализовав Analyzer#createComponents(String)
:
public class DialogueAnalyzer extends Analyzer { @Override protected TokenStreamComponents createComponents(String fieldName) { QuotationTokenizer tokenizer = new QuotationTokenizer(); TokenFilter filter = new QuotationTokenFilter(tokenizer); filter = new LowerCaseFilter(filter); filter = new StopFilter(filter, StopAnalyzer.ENGLISH_STOP_WORDS_SET); filter = new DialoguePayloadTokenFilter(filter); return new TokenStreamComponents(tokenizer, filter); } }
Как мы видели ранее, фильтры содержат ссылку на предыдущий этап конвейера, поэтому мы создаем их экземпляры именно так. Мы также вставляем несколько фильтров из StandardAnalyzer
: LowerCaseFilter
и StopFilter
. Эти два должны идти после QuotationTokenFilter
, чтобы гарантировать, что все кавычки были разделены. Мы можем быть более гибкими в размещении DialoguePayloadTokenFilter
, так как подойдет любое место после QuotationTokenFilter
. Мы поместили его после StopFilter
, чтобы не тратить время на вставку полезной нагрузки диалога в стоп-слова, которые в конечном итоге будут удалены.
Вот визуализация нашего нового конвейера в действии (за исключением тех частей стандартного конвейера, которые мы удалили или уже видели):
Теперь DialogueAnalyzer
можно использовать так же, как и любой другой биржевой Analyzer
, и теперь мы можем построить индекс и перейти к поиску.
Полнотекстовый поиск диалога
Если бы мы хотели искать только диалоги, мы могли бы просто отбросить все токены вне цитаты, и на этом все. Вместо этого, оставив все исходные токены нетронутыми, мы дали себе возможность либо выполнять запросы, учитывающие диалог, либо рассматривать диалог как любую другую часть текста.
Основы запросов к индексу Lucene хорошо документированы. Для наших целей достаточно знать, что запросы состоят из объектов Term
, склеенных вместе с такими операторами, как MUST
или SHOULD
, а также с документами сопоставления, основанными на этих терминах. Затем совпадающие документы оцениваются на основе настраиваемого объекта Similarity
, и эти результаты могут быть упорядочены по оценке, отфильтрованы или ограничены. Например, Lucene позволяет нам выполнить запрос для первых десяти документов, которые должны содержать оба термина [hello]
и [world]
.
Настроить результаты поиска на основе диалога можно, изменив оценку документа на основе полезной нагрузки. Первой точкой расширения для этого будет Similarity
, которое отвечает за взвешивание и оценку совпадающих терминов.
Сходство и оценка
Запросы по умолчанию будут использовать DefaultSimilarity
, который взвешивает термины в зависимости от того, как часто они встречаются в документе. Это хорошая точка расширения для настройки весов, поэтому мы расширили ее, чтобы также оценивать документы на основе полезной нагрузки. Для этого предусмотрен метод DefaultSimilarity#scorePayload
:
public final class DialogueAwareSimilarity extends DefaultSimilarity { @Override public float scorePayload(int doc, int start, int end, BytesRef payload) { if (payload.bytes[payload.offset] == 0) { return 0.0f; } return 1.0f; } }
DialogueAwareSimilarity
просто оценивает полезные нагрузки, не связанные с диалогами, как ноль. Поскольку каждый Term
может быть сопоставлен несколько раз, он потенциально может иметь несколько оценок полезной нагрузки. Интерпретация этих баллов зависит от реализации Query
.
Обратите особое внимание на BytesRef
, содержащий полезную нагрузку: мы должны проверить байт по offset
, так как мы не можем предположить, что массив байтов — это та же полезная нагрузка, которую мы сохранили ранее. При чтении индекса Lucene не собирается тратить память на выделение отдельного массива байтов только для вызова scorePayload
, поэтому мы получаем ссылку на существующий массив байтов. При написании кода с использованием Lucene API стоит помнить, что производительность является приоритетом, а не удобством для разработчиков.
Теперь, когда у нас есть новая реализация Similarity
, ее необходимо установить в IndexSearcher
используемом для выполнения запросов:
IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());
Запросы и условия
Теперь, когда наш IndexSearcher
может оценивать полезные нагрузки, мы также должны создать запрос, учитывающий полезные нагрузки. PayloadTermQuery
можно использовать для сопоставления одного Term
, а также для проверки полезной нагрузки этих совпадений:
PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());
Этот запрос соответствует термину [hello]
в поле body (напомним, что именно сюда мы помещаем содержимое документа). Мы также должны предоставить функцию для вычисления конечной оценки полезной нагрузки из всех совпадений терминов, поэтому мы подключаем AveragePayloadFunction
, которая усредняет все оценки полезной нагрузки. Например, если термин [hello]
встречается внутри диалога дважды и один раз вне диалога, итоговая оценка полезной нагрузки будет ²⁄₃. Эта окончательная оценка полезной нагрузки умножается на оценку, предоставленную DefaultSimilarity
для всего документа.
Мы используем среднее значение, потому что хотим не выделять результаты поиска, в которых многие термины появляются вне диалога, и получить нулевую оценку для документов без каких-либо терминов в диалогах.
Мы также можем составить несколько объектов PayloadTermQuery
, используя BooleanQuery
, если мы хотим искать несколько терминов, содержащихся в диалоге (обратите внимание, что порядок терминов не имеет значения в этом запросе, хотя другие типы запросов учитывают положение):
PayloadTermQuery worldQuery = new PayloadTermQuery(new Term("body", "world"), new AveragePayloadFunction()); BooleanQuery query = new BooleanQuery(); query.add(helloQuery, Occur.MUST); query.add(worldQuery, Occur.MUST);
Когда этот запрос выполняется, мы можем видеть, как структура запроса и реализация подобия работают вместе:
Выполнение запроса и объяснение
Чтобы выполнить запрос, мы передаем его IndexSearcher
:
TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();
Объекты- Collector
используются для подготовки коллекции совпадающих документов.
коллекторы могут быть составлены для достижения комбинации сортировки, ограничения и фильтрации. Чтобы получить, например, первую десятку оцениваемых документов, содержащих хотя бы один термин в диалоге, мы объединяем TopScoreDocCollector
и PositiveScoresOnlyCollector
. Принятие только положительных оценок гарантирует, что совпадения с нулевой оценкой (т. е. совпадения без терминов в диалоге) будут отфильтрованы.
Чтобы увидеть этот запрос в действии, мы можем выполнить его, а затем использовать IndexSearcher#explain
, чтобы увидеть, как были оценены отдельные документы:
for (ScoreDoc result : topDocs.scoreDocs) { Document doc = searcher.doc(result.doc, Collections.singleton("title")); System.out.println("--- document " + doc.getField("title").stringValue() + " ---"); System.out.println(this.searcher.explain(query, result.doc)); }
Здесь мы перебираем идентификаторы документов в TopDocs
, полученные в результате поиска. Мы также используем IndexSearcher#doc
для извлечения поля заголовка для отображения. Для нашего запроса "hello"
это приводит к следующему результату:
--- Document whelv10.txt --- 0.072256625 = (MATCH) btq, product of: 0.072256625 = weight(body:hello in 7336) [DialogueAwareSimilarity], result of: 0.072256625 = fieldWeight in 7336, product of: 2.345208 = tf(freq=5.5), with freq of: 5.5 = phraseFreq=5.5 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.009765625 = fieldNorm(doc=7336) 1.0 = AveragePayloadFunction.docScore() --- Document daved10.txt --- 0.061311778 = (MATCH) btq, product of: 0.061311778 = weight(body:hello in 6873) [DialogueAwareSimilarity], result of: 0.061311778 = fieldWeight in 6873, product of: 3.3166249 = tf(freq=11.0), with freq of: 11.0 = phraseFreq=11.0 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.005859375 = fieldNorm(doc=6873) 1.0 = AveragePayloadFunction.docScore() ...
Несмотря на то, что вывод перегружен жаргоном, мы можем видеть, как наша пользовательская реализация Similarity
использовалась при подсчете очков, и как MaxPayloadFunction
произвела множитель 1.0
для этих совпадений. Это означает, что полезная нагрузка была загружена и оценена, и все совпадения "Hello"
произошли в диалоге, поэтому эти результаты находятся прямо наверху, где мы их ожидаем.
Стоит также отметить, что размер индекса для Project Gutenberg с полезной нагрузкой составляет почти четыре гигабайта, и все же на моей скромной машине для разработки запросы выполняются мгновенно. Мы не пожертвовали скоростью для достижения наших целей поиска.
Подведение итогов
Lucene — это мощная специализированная библиотека полнотекстового поиска, которая берет необработанный поток символов, объединяет их в токены и сохраняет их как термины в индексе. Он может быстро запрашивать этот индекс и предоставлять ранжированные результаты, а также предоставляет широкие возможности для расширения при сохранении эффективности.
Используя Lucene непосредственно в наших приложениях или как часть сервера, мы можем выполнять полнотекстовый поиск в режиме реального времени по гигабайтам контента. Кроме того, с помощью пользовательского анализа и оценки мы можем использовать особенности предметной области в наших документах, чтобы повысить релевантность результатов или пользовательских запросов.
Полные листинги кода для этого руководства по Lucene доступны на GitHub. Репозиторий содержит два приложения: LuceneIndexerApp
для построения индекса и LuceneQueryApp
для выполнения запросов.
Корпус Project Gutenberg, который можно получить в виде образа диска через BitTorrent, содержит множество книг, которые стоит прочитать (либо с помощью Lucene, либо просто по старинке).
Удачной индексации!