Vektor Suche mit Qdrant

Marcus und ich haben uns zwei Tage Zeit genommen, um uns mit der Vektor-Suche in Qdrant zu beschäftigen. Unsere Idee war, zahlreiche Datenquellen vom Congress in Englisch und Deutsch zu indexieren und anschließend mithilfe einer Vektor-Suche zu durchsuchen. Der große Vorteil dieser Methode liegt darin, dass sie nicht nur exakte Übereinstimmungen findet, sondern auch semantische Ähnlichkeiten erkennt. So wird beispielsweise bei der Suche nach “Wlan” der einzige relevante Artikel gefunden, der das Wort “Wlan” nicht enthält, sondern stattdessen “Wifi” verwendet.

Mit dem Input von Johannes haben wir uns für Qdrant entschieden. Bislang habe ich nur Weaviate genutzt. Qdrant ist eine Open-Source-Vektor-Datenbank, die sich auf semantische Suche spezialisiert hat.

Die Nutzung von Qdrant ist nahezu so einfach wie die von Redis: Docker-Container starten, Index bzw. Collection erstellen, und schon kann es losgehen. Die API ist übersichtlich, die Dokumentation hervorragend und mit zahlreichen Beispielen versehen.

Embeddings

Embeddings wandeln Text in numerische Vektoren um, die semantische Bedeutungen repräsentieren. Ähnliche Texte haben ähnliche Vektoren - das ist die Grundlage für semantische Suche. Im Gegensatz zu klassischen Keyword-Suchen kann ich so auch Synonyme und kontextuelle Ähnlichkeiten erfassen. Damit ein Embeddings Modell gut funktioniert, muss es mit Texten in der jeweiligen Sprache trainiert sein. Für Englisch gibt es viele Modelle, für Deutsch sind die Modelle noch nicht so gut. Dazu kommt die Frage der Lizenzierung sowie ob es in der Cloud oder Lokal laufen soll.

Ollama und Jina für deutsche Embeddings

Für deutsche Texte haben wir uns für das Jina-Embeddings-v2-base-de Modell entschieden. Es kann Englisch und Deutsch, hat eine passende Lizenz und kann vor allem lokal laufen. Zum Ausführen nutzen wir Ollama, eine Open-Source-Lösung, die es einfach macht, KI-Modelle lokal zu betreiben. Ollama bietet eine OpenAI ähnliche einfache CLI und API, um Modelle zu starten und zu nutzen und kann auf OSX, Linux, Windows und in Docker sehr einfach auch ohne Grafikkarte genutzt werden.

Damit die Vektor-Suche funktioniert, müssen sowohl die zu indexierenden Texte als auch die Suchanfragen in Vektoren umgewandelt werden. Dies geschieht über den Ollama REST-Endpunkt /api/embed, bei dem der Text zusammen mit dem Modellnamen in einen Vektor transformiert wird. Das Ergebnis ist ein Array aus 768 Zahlen, das den Text repräsentiert.

curl -X POST http://localhost:11434/api/embed \
  -H "Content-Type: application/json" \
  -d '{"model": "jina/jina-embeddings-v2-base-de", "input": ["Dies ist ein Test"]}'

Das Ergebnis sieht dann so aus:

{
  "model": "jina/jina-embeddings-v2-base-de",
  "embeddings": [[0.123, -0.456, ...]]  // 768 Zahlen
}

Um mit Ollama Embeddings für direkt mehrere Texte gleichzeitig generieren, einfach mehrere Texte in das input Array packen.

Auf einem M1 MacBook geht das generieren sehr schnell. Im Docker-Container auf einem kleinen DigitalOcean-Server dauert es etwas länger. Bei der Suche fällt das kaum auf, aber beim Indexieren vieler Texte ist etwas Geduld gefragt.

Collections

In Qdrant werden die Daten in Collections organisiert. Sie sind ähnlich wie Tabellen in einer Datenbank. Eine Collection kann pro Eintrag (Point) mehrere Vektoren sowie zusätzliche Metadaten Felder enthalten. Die Metadaten erlauben es z.B. nach Kategorien, Tags oder anderen Attributen zu filtern, so dass weniger Daten durchsucht werden müssen. Unsere Datenmenge ist sehr überschaubar. Es müssen nur die Metadaten Felder definiert werden, die auch einen Index benötigen. Weitere Felder können einfach mit gespeichert werden und z.B. für die Anzeige genutzt werden.

Zum Start muss die Collection angelegt werden. Wenn diese schon existiert, gibt es einen Fehler, daher prüfen wir dies vorher.

found, err := client.CollectionExists(ctx, "congress2024")

