Volltextsuche von Dialogen mit Apache Lucene: Ein Tutorial
Veröffentlicht: 2022-03-11Apache 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.
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:
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 einenchar[]
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 inCharTermAttribute
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:
Wenn wir das Ende des Token-Streams erreicht haben (dh
hasNext
ist falsch), sind wir fertig und kehren einfach zurück.Wenn wir ein Token mit mehr als einem Zeichen haben und eines dieser Zeichen ein Anführungszeichen ist, teilen wir das Token auf.
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):
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:
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!