Beschleunigung der Zusammenführung von HNSW-Diagrammen

Informieren Sie sich über unsere Arbeit zur Reduzierung des Aufwands beim Erstellen mehrerer HNSW-Diagramme, insbesondere zur Reduzierung der Kosten für das Zusammenführen von Diagrammen.

Von der Vektorsuche bis hin zu leistungsstarken REST-APIs bietet Elasticsearch Entwicklern das umfangreichste Such-Toolkit. Sehen Sie sich die Beispiel-Notebooks auf GitHub an, um etwas Neues testen. Sie können auch noch heute Ihre kostenlose Testversion starten oder Elasticsearch lokal ausführen.

In der Vergangenheit haben wir einige der Herausforderungen erörtert , die mit der Suche in mehreren HNSW-Graphen einhergehen, und wie wir diese bewältigen konnten. Damals deuteten wir einige weitere Verbesserungen an, die wir geplant hatten. Dieser Beitrag ist der Höhepunkt dieser Arbeit.

Sie fragen sich vielleicht, warum Sie überhaupt mehrere Diagramme verwenden? Dies ist ein Nebeneffekt einer Architekturentscheidung in Lucene: unveränderliche Segmente. Wie bei den meisten architektonischen Entscheidungen gibt es Vor- und Nachteile. Beispielsweise haben wir vor Kurzem Serverless Elasticsearch allgemein zugänglich gemacht. In diesem Zusammenhang haben wir durch unveränderliche Segmente erhebliche Vorteile erzielt, darunter eine effiziente Indexreplikation und die Möglichkeit, die Index- und Abfrageberechnung zu entkoppeln und unabhängig voneinander automatisch zu skalieren. Bei der Vektorquantisierung bieten uns Segmentzusammenführungen die Möglichkeit, Parameter zu aktualisieren, um sie an die Dateneigenschaften anzupassen. In diesem Sinne sind wir der Meinung, dass die Möglichkeit, Datenmerkmale zu messen und Indexierungsentscheidungen zu überprüfen, noch weitere Vorteile mit sich bringt.

In diesem Beitrag besprechen wir die Arbeit, die wir geleistet haben, um den Aufwand für die Erstellung mehrerer HNSW-Diagramme deutlich zu reduzieren und insbesondere die Kosten für das Zusammenführen von Diagrammen zu senken.

Hintergrund

Um eine überschaubare Anzahl von Segmenten beizubehalten, prüft Lucene regelmäßig, ob Segmente zusammengeführt werden sollen. Dies läuft darauf hinaus, zu prüfen, ob die aktuelle Segmentanzahl eine Zielsegmentanzahl überschreitet, die durch die Basissegmentgröße und die Zusammenführungsrichtlinie bestimmt wird. Wenn die Anzahl überschritten wird, führt Lucene Segmentgruppen zusammen, während die Einschränkung verletzt wird. Dieser Vorgang wurde an anderer Stelle ausführlich beschrieben.

Lucene entscheidet sich für die Zusammenführung ähnlich großer Segmente, da dadurch ein logarithmisches Wachstum der Schreibverstärkung erreicht wird. Im Fall eines Vektorindex ist die Schreibverstärkung die Anzahl der Male, die ein Vektor in ein Diagramm eingefügt wird. Lucene versucht, Segmente in Gruppen von etwa 10 zusammenzuführen. Folglich werden Vektoren in einen Graphen eingefügt, der ungefähr 1+910log10(nn0)1+\frac{9}{10}\log_{10}\left(\frac{n}{n_0}\right)beträgt. mal, wobei nn die Anzahl der Indexvektoren und n0n_0 die erwartete Anzahl der Basissegmentvektoren ist. Aufgrund des logarithmischen Wachstums liegt die Schreibverstärkung selbst bei großen Indizes im einstelligen Bereich. Die Gesamtzeit, die zum Zusammenführen von Graphen benötigt wird, ist jedoch linear proportional zur Schreibverstärkung.

