<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Benjamin Trent - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Benjamin Trent - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/de/search-labs/author/benjamin-trent</link>
    </image>
    <link>https://www.elastic.co/de/search-labs/author/benjamin-trent</link>
    <atom:link href="https://www.elastic.co/de/search-labs/rss/author/benjamin-trent.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[de]]></language>
    <lastBuildDate>Mon, 21 Sep 2026 20:53:56 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Skalierung von späten Interaktionsmodellen in Elasticsearch – Teil 2]]></title>
    <description><![CDATA[Dieser Artikel behandelt Techniken, mit denen sich späte Interaktionsvektoren für groß angelegte Produktions-Workloads vorbereiten lassen, beispielsweise durch Reduzierung des Festplattenspeichers und Verbesserung der Recheneffizienz.]]></description>
    <content:encoded><![CDATA[<p>In unserem <a href="https://www.elastic.co/search-labs/blog/elastiacsearch-colpali-document-search">vorherigen Blog über ColPali</a> haben wir uns mit der Erstellung visueller Suchanwendungen mit Elasticsearch befasst. Wir haben uns vor allem auf den Wert konzentriert, den Modelle wie ColPali für unsere Anwendungen bieten, allerdings weisen sie im Vergleich zur Vektorsuche mit Bi-Encodern wie E5 Leistungsnachteile auf.</p><p>Aufbauend auf den Beispielen aus <a href="https://www.elastic.co/search-labs/blog/elastiacsearch-colpali-document-search">Teil 1</a> befasst sich dieser Blog damit, wie verschiedene Techniken und das leistungsstarke Toolkit zur Vektorsuche von Elasticsearch zur Bereitstellung später Interaktionsvektoren für umfassende Produktions-Workloads genutzt werden können.</p><p>Die vollständigen Code-Beispiele finden Sie auf <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/colpali">GitHub.</a></p><h2>Herausforderungen bei späten Interaktionsmodellen</h2><p>ColPali erstellt über 1000 Vektoren pro Seite für die Dokumente in unserem Index.</p><p>Dies führt zu zwei Herausforderungen bei der Arbeit mit späten Interaktionsvektoren:</p><ol><li><p>Speicherplatz: Das Speichern all dieser Vektoren auf Festplatten wird einen erheblichen Speicherplatzbedarf verursachen, was in großem Maßstab teuer wird.</p></li><li><p>Berechnung: Wenn wir unsere Dokumente mit dem <code>maxSimDotProduct()</code>-Vergleich bewerten, müssen wir alle diese Vektoren für jedes unserer Dokumente mit den N-Vektoren unserer Abfrage vergleichen.</p></li></ol><p>Sehen wir uns einige Techniken zur Bewältigung dieser Probleme an.</p><h2>Techniken zur Optimierung von späten Interaktionsmodellen</h2><h3>Bit-Vektoren</h3><p>Um den Festplattenspeicher zu reduzieren, können wir die Bilder in Bitvektoren komprimieren. Wir können eine einfache Python-Funktion verwenden, um unsere Multivektoren in Bitvektoren umzuwandeln.</p>def to_bit_vectors(embeddings: list) -&gt; list:
    return [
        np.packbits(np.where(np.array(embedding) &gt; 0, 1, 0))
        .astype(np.int8)
        .tobytes()
        .hex()
        for embedding in embeddings
    ]<p>Das Kernkonzept der Funktion ist einfach: Werte über 0 werden zu 1 und Werte unter 0 werden zu 0. Daraus ergibt sich ein Array aus Nullen und Einsen, das wir dann in eine Hexadezimalzeichenfolge umwandeln, die unseren Bitvektor darstellt.</p><p>Für unser Index-Mapping setzen wir den Parameter <code>element_type</code> auf <code>bit</code>:</p>mappings = {
    "mappings": {
        "properties": {
            "col_pali_vectors": {
                "type": "rank_vectors",
                "element_type": "bit"
            }
        }
    }
}

es.indices.create(index=INDEX_NAME, body=mappings)<p>Nachdem wir alle unsere neuen Bitvektoren in unseren Index geschrieben haben, können wir unsere Bitvektoren mit folgendem Code bewerten:</p>query = "What do companies use for recruiting?"
query_vector = to_bit_vectors(create_col_pali_query_vectors(query))
es_query = {
    "_source": False,
    "query": {
        "script_score": {
            "query": {
                "match_all": {}
            },
            "script": {
                "source": "maxSimInvHamming(params.query_vector, 'col_pali_vectors')",
                "params": {
                    "query_vector": query_vector
                }
            }
        }
    },
    "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcaf0c8f999d68701/6a17f46696142a6f46eb1c56/51b989446e4099745971e1eac27d147a78d13e0a-1600x480.png" alt="" /><p>Durch einen geringen Verlust an Genauigkeit können wir den Hamming-Abstand (<code>maxSimInvHamming(...)</code>) verwenden, wodurch Optimierungen wie Bitmasken oder SIMD genutzt werden können. Mehr über <a href="https://www.elastic.co/search-labs/blog/bit-vectors-in-elasticsearch">Bitvektoren und den Hamming-Abstand erfahren Sie in unserem Blog</a>.</p><p>Alternativ können wir unseren Abfragevektor nicht in Bitvektoren umwandeln und mit dem vollwertigen späten Interaktionsvektor suchen:</p>query = "What do companies use for recruiting?"
query_vector = create_col_pali_query_vectors(query)
es_query = {
    "_source": False,
    "query": {
        "script_score": {
            "query": {
                "match_all": {}
            },
            "script": {
                "source": "maxSimDotProduct(params.query_vector, 'col_pali_vectors')",
                "params": {
                    "query_vector": query_vector
                }
            }
        }
    },
    "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2fa6c89fb451b6e0/6a17f468af47b65da9cde0d9/89f3c795c6dc44b2f7d46801320288316b47e1b2-1600x488.png" alt="Ergebnisse der Verwendung von Bitvektoren zur Optimierung von späten Interaktionsmodellen" /><p>Hierbei werden unsere Vektoren mithilfe einer asymmetrischen Ähnlichkeitsfunktion verglichen.</p><p></p><p>Betrachten wir den regulären Hamming-Abstand zwischen zwei Bitvektoren. Angenommen, wir haben einen Dokumentenvektor <em>D:</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4b6ece1ec4dedac/6a17f4694b055d248143234e/366a70a23e37d403788327b7aefc873fd4482f5e-1235x86.png" alt="" /><p>Und ein Abfragevektor <em>Q:</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7721df9c8f0d84f1/6a17f46b3e03d77cf54f2dcb/978ec31e0afbc0eaebf548a015b628c0cad84625-1247x84.png" alt="" /><p></p><p>Eine einfache binäre Quantisierung transformiert Vektoren <em>D</em> in <code>10101101</code> und <em>Q</em> in <code>11111011</code>. Um den Hamming-Abstand zu ermitteln, benötigen wir direkte Bit-Mathematik – die extrem schnell ist. In diesem Fall ist der Hamming-Abstand <code>01010110</code>, was einer Bitanzahl von 4 entspricht. Das Scoring wird also zum Kehrwert dieses Hamming-Abstands. Denken Sie daran, dass ähnlichere Vektoren einen KLEINEREN Hamming-Abstand aufweisen, sodass durch die Umkehrung ähnlichere Vektoren eine höhere Bewertung erhalten können. Konkret wäre hier der Wert 1/4 = <code>0.25</code>.</p><p>Beachten Sie jedoch, wie wir die Größenordnung jeder Dimension verlieren. Ein <code>1</code> ist ein <code>1</code>. Für <em>Q</em> verschwindet also der Unterschied zwischen <code>0.01</code> und <code>0.79</code>. Da wir lediglich gemäß <code>&gt;0</code> quantisieren, können wir einen kleinen Trick anwenden, bei dem der Q-Vektor nicht quantisiert wird. Dies ermöglicht zwar keine extrem schnellen Bitoperationen, hält aber die Speicherkosten niedrig, da D weiterhin quantisiert wird.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blted50315518e2d599/6a17f46c414c6463709452d3/508e8498ba969534d2a0131d431c75735fc27cb9-1399x611.png" alt="" /><p>Das bedeutet, dass die in <em>Q</em> enthaltenen Informationen erhalten bleiben, wodurch die Qualität der Entfernungsschätzung verbessert und der Speicherbedarf gering gehalten wird.</p><p>Durch die Verwendung von Bitvektoren können wir bei der Abfragezeit deutlich an Festplattenspeicher und Rechenaufwand sparen. Aber wir können noch mehr tun.</p><h3>Mittlere Vektoren</h3><p>Um unsere Suche auf Hunderttausende von Dokumenten auszuweiten, reichen selbst die Leistungsvorteile, die Bitvektoren bieten, nicht aus. Für die Skalierung dieser Art von Workloads werden wir die HNSW-Indexstruktur von Elasticsearch für die Vektorsuche nutzen wollen.</p><p>ColPali generiert etwa tausend Vektoren pro Dokument. Das sind zu viele, um sie zu unserem HNSW-Graph hinzuzufügen. Daher müssen wir die Anzahl der Vektoren reduzieren. Dazu können wir eine einzige Repräsentation der Bedeutung des Dokuments erstellen, indem wir den Durchschnitt aller Dokumentvektoren bilden, die von ColPali beim Einbetten unseres Bildes erzeugt werden.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc053425fbfcf8de8/6a17f46f148009faf1b488aa/7c3b4dffb70bd95f67deb35f8c01e73c5286c2ab-1476x1102.png" alt="Mittlerer Vektor über alle späten Interaktionsvektoren" /><p>Aktuell ist das innerhalb von Elastic selbst nicht möglich, daher müssen wir die Vektoren vorverarbeiten, bevor wir sie in Elasticsearch ingestieren können. </p><p>Das ist mit Logstash- oder Ingest-Pipelines möglich, jedoch verwenden wir hier eine einfache Python-Funktion:</p>def to_avg_vector(vectors):
    vectors_array = np.array(vectors)
    
    avg_vector = np.mean(vectors_array, axis=0)
    
    norm = np.linalg.norm(avg_vector)
    if norm &gt; 0:
        normalized_avg_vector = avg_vector / norm
    else:
        normalized_avg_vector = avg_vector

    return normalized_avg_vector.tolist()<p>Außerdem normalisieren wir den Vektor, damit wir die Ähnlichkeit des Skalarprodukts verwenden können.</p><p>Nachdem wir alle unsere ColPali-Vektoren in mittlere Vektoren umgewandelt haben, können wir sie in unser dense_vector-Feld indexieren:</p>mappings = {
    "mappings": {
        "properties": {
            "avg_vector": {
                "type": "dense_vector",
                "dims": 128,
                "index": True,
                "similarity": "dot_product"
            },
            "col_pali_vectors": {
                "type": "rank_vectors",
                "element_type": "bit"
            }
        }
    }
}

es.indices.create(index=INDEX_NAME, body=mappings)<p>Wir müssen berücksichtigen, dass dadurch die gesamte Festplattennutzung erhöht wird, da wir neben unseren späten Interaktionsvektoren mehr Informationen speichern. Zusätzlich werden wir zusätzlichen RAM für den HNSW-Graphen verwenden, wodurch wir die Suche auf Milliarden von Vektoren skalieren können. Um den RAM-Verbrauch zu reduzieren, können wir unser beliebtes <a href="https://www.elastic.co/search-labs/blog/optimized-scalar-quantization-elasticsearch">BBQ-Feature</a> nutzen. Dadurch erhalten wir schnelle Suchergebnisse über enorme Datensätze, die sonst nicht möglich wären.</p><p>Jetzt suchen wir einfach mit der kNN-Abfrage, um unsere relevantesten Dokumente zu finden.</p>query = "What do companies use for recruiting?"
query_vector = to_avg_vector(create_col_pali_query_vectors(query))
es_query = {
    "_source": False,
    "knn": {
        "field": "avg_vector",
        "query_vector": query_vector,
        "k": 10,
        "num_candidates": 100
    },
    "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf4ac6b7fd444f935/6a17f471a29299d76ad02dc0/a500f9907020f032c3b1c7a48ed8c759dc2a1dd4-1600x498.png" alt="" /><p>Das bisher beste Ergebnis ist leider auf Platz 3 abgerutscht.</p><p>Um dieses Problem zu beheben, können wir ein mehrstufiges Abrufen durchführen. Im ersten Schritt verwenden wir die kNN-Abfrage, um unter Millionen von Dokumenten die besten Kandidaten für unsere Abfrage zu suchen. Im zweiten Schritt ordnen wir nur die besten k (hier: 10) mit der höheren Genauigkeit der späten ColPali-Interaktionsmodelle neu. </p>query = "What do companies use for recruiting?"
col_pali_vector = create_col_pali_query_vectors(query)
avg_vector = to_avg_vector(col_pali_vector)
es_query = {
  "_source": False,
  "retriever": {
    "rescorer": {
      "retriever": {
        "knn": {
          "field": "avg_vector",
          "query_vector": avg_vector,
          "k": 10,
          "num_candidates": 100
        }
      },
      "rescore": {
        "window_size": 10,
        "query": {
          "rescore_query": {
            "script_score": {
              "query": {
                "match_all": {}
              },
              "script": {
                "source": "maxSimDotProduct(params.query_vector, 'col_pali_vectors')",
                "params": {
                  "query_vector": col_pali_vector
                }
              }
            }
          }
        }
      }
    }
  },
  "size": 5
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt265edd5f37be40b7/6a17f4733e9e45583dba15e6/797f490860af87fcf29f34668a5c4511419c6fd0-1600x501.png" alt="Ergebnisse der Verwendung von mittleren Vektoren zur Optimierung von späten Interaktionsmodellen" /><p>Hier verwenden wir den in 8.18 vorgestellten <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/retriever.html#rescorer-retriever">Rescore-Retriever</a> zum Reranking unserer Ergebnisse. Nach dem Rescoring sehen wir, dass unser bester Treffer wieder an erster Stelle steht. </p><p>Hinweis: In einer Produktionsanwendung können wir einen viel höheren Wert für k als 10 verwenden, da die Max-Sim-Funktion immer noch vergleichsweise leistungsfähig ist.</p><h3>Token-Pooling</h3><p>Token-Pooling reduziert die Sequenzlänge von Multi-Vektor-Einbettungen, indem redundante Informationen wie weiße Hintergrund-Patches gepoolt werden. Diese Technik verringert die Anzahl der Einbettungen, während der Großteil des Seitensignals erhalten bleibt.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3d643e6ac908f640/6a17f4754b055d3783432352/09eae0b768f4b450e555d7e53e57561bd52de2c0-1412x1056.png" alt="Token-Pooling zur Optimierung von späten Interaktionsmodellen" /><p>Token-Pooling funktioniert, indem ähnliche Token-Einbettungen innerhalb eines Dokuments mithilfe eines Clustering-Algorithmus in Cluster gruppiert werden. Anschließend wird der Mittelwert der Vektoren in jedem Cluster berechnet, um eine einzige, aggregierte Darstellung zu erzeugen. Dieser aggregierte Vektor ersetzt die ursprünglichen Token in der Gruppe und reduziert die Gesamtzahl der Vektoren ohne nennenswerten Verlust des Dokumentsignals.</p><p>Im ColPali-Paper wird für die meisten Datensätze ein anfänglicher Pooling-Faktor von 3 vorgeschlagen, der 97,8 % der ursprünglichen Leistung beibehält und gleichzeitig die Gesamtzahl der Vektoren um 66,7 % reduziert. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d78a15c6944a1ea/6a17f477414c64a2929452d9/343cc9eaf54af8c7a125d4115838f3c7d5659de0-1600x1007.png" alt="Poolfaktor für die Optimierung von späten Interaktionsmodellen" /><p>Aber Vorsicht ist geboten: Der Datensatz „Shift“, der sehr dichte, textlastige Dokumente mit wenig Leerzeichen enthält, zeigt eine rapide Leistungsverschlechterung bei steigenden Poolfaktoren.</p><p>Um die gepoolten Vektoren zu erstellen, können wir die Bibliothek colpali_engine verwenden:</p>from colpali_engine.compression.token_pooling import HierarchicalTokenPooler

pooler = HierarchicalTokenPooler(pool_factor=3) # test on your data for a good pool_factor

def pool_vectors(embedding: list) -&gt; list:
    tensor = torch.tensor(embedding).unsqueeze(0)
    pooled = pooler.pool_embeddings(tensor)
    return pooled.squeeze(0).tolist()<p>Wir haben nun einen Vektor, der in seinen Dimensionen um etwa 66,7 % reduziert wurde. Wir indexieren ihn wie üblich und können mit unserer <code>maxSimDotProduct()</code>-Funktion danach suchen.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc460c14031814ede/6a17f4794202292bf629f72d/2412c5db7d79a590b96d42fe01f140c42f010612-1600x481.png" alt="Ergebnisse später Interaktionsmodelle" /><p>Wir erzielen gute Suchergebnisse, auch wenn die Genauigkeit der Ergebnisse dadurch etwas beeinträchtigt wird.</p><p>Tipp: Mit einem höheren pool_factor (100–200) kann man auch einen Mittelweg zwischen der mittleren Vektorlösung und der hier besprochenen Lösung finden. Bei etwa 5–10 Vektoren pro Dokument ist es sinnvoll, diese in einem verschachtelten Feld zu indizieren, um den HNSW-Index optimal zu nutzen.</p><h2>Cross-Encoder vs. späte Interaktion vs. Bi-Encoder</h2><p>Auf Grundlage unserer bisherigen Erkenntnisse stellt sich die Frage: Wo ordnen sich späte Interaktionsmodelle wie ColPali oder ColBERT im Vergleich zu anderen KI-gestützten Retrieval-Techniken ein?</p><p>Obwohl die Max-Sim-Funktion im Vergleich zu Cross-Encodern günstiger ist, erfordert sie dennoch deutlich mehr Vergleiche und Berechnungen als die Vektorsuche mit Bi-Encodern, bei denen wir einfach zwei Vektoren für jedes Abfrage-Dokumentpaar vergleichen. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbfd97f3bba3e5b17/6a17f47be31791b0052d5943/75e1fc9e601aa7a6e88f137565c1919166c7a71c-1480x458.png" alt="Cross-Encoder vs. späte Interaktionsmodelle vs. Bi-Encoder" /><p>Aus diesem Grund empfehlen wir, späte Interaktionsmodelle generell nur für das Reranking der besten k-Suchergebnisse zu verwenden. Wir halten das auch im Namen des Feldtyps fest: rank_vectors.</p><p>Aber was ist mit dem Cross-Encoder? Sind späte Interaktionsmodelle besser, weil sie zum Zeitpunkt der Abfrage günstiger auszuführen sind? Wie so oft lautet die Antwort: Es kommt darauf an. Cross-Encoder liefern in der Regel qualitativ hochwertigere Ergebnisse, sind aber sehr rechenintensiv, da die abgefragten Dokumentenpaare das Transformatormodell vollständig durchlaufen müssen. Sie profitieren außerdem davon, dass sie keine Indizierung von Vektoren erfordern und zustandslos arbeiten können. Dies führt zu Folgendem:</p><ul><li><p>Weniger verwendeter Festplattenspeicher</p></li><li><p>Ein einfacheres System</p></li><li><p>Höhere Qualität der Suchergebnisse</p></li><li><p>Höhere Latenz und daher keine Möglichkeit, ein umfassendes Reranking vorzunehmen</p></li></ul><p>Andererseits können späte Interaktionsmodelle einen Teil dieser Berechnungen beim Indizieren auslagern, wodurch die Abfrage kostengünstiger wird. Der Preis, den wir dafür zahlen, ist, dass wir die Vektoren indizieren müssen, was unsere Indizierungspipelines komplexer macht und auch mehr Festplattenspeicher zum Speichern dieser Vektoren erfordert.</p><p>Insbesondere im Fall von ColPali ist die Analyse von Informationen aus Bildern sehr teuer, da sie viele Daten enthalten. In diesem Fall verschiebt sich der Kompromiss zugunsten eines späten Interaktionsmodells wie ColPali, da die Bewertung dieser Informationen zur Abfragezeit zu ressourcenintensiv bzw. zu langsam wäre. </p><p>Bei einem späten Interaktionsmodell wie ColBERT, das wie die meisten Cross-Encoder (z. B. elastic-rerank-v1) mit Textdaten arbeitet, könnte die Entscheidung eher zugunsten des Cross-Encoders ausfallen, um von den Festplattenspeicherersparnissen und der Einfachheit zu profitieren.</p><p>Wir empfehlen Ihnen, die Vor- und Nachteile für Ihren Anwendungsfall abzuwägen und mit den verschiedenen Tools von Elasticsearch zu experimentieren, um die besten Suchanwendungen zu entwickeln.</p><h2>Fazit</h2><p>In diesem Blog befassen wir uns mit verschiedenen Techniken zur Optimierung von späten Interaktionsmodellen wie ColPali für umfassende Vektorsuche in Elasticsearch. Während späte Interaktionsmodelle ein gutes Gleichgewicht zwischen Retrieval-Effizienz und Ranking-Qualität bieten, bringen sie auch Herausforderungen in Bezug auf Speicher und Berechnung mit sich.</p><p>Für die Bewältigung dieser Herausforderungen haben wir Folgendes untersucht:</p><ul><li><p><strong>Bitvektoren</strong> zur deutlichen Reduzierung des Festplattenspeichers bei gleichzeitiger Nutzung effizienter Ähnlichkeitsberechnungen wie dem Hamming-Abstand oder der asymmetrischen maximalen Ähnlichkeit.</p></li><li><p><strong>Mittlere Vektoren</strong> zur Komprimierung mehrerer Einbettungen in eine einzige dichte Darstellung, wodurch ein effizientes Abrufen mit HNSW-Indizierung ermöglicht wird.</p></li><li><p><strong>Token-Pooling</strong> zur intelligenten Zusammenführung redundanter Einbettungen unter Beibehaltung der semantischen Integrität, wodurch der Rechenaufwand zum Zeitpunkt der Abfrage reduziert wird.</p></li></ul><p>Elasticsearch bietet ein leistungsstarkes Toolkit zur Anpassung und Optimierung von Suchanwendungen gemäß Ihren Anforderungen. Unabhängig davon, ob Sie Retrieval-Geschwindigkeit, Ranking-Qualität oder Speichereffizienz priorisieren – mit diesen Tools und Techniken können Sie die Leistung und Qualität entsprechend den Anforderungen Ihrer Anwendungen in der Praxis aufeinander abstimmen.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/late-interaction-model-colpali-scale</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/late-interaction-model-colpali-scale</guid>
    <category><![CDATA[Relevanz]]></category>
    <category><![CDATA[Vektordatenbank]]></category>
    <dc:creator><![CDATA[Peter Straßer,Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt97a536033e6b0a56/6a17f47dfbc5f88c86491c0d/c780b78a07573f2df1cfef8b29a7109f839b0ab3-1200x628.png" length="0" type="image/png"/>
    <pubDate>Thu, 20 Mar 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Parallelitätsfehler in Lucene: Wie man optimistische Parallelitätsfehler behebt]]></title>
    <description><![CDATA[Dank Fray, einem Framework für deterministische Parallelitätstests aus dem PASTA Lab der Carnegie Mellon University, konnten wir einen kniffligen Lucene-Fehler aufspüren und beheben.]]></description>
    <content:encoded><![CDATA[<p>Ja, noch ein Blogbeitrag zur Fehlerbehebung. Doch diese Geschichte hat eine Wendung: Ein Open-Source-Held eilt herbei und rettet die Situation. </p><p>Das Debuggen von Parallelitätsfehlern ist kein Zuckerschlecken, aber wir werden uns damit befassen. Hier kommt Fray ins Spiel, ein deterministisches Framework für Parallelitätstests aus dem PASTA Lab der CMU, das unzuverlässige Fehler in zuverlässig reproduzierbare Fehler verwandelt. Dank Frays cleverem Shadow-Lock-Design und präziser Gewindesteuerung konnten wir einen kniffligen Fehler in Lucene aufspüren und ihn schließlich beheben. Dieser Beitrag untersucht, wie Open-Source-Helden und -Tools das Debuggen von Parallelverarbeitung weniger schmerzhaft machen – und die Softwarewelt um einiges besser.</p><h2>Parallelitätsfehler: Der Fluch von Softwareentwicklern</h2><p>Parallelitätsfehler sind das Schlimmste. Sie sind nicht nur schwer zu reparieren, sondern es ist schon die größte Herausforderung, sie zuverlässig zum Ausfall zu bringen. Nehmen wir diesen Testfehler, <a href="https://github.com/apache/lucene/issues/13127"><code>TestIDVersionPostingsFormat#testGlobalVersions</code></a>, als Beispiel. Es erzeugt mehrere Threads zum Schreiben und Aktualisieren von Dokumenten und stellt damit Lucenes optimistisches Parallelitätsmodell in Frage. Dieser Test deckte eine Race Condition in der optimistischen Parallelitätskontrolle auf. Das heißt, eine Dokumentenoperation kann fälschlicherweise als die letzte in einer Abfolge von Operationen ausgegeben werden 😱. Das bedeutet, dass unter bestimmten Bedingungen eine Aktualisierungs- oder Löschoperation tatsächlich erfolgreich sein kann, obwohl sie aufgrund optimistischer Parallelitätsbeschränkungen eigentlich hätte fehlschlagen müssen.</p><p></p>org.apache.lucene.sandbox.codecs.idversion.TestIDVersionPostingsFormat &gt; testGlobalVersions FAILED
    java.lang.AssertionError: maxSeqNo must be greater or equal to 7442 but was 7441
        at __randomizedtesting.SeedInfo.seed([B97A2BDBC7E40BF6:B4D76006D5101E6]:0)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DocumentsWriterDeleteQueue.close(DocumentsWriterDeleteQueue.java:325)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DocumentsWriter.flushAllThreads(DocumentsWriter.java:659)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.IndexWriter.getReader(IndexWriter.java:576)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenFromWriter(StandardDirectoryReader.java:381)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:355)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:345)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DirectoryReader.openIfChanged(DirectoryReader.java:170)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:144)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:52)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.ReferenceManager.doMaybeRefresh(ReferenceManager.java:167)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.ReferenceManager.maybeRefresh(ReferenceManager.java:213)<p>Entschuldigung an alle, die Java-Stacktraces hassen. Hinweis: Löschen bedeutet nicht unbedingt „löschen“. Es kann auch auf eine Dokumentenaktualisierung hinweisen, da die Segmente von Lucene schreibgeschützt sind.
</p><p>Apache Lucene verwaltet jeden Thread, der Dokumente schreibt, über die Klasse <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java"><code>DocumentsWriter</code></a> . Diese Klasse erstellt oder verwendet Threads zum Schreiben von Dokumenten wieder, wobei jede Schreibaktion ihre Informationen innerhalb der Klasse <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java"><code>DocumentsWriterPerThread</code></a> (DWPT) steuert. Darüber hinaus verfolgt der Autor, welche Dokumente in <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> (DWDQ) gelöscht werden. Diese Strukturen halten alle Dokumentänderungsaktionen im Speicher und werden periodisch geleert, wodurch Speicherressourcen freigegeben und Strukturen auf der Festplatte gespeichert werden.</p><p></p><p>Um <a href="https://en.wikipedia.org/wiki/Blocking_(computing)">blockierende Threads</a> zu vermeiden und einen hohen Durchsatz in parallelen Systemen zu gewährleisten, versucht Apache Lucene, nur in sehr kritischen Abschnitten zu <a href="https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html">synchronisieren</a> . Das kann in der Praxis zwar gut sein, aber wie bei jedem System mit parallelen Prozessen gibt es auch hier Tücken.</p><h2>
Eine falsche Hoffnung</h2><p>Meine ersten Nachforschungen führten mich zu einigen kritischen Abschnitten, die nicht ordnungsgemäß synchronisiert waren. Alle Interaktionen eines gegebenen <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> werden durch das ihn umschließende <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java"><code>DocumentsWriter</code></a> gesteuert. Während also einzelne Methoden in <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> möglicherweise nicht angemessen synchronisiert sind, ist ihr Zugriff auf die Welt (oder sollte es sein) gewährleistet. (Wir wollen uns nicht näher damit befassen, wie dies die Eigentums- und Zugriffsrechte verkompliziert – es handelt sich um ein langjähriges Projekt, an dem viele Mitwirkende beteiligt sind.) Seid etwas nachsichtiger.)</p><p></p><p>Ich habe jedoch <a href="https://github.com/apache/lucene/blob/40060f8b7080d06a218518445a0a1dfc520c812a/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java#L572-L576">während eines Spülvorgangs eine Stelle</a> gefunden, die nicht synchronisiert war.</p>// Advance the queue, meaning create a new one to keep track of deletes
// Since we have been flushed, let's starting tracking again
DocumentsWriterDeleteQueue newQueue = documentsWriter.deleteQueue.advanceQueue(perThreadPool.size());
// OK, get the current new maximum sequence number for optimistic concurrency
seqNo = documentsWriter.deleteQueue.getMaxSeqNo();
// Reset to the new queue
documentsWriter.resetDeleteQueue(newQueue);<p>Diese Aktionen werden nicht zu einer einzigen atomaren Operation synchronisiert. Das heißt, zwischen der Erstellung <code>newQueue</code> und dem Aufruf von <code>getMaxSeqNo</code> könnte anderer Code ausgeführt worden sein, der die Sequenznummer in der Klasse <code>documentsWriter</code> inkrementiert. Ich habe den Fehler gefunden!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc55033280b45913/6a1706b4dc55dec27fe00d30/3f2617a102d28735e755a7539b047c28beda41be-1600x453.jpg" alt="" /><p>
Doch wie bei den meisten komplexen Fehlern war die Ursachenfindung nicht einfach. Da trat ein Held auf den Plan.</p><h2>Ein Held im Kampf</h2><p>Auftritt unseres Helden: <a href="https://aoli.al/">Ao Li</a> und seine Kollegen im PASTA-Labor. Ich lasse ihn erklären, wie sie mit Fray die Situation gerettet haben.</p><p><a href="https://github.com/cmu-pasta/fray">Fray</a> ist ein deterministisches Framework für Parallelitätstests, das von Forschern des <a href="https://pastalab.org/">PASTA Lab</a> an der Carnegie Mellon University entwickelt wurde. Die Motivation für die Entwicklung von Fray liegt in einer deutlich erkennbaren Kluft zwischen Wissenschaft und Industrie: Während deterministisches Concurrency-Testing in der akademischen Forschung seit über 20 Jahren intensiv untersucht wird, verlassen sich Praktiker weiterhin auf Stresstests – eine Methode, die allgemein als unzuverlässig und fehleranfällig gilt – um ihre Concurrency-Programme zu testen. Unser Hauptziel war daher die Entwicklung und Implementierung eines deterministischen Frameworks für Parallelitätstests, bei dem Allgemeingültigkeit und praktische Anwendbarkeit im Vordergrund standen.</p><p></p><h2>Die Kernidee</h2><p>Im Kern nutzt Fray ein einfaches, aber wirkungsvolles Prinzip: die sequentielle Ausführung. Das Nebenläufigkeitsmodell von Java bietet eine <a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.3">Schlüsseleigenschaft</a> : Wenn ein Programm frei von Datenkonflikten ist, erscheinen alle Ausführungen sequenziell konsistent. Das bedeutet, dass das Verhalten des Programms als eine Folge von Programmanweisungen dargestellt werden kann.</p><p>Fray arbeitet, indem es das Zielprogramm sequenziell ausführt: Bei jedem Schritt werden alle Threads außer einem angehalten, wodurch Fray die Thread-Planung präzise steuern kann. Um Parallelität zu simulieren, werden die Threads zufällig ausgewählt, die Auswahl wird jedoch für die spätere deterministische Wiedergabe aufgezeichnet. Um die Ausführung zu optimieren, führt Fray Kontextwechsel nur dann durch, wenn ein Thread im Begriff ist, eine Synchronisierungsanweisung wie Sperren oder atomaren/flüchtigen Zugriff auszuführen. Eine schöne Eigenschaft der Data-Race-Freiheit ist, dass dieser begrenzte Kontextwechsel ausreicht, um alle beobachtbaren Verhaltensweisen aufgrund von Thread-Interleaving zu untersuchen (<a href="https://arxiv.org/abs/2501.12618">in unserem Paper</a> findet sich eine Beweisskizze).</p><p></p><h2>Die Herausforderung: die Thread-Planung steuern</h2><p>Obwohl die Grundidee einfach erscheint, stellte die Implementierung von Fray erhebliche Herausforderungen dar. Um die Thread-Planung zu steuern, muss Fray die Ausführung jedes Anwendungsthreads verwalten. Auf den ersten Blick mag dies unkompliziert erscheinen – die grundlegenden Mechanismen zur Parallelverarbeitung werden einfach durch maßgeschneiderte Implementierungen ersetzt. Die Steuerung der Parallelverarbeitung in der JVM ist jedoch komplex und beinhaltet eine Mischung aus <a href="https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-6.html">Bytecode-Anweisungen</a>, <a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html">High-Level-Bibliotheken</a> und <a href="https://github.com/openjdk/jdk/blob/b720517cb33c2119ec6ed85504bce321de748228/src/java.base/share/classes/java/lang/Object.java#L394">nativen Methoden</a>.</p><p></p><p>Das entpuppte sich als Sackgasse:</p><p></p><ul><li><p>Beispielsweise muss zu jeder <code>MONITORENTER</code> -Anweisung eine entsprechende <code>MONITOREXIT</code> in derselben Methode gehören. Wenn Fray <code>MONITORENTER</code> durch einen Methodenaufruf an einen Stub/Mock ersetzt, muss es auch <code>MONITOREXIT</code> ersetzen.</p></li><li><p>In Code, der <code>object.wait/notify</code> verwendet, muss, wenn <code>MONITORENTER</code> ersetzt wird, auch das entsprechende <code>object.wait</code> ersetzt werden. Diese Ersatzkette erstreckt sich bis <code>object.notify</code> und darüber hinaus.</p></li><li><p>Die JVM ruft bestimmte Methoden im Zusammenhang mit Parallelität auf (z. B. <code>object.notify</code> , wenn ein Thread beendet wird) innerhalb des nativen Codes. Um diese Operationen zu ersetzen, müsste die JVM selbst modifiziert werden.</p></li><li><p>JVM-Funktionen wie Klassenlader und Garbage-Collection-Threads (GC-Threads) nutzen ebenfalls Parallelitätsprimitive. Durch die Modifizierung dieser Grundfunktionen können Inkompatibilitäten mit den entsprechenden JVM-Funktionen entstehen.</p></li><li><p>Das Ersetzen von Parallelitätsprimitiven im JDK führt oft zu JVM-Abstürzen während der Initialisierungsphase.</p></li></ul><p></p><p>Diese Herausforderungen machten deutlich, dass ein umfassender Ersatz der Parallelverarbeitungsprimitive nicht realisierbar war.</p><h2>
Unsere Lösung: Schattenverriegelungsdesign</h2><p>Um diese Herausforderungen zu bewältigen, verwendet Fray einen neuartigen Schattensperrmechanismus, um die Thread-Ausführung zu orchestrieren, ohne die Parallelitätsprimitive zu ersetzen. Schattensperren fungieren als Vermittler, die die Thread-Ausführung steuern. Bevor ein Anwendungsthread beispielsweise eine Sperre erwerben kann, muss er mit der entsprechenden Schatten-Sperre interagieren. Die Schattensperre bestimmt, ob der Thread die Sperre erwerben kann. Kann der Thread nicht fortfahren, blockiert die Schattensperre ihn und ermöglicht anderen Threads die Ausführung, wodurch Deadlocks vermieden und eine kontrollierte Parallelität ermöglicht wird. Dieses Design ermöglicht es Fray, die Thread-Verschachtelung transparent zu steuern und gleichzeitig die Korrektheit der Parallelitätssemantik zu erhalten. Jedes Parallelitätsprimitiv wird innerhalb des Shadow-Lock-Frameworks sorgfältig modelliert, um Korrektheit und Vollständigkeit zu gewährleisten. Weitere technische Details finden Sie in unserem Artikel.</p><p></p><p>Darüber hinaus ist dieses Design zukunftssicher ausgelegt. Da lediglich die Instrumentierung von Schattensperren um Parallelitätsprimitive herum erforderlich ist, wird die Kompatibilität mit neueren JVM-Versionen sichergestellt. Dies ist möglich, da die Schnittstellen der Parallelverarbeitungsprimitive in der JVM relativ stabil sind und seit Jahren unverändert geblieben sind.</p><h2>
Testkampf</h2><p>Nach der Entwicklung von Fray folgte der nächste Schritt: die Evaluierung. Zum Glück beinhalten viele Anwendungen, wie beispielsweise Apache Lucene, bereits Tests zur Parallelverarbeitung. Bei solchen Parallelitätstests handelt es sich um reguläre JUnit-Tests, die mehrere Threads erzeugen, eine bestimmte Arbeit verrichten, dann (in der Regel) warten, bis diese Threads fertig sind, und dann eine bestimmte Eigenschaft überprüfen. In den meisten Fällen bestehen diese Tests, weil sie nur eine einzige Verschachtelung durchführen. Noch schlimmer ist, dass einige Tests in der CI/CD-Umgebung nur gelegentlich fehlschlagen, wie bereits erwähnt, was die Fehlersuche extrem erschwert. Als wir die gleichen Tests mit Fray durchführten, entdeckten wir zahlreiche Fehler. Besonders bemerkenswert ist, dass Fray zuvor gemeldete Fehler wiederentdeckte, die aufgrund des Fehlens einer zuverlässigen Reproduktion ungelöst geblieben waren, einschließlich des Themas dieses Blogs: <a href="https://github.com/apache/lucene/issues/13127"><code>TestIDVersionPostingsFormat.testGlobalVersions</code></a>. Zum Glück können wir mit Fray diese deterministisch wiedergeben und den Entwicklern detaillierte Informationen liefern, sodass sie das Problem zuverlässig reproduzieren und beheben können.</p><p></p><h2>Nächste Schritte für Fray</h2><p>Wir freuen uns sehr über die Rückmeldung der Entwickler von Elastic, dass Fray bei der Fehlersuche in Parallelverarbeitungsproblemen hilfreich war. Wir werden weiterhin an Fray arbeiten, um es mehr Entwicklern zugänglich zu machen.</p><p>Zu unseren kurzfristigen Zielen gehört es, die Fähigkeit von Fray zu verbessern, den Zeitplan deterministisch wiederzugeben, selbst bei Vorhandensein anderer nicht-deterministischer Operationen wie einem Zufallswertgenerator oder der Verwendung von <code>object.hashcode</code>. Wir streben außerdem eine höhere Benutzerfreundlichkeit von Fray an, um Entwicklern die Analyse und das Debuggen bestehender Parallelitätstests ohne manuelle Eingriffe zu ermöglichen. Am wichtigsten ist jedoch, dass wir uns freuen, von Ihnen zu hören, wenn Sie Schwierigkeiten beim Debuggen oder Testen von Parallelitätsproblemen in Ihrem Programm haben. Bitte zögern Sie nicht, ein Problem im <a href="https://github.com/cmu-pasta/fray">Fray GitHub-Repository</a> zu melden.</p><p></p><h2>Es ist an der Zeit, den Parallelitätsfehler zu beheben.</h2><p>Dank Ao Li und dem PASTA-Labor haben wir nun eine zuverlässig fehlschlagende Instanz dieses Tests! Wir können das Problem endlich lösen. Die Kernfrage bestand darin, wie <a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThreadPool.java"><code>DocumentsWriterPerThreadPool</code></a> die Wiederverwendung von Threads und Ressourcen ermöglichte.</p>1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t0 20 getNextSequenceNumber 1 called from stack:
&lt;snip&gt;...&lt;/snip&gt;
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_1, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_5, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_2, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_6, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_4, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]<p>Hier können wir sehen, wie jeder Thread erstellt wird, wobei auf die anfängliche Löschwarteschlange in Generation 0 verwiesen wird.</p><p>Beim Leeren der Warteschlange erfolgt dann ein Fortschrittsschritt, wobei die vorherigen 7 Aktionen in der Warteschlange korrekt angezeigt werden.</p>1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t020 advanceQueue called from stack with maxSeq 9 lastSeqNo: 1 maxNumPendingOps: 7:
&lt;snip&gt;...&lt;/snip&gt;
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t525 getNextSequenceNumber 2 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t828 getNextSequenceNumber 3 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t727 getNextSequenceNumber 4 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t626 getNextSequenceNumber 5 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t424 getNextSequenceNumber 6 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t323 getNextSequenceNumber 7 called from stack:<p>Bevor jedoch alle Threads vollständig verarbeitet werden können, werden zwei für ein zusätzliches Dokument wiederverwendet:</p>1&gt; getAndLock: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; getAndLock: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]<p>Dadurch wird der Wert <code>seqNo</code> über den angenommenen Maximalwert, der während des Spülvorgangs mit 7 berechnet wurde, erhöht. Beachten Sie die zusätzlichen <code>numDocsInRAM</code> für die Segmente <code>_3</code> und <code>_0</code></p>1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_2, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=2, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_6, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_4, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_5, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_1, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=2, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove<p>Dadurch wird die Reihenfolge der Dokumentaktionen während eines Flush-Vorgangs von Lucene falsch berücksichtigt, was zu diesem Testfehler führt.</p><p>Wie bei allen guten Bugfixes besteht die eigentliche Korrektur aus etwa <a href="https://github.com/apache/lucene/pull/13627/files">10 Codezeilen</a>. Doch zwei Ingenieure brauchten mehrere Tage, um das herauszufinden:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71d78f22ce6ec215/6a1706b61949f7bcd0e7a94a/7915e5ba2feba0fb8e846da0af11bfdca470c467-1600x622.jpg" alt="" /><h2>Nicht alle Helden tragen Umhänge.</h2><p>Ja, es ist ein Klischee – aber es stimmt.</p><p></p><p>Das Debuggen von parallelen Programmen ist unglaublich wichtig. Diese kniffligen Parallelitätsfehler erfordern einen unverhältnismäßig hohen Zeitaufwand für die Fehlersuche und -behebung. Während neue Sprachen wie Rust eingebaute Mechanismen zur Vermeidung solcher Race Conditions bieten, ist der Großteil der Software weltweit bereits geschrieben – und zwar in einer anderen Sprache als <a href="https://www.rust-lang.org/">Rust</a>. Auch nach all den Jahren ist Java immer noch eine der meistgenutzten Programmiersprachen. Eine verbesserte Fehlersuche in JVM-basierten Sprachen macht die Welt der Softwareentwicklung besser. Und angesichts der Tatsache, dass manche Leute glauben, Code werde von großen Sprachmodellen geschrieben, werden unsere Aufgaben als Ingenieure vielleicht irgendwann nur noch darin bestehen, schlechten LLM-Code zu debuggen, anstatt nur unseren eigenen schlechten Code. Doch unabhängig von der Zukunft der Softwareentwicklung wird das Debuggen paralleler Programme für die Wartung und Entwicklung von Software weiterhin von entscheidender Bedeutung sein.</p><p></p><p>Vielen Dank an Ao Li und seine Kollegen vom PASTA Lab, die das Ganze noch viel besser gemacht haben.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/concurrency-bugs-lucene-debugging</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/concurrency-bugs-lucene-debugging</guid>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Benjamin Trent,Ao Li]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf9fcbf843affc566/6a1706b8964ceaea3208bab9/7884e35f40c15fe349379ef7ae4648b21708962f-1070x712.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 07 Feb 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Lucene-Bug-Abenteuer: Behebung einer beschädigten Index-Ausnahme]]></title>
    <description><![CDATA[Manchmal dauert es Tage, eine einzige Zeile Code zu schreiben. Hier erhalten wir einen Einblick in die Mühen und die mehrtägige Fehlersuche eines Ingenieurs, der versucht, eine mögliche Beschädigung des Apache Lucene-Index zu beheben.]]></description>
    <content:encoded><![CDATA[<h2>Sei vorbereitet: </h2><p>Dieser Blog ist anders als üblich. Es handelt sich weder um die Erklärung einer neuen Funktion noch um ein Tutorial. Es geht hier um eine einzige Codezeile, deren Schreiben drei Tage gedauert hat. Wir werden eine mögliche Beschädigung des Apache Lucene-Index beheben. Einige Erkenntnisse, die Sie hoffentlich mitnehmen können:</p><ul><li><p>Alle unzuverlässigen Tests sind bei ausreichend Zeit und den richtigen Werkzeugen wiederholbar.</p></li><li><p>Für robuste Systeme sind viele Testebenen unerlässlich. Höhere Testebenen werden jedoch zunehmend schwieriger zu debuggen und zu reproduzieren.</p></li><li><p>Sleep ist ein ausgezeichneter Debugger.</p></li></ul><h2>Wie Elasticsearch testet</h2><p>Bei Elastic verfügen wir über eine Vielzahl von Tests, die gegen die Elasticsearch-Codebasis ausgeführt werden. Manche sind einfache und fokussierte Funktionstests, andere sind Integrationstests für einen einzelnen Knoten im „Happy Path“-Modus, und wieder andere versuchen, den Cluster zum Absturz zu bringen, um sicherzustellen, dass sich in einem Fehlerszenario alles korrekt verhält. Wenn ein Test wiederholt fehlschlägt, erstellt ein Ingenieur oder die Tool-Automatisierung ein GitHub-Issue und kennzeichnet es zur Untersuchung durch ein bestimmtes Team. Dieser <a href="https://github.com/elastic/elasticsearch/issues/105122">spezielle Fehler</a> wurde bei einem Test der letztgenannten Art entdeckt. Diese Tests sind knifflig und manchmal erst nach vielen Durchläufen wiederholbar.</p><h2>Was genau wird mit diesem Test geprüft?</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt58aab468587a7d35/6a17dd8e3e03d74b8d4f2b5f/63268f3b5714ebf9df8070ec2e3c4f0486ed822e-1600x1088.jpg" alt="GitHub-Problem: https://github.com/elastic/elasticsearch/issues/105122" /><p>Dieser Test ist besonders interessant. Es wird eine bestimmte Zuordnung erstellen und diese auf einen primären Shard anwenden. Dann beim Versuch, eine Replik zu erstellen. Der entscheidende Unterschied besteht darin, dass beim Versuch der Replik, das Dokument zu parsen, der Test eine Ausnahme auslöst, was dazu führt, dass die Wiederherstellung auf eine überraschende (aber erwartete) Weise fehlschlägt.</p><p></p><p>Alles funktionierte wie erwartet, allerdings mit einem entscheidenden Haken. Bei der Bereinigung der Testergebnisse haben wir die Konsistenz überprüft, und dabei ist dieser Test auf ein Problem gestoßen.</p><p>
Dieser Test schlug erwartungsgemäß fehl. Im Rahmen der Konsistenzprüfung würden wir überprüfen, ob alle replizierten und primären Lucene-Segmentdateien konsistent sind. Das heißt, unverfälscht und vollständig repliziert. Teilweise oder beschädigte Daten sind viel schlimmer als ein vollständiger Ausfall. Hier ist der beängstigende und abgekürzte Stacktrace des Fehlers.</p><p></p>Caused by: org.apache.lucene.index.CorruptIndexException: Problem reading index from store(ByteSizeCachingDirectory(ElasticsearchMockDirectoryWrapper(HybridDirectory@/opt/buildkite-agent/builds/bk-agent-prod-gcp-1707109485745743789/elastic/elasticsearch-periodic/server/build/testrun/internalClusterTest/temp/org.elasticsearch.indices.recovery.IndexRecoveryIT_40853F21F419B395-001/tempDir-005/node_t0/indices/ZNwxG7VvShuwYV78RTjknA/0/index lockFactory=org.apache.lucene.store.NativeFSLockFactory@2c169f59))) (resource=store(ByteSizeCachingDirectory(ElasticsearchMockDirectoryWrapper(HybridDirectory@/opt/buildkite-agent/builds/bk-agent-prod-gcp-1707109485745743789/elastic/elasticsearch-periodic/server/build/testrun/internalClusterTest/temp/org.elasticsearch.indices.recovery.IndexRecoveryIT_40853F21F419B395-001/tempDir-005/node_t0/indices/ZNwxG7VvShuwYV78RTjknA/0/index lockFactory=org.apache.lucene.store.NativeFSLockFactory@2c169f59))))

    at org.apache.lucene.index.SegmentCoreReaders.&lt;init&gt;(SegmentCoreReaders.java:165)
    at org.apache.lucene.index.SegmentReader.&lt;init&gt;(SegmentReader.java:96)
    at org.apache.lucene.index.ReadersAndUpdates.getReader(ReadersAndUpdates.java:178)
    at org.apache.lucene.index.ReadersAndUpdates.getLatestReader(ReadersAndUpdates.java:243)
    at org.apache.lucene.index.SoftDeletesRetentionMergePolicy.keepFullyDeletedSegment(SoftDeletesRetentionMergePolicy.java:82)
    at org.apache.lucene.index.FilterMergePolicy.keepFullyDeletedSegment(FilterMergePolicy.java:118)
    at org.apache.lucene.index.FilterMergePolicy.keepFullyDeletedSegment(FilterMergePolicy.java:118)
    at org.apache.lucene.index.ReadersAndUpdates.keepFullyDeletedSegment(ReadersAndUpdates.java:822)
    at org.apache.lucene.index.IndexWriter.isFullyDeleted(IndexWriter.java:6078)
    &lt;snip&gt;

    Caused by: java.io.FileNotFoundException: No sub-file with id .kdi found in compound file "_0.cfs" (fileName=_0.kdi files: [_0.pos, .nvm, .fnm, _0.tip, _Lucene90_0.dvd, _0.doc, _0.tim, _Lucene90_0.dvm, _ES87BloomFilter_0.bfm, .fdm, .nvd, _ES87BloomFilter_0.bfi, _0.tmd, .fdx, .fdt])

      at org.apache.lucene.codecs.lucene90.Lucene90CompoundReader.openInput(Lucene90CompoundReader.java:170)
      at org.apache.lucene.codecs.lucene90.Lucene90PointsReader.&lt;init&gt;(Lucene90PointsReader.java:63)
      at org.apache.lucene.codecs.lucene90.Lucene90PointsFormat.fieldsReader(Lucene90PointsFormat.java:74)
      at org.apache.lucene.index.SegmentCoreReaders.&lt;init&gt;(SegmentCoreReaders.java:152)
      &lt;snip&gt;
<p>Irgendwie wurde der replizierte Shard während des erzwungenen Replikationsfehlers beschädigt! Ich möchte Ihnen den Kern des Fehlers in einfachen Worten erklären.</p><p></p><p>Lucene ist eine segmentbasierte Architektur, das heißt, jedes Segment kennt und verwaltet seine eigenen schreibgeschützten Dateien. Dieses spezielle Segment wurde über seine <a href="https://github.com/apache/lucene/blob/add9c09c84ee66d4522c566c9f679035a0dfec13/lucene/core/src/java/org/apache/lucene/index/SegmentCoreReaders.java">SegmentCoreReaders</a> validiert, um sicherzustellen, dass alles in Ordnung war. Jeder Core-Reader speichert Metadaten, die angeben, welche Feldtypen und Dateien für ein bestimmtes Segment vorhanden sind. Bei der Validierung des <a href="https://github.com/apache/lucene/blob/add9c09c84ee66d4522c566c9f679035a0dfec13/lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsFormat.java">Lucene90PointsFormat</a> fehlten jedoch bestimmte erwartete Dateien. Mit der Segmentdatei <code>_0.cfs</code> erwarteten wir eine Punktformatdatei namens <code>kdi</code>. <code>cfs</code> steht für "zusammengesetztes Dateisystem", in dem Lucene manchmal alle Feldtypen und alle kleinen Dateien zu einer einzigen größeren Datei kombiniert, um eine effizientere Replikation und Ressourcennutzung zu ermöglichen. Tatsächlich fehlten alle drei Punktdateierweiterungen: <code>kdd</code>, <code>kdi</code>, und <code>kdm</code> . Wie konnten wir an eine Stelle gelangen, an der ein Lucene-Segment eine Punktdatei erwartet, diese aber fehlt?! Das sieht nach einem schwerwiegenden Datenkorruptionsfehler aus!</p><p></p><h2>Der erste Schritt bei jeder Fehlerbehebung: den Fehler reproduzieren.</h2><p></p><p>Es war äußerst mühsam, den Fehler bei diesem speziellen Bug zu reproduzieren. Obwohl wir in Elasticsearch die Möglichkeit des <a href="https://en.wikipedia.org/wiki/Random_testing">randomisierten Wertetests</a> nutzen, stellen wir sicher, dass jeder Fehler mit einem (hoffentlich) reproduzierbaren Zufallswert versehen wird, um zu gewährleisten, dass alle Fehler untersucht werden können. Das funktioniert hervorragend für alle Fehler, außer für solche, die durch eine <a href="https://en.wikipedia.org/wiki/Race_condition">Race Condition</a> verursacht werden.</p>./gradlew ':server:internalClusterTest' --tests "org.elasticsearch.indices.recovery.IndexRecoveryIT.testDoNotInfinitelyWaitForMapping" -Dtests.seed=40853F21F419B395 -Dtests.jvm.argline="-Des.concurrent_search=true" -Dtests.locale=id-ID -Dtests.timezone=Asia/Jerusalem -Druntime.java=21<p>Egal wie oft ich es versucht habe, der Fehler konnte mit diesem speziellen Seed lokal nie reproduziert werden. Es gibt jedoch Möglichkeiten, die Tests durchzuführen und ein reproduzierbareres Scheitern zu erzielen.</p><p></p><p>Unsere spezielle Testsuite ermöglicht es, einen bestimmten Test über den Parameter <code>-Dtests.iters</code> im selben Befehl mehr als einmal auszuführen. Das reichte mir aber nicht, ich musste sicherstellen, dass die Ausführungsthreads wechselten und dadurch die Wahrscheinlichkeit des Auftretens dieser Race Condition erhöht wurde. Ein weiteres Problem im System war, dass der Test am Ende so lange dauerte, dass der Test-Runner eine Zeitüberschreitung meldete. Am Ende verwendete ich den folgenden albtraumhaften Bash-Code, um den Test reproduzierbar auszuführen:</p>for run in {1..10}; do ./gradlew ':server:internalClusterTest' --tests "org.elasticsearch.indices.recovery.IndexRecoveryIT.testDoNotInfinitelyWaitForMapping" -Dtests.jvm.argline="-Des.concurrent_search=true" -Dtests.iters=10 ; done || exit 1<p>Und da kommt <a href="https://github.com/ColinIanKing/stress-ng">Stress ins Spiel</a>. Dies ermöglicht es Ihnen, schnell einen Prozess zu starten, der die CPU-Kerne im Handumdrehen belegt. Durch wiederholtes, wahlloses Ausführen von stress-ng während zahlreicher Iterationen des fehlgeschlagenen Tests konnte ich den Fehler schließlich reproduzieren. Ein Schritt näher. Um das System zu belasten, öffnen Sie einfach ein weiteres Terminalfenster und führen Sie folgenden Befehl aus:</p>stress-ng --cpu 16<h2>
Aufdeckung des Fehlers</h2><p>

Da der Testfehler, der den Bug aufdeckt, nun größtenteils reproduzierbar ist, ist es an der Zeit, die Ursache zu finden. Das Merkwürdige an diesem speziellen Test ist, dass Lucene eine Ausnahme auslöst, weil es Punktwerte erwartet, aber vom Test direkt keine hinzugefügt werden. Nur Textwerte. Dies veranlasste mich, die jüngsten Änderungen an unseren <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/optimistic-concurrency-control.html">optimistischen Parallelitätskontrollfeldern</a> <code>_seq_no</code> und <code>_primary_term</code> genauer zu betrachten. Beide werden als Punkte indexiert und sind in jedem Elasticsearch-Dokument vorhanden.</p><p></p><p>Tatsächlich hat ein <a href="https://github.com/elastic/elasticsearch/pull/105036">Commit</a> unseren <code>_seq_no</code> Mapper verändert! JA! Das muss die Ursache sein! Doch meine Begeisterung war nur von kurzer Dauer. Dies änderte lediglich die Reihenfolge, in der Felder zum Dokument hinzugefügt wurden. Vor dieser Änderung wurden <code>_seq_no</code> Felder zuletzt zum Dokument hinzugefügt. Anschließend wurden sie zuerst hinzugefügt. Die Reihenfolge beim Hinzufügen von Feldern zu einem Lucene-Dokument kann unmöglich diesen Fehler verursachen...</p><p></p><p>Ja, die Änderung der Reihenfolge beim Hinzufügen der Felder hat den Fehler verursacht. Das war überraschend und entpuppte sich als Fehler in Lucene selbst! Die Änderung der Reihenfolge, in der die Felder analysiert werden, sollte das Verhalten beim Parsen eines Dokuments nicht verändern.</p><p></p><h2>Der Fehler in Lucene</h2><p>Der Fehler in Lucene bezog sich tatsächlich auf folgende Bedingungen:</p><ul><li><p>Indizieren eines Punktewertfeldes (z. B. <code>_seq_no</code>)</p></li><li><p>Beim Versuch, ein Textfeld zu indizieren, trat während der Analyse ein Fehler auf.</p></li><li><p>In diesem ungewöhnlichen Zustand öffnen wir einen <a href="https://blog.mikemccandless.com/2011/06/lucenes-near-real-time-search-is-fast.html">Near Real Time Reader</a> des Autors, der die Textindexanalyse-Ausnahme erlebt.</p></li></ul><p>Aber egal, wie viele Wege ich auch versucht habe, ich konnte es nicht vollständig nachbilden. Ich habe direkt Haltepunkte zum Debuggen im gesamten Lucene-Quellcode eingefügt. Ich habe versucht, während des Ausnahmepfads zufällig Reader zu öffnen. Ich habe sogar Megabytes über Megabytes an Protokolldateien ausgedruckt, um den genauen Pfad zu finden, an dem dieser Fehler aufgetreten ist. Ich konnte es einfach nicht. Ich habe einen ganzen Tag lang gekämpft und verloren.</p><p></p><p>Dann habe ich geschlafen.</p><p></p><p>Am nächsten Tag las ich den ursprünglichen Stacktrace erneut und entdeckte die folgende Zeile:</p><p>
</p>    at org.apache.lucene.index.SoftDeletesRetentionMergePolicy.keepFullyDeletedSegment(SoftDeletesRetentionMergePolicy.java:82)<p>Bei all meinen Nachbildungsversuchen habe ich die Aufbewahrungs- und Zusammenführungsrichtlinie nie explizit festgelegt. Die <a href="https://github.com/apache/lucene/blob/5f0fa2b291ff9e7d878642f025a70c15b788a470/lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java">SoftDeletesRetentionMergePolicy</a> wird von Elasticsearch verwendet, um Löschungen in Replikaten genau zu replizieren und sicherzustellen, dass alle unsere Parallelitätskontrollen dafür zuständig sind, wann Dokumente tatsächlich entfernt werden. Andernfalls hat Lucene die volle Kontrolle und entfernt sie bei jedem Merge.</p><p></p><p>Nachdem ich diese Richtlinie hinzugefügt und die oben genannten grundlegendsten Schritte wiederholt hatte, trat der Fehler sofort wieder auf.</p><p>
Ich habe mich noch nie so sehr gefreut, einen <a href="https://github.com/apache/lucene/issues/13353">Fehler in Lucene</a> zu melden.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt89d37c3454ec298b/6a17dd90ec0f8993c75a6511/c7d9ce3de5278923a8454097e2bdf487c861168a-1600x920.jpg" alt="GitHub-Problem https://github.com/apache/lucene/issues/13353" /><p>
Während sich dies in Elasticsearch als Race Condition darstellte, war es einfach, in Lucene einen wiederholt fehlschlagenden Test zu schreiben, sobald alle Bedingungen erfüllt waren.</p><p></p><p>Am Ende wurde es, wie alle guten Bugs, mit nur einer einzigen Codezeile behoben. Mehrere Tage Arbeit für nur eine einzige Codezeile.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdab62db3402b27f7/6a17dd92dbb4ff23e7fb55ad/2c10a02eb41da181b1dceee47186c076c9b14a90-1600x539.jpg" alt="Eine Zeile Code behebt das Problem" /><p>Aber es hat sich gelohnt.</p><h2>
Nicht das Ende</h2><p>Ich hoffe, ihr hattet Spaß an dieser turbulenten Reise mit mir! Software zu entwickeln, insbesondere so weit verbreitete und komplexe Software wie Elasticsearch und Apache Lucene, ist sehr lohnend. Manchmal ist es jedoch außerordentlich frustrierend. Ich liebe und hasse Software gleichermaßen. Die Fehlerbehebung hört nie auf!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/lucene-corrupted-index-exception</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/lucene-corrupted-index-exception</guid>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc25119c4add97ba/6a17dd94420229633929f4f7/2c918bad62530ff6fbe419092b2ca44bfe9408c4-944x612.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 27 Dec 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Skalarquantisierung in Lucene verstehen]]></title>
    <description><![CDATA[Erfahren Sie, wie Elastic die Skalarquantisierung in Lucene eingeführt hat, einschließlich automatischer Byte-Quantisierung, Quantisierung pro Segment und Einblicken in die Leistung.]]></description>
    <content:encoded><![CDATA[<h2>Automatische Byte-Quantisierung in Lucene</h2><p>HNSW ist zwar eine leistungsstarke und flexible Methode zum Speichern und Durchsuchen von Vektoren, benötigt aber eine beträchtliche Menge an Speicherplatz, um schnell ausgeführt werden zu können. Beispielsweise benötigt die Abfrage von 1 Million float32-Vektoren mit 768 Dimensionen ungefähr  RAM. Sobald man eine beträchtliche Anzahl von Vektoren durchsucht, wird dies teuer. Eine Möglichkeit, den Speicherverbrauch um etwa  zu reduzieren, besteht in der Byte-Quantisierung. Lucene und folglich auch Elasticsearch unterstützen die Indizierung  -Vektoren schon seit einiger Zeit, die Erstellung dieser Vektoren lag jedoch bisher in der Verantwortung des Benutzers. Das wird sich bald ändern, da wir in Lucene die Skalarquantisierung  eingeführt haben.</p><h2>Skalarquantisierung 101</h2><p>Alle Quantisierungstechniken werden als verlustbehaftete Transformationen der Rohdaten betrachtet. Das bedeutet, dass aus Platzgründen einige Informationen verloren gehen. Eine ausführliche Erklärung der Skalarquantisierung finden Sie unter: <a href="https://www.elastic.co/search-labs/scalar-quantization-101">Skalarquantisierung 101</a>. Im Prinzip ist die Skalarquantisierung eine verlustbehaftete Kompressionstechnik. Durch einfache Berechnungen lassen sich erhebliche Speicherplatzeinsparungen erzielen, ohne dass die Speicherkapazität nennenswert beeinträchtigt wird.</p><h2>Die Architektur erkunden</h2><p>Wer bereits Erfahrung mit Elasticsearch hat, kennt diese Konzepte möglicherweise schon, aber hier ist ein kurzer Überblick über die Verteilung der Dokumente für die Suche.</p><p>Jeder Elasticsearch-Index besteht aus <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/size-your-shards.html#size-your-shards">mehreren Shards</a>. Obwohl jeder Shard nur einem einzigen Knoten zugewiesen werden kann, ermöglicht die Verwendung mehrerer Shards pro Index eine parallele Berechnung über mehrere Knoten hinweg.</p><p>Jeder Shard besteht aus einem einzelnen <a href="https://lucene.apache.org/core/9_8_0/core/org/apache/lucene/index/package-summary.html">Lucene-Index</a>. Ein Lucene-Index besteht aus mehreren schreibgeschützten Segmenten. Während der Indizierung werden Dokumente zwischengespeichert und regelmäßig in ein schreibgeschütztes Segment geschrieben. Wenn bestimmte Bedingungen erfüllt sind, können diese Segmente im Hintergrund zu einem größeren Segment zusammengeführt werden. Das alles ist konfigurierbar und birgt seine eigenen Komplexitäten. Wenn wir aber von Segmenten und deren Zusammenführung sprechen, meinen wir schreibgeschützte Lucene-Segmente und die automatische, periodische Zusammenführung dieser Segmente. <a href="https://www.elastic.co/search-labs/vector-search-elasticsearch-rationale">Hier folgt ein detaillierterer Einblick</a> in die Segmentzusammenführung und die damit verbundenen Designentscheidungen.</p><h2>Quantisierung pro Segment in Lucene</h2><p>Jedes Segment in Lucene speichert Folgendes: die einzelnen Vektoren, die HNSW-Graphindizes, die quantisierten Vektoren und die berechneten Quantile. Aus Gründen der Kürze konzentrieren wir uns darauf, wie Lucene quantisierte und rohe Vektoren speichert. Für jedes Segment erfassen wir die Rohvektoren in der  Datei, die quantisierten Vektoren und einen einzelnen Korrekturmultiplikator (Gleitkommazahl) in  sowie die Metadaten rund um die Quantisierung in der  Datei.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1861d0af970fd096/6a17d9887f6f15a45dc099c0/507a4d578f8a5d2225b11d16ac1fc0b7dd39eaa1-1207x228.png" alt="Die .vec-Datei Datei" /><p>Abbildung 1: Vereinfachtes Layout der Rohvektorspeicherdatei. Benötigt Speicherplatz in  da  4 Byte groß sind. Da wir quantisieren, werden diese Daten während der HNSW-Suche nicht geladen. Sie werden nur auf ausdrücklichen Wunsch verwendet (z. B. Brute-Force-Sekundärverfahren mittels <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/filter-search-results.html#query-rescorer">Rescore</a>), oder zur erneuten Quantisierung während der Segmentzusammenführung.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8c52b51939357280/6a17d9897b54f9130f8b3761/ffc6e8bffefc5cd36f14aae315184c89e04a6587-1440x207.png" alt="Die .veq-Datei" /><p>Abbildung 2: Vereinfachtes Layout der  Datei. Benötigt  Speicherplatz und wird während der Suche in den Speicher geladen. Die  Bytes dienen der Berücksichtigung des Korrekturmultiplikators, der zur Anpassung der Bewertung für eine bessere Genauigkeit und Trefferquote verwendet wird.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt28e007988f81e5c0/6a17d98b25daabe23208a0d9/555cc92d7d179d8c9711cefbaaac3e0b815d2e42-1440x182.png" alt="Die .vemq-Datei" /><p>Abbildung 3: Vereinfachter Aufbau der Metadatendatei. Hier erfassen wir die Quantisierung und Vektorkonfiguration sowie die berechneten Quantile für dieses Segment.</p><p>Für jedes Segment speichern wir also nicht nur die quantisierten Vektoren, sondern auch die Quantile, die zur Erstellung dieser quantisierten Vektoren verwendet wurden, sowie die ursprünglichen Rohvektoren. Aber warum bewahren wir die Rohvektoren überhaupt auf?</p><h2>Quantisierung, die mit Ihnen wächst</h2><p>Da Lucene periodisch in schreibgeschützte Segmente schreibt, hat jedes Segment nur einen Teil Ihrer Daten zur Verfügung. Das bedeutet, dass die berechneten Quantile nur für diese Stichprobe Ihrer gesamten Daten direkt gelten. Das ist kein großes Problem, wenn Ihre Stichprobe Ihr gesamtes Korpus angemessen repräsentiert. Lucene ermöglicht es Ihnen jedoch, Ihren Index auf verschiedene Arten zu sortieren. Es könnte also vorkommen, dass die Daten so indiziert werden, dass sie so sortiert sind, dass dies zu Verzerrungen bei der Berechnung der Quantile pro Segment führt. Außerdem können Sie die Daten jederzeit löschen! Ihr Stichprobensatz kann winzig sein, sogar nur ein einziger Vektor. Ein weiterer Haken ist, dass Sie die Kontrolle darüber haben, wann Zusammenführungen erfolgen. Elasticsearch verfügt zwar über voreingestellte Standardwerte und eine regelmäßige Zusammenführung, Sie können aber jederzeit über <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.10/indices-forcemerge.html">die _force_merge</a> -API eine Zusammenführung anfordern. Wie können wir also all diese Flexibilität ermöglichen und gleichzeitig eine gute Quantisierung mit guter Recall-Qualität gewährleisten?</p><p>Die Vektorquantisierung von Lucene passt sich im Laufe der Zeit automatisch an. Da Lucene mit einer Architektur für schreibgeschützte Segmente konzipiert ist, haben wir die Garantie, dass sich die Daten in jedem Segment nicht verändert haben, und klare Abgrenzungen im Code, wann Aktualisierungen möglich sind. Das bedeutet, dass wir während der Segmentzusammenführung die Quantile nach Bedarf anpassen und gegebenenfalls Vektoren neu quantisieren können.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0d9a2390958c3e82/6a17d98cbe6086e4fd0045d5/646baa8279719d06c05c1a4fdb80a0c060e0dd94-1440x446.png" alt="Mehrere Segmentquantile" /><p>Abbildung 4: Drei Beispielsegmente mit unterschiedlichen Quantilen.</p><p>Aber ist eine erneute Quantifizierung nicht teuer? Es verursacht zwar einen gewissen Mehraufwand, aber Lucene geht intelligent mit Quantilen um und führt nur dann eine vollständige Neuquantisierung durch, wenn dies erforderlich ist. Nehmen wir die Segmente in Abbildung 4 als Beispiel. Wir geben den Segmenten  und  jeweils  Dokumente und Segment  nur  Dokumente. Lucene berechnet einen gewichteten Durchschnitt der Quantile. Wenn das resultierende zusammengeführte Quantil nahe genug an den ursprünglichen Quantilen des Segments liegt, muss das Segment nicht erneut quantisiert werden, sondern es werden die neu zusammengeführten Quantile verwendet.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte2596c10ee616cf3/6a17d98eec0f8903b85a6497/58fa20d60dc337ca7108eb01e84a07c330e87ee6-1440x447.png" alt="Zusammengeführte Quantile" /><p>Abbildung 5: Beispiel für zusammengeführte Quantile, wobei die Segmente  und   Dokumente enthalten und  nur  enthält.</p><p>In der in Abbildung 5 dargestellten Situation ist zu erkennen, dass die resultierenden zusammengeführten Quantile den ursprünglichen Quantilen in  und  sehr ähnlich sind. Daher rechtfertigen sie keine Quantisierung der Vektoren. Segment  weicht anscheinend zu stark ab. Folglich würden die Vektoren in  mit den neu zusammengeführten Quantilwerten erneut quantisiert.</p><p>Es gibt tatsächlich extreme Fälle, in denen die zusammengeführten Quantile dramatisch von allen ursprünglichen Quantilen abweichen. In diesem Fall werden wir aus jedem Segment eine Stichprobe entnehmen und die Quantile vollständig neu berechnen.</p><h2>Quantisierungsleistung und Zahlen</h2><p>Ist es also schnell und bietet es trotzdem noch eine gute Speicherkapazität? Die folgenden Zahlen wurden bei der Durchführung des Experiments auf einer <code>c3-standard-8</code> GCP-Instanz ermittelt. Um einen fairen Vergleich mit  zu gewährleisten, verwendeten wir eine ausreichend große Instanz, um Rohvektoren im Speicher zu halten. Wir haben  <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-simple-embeddings">Cohere Wiki-</a> Vektoren mithilfe des Maximum-Skalarprodukts indiziert.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfa48e3724ad97fed/6a17d98f445de96aca4cff88/81958f99b8a1397f669db48de00f264cacc6b49f-576x455.png" alt="Quantisierungsrückruf" /><p>Abbildung 6: Recall@10 für quantisierte Vektoren im Vergleich zu Rohvektoren. Die Suchleistung von quantisierten Vektoren ist deutlich schneller als die von Rohvektoren, und der Recall kann schnell wiederhergestellt werden, indem nur 5 weitere Vektoren gesammelt werden; sichtbar durch .</p><p>Abbildung 6 veranschaulicht die Geschichte. Es gibt zwar einen Unterschied in der Erinnerungsleistung, wie zu erwarten war, dieser ist jedoch nicht signifikant. Und der Unterschied in der Trefferquote verschwindet bereits durch das Sammeln von nur 5 weiteren Vektoren. All dies bei  schnellen Segmentzusammenführungen und nur einem Viertel des Speicherbedarfs von  Vektoren.</p><h2>Fazit</h2><p>Lucene bietet eine einzigartige Lösung für ein schwieriges Problem. Für die Quantisierung ist kein „Trainings“- oder „Optimierungs“-Schritt erforderlich. In Lucene funktioniert es einfach. Sie müssen sich keine Sorgen machen, Ihren Vektorindex „neu trainieren“ zu müssen, falls sich Ihre Daten ändern. Lucene erkennt signifikante Änderungen und kümmert sich während der gesamten Lebensdauer Ihrer Daten automatisch darum. Wir freuen uns darauf, wenn wir diese Funktion in Elasticsearch integrieren!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/scalar-quantization-in-lucene</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/scalar-quantization-in-lucene</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[ML-Forschung]]></category>
    <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb382d99ffac9992c/6a17d991b1e113640179f12c/dc07f16d347a68476bded5df9adb831452f61278-1024x1024.jpg" length="0" type="image/jpeg"/>
    <pubDate>Sat, 11 Nov 2023 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>