Volltextsuche von Dialogen mit Apache Lucene: Ein Tutorial

Veröffentlicht: 2022-03-11

Apache Lucene ist eine Java-Bibliothek, die für die Volltextsuche von Dokumenten verwendet wird und den Kern von Suchservern wie Solr und Elasticsearch bildet. Es kann auch in Java-Anwendungen wie Android-Apps oder Web-Backends eingebettet werden.

Obwohl die Konfigurationsoptionen von Lucene umfangreich sind, sind sie für die Verwendung durch Datenbankentwickler auf einem allgemeinen Textkorpus gedacht. Wenn Ihre Dokumente eine bestimmte Struktur oder einen bestimmten Inhaltstyp haben, können Sie beides nutzen, um die Suchqualität und die Abfragefähigkeit zu verbessern.

Volltextsuche mit Apache Lucene

Als Beispiel für diese Art der Anpassung indizieren wir in diesem Lucene-Tutorial den Korpus des Projekts Gutenberg, das Tausende von kostenlosen E-Books anbietet. Wir wissen, dass viele dieser Bücher Romane sind. Angenommen, wir interessieren uns besonders für den Dialog innerhalb dieser Romane. Weder Lucene, Elasticsearch noch Solr bieten vorkonfigurierte Tools, um Inhalte als Dialoge zu identifizieren. Tatsächlich werden sie Satzzeichen in den frühesten Stadien der Textanalyse wegwerfen, was der Fähigkeit widerspricht, Teile des Textes zu identifizieren, die Dialoge sind. Daher muss unsere Anpassung in diesen frühen Stadien beginnen.

Teile der Apache Lucene Analysis Pipeline

Das Lucene-Analyse-JavaDoc bietet einen guten Überblick über alle beweglichen Teile in der Textanalyse-Pipeline.

Auf hoher Ebene können Sie sich die Analyse-Pipeline so vorstellen, dass sie am Anfang einen rohen Strom von Zeichen verbraucht und am Ende „Begriffe“ produziert, die ungefähr Wörtern entsprechen.

Die Standard-Analysepipeline kann wie folgt visualisiert werden:

Lucene-Analysepipeline

Wir werden sehen, wie diese Pipeline angepasst werden kann, um Textbereiche zu erkennen, die durch doppelte Anführungszeichen gekennzeichnet sind, was ich als Dialog bezeichnen werde, und dann Übereinstimmungen zu erhöhen, die bei der Suche in diesen Bereichen auftreten.

Zeichen lesen

Wenn Dokumente erstmalig dem Index hinzugefügt werden, werden die Zeichen aus einem Java-InputStream gelesen und können daher aus Dateien, Datenbanken, Webdienstaufrufen usw. stammen. Um einen Index für das Projekt Gutenberg zu erstellen, laden wir die E-Books herunter und Erstellen Sie eine kleine Anwendung, um diese Dateien zu lesen und in den Index zu schreiben. Das Erstellen eines Lucene-Index und das Lesen von Dateien sind weit verbreitete Pfade, daher werden wir sie nicht viel untersuchen. Der wesentliche Code zum Erstellen eines Index lautet:

 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);

Wir können sehen, dass jedes E-Book einem einzelnen Lucene- Document entspricht, sodass unsere Suchergebnisse später eine Liste übereinstimmender Bücher sein werden. Store.YES gibt an, dass wir das Titelfeld speichern, das nur der Dateiname ist. Wir möchten jedoch den Hauptteil des E-Books nicht speichern, da er bei der Suche nicht benötigt wird und nur Speicherplatz verschwenden würde.

Das eigentliche Lesen des Streams beginnt mit addDocument . Der IndexWriter zieht Token vom Ende der Pipeline. Dieser Pull wird durch die Pipe zurückgeführt, bis die erste Stufe, der Tokenizer , aus dem InputStream liest.

Beachten Sie auch, dass wir den Stream nicht schließen, da Lucene dies für uns übernimmt.

Tokenisierung von Zeichen

Der Lucene StandardTokenizer wirft Satzzeichen weg, und daher beginnt unsere Anpassung hier, da wir Anführungszeichen beibehalten müssen.

Die Dokumentation für StandardTokenizer lädt Sie ein, den Quellcode zu kopieren und an Ihre Bedürfnisse anzupassen, aber diese Lösung wäre unnötig komplex. Stattdessen werden wir CharTokenizer erweitern, mit dem Sie Zeichen angeben können, die „akzeptiert“ werden sollen, wobei diejenigen, die nicht „akzeptiert“ werden, als Trennzeichen zwischen Token behandelt und weggeworfen werden. Da wir an Wörtern und den sie umgebenden Zitaten interessiert sind, ist unser benutzerdefinierter Tokenizer einfach:

 public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }

