Wyszukiwanie pełnotekstowe dialogów za pomocą Apache Lucene: samouczek
Opublikowany: 2022-03-11Apache Lucene to biblioteka Java używana do pełnotekstowego wyszukiwania dokumentów i stanowi rdzeń serwerów wyszukiwania, takich jak Solr i Elasticsearch. Może być również osadzony w aplikacjach Java, takich jak aplikacje na Androida lub backendy internetowe.
Chociaż opcje konfiguracyjne Lucene są rozbudowane, są przeznaczone do użytku przez programistów baz danych na ogólnym korpusie tekstu. Jeśli Twoje dokumenty mają określoną strukturę lub typ zawartości, możesz skorzystać z jednej z nich, aby poprawić jakość wyszukiwania i możliwości zapytań.
Jako przykład tego rodzaju dostosowywania, w tym samouczku Lucene zindeksujemy korpus Projektu Gutenberg, który oferuje tysiące bezpłatnych e-booków. Wiemy, że wiele z tych książek to powieści. Załóżmy, że szczególnie interesuje nas dialog w tych powieściach. Ani Lucene, Elasticsearch, ani Solr nie dostarczają gotowych narzędzi do identyfikacji treści jako dialogów. W rzeczywistości odrzucą interpunkcję na najwcześniejszych etapach analizy tekstu, co jest sprzeczne z możliwością zidentyfikowania fragmentów tekstu, które są dialogami. Dlatego właśnie na tych wczesnych etapach musi zacząć się nasza personalizacja.
Fragmenty rurociągu analizy Apache Lucene
JavaDoc analizy Lucene zapewnia dobry przegląd wszystkich ruchomych części w potoku analizy tekstu.
Na wysokim poziomie można wyobrazić sobie potok analizy jako zużywający surowy strumień znaków na początku i tworzący „terminy”, z grubsza odpowiadające słowom, na końcu.
Standardowy potok analityczny można wizualizować w następujący sposób:
Zobaczymy, jak dostosować ten potok, aby rozpoznawał regiony tekstu oznaczone podwójnymi cudzysłowami, które będę nazywał dialogiem, a następnie podbijamy dopasowania, które występują podczas wyszukiwania w tych regionach.
Czytanie znaków
Kiedy dokumenty są początkowo dodawane do indeksu, znaki są odczytywane z Java InputStream, a więc mogą pochodzić z plików, baz danych, wywołań usług internetowych itp. Aby utworzyć indeks dla Projektu Gutenberg, pobieramy e-booki i utwórz małą aplikację do odczytywania tych plików i zapisywania ich w indeksie. Tworzenie indeksu Lucene i odczytywanie plików to dobrze uczęszczane ścieżki, więc nie będziemy ich zbytnio eksplorować. Podstawowym kodem do tworzenia indeksu jest:
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);
Widzimy, że każdy e-book będzie odpowiadał jednemu Document
Lucene, więc później nasze wyniki wyszukiwania będą listą pasujących książek. Store.YES
oznacza, że przechowujemy pole tytułu , które jest tylko nazwą pliku. Nie chcemy jednak przechowywać treści ebooka, ponieważ nie jest to potrzebne podczas wyszukiwania i marnuje tylko miejsce na dysku.
Rzeczywiste odczytywanie strumienia rozpoczyna się od addDocument
. IndexWriter
pobiera tokeny z końca potoku. To ściąganie przechodzi z powrotem przez potok, aż pierwszy etap, Tokenizer
, odczyta z InputStream
.
Pamiętaj też, że nie zamykamy strumienia, ponieważ Lucene zajmuje się tym za nas.
Znaki tokenizacji
Lucene StandardTokenizer odrzuca znaki interpunkcyjne, więc nasze dostosowywanie rozpocznie się tutaj, ponieważ musimy zachować cytaty.
Dokumentacja StandardTokenizer
zachęca do skopiowania kodu źródłowego i dostosowania go do własnych potrzeb, ale to rozwiązanie byłoby niepotrzebnie skomplikowane. Zamiast tego rozszerzymy CharTokenizer
, który pozwala określić znaki do „akceptacji”, przy czym te, które nie są „zaakceptowane”, będą traktowane jako ograniczniki między tokenami i wyrzucane. Ponieważ interesują nas słowa i otaczające je cytaty, nasz niestandardowy Tokenizer to po prostu:
public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }
Biorąc pod uwagę strumień wejściowy [He said, "Good day".]
, wyprodukowane tokeny to [He]
, [said]
, ["Good]
, [day"]
Zwróć uwagę, jak cytaty są przeplatane w tokenach. Możliwe jest napisanie Tokenizer
, który tworzy oddzielne tokeny dla każdego cytatu, ale Tokenizer
zajmuje się również nieporęcznymi, łatwymi do zakręcenia szczegółami, takimi jak buforowanie i skanowanie, więc najlepiej jest zachować prostotę Tokenizer
i wyczyścić strumień tokenów dalej w potoku.
Dzielenie tokenów za pomocą filtrów
Po tokenizerze pojawia się seria obiektów TokenFilter
. Zauważ, nawiasem mówiąc, że ten filtr jest trochę mylący, ponieważ TokenFilter
może dodawać, usuwać lub modyfikować tokeny.
Wiele klas filtrów dostarczanych przez Lucene oczekuje pojedynczych słów, więc nie wystarczy, aby wpłynęły do nich nasze mieszane tokeny słów i cudzysłowów. Dlatego następnym dostosowaniem naszego samouczka Lucene musi być wprowadzenie filtra, który oczyści dane wyjściowe QuotationTokenizer
.
To czyszczenie będzie obejmować wygenerowanie dodatkowego znacznika cytatu początkowego , jeśli cytat pojawia się na początku słowa, lub znacznika końcowego cytatu , jeśli cytat pojawia się na końcu. Dla uproszczenia odłożymy na bok obsługę pojedynczych słów cytowanych.
Tworzenie podklasy TokenFilter
polega na zaimplementowaniu jednej metody: incrementToken
. Ta metoda musi wywołać incrementToken
na poprzednim filtrze w potoku, a następnie manipulować wynikami tego wywołania, aby wykonać dowolną pracę, za którą odpowiada filtr. Wyniki incrementToken
są dostępne za pośrednictwem obiektów Attribute
, które opisują aktualny stan przetwarzania tokena. Po naszej implementacji zwracanych przez nas incrementToken
, oczekuje się, że atrybuty zostały zmanipulowane w celu ustawienia tokena dla następnego filtru (lub indeksu, jeśli jesteśmy na końcu potoku).
Atrybuty, którymi jesteśmy zainteresowani w tym momencie w potoku to:
CharTermAttribute
: zawiera buforchar[]
przechowujący znaki bieżącego tokenu. Będziemy musieli manipulować tym, aby usunąć cytat lub wygenerować token cytatu.TypeAttribute
: zawiera „typ” bieżącego tokena. Ponieważ dodajemy cytaty początkowe i końcowe do strumienia tokenów, wprowadzimy dwa nowe typy za pomocą naszego filtra.OffsetAttribute
: Lucene może opcjonalnie przechowywać odniesienia do lokalizacji terminów w oryginalnym dokumencie. Te odniesienia są nazywane „przesunięciami”, które są po prostu indeksami początkowymi i końcowymi w oryginalnym strumieniu znaków. Jeśli zmienimy bufor wCharTermAttribute
, aby wskazywał tylko podciąg tokenu, musimy odpowiednio dostosować te przesunięcia.
Być może zastanawiasz się, dlaczego interfejs API do manipulowania strumieniami tokenów jest tak zawiły, a w szczególności, dlaczego nie możemy po prostu zrobić czegoś takiego jak String#split
na przychodzących tokenach. Dzieje się tak, ponieważ Lucene jest przeznaczony do szybkiego indeksowania o niskim nakładzie pracy, dzięki czemu wbudowane tokenizatory i filtry mogą szybko przeżuwać gigabajty tekstu przy użyciu tylko megabajtów pamięci. Aby to osiągnąć, podczas tokenizacji i filtrowania jest wykonywanych niewiele lub wcale alokacji, dlatego też instancje Attribute
wymienione powyżej są przeznaczone do jednorazowej alokacji i ponownego użycia. Jeśli Twoje tokenizatory i filtry są napisane w ten sposób i minimalizują ich własne alokacje, możesz dostosować Lucene bez uszczerbku dla wydajności.
Mając to wszystko na uwadze, zobaczmy, jak zaimplementować filtr, który pobiera token, taki jak ["Hello]
, i generuje dwa tokeny, ["]
i [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);
Zaczynamy od uzyskania referencji do niektórych atrybutów, które widzieliśmy wcześniej. Do nazw pól dodajemy „Attr”, aby później było jasne, kiedy się do nich odwołujemy. Możliwe, że niektóre implementacje Tokenizer
nie zapewniają tych atrybutów, dlatego używamy addAttribute
, aby uzyskać nasze referencje. addAttribute
utworzy instancję atrybutu, jeśli jej brakuje, w przeciwnym razie pobierze wspólne odniesienie do atrybutu tego typu. Zauważ, że Lucene nie zezwala na wiele wystąpień tego samego typu atrybutu na raz.
private boolean emitExtraToken; private int extraTokenStartOffset, extraTokenEndOffset; private String extraTokenType;
Ponieważ nasz filtr wprowadzi nowy token, który nie był obecny w oryginalnym strumieniu, potrzebujemy miejsca do zapisywania stanu tego tokena między wywołaniami incrementToken
. Ponieważ dzielimy istniejący token na dwie części, wystarczy znać tylko przesunięcia i typ nowego tokena. Mamy również flagę, która mówi nam, czy następne wywołanie incrementToken
będzie emitować ten dodatkowy token. Lucene faktycznie udostępnia parę metod, captureState
i restoreState
, które zrobią to za Ciebie. Ale te metody obejmują alokację obiektu State
i mogą być w rzeczywistości trudniejsze niż samodzielne zarządzanie tym stanem, więc unikniemy ich używania.
@Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }
W ramach agresywnego unikania alokacji Lucene może ponownie wykorzystywać instancje filtrów. W tej sytuacji oczekuje się, że wywołanie reset
spowoduje powrót filtra do stanu początkowego. Więc tutaj po prostu resetujemy nasze dodatkowe pola tokenów.
@Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...
Teraz dochodzimy do interesujących fragmentów. Gdy wywoływana jest nasza implementacja incrementToken
, mamy możliwość nie wywoływania incrementToken
na wcześniejszym etapie potoku. W ten sposób skutecznie wprowadzamy nowy token, ponieważ nie pobieramy tokena z Tokenizer
.
Zamiast tego wywołujemy advanceToExtraToken
, aby skonfigurować atrybuty dla naszego dodatkowego tokenu, ustawiamy emitExtraToken
na false, aby uniknąć tej gałęzi przy następnym wywołaniu, a następnie zwracamy true
, co wskazuje, że dostępny jest inny token.
@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; }
Pozostała część incrementToken
wykona jedną z trzech różnych rzeczy. Przypomnijmy, że termBufferAttr
służy do sprawdzania zawartości tokena przechodzącego przez potok:
Jeśli doszliśmy do końca strumienia tokenów (tzn.
hasNext
ma wartość false), skończyliśmy i po prostu wracamy.Jeśli mamy token składający się z więcej niż jednego znaku, a jeden z tych znaków jest cytatem, dzielimy token.
Jeśli token jest kwotą samotną, zakładamy, że jest kwotą końcową. Aby zrozumieć dlaczego, zauważ, że początkowe cudzysłowy zawsze pojawiają się po lewej stronie słowa (tj. bez pośredniej interpunkcji), podczas gdy końcowe cudzysłowy mogą następować po interpunkcji (tak jak w zdaniu,
[He told us to "go back the way we came."]
). W takich przypadkach cytat końcowy będzie już osobnym tokenem, więc wystarczy tylko ustawić jego typ.
splitTermQuoteFirst
i splitTermWordFirst
atrybuty, aby bieżący token był albo słowem, albo cudzysłowem, a także skonfigurują pola „dodatkowe”, aby druga połowa mogła zostać wykorzystana później. Te dwie metody są podobne, więc przyjrzymy się tylko 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); }
Ponieważ chcemy podzielić ten token z cytatem pojawiającym się w strumieniu jako pierwszy, obcinamy bufor, ustawiając długość na jeden (tj. jeden znak, a mianowicie cytat). Dostosowujemy odpowiednio offsety (tj. wskazując na cytat w oryginalnym dokumencie), a także ustawiamy typ na cytat początkowy.
przygotowanieExtraTerm ustawi extra*
pola i emitExtraToken
prepareExtraTerm
true. Jest wywoływany z przesunięciem wskazującym na „dodatkowy” token (tj. słowo po cudzysłowie).
Całość QuotationTokenFilter
jest dostępna w serwisie GitHub.
Na marginesie, chociaż ten filtr generuje tylko jeden dodatkowy token, to podejście można rozszerzyć, aby wprowadzić dowolną liczbę dodatkowych tokenów. Po prostu zamień extra*
pola na kolekcję lub, jeszcze lepiej, tablicę o stałej długości, jeśli istnieje limit liczby dodatkowych tokenów, które można wyprodukować. Zobacz SynonymFilter
i jego wewnętrzną klasę PendingInput
na przykład.