Falls die Collection noch nicht existiert, wird sie angelegt. Dabei definieren wir die Vektoren für die Suche, aktuell experimentieren wir mit zwei Vektoren: title und content. Da unser Content redaktionell stark variiert, ist diese Konfiguration nicht optimal und dient lediglich als Inspiration für eigene Experimente.

err := client.CreateCollection(ctx, &qdrant.CreateCollection{
    CollectionName: "congress2024",
    VectorsConfig: qdrant.NewVectorsConfigMap(map[string]*qdrant.VectorParams{
        "title": {
            Size:     768,
            Distance: qdrant.Distance_Cosine,
        },
        "content": {
            Size:     768,
            Distance: qdrant.Distance_Cosine,
        },
    }),
})

Die Size gibt die Anzahl der Zahlen im Vektor an, die Distance definiert, wie der Abstand zwischen den Vektoren berechnet wird. Cosine ist anscheinend eine gängige Wahl für semantische Suche.

Index für Filter

Um weitere Filtermöglichkeiten zu haben, definieren wir an der Collection noch Index Felder. Hier im Beispiel legen wir ein Index Feld für den Content-Type (kind) an und ein Fulltext Index für den Titel (title). So können später Einträge höher gewichtet werden, die im Titel exakte Übereinstimmungen haben.

_, err = client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
    CollectionName: "congress2024",
    FieldName:      "kind",
    FieldType:      qdrant.PtrOf(qdrant.FieldType_FieldTypeKeyword),
})

_, err = client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
    CollectionName: "congress2024",
    FieldName:      "title",
    FieldType:      qdrant.PtrOf(qdrant.FieldType_FieldTypeText),
    FieldIndexParams: qdrant.NewPayloadIndexParamsText(
        &qdrant.TextIndexParams{
            Tokenizer:   qdrant.TokenizerType_Multilingual,
            MinTokenLen: qdrant.PtrOf(uint64(2)),
            MaxTokenLen: qdrant.PtrOf(uint64(10)),
            Lowercase:   qdrant.PtrOf(true),
        },
    ),
})

Für den Text-Index verwenden wir erweiterte Einstellungen und experimentieren noch mit verschiedenen Optionen. Aktuell testen wir die Verwendung von ausschließlich Kleinbuchstaben und beobachten, ob dies zu besseren Ergebnissen führt. Feedback dazu ist willkommen.

Indexieren von Daten

Die Daten werden in Qdrant als Point gespeichert. Ein Point besteht aus mindestens einem Vektor und optionalen Metadaten. Im Gegensatz zu Weaviate ist es bei Qdrant unser Job, die Vektoren zu generieren und sie dann zusammen mit den Metadaten zu speichern. Das bedeutet etwas mehr Aufwand, aber wir haben dadurch die volle Kontrolle über die Vektoren.

Wir erstellen die Embeddings für Titel und Content als Batch mit Ollama und speichern sie zusammen mit den Metadaten (Payload) im Point. Den Content nutzen wir später nur zur Anzeige in den Suchergebnissen.

point := &qdrant.Point{
    ID:     qdrant.NewID(id),
    Vectors: qdrant.NewVectorsMap(map[string]*qdrant.Vector{
        "title":   qdrant.NewVectorDense(embeddings[0]),
        "content": qdrant.NewVectorDense(embeddings[1]),
    }),
    Payload: map[string]any{
        "title":   title,
        "content": content,
        "kind":    kind,
    },
}

Zum Hinzufügen der Points nutzen wir die Upsert Methode von Qdrant. Diese fügt die Punkte hinzu oder aktualisiert sie, falls sie schon existieren.

_, err = client.Upsert(ctx, &qdrant.UpsertCollection{
    CollectionName: "congress2024",
    Points:         []*qdrant.Point{point},
})

Einfache Suche

Die einfache Suche in Qdrant ist sehr simpel, kann aber auch stark erweitert werden – dazu später mehr. Um eine Suche durchzuführen, müssen wir zuerst mit Ollama die Embeddings für die Suchanfrage generieren. Im Query müssen wir angeben, welcher Vektor für die Suche verwendet werden soll. Dies entfällt, wenn nur ein Vektor verwendet wird.

Optional können wir auch einen Filter angeben, um die Suche auf bestimmte Metadaten zu beschränken. In unserem Fall filtern wir nach dem kind Feld, um nur Artikel zu finden.

searchResult, err := client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "congress2024",
	Query:          qdrant.NewQuery(queryEmbedding),
	Filter: &qdrant.Filter{
		Must: []*qdrant.Condition{
			qdrant.NewMatch("kind", "article"),
		},
	},
    Using:          qdrant.PtrOf("content"),
    WithPayload: qdrant.NewWithPayload(true),
})