Bei einem Eingabestrom von [He said, "Good day".] , wären die erzeugten Token [He] , [said] , ["Good] , [day"]

Beachten Sie, wie die Anführungszeichen in die Token eingestreut sind. Es ist möglich, einen Tokenizer zu schreiben, der separate Tokens für jedes Angebot erstellt, aber Tokenizer sich auch um knifflige, leicht zu vermasselnde Details wie Pufferung und Scannen, daher ist es am besten, Ihren Tokenizer einfach zu halten und aufzuräumen Token-Stream weiter in der Pipeline.

Aufteilen von Tokens mithilfe von Filtern

Nach dem Tokenizer kommt eine Reihe von TokenFilter Objekten. Beachten Sie übrigens, dass Filter etwas irreführend ist, da ein TokenFilter Token hinzufügen, entfernen oder ändern kann.

Viele der von Lucene bereitgestellten Filterklassen erwarten einzelne Wörter, daher reicht es nicht aus, unsere gemischten Wort-und-Anführungszeichen-Token in sie einfließen zu lassen. Daher muss die nächste Anpassung unseres Lucene-Tutorials die Einführung eines Filters sein, der die Ausgabe von QuotationTokenizer bereinigt.

Diese Bereinigung beinhaltet die Erzeugung eines zusätzlichen Start-Zitat -Tokens, wenn das Zitat am Anfang eines Wortes erscheint, oder eines End-Zitat -Tokens, wenn das Zitat am Ende erscheint. Wir werden der Einfachheit halber auf die Behandlung von Wörtern in einfachen Anführungszeichen verzichten.

Das Erstellen einer TokenFilter Unterklasse umfasst das Implementieren einer Methode: incrementToken . Diese Methode muss incrementToken für den vorherigen Filter in der Pipe aufrufen und dann die Ergebnisse dieses Aufrufs bearbeiten, um die Arbeit auszuführen, für die der Filter verantwortlich ist. Die Ergebnisse von incrementToken sind über Attribute -Objekte verfügbar, die den aktuellen Zustand der Token-Verarbeitung beschreiben. Nachdem unsere Implementierung von incrementToken zurückkehrt, wird erwartet, dass die Attribute manipuliert wurden, um das Token für den nächsten Filter einzurichten (oder den Index, wenn wir am Ende der Pipe sind).

Die Attribute, an denen wir an diesem Punkt in der Pipeline interessiert sind, sind:

  • CharTermAttribute : Enthält einen char[] Puffer, der die Zeichen des aktuellen Tokens enthält. Wir müssen dies manipulieren, um das Zitat zu entfernen oder ein Zitat-Token zu erstellen.

  • TypeAttribute : Enthält den „Typ“ des aktuellen Tokens. Da wir Start- und Endzitate zum Token-Stream hinzufügen, werden wir mit unserem Filter zwei neue Typen einführen.

  • OffsetAttribute : Lucene kann optional Verweise auf die Position von Begriffen im Originaldokument speichern. Diese Referenzen werden „Offsets“ genannt, die einfach Start- und Endindizes in den ursprünglichen Zeichenstrom sind. Wenn wir den Puffer in CharTermAttribute so ändern, dass er nur auf einen Teilstring des Tokens zeigt, müssen wir diese Offsets entsprechend anpassen.

Sie fragen sich vielleicht, warum die API zum Manipulieren von Token-Streams so kompliziert ist und insbesondere, warum wir nicht einfach so etwas wie String#split auf den eingehenden Token machen können. Dies liegt daran, dass Lucene für eine Hochgeschwindigkeits-Indizierung mit geringem Overhead ausgelegt ist, wobei die integrierten Tokenizer und Filter Gigabytes an Text schnell durchkauen können, während sie nur Megabytes an Speicher verwenden. Um dies zu erreichen, werden während der Tokenisierung und Filterung nur wenige oder keine Zuweisungen vorgenommen, und daher sollen die oben erwähnten Attribute einmal zugewiesen und wiederverwendet werden. Wenn Ihre Tokenizer und Filter auf diese Weise geschrieben sind und ihre eigenen Zuordnungen minimieren, können Sie Lucene anpassen, ohne die Leistung zu beeinträchtigen.