Zużywanie tokenów wyceny i dialog dotyczący oznaczania
Teraz, gdy dołożyliśmy wszelkich starań, aby dodać te cytaty do strumienia tokenów, możemy ich użyć do rozgraniczenia części dialogu w tekście.
Ponieważ naszym celem końcowym jest dostosowanie wyników wyszukiwania w zależności od tego, czy terminy są częścią dialogu, czy nie, musimy dołączyć do tych terminów metadane. Lucene udostępnia w tym celu PayloadAttribute
. Ładunki to tablice bajtów, które są przechowywane obok terminów w indeksie i można je odczytać później podczas wyszukiwania. Oznacza to, że nasza flaga będzie marnować cały bajt, więc dodatkowe ładunki mogą zostać zaimplementowane jako flagi bitowe, aby zaoszczędzić miejsce.
Poniżej znajduje się nowy filtr DialoguePayloadTokenFilter
, który został dodany na samym końcu potoku analizy. Dołącza ładunek wskazujący, czy token jest częścią dialogu.
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; } }
Ponieważ ten filtr musi utrzymywać tylko jeden element stanu w withinDialogue
, jest to znacznie prostsze. Cytat początkowy wskazuje, że znajdujemy się teraz w sekcji dialogu, a cytat końcowy wskazuje, że sekcja dialogu się zakończyła. W obu przypadkach token cytatu jest odrzucany przez wykonanie drugiego wywołania incrementToken
, więc w efekcie tokeny początku cytatu lub końca cytatu nigdy nie przechodzą poza ten etap w potoku.
Na przykład DialoguePayloadTokenFilter
przekształci strumień tokenów:
[the], [program], [printed], ["], [hello], [world], ["]`
do tego nowego strumienia:
[the][0], [program][0], [printed][0], [hello][1], [world][1]
Wiązanie tokenizatorów i filtrów razem
Analyzer
jest odpowiedzialny za złożenie potoku analizy, zwykle przez połączenie Tokenizer
z serią TokenFilter
s. Analyzer
mogą również określić, w jaki sposób ten potok jest ponownie używany między analizami. Nie musimy się tym martwić, ponieważ nasze komponenty nie wymagają niczego poza wywołaniem reset()
między użyciami, co Lucene zawsze zrobi. Musimy tylko wykonać montaż poprzez zaimplementowanie 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); } }
Jak widzieliśmy wcześniej, filtry zawierają odwołanie do poprzedniego etapu w potoku, więc w ten sposób tworzymy ich instancję. Wstawiamy również kilka filtrów ze StandardAnalyzer
: LowerCaseFilter
i StopFilter
. Te dwa elementy muszą następować po QuotationTokenFilter
, aby zapewnić, że wszelkie cytaty zostały oddzielone. Możemy być bardziej elastyczni w naszym umieszczeniu DialoguePayloadTokenFilter
, ponieważ zrobi to w dowolnym miejscu po QuotationTokenFilter
. Umieściliśmy to po StopFilter
, aby uniknąć marnowania czasu na wstrzykiwanie zawartości dialogu do słów zatrzymania, które ostatecznie zostaną usunięte.
Oto wizualizacja naszego nowego potoku w akcji (bez tych części standardowego potoku, które usunęliśmy lub już widzieliśmy):
DialogueAnalyzer
może być teraz używany jak każdy inny Analyzer
giełdowy, a teraz możemy zbudować indeks i przejść do wyszukiwania.
Wyszukiwanie pełnotekstowe w dialogu
Gdybyśmy chcieli tylko przeszukać dialog, moglibyśmy po prostu odrzucić wszystkie tokeny poza cytatem i skończylibyśmy. Zamiast tego, pozostawiając wszystkie oryginalne tokeny nienaruszone, daliśmy sobie swobodę wykonywania zapytań uwzględniających dialog lub traktowania dialogu jak każdej innej części tekstu.
Podstawy odpytywania indeksu Lucene są dobrze udokumentowane. Do naszych celów wystarczy wiedzieć, że zapytania składają się z obiektów Term
połączonych operatorami takimi jak MUST
lub SHOULD
, wraz z dokumentami dopasowania opartymi na tych terminach. Dopasowane dokumenty są następnie oceniane na podstawie konfigurowalnego obiektu Similarity
, a wyniki mogą być sortowane według oceny, filtrowane lub ograniczone. Na przykład Lucene pozwala nam wykonać zapytanie dla dziesięciu najpopularniejszych dokumentów, które muszą zawierać oba terminy [hello]
i [world]
.
Dostosowywanie wyników wyszukiwania na podstawie dialogu można wykonać, dostosowując wynik dokumentu na podstawie ładunku. Pierwszym punktem rozszerzenia do tego będzie Similarity
, które jest odpowiedzialne za ważenie i ocenianie pasujących terminów.
Podobieństwo i punktacja
Zapytania domyślnie używają DefaultSimilarity
, które ważą terminy na podstawie częstotliwości ich występowania w dokumencie. Jest to dobry punkt rozszerzenia do regulacji wag, dlatego rozszerzamy go również o punktację dokumentów na podstawie ładowności. Do tego celu służy metoda 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
po prostu ocenia ładunki niebędące dialogami jako zero. Ponieważ każdy Term
może być dopasowywany wiele razy, potencjalnie będzie miał wiele punktów dotyczących ładunku. Interpretacja tych wyników aż do implementacji Query
.
Zwróć szczególną uwagę na BytesRef
zawierający ładunek: musimy sprawdzić bajt w miejscu offset
, ponieważ nie możemy założyć, że tablica bajtów jest tym samym ładunkiem, który zapisaliśmy wcześniej. Podczas odczytu indeksu Lucene nie będzie marnować pamięci na przydzielanie oddzielnej tablicy bajtów tylko na wywołanie scorePayload
, więc otrzymujemy odwołanie do istniejącej tablicy bajtów. Podczas kodowania w interfejsie API Lucene warto pamiętać, że wydajność jest priorytetem, na długo przed wygodą programisty.
Teraz, gdy mamy naszą nową implementację Similarity
, należy ją ustawić w IndexSearcher
używanym do wykonywania zapytań:
IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());
Zapytania i warunki
Teraz, gdy nasz IndexSearcher
może oceniać ładunki, musimy również skonstruować zapytanie, które uwzględnia ładunek. PayloadTermQuery
może służyć do dopasowania pojedynczego Term
, jednocześnie sprawdzając ładunki tych dopasowań:
PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());
To zapytanie pasuje do terminu [hello]
w polu body (przypomnij sobie, że w tym miejscu umieszczamy treść dokumentu). Musimy również zapewnić funkcję do obliczania końcowego wyniku ładunku ze wszystkich dopasowań terminów, dlatego podłączamy AveragePayloadFunction
, która uśrednia wszystkie wyniki ładunku. Na przykład, jeśli słowo [hello]
pojawia się dwa razy w dialogu, a raz na zewnątrz, ostateczny wynik ładunku wyniesie ²⁄₃. Ta końcowa ocena ładunku jest mnożona przez ocenę podaną przez DefaultSimilarity
dla całego dokumentu.
Używamy średniej, ponieważ chcielibyśmy zmniejszyć znaczenie wyników wyszukiwania, w których wiele terminów pojawia się poza dialogiem, i uzyskać wynik zero dla dokumentów bez żadnych terminów w dialogu.
Możemy również skomponować kilka obiektów PayloadTermQuery
za pomocą BooleanQuery
, jeśli chcemy wyszukać wiele terminów zawartych w dialogu (zwróć uwagę, że kolejność terminów nie ma znaczenia w tym zapytaniu, chociaż inne typy zapytań są zależne od pozycji):
PayloadTermQuery worldQuery = new PayloadTermQuery(new Term("body", "world"), new AveragePayloadFunction()); BooleanQuery query = new BooleanQuery(); query.add(helloQuery, Occur.MUST); query.add(worldQuery, Occur.MUST);
Po wykonaniu tego zapytania możemy zobaczyć, jak struktura zapytania i implementacja podobieństwa współdziałają ze sobą:
Wykonanie zapytania i wyjaśnienie
Aby wykonać zapytanie, przekazujemy je do IndexSearcher
:
TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();
Obiekty Collector
służą do przygotowania kolekcji pasujących dokumentów.
kolektory można skomponować tak, aby uzyskać kombinację sortowania, ograniczania i filtrowania. Aby uzyskać na przykład dziesięć najlepiej ocenianych dokumentów, które zawierają co najmniej jeden termin w dialogu, łączymy TopScoreDocCollector
i PositiveScoresOnlyCollector
. Przyjmowanie tylko pozytywnych wyników gwarantuje, że dopasowania z zerowym wynikiem (tj. te, które nie zawierają terminów w dialogu) zostaną odfiltrowane.
Aby zobaczyć to zapytanie w działaniu, możemy je wykonać, a następnie użyć IndexSearcher#explain
, aby zobaczyć, jak oceniano poszczególne dokumenty:
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)); }
Tutaj iterujemy po identyfikatorach dokumentów w TopDocs
uzyskanych w wyniku wyszukiwania. Używamy również IndexSearcher#doc
do pobrania pola tytułu do wyświetlenia. W przypadku naszego zapytania "hello"
skutkuje to:
--- 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() ...
Chociaż dane wyjściowe są pełne żargonu, możemy zobaczyć, jak nasza niestandardowa implementacja Similarity
została wykorzystana do oceniania i jak funkcja MaxPayloadFunction
wygenerowała mnożnik 1.0
dla tych dopasowań. Oznacza to, że ładunek został załadowany i oceniony, a wszystkie dopasowania "Hello"
odbyły się w dialogu, więc te wyniki znajdują się na samej górze, tam, gdzie się ich spodziewamy.
Warto również zauważyć, że indeks Project Gutenberg z ładunkami dochodzi do prawie czterech gigabajtów, a mimo to na mojej skromnej maszynie programistycznej zapytania pojawiają się natychmiast. Nie poświęciliśmy żadnej szybkości, aby osiągnąć nasze cele wyszukiwania.
Zawijanie
Lucene jest potężną, wbudowaną biblioteką wyszukiwania pełnotekstowego, która pobiera nieprzetworzony strumień znaków, łączy je w tokeny i utrzymuje je jako terminy w indeksie. Może szybko przeszukiwać ten indeks i dostarczać wyniki w rankingu, a także zapewnia szerokie możliwości rozszerzenia przy jednoczesnym zachowaniu wydajności.
Używając Lucene bezpośrednio w naszych aplikacjach lub jako część serwera, możemy w czasie rzeczywistym przeprowadzać wyszukiwanie pełnotekstowe w gigabajtach treści. Co więcej, dzięki niestandardowej analizie i punktacji możemy skorzystać z funkcji specyficznych dla domeny w naszych dokumentach, aby poprawić trafność wyników lub niestandardowych zapytań.
Pełne wykazy kodu dla tego samouczka Lucene są dostępne w serwisie GitHub. Repozytorium zawiera dwie aplikacje: LuceneIndexerApp
do budowania indeksu oraz LuceneQueryApp
do wykonywania zapytań.
Korpus Projektu Gutenberg, który można uzyskać jako obraz dysku za pośrednictwem BitTorrenta, zawiera mnóstwo książek wartych przeczytania (albo za pomocą Lucene, albo po prostu w staromodny sposób).
Miłego indeksowania!