Mit WithPayload können wir angeben, dass wir die Metadaten der gefundenen Punkte zurückerhalten möchten.

[
    {
        "id": 2,
        "version": 0,
        "score": 0.871,
        "payload": {
            "title": "Ein Testartikel",
            "content": "Dies ist ein Testartikel, der das Thema Wlan behandelt.",
            "kind": "article"
        },
        "vector": null
    }
]

Hybride Suche

Ich bin mir unsicher, ob es für die hybride Suche eine richtige Definition gibt. Für unsere Suche starten wir mehrere Suchen parallel und kombinieren die Ergebnisse. Wir können in jedem Query auch Prefetch Queries definieren. Diese laufen vor dem jeweiligen Query und filtern die Ergebnisse vorab. Dies ist unbegrenzt verschachtelbar, schlägt aber auch auf die Performance. In der Qdrant Dokumentation gibt es Beispiele, wie z.B. erst mit einem groben Embedding Model vorzufiltern und dann mit einem genaueren Modell die Ergebnisse zu verfeinern.

Für unser Experiment nutzen wir Prefetch Queries, die versuchen, auf unterschiedliche Weise die gesuchten Wörter in Artikeln, Raumnavigation, Speakern, Videos und dem Vortragsprogramm zu finden. Um die Ergebnisse zu kombinieren und neu zu gewichten, nutzen wir die Fusion RRF. Es wäre auch möglich, dass dies ein weiteres Modell übernimmt, das auf diese Aufgabe trainiert wurde.

Fusion RRF steht für “Reciprocal Rank Fusion” und ist ein Verfahren, um die Ergebnisse mehrerer Suchanfragen zu kombinieren. Dabei werden die Ranglisten verschiedener Suchstrategien so zusammengeführt, dass Treffer, die in mehreren Listen weit oben stehen, besonders hoch gewichtet werden. Das sorgt dafür, dass relevante Ergebnisse, die von mehreren Ansätzen gefunden werden, im Gesamtranking bevorzugt angezeigt werden.

searchResult, err := s.dbClient.Query(ctx, &qdrant.QueryPoints{
    CollectionName: "congress2024",
    Query:          qdrant.NewQueryFusion(qdrant.Fusion_RRF),
    Prefetch: []*qdrant.PrefetchQuery{
        // search at content and exact match in title
        {
            Query: qdrant.NewQueryDense(queryEmbedding),
            Using: qdrant.PtrOf("content"),
            Filter: &qdrant.Filter{
                Must: append(conditions, qdrant.NewMatchText("title", strings.ToLower(query))),
            },
        },
        // search at title
        {
            Query: qdrant.NewQueryDense(queryEmbedding),
            Using: qdrant.PtrOf("title"),
            Filter: &qdrant.Filter{
                Must: conditions,
            },
        },
        // search at content
        {
            Query: qdrant.NewQueryDense(queryEmbedding),
            Using: qdrant.PtrOf("content"),
            Filter: &qdrant.Filter{
                Must: conditions,
            },
        },
    },
    WithPayload: qdrant.NewWithPayload(true),
})

Am Ende erhalten wir eine zusammengeführte Liste von Points.

Fazit

Die Kombination aus Qdrant, Ollama und dem Jina-Modell hat sich als extrem effektiv herausgestellt. Die hybride Suche liefert deutlich bessere Ergebnisse als klassische Keyword- oder simple Vektor-Suchen. Besonders bei der Suche nach spezifischen Begriffen wie “Wlan” oder “Wifi” sowie Locations wie “WC”, “Toiletten”, “Klos” zeigt sich die Stärke der semantischen Suche. Aktuell haben wir noch Herausforderungen mit Einträgen, die sehr wenig Content enthalten. Hier wird jedes Wort übermäßig gewichtet, und generelle Vektoren kommen nicht mehr zum Einsatz. Es gäbe bessere Treffer, aber die aus dem Navigationssystem werden bevorzugt.

Das Jina-Modell läuft mit Ollama schnell genug auf eigener Hardware, und die Integration mit Qdrant ist super einfach. Ich schätze an Qdrant besonders, wie gut und übersichtlich die API gebaut ist.

Qdrant bietet viele Optionen und Möglichkeiten, die Suche zu optimieren. Nach unseren Beobachtungen muss die Konfiguration stark an den jeweiligen Content angepasst werden – eine universelle Lösung gibt es nicht. Das Experimentieren und Fine-Tuning kostet viel Zeit. Die Qdrant-Dokumentation ist jedoch hervorragend und bietet gerade im Bereich Performance-Optimierung noch viele Optionen.

Für uns waren es zwei sehr spannende Tage: ein schneller Einstieg und viele neue Erkenntnisse für das passende Projekt.