Sehen wir uns vor diesem Hintergrund an, wie ein Filter implementiert wird, der ein Token wie ["Hello] nimmt und die beiden Token ["] und [Hello] erzeugt:

 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);

Wir beginnen damit, Verweise auf einige der Attribute zu erhalten, die wir zuvor gesehen haben. Wir fügen den Feldnamen „Attr“ hinzu, damit es später klar ist, wenn wir uns darauf beziehen. Es ist möglich, dass einige Tokenizer Implementierungen diese Attribute nicht bereitstellen, daher verwenden wir addAttribute , um unsere Referenzen abzurufen. addAttribute erstellt eine Attributinstanz, wenn sie fehlt, andernfalls greifen Sie auf eine gemeinsame Referenz auf das Attribut dieses Typs zu. Beachten Sie, dass Lucene nicht mehrere Instanzen desselben Attributtyps gleichzeitig zulässt.

 private boolean emitExtraToken; private int extraTokenStartOffset, extraTokenEndOffset; private String extraTokenType;

Da unser Filter ein neues Token einführt, das im ursprünglichen Stream nicht vorhanden war, benötigen wir einen Ort, an dem der Status dieses Tokens zwischen Aufrufen von incrementToken werden kann. Da wir ein vorhandenes Token in zwei Teile aufteilen, reicht es aus, nur die Offsets und den Typ des neuen Tokens zu kennen. Wir haben auch ein Flag, das uns mitteilt, ob der nächste Aufruf von incrementToken dieses zusätzliche Token ausgeben wird. Lucene bietet tatsächlich ein Methodenpaar, captureState und restoreState , das dies für Sie erledigt. Aber diese Methoden beinhalten die Zuweisung eines State Objekts und können tatsächlich kniffliger sein, als diesen Zustand einfach selbst zu verwalten, also werden wir sie vermeiden.

 @Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }

Als Teil seiner aggressiven Vermeidung von Zuweisungen kann Lucene Filterinstanzen wiederverwenden. In dieser Situation wird erwartet, dass ein reset -Aufruf den Filter wieder in seinen Anfangszustand versetzt. Hier setzen wir also einfach unsere zusätzlichen Token-Felder zurück.

 @Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...

Jetzt kommen wir zu den interessanten Stellen. Wenn unsere Implementierung von incrementToken aufgerufen wird, haben wir die Möglichkeit, incrementToken nicht in der früheren Phase der Pipeline aufzurufen. Dadurch führen wir effektiv ein neues Token ein, da wir kein Token aus dem Tokenizer .

Stattdessen rufen wir advanceToExtraToken auf, um die Attribute für unser zusätzliches Token einzurichten, setzen emitExtraToken auf false, um diese Verzweigung beim nächsten Aufruf zu vermeiden, und geben dann true zurück, was anzeigt, dass ein weiteres Token verfügbar ist.

 @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; }

Der Rest von incrementToken führt eines von drei verschiedenen Dingen aus. Denken Sie daran, dass termBufferAttr verwendet wird, um den Inhalt des Tokens zu untersuchen, das durch die Pipe kommt:

  1. Wenn wir das Ende des Token-Streams erreicht haben (dh hasNext ist falsch), sind wir fertig und kehren einfach zurück.

  2. Wenn wir ein Token mit mehr als einem Zeichen haben und eines dieser Zeichen ein Anführungszeichen ist, teilen wir das Token auf.

  3. Wenn der Token ein einzelnes Zitat ist, nehmen wir an, dass es sich um ein End-Quote handelt. Um zu verstehen warum, beachten Sie, dass Anführungszeichen am Anfang immer links von einem Wort stehen (d. h. ohne zwischengeschaltete Satzzeichen), während abschließende Anführungszeichen auf Satzzeichen folgen können (wie in dem Satz [He told us to "go back the way we came."] ). In diesen Fällen ist das abschließende Anführungszeichen bereits ein separates Token, sodass wir nur seinen Typ festlegen müssen.

splitTermQuoteFirst und splitTermWordFirst legen Attribute fest, um das aktuelle Token entweder zu einem Wort oder zu einem Zitat zu machen, und richten die „zusätzlichen“ Felder ein, damit die andere Hälfte später verwendet werden kann. Die beiden Methoden sind ähnlich, also betrachten wir nur 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); }

Da wir dieses Token mit dem im Stream zuerst erscheinenden Anführungszeichen aufteilen möchten, kürzen wir den Puffer, indem wir die Länge auf eins setzen (dh ein Zeichen; nämlich das Anführungszeichen). Wir passen die Offsets entsprechend an (dh zeigen auf das Zitat im Originaldokument) und setzen auch den Typ auf ein Anfangszitat.