Beim Zusammenführen von HNSW-Graphen nehmen wir bereits eine kleine Optimierung vor: Wir behalten den Graphen für das größte Segment bei und fügen Vektoren aus den anderen Segmenten darin ein. Dies ist der Grund für den oben genannten 9/10-Faktor. Im Folgenden zeigen wir, wie wir durch die Verwendung von Informationen aus allen Diagrammen, die wir zusammenführen, deutlich bessere Ergebnisse erzielen können.

HNSW-Graphzusammenführung

Bisher haben wir den größten Graphen beibehalten und Vektoren aus den anderen eingefügt, wobei wir die Graphen, die sie enthalten, ignoriert haben. Die zentrale Erkenntnis, die wir im Folgenden nutzen, ist, dass jeder HNSW-Graph, den wir verwerfen, wichtige Näheinformationen über die darin enthaltenen Vektoren enthält. Wir möchten diese Informationen nutzen, um das Einfügen zumindest einiger Vektoren zu beschleunigen.

Wir konzentrieren uns auf das Problem des Einfügens eines kleineren Graphen Gs=(Vs,Es)G_s=(V_s,E_s) in einen größeren Graphen Gl=(Vl,El)G_l=(V_l,E_l), da dies eine atomare Operation ist, die wir zum Erstellen beliebiger Zusammenführungsrichtlinien verwenden können.

Die Strategie besteht darin, eine Teilmenge von Eckpunkten von JVsJ\subset V_s zu finden, die in den großen Graphen eingefügt werden soll. Wir verwenden dann die Konnektivität dieser Knoten im kleinen Graphen, um das Einfügen der verbleibenden Knoten VsJV_s \setminus J zu beschleunigen. Im Folgenden verwenden wir Ns(u)N_s(u) und Nl(u),N_l(u), um die Nachbarn eines Knotens uu im kleinen bzw. großen Graphen zu bezeichnen. Schematisch ist der Ablauf wie folgt.

MERGE-HNSW

Inputs GsG_s and GlG_l

1    \;\;Find JVsJ\subset V_s to insert into GlG_l using COMPUTE-JOIN-SET
2    \;\;Insert each vertex uJu\in J into GlG_l
3    \;\;for uVsJu\in V_s \setminus J do
4        JuJNs(u)\;\;\;\;J_u\leftarrow J\cap N_s(u)
5        EuvJuNl(u)\;\;\;\;E_u\leftarrow \cup_{v\in J_u} N_l(u)
6        W\;\;\;\;W\leftarrow\,FAST-SEARCH-LAYER(Ju,Eu)(J_u, E_u)
7        neighbors\;\;\;\;neighbors \leftarrow\,SELECT-NEIGHBORS-HEURISTIC(u,W)(u, W)
8        JJ{u}\;\;\;\;J\leftarrow J \cup \{u\}

Wir berechnen die Menge JJ mithilfe eines Verfahrens, das wir unten besprechen (Zeile 1). Dann fügen wir jeden Knoten in JJ mithilfe des Standard-HNSW-Einfügeverfahrens in den großen Graphen ein (Zeile 2). Für jeden Knoten, den wir nicht eingefügt haben, finden wir seine Nachbarn, die wir eingefügt haben, und deren Nachbarn im großen Graphen (Zeilen 4 und 5). Wir verwenden ein mit diesem Set (Zeile 6) gesätes FAST-SEARCH-LAYER -Verfahren, um die Kandidaten für SELECT-NEIGHBORS-HEURISTIC aus dem HNSW- Papier (Zeile 7) zu finden. Tatsächlich ersetzen wir SEARCH-LAYER , um den Kandidatensatz in der Methode INSERT (Algorithmus 1 aus dem Dokument) zu finden, die ansonsten unverändert bleibt. Schließlich fügen wir den Scheitelpunkt hinzu, den wir gerade in JJ eingefügt haben (Zeile 8).