prepareExtraTerm “ setzt die extra* -Felder und emitExtraToken auf „true“. Es wird mit Offsets aufgerufen, die auf das „zusätzliche“ Token zeigen (dh das Wort nach dem Anführungszeichen).

Der gesamte QuotationTokenFilter ist auf GitHub verfügbar.

Abgesehen davon, während dieser Filter nur ein zusätzliches Token erzeugt, kann dieser Ansatz erweitert werden, um eine beliebige Anzahl zusätzlicher Token einzuführen. Ersetzen Sie einfach die extra* Felder durch eine Sammlung oder, noch besser, durch ein Array fester Länge, wenn die Anzahl der zusätzlichen Token, die produziert werden können, begrenzt ist. Ein Beispiel dafür finden Sie SynonymFilter und seiner inneren Klasse PendingInput .

Verbrauchen von Quote-Tokens und Markierungsdialog

Nachdem wir uns nun all die Mühe gemacht haben, diese Anführungszeichen zum Token-Stream hinzuzufügen, können wir sie verwenden, um Dialogabschnitte im Text abzugrenzen.

Da unser Endziel darin besteht, die Suchergebnisse basierend darauf anzupassen, ob Begriffe Teil des Dialogs sind oder nicht, müssen wir diesen Begriffen Metadaten hinzufügen. Lucene stellt für diesen Zweck PayloadAttribute bereit. Payloads sind Byte-Arrays, die zusammen mit Begriffen im Index gespeichert werden und später während einer Suche gelesen werden können. Das bedeutet, dass unser Flag verschwenderisch ein ganzes Byte belegt, sodass zusätzliche Nutzlasten als Bit-Flags implementiert werden könnten, um Platz zu sparen.

Unten ist ein neuer Filter, DialoguePayloadTokenFilter , der ganz am Ende der Analysepipeline hinzugefügt wird. Es hängt die Nutzlast an, die angibt, ob das Token Teil des Dialogs ist oder nicht.

 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; } }

Da dieser Filter nur einen einzigen Zustand innerhalb von withinDialogue muss, ist er viel einfacher. Ein Anfangszitat zeigt an, dass wir uns jetzt in einem Dialogabschnitt befinden, während ein Endzitat anzeigt, dass der Dialogabschnitt beendet ist. In beiden Fällen wird das Zitat-Token durch einen zweiten Aufruf von incrementToken verworfen, sodass Anfangs- oder End-Zitat -Token niemals über diese Phase in der Pipeline hinaus fließen.