Damit dies funktioniert, ist klar, dass jeder Scheitelpunkt in VsJV_s \setminus J mindestens einen Nachbarn in JJ haben muss. Tatsächlich verlangen wir, dass für jeden Scheitelpunkt in uVsJu\in V_s \setminus J JNs(u)ku|J\cap N_s(u) |\geq k_u für ein gewisses ku<Mk_u<M, die maximale Schichtkonnektivität, gilt. Wir beobachten, dass in realen HNSW-Diagrammen eine ziemliche Streuung der Scheitelpunktgrade zu sehen ist. Die folgende Abbildung zeigt eine typische kumulative Dichtefunktion des Scheitelpunktgrads für die unterste Ebene eines Lucene HNSW-Diagramms.

Wir haben die Verwendung eines festen Werts für kuk_u sowie die Möglichkeit untersucht, ihn zu einer Funktion des Scheitelpunktgrads zu machen. Diese zweite Wahl führt zu größeren Beschleunigungen bei minimalen Auswirkungen auf die Grafikqualität, daher habe ich mich für Folgendes entschieden

ku=max(2,14Ns(u))k_u=\max\left(2,\frac{1}{4}|N_s(u)|\right)

Beachten Sie, dass Ns(u)|N_s(u)| per Definition gleich dem Grad des Scheitelpunkts uu im kleinen Graphen ist. Eine Untergrenze von zwei bedeutet, dass wir jeden Scheitelpunkt einfügen, dessen Grad kleiner als zwei ist.

Ein einfaches Zählargument legt nahe, dass wir bei sorgfältiger Wahl von JJ nur etwa 15Vs\frac{1}{5}|V_s| direkt in GlG_l einfügen müssen. Konkret färben wir eine Kante des Graphen, wenn wir genau einen ihrer Endknoten in JJ einfügen. Dann wissen wir, dass wir für jeden Knoten in VsJV_s \setminus J mindestens uVsJku\sum_{u\in V_s\setminus J} k_u Kanten einfärben müssen, um mindestens kuk_u Nachbarn in Jzuhaben.J zu haben. Darüber hinaus erwarten wir, dass

uVsJku(VsJ)14EU[Ns(U)]\sum_{u\in V_s\setminus J} k_u\approx \left(|V_s|-|J|\right)\frac{1}{4}\mathbb{E}_U\left[|N_s(U)|\right]

Dabei ist EU[Ns(U)]\mathbb{E}_U\left[N_s(U)|\right] der durchschnittliche Knotengrad im kleinen Graphen. Für jeden Knoten uJu\in J färben wir höchstens Ns(u)|N_s(u)| Kanten. Daher beträgt die Gesamtzahl der Kanten, die wir voraussichtlich einfärben werden, höchstens JEU[Ns(U)]|J|\, \mathbb{E}_U\left[|N_s(U)|\right]. Wir hoffen, dass wir durch sorgfältige Wahl von JJ annähernd diese Anzahl von Kanten einfärben können. Um alle Eckpunkte abzudecken, muss J|J| folgende Bedingung erfüllen:

JEU[Ns(U)]=(VsJ)14EU[Ns(U)]|J|\, \mathbb{E}_U\left[|N_s(U)|\right] =\left(|V_s|-|J|\right)\frac{1}{4}\mathbb{E}_U\left[|N_s(U)|\right]

Dies impliziert, dass J=14(VsJ)=4514Vs=15Vs|J|=\frac{1}{4}(|V_s|-|J|)=\frac{4}{5}\frac{1}{4}|V_s|=\frac{1}{5}|V_s|.

Vorausgesetzt, SEARCH-LAYER dominiert die Laufzeit, lässt dies darauf schließen, dass wir eine bis zu fu¨nffachefünffache Beschleunigung der Zusammenführungszeit erreichen könnten. Angesichts des logarithmischen Wachstums der Schreibverstärkung bedeutet dies, dass wir selbst bei sehr großen Indizes die Erstellungszeit im Vergleich zum Erstellen eines Diagramms normalerweise nur verdoppeln würden.