Beispielsweise transformiert DialoguePayloadTokenFilter den Token-Stream:

 [the], [program], [printed], ["], [hello], [world], ["]`

in diesen neuen Stream:

 [the][0], [program][0], [printed][0], [hello][1], [world][1]

Verknüpfung von Tokenizern und Filtern

Ein Analyzer ist für die Zusammenstellung der Analysepipeline verantwortlich, typischerweise durch Kombinieren eines Tokenizer mit einer Reihe von TokenFilter s. Analyzer können auch definieren, wie diese Pipeline zwischen Analysen wiederverwendet wird. Wir müssen uns darüber keine Gedanken machen, da unsere Komponenten nichts außer einem Aufruf von reset() zwischen den Verwendungen benötigen, was Lucene immer tun wird. Wir müssen nur die Assemblierung durchführen, indem wir Analyzer#createComponents(String) implementieren:

 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); } }

Wie wir bereits gesehen haben, enthalten Filter einen Verweis zurück auf die vorherige Stufe in der Pipeline, sodass wir sie auf diese Weise instanziieren. Wir fügen auch einige Filter von StandardAnalyzer ein: LowerCaseFilter und StopFilter . Diese beiden müssen nach QuotationTokenFilter kommen, um sicherzustellen, dass alle Anführungszeichen getrennt wurden. Wir können bei der Platzierung von DialoguePayloadTokenFilter flexibler sein, da alles nach QuotationTokenFilter ausreicht. Wir haben es nach StopFilter , um Zeitverschwendung zu vermeiden, indem wir die Dialognutzlast in Stoppwörter einfügen, die letztendlich entfernt werden.

Hier ist eine Visualisierung unserer neuen Pipeline in Aktion (abzüglich der Teile der Standardpipeline, die wir entfernt oder bereits gesehen haben):

Neue Pipeline-Visualisierung in Apache Lucene

DialogueAnalyzer kann jetzt wie jeder andere Analyzer verwendet werden, und jetzt können wir den Index erstellen und mit der Suche fortfahren.

Volltextsuche des Dialogs

Wenn wir nur den Dialog suchen wollten, hätten wir einfach alle Tokens außerhalb eines Zitats verwerfen können, und wir wären fertig gewesen. Indem wir stattdessen alle ursprünglichen Token intakt lassen, haben wir uns die Flexibilität gegeben, entweder Abfragen durchzuführen, die den Dialog berücksichtigen, oder den Dialog wie jeden anderen Teil des Textes zu behandeln.

Die Grundlagen der Abfrage eines Lucene-Index sind gut dokumentiert. Für unsere Zwecke reicht es aus zu wissen, dass Abfragen aus Term zusammengesetzt sind, die mit Operatoren wie MUST oder SHOULD verbunden sind, zusammen mit Übereinstimmungsdokumenten, die auf diesen Begriffen basieren. Übereinstimmende Dokumente werden dann basierend auf einem konfigurierbaren Similarity bewertet, und diese Ergebnisse können nach Bewertung geordnet, gefiltert oder eingeschränkt werden. Beispielsweise erlaubt uns Lucene, eine Abfrage nach den Top-Ten-Dokumenten durchzuführen, die beide Begriffe [hello] und [world] enthalten müssen.

Das Anpassen von Suchergebnissen basierend auf Dialogen kann durch Anpassen der Punktzahl eines Dokuments basierend auf der Nutzlast erfolgen. Der erste Erweiterungspunkt dafür wird in Similarity sein, das für das Gewichten und Bewerten übereinstimmender Begriffe zuständig ist.

Ähnlichkeit und Bewertung

Abfragen verwenden standardmäßig DefaultSimilarity , das Begriffe basierend darauf gewichtet, wie häufig sie in einem Dokument vorkommen. Es ist ein guter Erweiterungspunkt zum Anpassen von Gewichten, daher erweitern wir es, um auch Dokumente basierend auf der Nutzlast zu bewerten. Dafür steht die Methode DefaultSimilarity#scorePayload zur Verfügung:

 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 bewertet Nicht-Dialog-Nutzlasten einfach mit null. Da jeder Term mehrmals abgeglichen werden kann, hat er möglicherweise mehrere Payload-Scores. Die Interpretation dieser Scores bis hin zur Query Implementierung.

Achten Sie genau auf die BytesRef , die die Nutzlast enthält: Wir müssen das Byte bei offset überprüfen, da wir nicht davon ausgehen können, dass das Byte-Array dieselbe Nutzlast ist, die wir zuvor gespeichert haben. Beim Lesen des Index verschwendet Lucene keinen Speicher, indem es ein separates Byte-Array nur für den Aufruf von scorePayload , sodass wir einen Verweis auf ein vorhandenes Byte-Array erhalten. Beim Codieren mit der Lucene-API lohnt es sich, daran zu denken, dass Leistung die Priorität ist, weit vor Entwicklerkomfort.

Nachdem wir nun unsere neue Similarity -Implementierung haben, muss sie auf dem IndexSearcher werden, der zum Ausführen von Abfragen verwendet wird:

 IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());

Anfragen und Bedingungen

Da unser IndexSearcher nun Payloads bewerten kann, müssen wir auch eine Abfrage erstellen, die Payload-fähig ist. PayloadTermQuery kann verwendet werden, um einen einzelnen Term abzugleichen und gleichzeitig die Payloads dieser Übereinstimmungen zu überprüfen:

 PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());

Diese Abfrage stimmt mit dem Begriff [hello] innerhalb des body -Felds überein (denken Sie daran, dass wir hier den Inhalt des Dokuments abgelegt haben). Wir müssen auch eine Funktion bereitstellen, um den endgültigen Payload-Score aus allen Begriffsübereinstimmungen zu berechnen, also schließen wir AveragePayloadFunction an, die alle Payload-Scores mittelt. Wenn beispielsweise der Begriff [hello] zweimal innerhalb des Dialogs und einmal außerhalb des Dialogs vorkommt, ist die endgültige Payload-Punktzahl ²⁄₃. Dieser endgültige Payload-Score wird mit dem Wert multipliziert, der von DefaultSimilarity für das gesamte Dokument bereitgestellt wird.

Wir verwenden einen Durchschnitt, weil wir Suchergebnisse, in denen viele Begriffe außerhalb des Dialogs vorkommen, weniger hervorheben und für Dokumente ohne Begriffe im Dialog überhaupt eine Punktzahl von null erzielen möchten.

Wir können auch mehrere PayloadTermQuery Objekte mit einer BooleanQuery , wenn wir nach mehreren im Dialog enthaltenen Begriffen suchen möchten (beachten Sie, dass die Reihenfolge der Begriffe in dieser Abfrage irrelevant ist, obwohl andere Abfragetypen positionsbewusst sind):

 PayloadTermQuery worldQuery = new PayloadTermQuery(new Term("body", "world"), new AveragePayloadFunction()); BooleanQuery query = new BooleanQuery(); query.add(helloQuery, Occur.MUST); query.add(worldQuery, Occur.MUST);

Wenn diese Abfrage ausgeführt wird, können wir sehen, wie die Abfragestruktur und die Ähnlichkeitsimplementierung zusammenarbeiten:

Lucene Dialoganalysepipeline

Abfrageausführung und Erläuterung

Um die Abfrage auszuführen, übergeben wir sie an den IndexSearcher :

 TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();

Collector Objekte werden verwendet, um die Sammlung übereinstimmender Dokumente vorzubereiten.

Sammler können zusammengesetzt werden, um eine Kombination aus Sortieren, Begrenzen und Filtern zu erreichen. Um beispielsweise die Top-Ten-Scoring-Dokumente zu erhalten, die mindestens einen Begriff im Dialog enthalten, kombinieren wir TopScoreDocCollector und PositiveScoresOnlyCollector . Das Nehmen nur positiver Bewertungen stellt sicher, dass die Übereinstimmungen mit Nullbewertung (dh diejenigen ohne Begriffe im Dialog) herausgefiltert werden.

Um diese Abfrage in Aktion zu sehen, können wir sie ausführen und dann IndexSearcher#explain verwenden, um zu sehen, wie einzelne Dokumente bewertet wurden:

 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)); }

Hier iterieren wir über die Dokument-IDs in den durch die Suche erhaltenen TopDocs . Wir verwenden auch IndexSearcher#doc , um das Titelfeld für die Anzeige abzurufen. Für unsere Abfrage "hello" ergibt dies:

 --- 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() ...

Obwohl die Ausgabe voller Jargon ist, können wir sehen, wie unsere benutzerdefinierte Similarity -Implementierung beim Scoring verwendet wurde und wie die MaxPayloadFunction einen Multiplikator von 1.0 für diese Übereinstimmungen erzeugte. Dies impliziert, dass die Payload geladen und gewertet wurde und alle Übereinstimmungen von "Hello" im Dialog stattfanden, und daher stehen diese Ergebnisse ganz oben, wo wir sie erwarten.

Es ist auch erwähnenswert, dass der Index für das Projekt Gutenberg mit Nutzlasten fast vier Gigabyte groß ist und dennoch auf meiner bescheidenen Entwicklungsmaschine Abfragen sofort erfolgen. Wir haben keine Geschwindigkeit geopfert, um unsere Suchziele zu erreichen.

Einpacken

Lucene ist eine leistungsstarke, zweckgebundene Volltextsuchbibliothek, die einen rohen Zeichenstrom nimmt, sie in Token bündelt und sie als Begriffe in einem Index speichert. Es kann diesen Index schnell abfragen und Rangfolgeergebnisse liefern und bietet reichlich Gelegenheit zur Erweiterung bei gleichzeitiger Aufrechterhaltung der Effizienz.

Durch die Verwendung von Lucene direkt in unseren Anwendungen oder als Teil eines Servers können wir Volltextsuchen in Echtzeit über Gigabyte an Inhalten durchführen. Darüber hinaus können wir durch benutzerdefinierte Analysen und Bewertungen domänenspezifische Funktionen in unseren Dokumenten nutzen, um die Relevanz von Ergebnissen oder benutzerdefinierten Abfragen zu verbessern.

Vollständige Codelisten für dieses Lucene-Lernprogramm sind auf GitHub verfügbar. Das Repo enthält zwei Anwendungen: LuceneIndexerApp zum Erstellen des Indexes und LuceneQueryApp zum Durchführen von Abfragen.

Das Korpus des Projekts Gutenberg, das als Disk-Image über BitTorrent bezogen werden kann, enthält viele lesenswerte Bücher (entweder mit Lucene oder einfach auf die altmodische Art).

Viel Spaß beim Indexieren!