Das Risiko bei dieser Strategie besteht darin, dass wir die Grafikqualität beeinträchtigen. Wir haben es zunächst mit einem No-Op FAST-SEARCH-LAYER versucht. Wir haben festgestellt, dass dies die Qualität des Diagramms in dem Maße verschlechtert, dass die Rückrufrate als Funktion der Latenz beeinträchtigt wird, insbesondere beim Zusammenführen auf ein einzelnes Segment. Anschließend haben wir mithilfe einer eingeschränkten Suche im Diagramm verschiedene Alternativen untersucht. Am Ende war die effektivste Wahl die einfachste. Verwenden Sie SEARCH-LAYER , aber mit einem niedrigen ef_construction. Mit dieser Parametrisierung konnten wir Grafiken von hervorragender Qualität erzielen und die Zusammenführungszeit dennoch durchschnittlich um etwas mehr als 30 % verkürzen.

Berechnung der Join-Menge

Die Suche nach einer guten Join-Menge kann als HNSW-Graphüberdeckungsproblem formuliert werden. Eine Greedy-Heuristik ist eine einfache und effektive Heuristik zur Annäherung an optimale Graphüberdeckungen. Bei unserem Ansatz werden die Knotenpunkte nacheinander in absteigender Reihenfolge der Verstärkung zu JJ hinzugefügt. Der Gewinn ist wie folgt definiert:

Gain(v)=max(kvc(v),0)+uNs(v)J1{c(u)<ku}Gain(v)=\max(k_v-c(v),0)+\sum_{u\in N_s(v)\setminus J} 1\left\{c(u)<k_u\right\}

Dabei bezeichnet c(v)c(v) die Anzahl der Nachbarn eines Vektors vv in JJ und 1{}1\{\cdot\} ist die Indikatorfunktion. Der Gewinn beinhaltet die Änderung der Anzahl der Scheitelpunkte, die wir zu JJ hinzugefügt haben, also max(kvc(v),0)\max(k_v-c(v),0), da wir unserem Ziel durch das Hinzufügen eines weniger abgedeckten Scheitelpunkts näher kommen. Die Gewinnberechnung wird in der folgenden Abbildung für den zentralen orangefarbenen Scheitelpunkt veranschaulicht.

Wir behalten für jeden Knoten vv den folgenden Zustand bei:

  1. Ob es abgestanden ist,
  2. Sein Gewinn Gain(v)Gain(v),
  3. Die Anzahl der benachbarten Eckpunkte in JJ wird mitc(v)mit c(v) bezeichnet,
  4. Eine Zufallszahl im Bereich [0,1], die zum Auflösen eines Gleichstands verwendet wird.

Der Pseudocode zum Berechnen des Join-Sets lautet wie folgt.

COMPUTE-JOIN-SET

Inputs GsG_s

1    C\;\;C\leftarrow\emptyset
2    Gainexit0\;\;Gain_{exit}\leftarrow 0
3    \;\;for uGsu\in G_s do
4        CC{(false,ku+deg(u),0,rand in [0,1])}\;\;\;\;C\leftarrow C \cup \{(\text{false}, k_u+deg(u), 0, \text{rand in }[0,1])\}
5        GainexitGainexit+ku\;\;\;\;Gain_{exit}\leftarrow Gain_{exit}+k_u
6    Gaintot0\;\;Gain_{tot}\leftarrow 0
7    \;\;while Gaintot<GainexitGain_{tot}<Gain_{exit} do
8        v\;\;\;\;v^*\leftarrow\, maximum gain vertex in CC

9        \;\;\;\;Remove the state for vv^* from CC
10      \;\;\;if vv^* is not stale then
11          JJ{v}\;\;\;\;\;J\leftarrow J\cup\{v^*\}
12          GaintotGaintot+Gain(v)\;\;\;\;\;Gain_{tot}\leftarrow Gain_{tot}+Gain(v^*)
13          \;\;\;\;\;for uNs(v)u \in N_s(v^*) do
14              \;\;\;\;\;\;\;mark uu as stale if c(v)<kvc(v^*)<k_{v^*}
15              \;\;\;\;\;\;\;mark neighbors of uu stale if c(u)=ku1c(u)=k_u-1
16              c(u)c(u)+1\;\;\;\;\;\;\;c(u)\leftarrow c(u)+1
17      \;\;\;else
18          Gain(v)max(kvc(v),0)+uNs(v)J1{c(u)<ku}\;\;\;\;\;Gain(v^*)\leftarrow \max(k_v-c(v^*),0)+\sum_{u\in N_s(v^*)\setminus J} 1\left\{c(u)<k_u\right\}
19          \;\;\;\;\;if Gain(v)>0Gain(v^*)>0 then
20              CC{(false,Gain(v),c(v),copy rand)}\;\;\;\;\;\;\;C\leftarrow C \cup \{(\text{false},Gain(v^*),c(v^*),\text{copy rand})\}
21    \;\;return JJ

Wir initialisieren zunächst den Status in den Zeilen 1-5.

In jeder Iteration der Hauptschleife extrahieren wir zunächst den Scheitelpunkt mit der maximalen Verstärkung (Zeile 8) und lösen Gleichstände nach dem Zufallsprinzip auf. Bevor wir Änderungen vornehmen, müssen wir prüfen, ob die Verstärkung des Scheitelpunkts veraltet ist. Insbesondere beeinflussen wir jedes Mal, wenn wir einen Scheitelpunkt zu JJ hinzufügen, den Gewinn anderer Scheitelpunkte:

  1. Da alle seine Nachbarn einen zusätzlichen Nachbarn in JJ haben, können sich ihre Gewinne ändern (Zeile 14).
  2. Wenn einer seiner Nachbarn jetzt vollständig gedeckt ist, können sich die Gewinne aller seiner Nachbarn ändern (Zeilen 14-16).

Wir berechnen die Gewinne auf verzögerte Weise neu, d. h. wir berechnen den Gewinn eines Scheitelpunkts nur dann neu, wenn wir ihn in JJ einfügen möchten (Zeilen 18–20). Da die Gewinne immer nur abnehmen, können wir nie einen Scheitelpunkt verpassen, den wir einfügen sollten.

Beachten Sie, dass wir lediglich den Gesamtgewinn der zu JJ hinzugefügten Scheitelpunkte im Auge behalten müssen, um zu bestimmen, wann wir aussteigen müssen. Darüber hinaus wird, obwohl Gaintot<Gainexitist,Gain_{tot}<Gain_{exit}ist, mindestens ein Scheitelpunkt einen von Null verschiedenen Gewinn aufweisen, sodass wir immer Fortschritte machen.

Ergebnisse

Wir haben Experimente mit vier Datensätzen durchgeführt, die zusammen unsere drei unterstützten Distanzmetriken (euklidisch, Kosinus und inneres Produkt) abdecken:

  1. quora-E5-small: 522931 Dokumente, 384 Dimensionen und verwendet Kosinusähnlichkeit,
  2. cohere-wikipedia-v2: 1 Million Dokumente, 768 Dimensionen und verwendet Kosinusähnlichkeit,
  3. Kern: 1 Million Dokumente, 960 Dimensionen und verwendet euklidische Distanz und
  4. cohere-wikipedia-v3: 1 Mio. Dokumente, 1024 Dimensionen und verwendet das maximale innere Produkt.

Für jeden Datensatz bewerten wir zwei Quantisierungsstufen:

  1. int8 – verwendet eine 1-Byte-Ganzzahl pro Dimension und
  2. BBQ – das ein einzelnes Bit pro Dimension verwendet.

Schließlich haben wir für jedes Experiment die Suchqualität bei zwei Abruftiefen bewertet und nach dem Erstellen des Index und anschließend nach der erzwungenen Zusammenführung zu einem einzelnen Segment untersucht.

Zusammenfassend lässt sich sagen, dass wir durchgängig erhebliche Beschleunigungen bei der Indizierung und Zusammenführung erzielen und dabei in allen Fällen die Grafikqualität und damit die Suchleistung beibehalten.

Experiment 1: int8-Quantisierung

Die durchschnittlichen Beschleunigungen von der Basislinie bis zum Kandidaten, die vorgeschlagenen Änderungen, sind:

Beschleunigung der Indexzeit: 1,28×\times

Beschleunigung der Force Merge: 1,72×\times

Dies entspricht folgender Laufzeitaufteilung

Der Vollständigkeit halber sind die genauen Zeiten

IndexVerschmelzen
DatensatzBasislinieKandidatEntwickelnKandidat
Quora-E5-klein112,41 s81,55 s113,81 s70,87 s
wiki-cohere-v2158,1 s122,95 s425,20 s239,28 s
Kern141,82 s119,26 s536,07 s279,05 s
wiki-cohere-v3211,86 s168,22 s654,97 s414,12 s

Unten zeigen wir die Diagramme „Rückruf vs. Latenz“, die den Kandidaten (gestrichelte Linien) mit der Basislinie bei zwei Abruftiefen vergleichen: Rückruf@10 und Rückruf@100 für Indizes mit mehreren Segmenten (das Endergebnis unserer Standard-Zusammenführungsstrategie nach der Indizierung aller Vektoren) und nach der erzwungenen Zusammenführung zu einem einzelnen Segment. Eine Kurve, die höher und weiter links liegt, ist besser, da sie eine höhere Rückrufrate bei geringerer Latenz bedeutet.

Wie Sie sehen, ist der Kandidat für mehrere Segmentindizes für den Cohere v3-Datensatz besser und für alle anderen Datensätze etwas schlechter, aber fast vergleichbar. Nach der Zusammenführung zu einem einzigen Segment sind die Erinnerungskurven in allen Fällen nahezu identisch.

Experiment 2: BBQ-Quantisierung

Die durchschnittlichen Beschleunigungen von der Basislinie zum Kandidaten sind:

Beschleunigung der Indexzeit: 1,33×\times

Beschleunigung der Force Merge: 1,34×\times

Dies entspricht folgender Laufzeitaufteilung

Der Vollständigkeit halber sind die genauen Zeiten

IndexVerschmelzen
DatensatzBasislinieKandidatEntwickelnKandidat
Quora-E5-klein70,71 s58,25 s59,38 s40,15 s
wiki-cohere-v2203,08 s142,27 s107,27 s85,68 s
Kern110,35 s105,52 s323,66 s202,2 s
wiki-cohere-v3313,43 s190,63 s165,98 s159,95 s

Bei Indizes mit mehreren Segmenten ist der Kandidat für fast alle Datensätze besser, mit Ausnahme von Cohere v2, wo die Basislinie etwas besser ist. Für die einzelnen Segmentindizes sind die Recall-Kurven in allen Fällen nahezu identisch.

Fazit

Der in diesem Blog besprochene Algorithmus wird im kommenden Lucene 10.2 und in der darauf basierenden Elasticsearch-Version verfügbar sein. Benutzer können in diesen neuen Versionen von der verbesserten Zusammenführungsleistung und der verkürzten Indexerstellungszeit profitieren. Diese Änderung ist Teil unserer kontinuierlichen Bemühungen, Lucene und Elasticsearch für die Vektor- und Hybridsuche schnell und effizient zu machen.

Zugehörige Inhalte

Sind Sie bereit, hochmoderne Sucherlebnisse zu schaffen?

Eine ausreichend fortgeschrittene Suche kann nicht durch die Bemühungen einer einzelnen Person erreicht werden. Elasticsearch wird von Datenwissenschaftlern, ML-Ops-Experten, Ingenieuren und vielen anderen unterstützt, die genauso leidenschaftlich an der Suche interessiert sind wie Sie. Lasst uns in Kontakt treten und zusammenarbeiten, um das magische Sucherlebnis zu schaffen, das Ihnen die gewünschten Ergebnisse liefert.

Probieren Sie es selbst aus