Startseite / Artikel

Vektorabfrage in Azure AI Search: HNSW, Profile und ein Python-RAG-Retriever

Ein Vektorindex in Azure AI Search mit Python erstellen: HNSW gegen ausführliche KNN-Methoden, Profile, Embeddings, Vektorabfragen und RAG-Aufforderungen sowie Tipps für hybride Suchverfahren.

Verlag: REACTAPP.TOP9 Min. Lesezeit2084 Wörter

Quellen: Building a Vector Index in Azure AI Search: HNSW, Profiles, and RAG Retrieval

Die Informationsabrufung ist der Teil eines RAG-Systems, der entscheidet, ob die endgültige Antwort fundiert oder vage ist. In dieser Übersicht wird erläutert, wie die Vektorsuche in Azure AI Search funktioniert, und anschließend wird ein kleiner Python-Dienst erstellt, der eine Frage entgegennimmt, den bedeutungsgleichen passendsten Text findet und einen Prompt für ein Sprachmodell zusammenstellt. Eine Hinweis zur Benennung: Der Dienst hieß früher Azure Cognitive Search, und viele ältere Beiträge sowie Beispiele verwenden immer noch diesen Namen. Die Konzepte bleiben dabei unverändert.

Zwei Komponenten von RAG

Die durch Informationsabrufung erweiterte Generierung setzt sich aus Informationsabrufung und Generierung zusammen. Wenn eine Frage eintrifft, durchsucht das System eine Wissensdatenbank und zieht die relevantesten Abschnitte heraus. Anschließend übergibt es diese Abschnitte zusammen mit der Frage an ein Sprachmodell, sodass die Antwort auf echtem Quelltext beruht.

Die Qualität der Erzeugung hängt ausschließlich davon ab, was ihr zur Verfügung gestellt wird. Wenn die Abfrage falsche Abschnitte zurückgibt, wird das Modell – unabhängig von seiner Fähigkeit – falsch oder ausweichend antworten. Deshalb ist die Wahl der Suchtechnik so wichtig.

Warum die Schlüsselwortabgleichmethode unzureichend ist

Der klassische Suchabgleich vergleicht wörtliche Wörter. Eine Abfrage nach „Auto“ kann ein Dokument übersehen, das nur „Kraftfahrzeug“ enthält. Der Vektorabgleich behebt dieses Problem, indem er auf der Bedeutung arbeitet.

Jeder Textabschnitt wird in eine Liste von Zahlen umgewandelt, die als Embedding bezeichnet werden. Texte, die ähnliche Bedeutungen haben, befinden sich im Vektorraum in der Nähe voneinander. Zum Zeitpunkt der Abfrage wird auch die Frage als Embedding umgewandelt, und der Dienst sucht nach den gespeicherten Vektoren, die am nächsten dazu liegen – eine Aufgabe, die als Nahebst- Nachbarsuchung bekannt ist. In Azure AI Search deklarieren Sie ein Vektorfeld im Index, speichern dort die Embeddings, und der Dienst erstellt eine Struktur, mit der große Anzahlen von Vektoren schnell durchsucht werden können.

Der Dienst bietet zwei Suchalgorithmen für Vektoren an:

  • HNSW (Hierarchical Navigable Small World) ist eine annähernde Methode zur Nahebst- Nachbarsuchung. Sie ist schnell und wird in der Regel für Produktivumgebungen verwendet.
  • Ausführliches KNN vergleicht die Abfrage mit jedem gespeicherten Vektor. Es ist präzise, aber langsamer, weshalb es sich für kleine Datensätze oder zur Überprüfung der Genauigkeit der approximativen Methode eignet.
  • Algorithmen sind nicht direkt an ein Feld gebunden. Ein Index enthält eine Vektorsuchkonfiguration mit einer Liste von Algorithmen und einer Liste von Profilen, wobei jedes Profil einem Algorithmus eine Bezeichnung zuweist. Ein Vektorfeld bezieht sich anschließend über den Namen auf ein Profil, sodass identische Algorithmuseinstellungen zwischen mehreren Feldern geteilt werden können.

    Der Feldtyp ist Collection(Edm.Single), eine Sammlung von Single-Precision-Fließkommazahlen. Sie müssen eine Dimensionenzahl angeben, und diese Zahl muss der Ausgabegröße des von Ihnen verwendeten Embedding-Modells entsprechen.

    Die Struktur des Systems

    Es gibt zwei Ansätze. Der Einpflegungsansatz wird einmal ausgeführt oder jedes Mal, wenn sich die Daten ändern, und füllt den Index. Der Abfragesansatz wird bei jeder Anfrage genutzt. Eine Regel verbindet sie miteinander: Beide Ansätze müssen dasselbe Embedding-Modell verwenden. Wenn Dokumente mit einem Modell und Fragen mit einem anderen Modell eingebettet werden, können die Vektoren nicht verglichen werden, wodurch die Ergebnisse sinnlos werden.

    Dokumente werden in der Regel als Blöcke statt als vollständige Dateien gespeichert. Kleinere Blöcke ermöglichen eine präzisere Suche und passen leichter in einen Prompt. Jeder Block wird zu einem Suchdokument, das seinen Text, sein Embedding sowie Metadaten enthält, die ihn auf die Quelle zurückführen. Im Beispiel hier werden kurze Dokumente verwendet, sodass jedes als einziger Block gilt. Ein Produktivsystem würde einen Schritt zur Aufteilung in Blöcke hinzufügen, doch die Struktur des Index bleibt unverändert.

    Praktische Umsetzung

    Man benötigt ein Azure-Abo, Python 3.9 oder neuer sowie Grundkenntnisse im Ausführen von Skripten. Die Embeddings werden lokal mit einem kleinen offenen Modell erzeugt, sodass keine Azure OpenAI-Implementierung erforderlich ist. Der Wechsel zu Azure OpenAI wird gegen Ende behandelt.

    Schritt 1: Suchdienst erstellen

    Im Azure-Portal wird eine Ressource für Azure AI Search erstellt. Die kostenlose Stufe reicht hier aus. Sobald sie vorhanden ist, werden zwei Werte kopiert: der Service-Endpunkt in der Form https://<your-service>.search.windows.net sowie eine Admin-Schlüssel aus dem Abschnitt „Keys“. Behandeln Sie diesen Schlüssel als Geheimnis. In einem echten Projekt sollte er in einer Umgebungsvariable oder im Azure Key Vault gespeichert werden und niemals im Quellcode.

    Schritt 2: Bibliotheken installieren

    Zwei Pakete sind erforderlich: das Azure Search SDK und sentence-transformers.

    pip install azure-search-documents sentence-transformers
    

    Schritt 3: Der Vektorindex definieren

    Die nächste Skriptversion erstellt zunächst einen Index mit einem Vektorfeld und definiert anschließend die Konfiguration für die Vektorsuche unter Verwendung des Algorithmus sowie des Profils, bevor der Index erstellt wird.

    from azure.core.credentials import AzureKeyCredential
    from azure.search.documents.indexes import SearchIndexClient
    from azure.search.documents.indexes.models import (
        SearchIndex,
        SimpleField,
        SearchableField,
        SearchField,
        SearchFieldDataType,
        VectorSearch,
        HnswAlgorithmConfiguration,
        HnswParameters,
        VectorSearchProfile,
    )
    
    endpoint = "https://<your-service>.search.windows.net"
    admin_key = "<your-admin-key>"
    index_name = "rag-docs"
    index_client = SearchIndexClient(
        endpoint=endpoint,
        credential=AzureKeyCredential(admin_key),
    )
    # 1. Define the fields. The embedding field is the vector field.
    fields = [
        SimpleField(name="doc_id", type=SearchFieldDataType.String, key=True, filterable=True),
        SearchableField(name="text", type=SearchFieldDataType.String),
        SimpleField(name="source", type=SearchFieldDataType.String, filterable=True),
        SearchField(
            name="embedding",
            type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
            searchable=True,
            vector_search_dimensions=384,             # must match the embedding model
            vector_search_profile_name="my-vector-profile",
        ),
    ]
    # 2. Define the vector search configuration: one algorithm and one profile.
    vector_search = VectorSearch(
        algorithms=[
            HnswAlgorithmConfiguration(
                name="my-hnsw",
                parameters=HnswParameters(
                    m=4,
                    ef_construction=400,
                    ef_search=500,
                    metric="cosine",
                ),
            )
        ],
        profiles=[
            VectorSearchProfile(
                name="my-vector-profile",
                algorithm_configuration_name="my-hnsw",
            )
        ],
    )
    # 3. Create (or update) the index.
    index = SearchIndex(name=index_name, fields=fields, vector_search=vector_search)
    index_client.create_or_update_index(index)
    print("Index created:", index_name)
    

    Mehrere Auswahlmöglichkeiten in diesem Skript erfordern eine Erklärung. Die Anzahl der Dimensionen beträgt 384, weil dies das von dem gewählten Embedding-Modell erzeugte Ergebnis ist. Das Vektorfeld weist auf ein Profil mit dem Namen my-vector-profile hin, das wiederum auf einen HNSW-Algorithmus namens my-hnsw verweist. Die Distanzmessung erfolgt mittels Kosinus, was ein gängiger Standard für Text-Embeddings ist.

    HNSW stellt Einstellparameter zur Verfügung, und Azure liefert sinnvolle Standardwerte mit. Die dokumentierten Werte sind m mit 4 für die Anzahl der bidirektionalen Verbindungen pro Knoten, efConstruction mit 400 für die Liste der Kandidaten beim Aufbau des Graphen sowie efSearch mit 500 für die Liste der Kandidaten bei Abfragen. Eine Erhöhung dieser Werte verbessert die Trefferquote, kostet jedoch mehr Latenzzeit und Zeit zum Aufbau des Graphen; beginnen Sie daher mit den Standardwerten und passen Sie sie an Ihre eigenen Ziele an.

    Schritt 4: Embedding erstellen und hochladen

    Dieses Skript lädt das Modell, erstellt eine kleine Wissensdatenbank mit fünf kurzen Dokumenten, berechnet deren Embeddings und lädt sie in den Index hoch.

    from azure.search.documents import SearchClient
    from sentence_transformers import SentenceTransformer
    
    search_client = SearchClient(
        endpoint=endpoint,
        index_name=index_name,
        credential=AzureKeyCredential(admin_key),
    )
    # Load the embedding model (produces 384-dimensional vectors)
    model = SentenceTransformer("all-MiniLM-L6-v2")
    # Our small knowledge base
    documents = [
        {"doc_id": "1", "source": "billing-faq",
         "text": "You can update your payment method from the account settings page under Billing."},
        {"doc_id": "2", "source": "billing-faq",
         "text": "Refunds are processed within five to seven business days to the original payment method."},
        {"doc_id": "3", "source": "shipping-faq",
         "text": "Standard delivery takes three to five working days within the country."},
        {"doc_id": "4", "source": "account-faq",
         "text": "To reset your password, click the forgot password link on the login screen."},
        {"doc_id": "5", "source": "shipping-faq",
         "text": "International orders may take up to fourteen working days depending on customs."},
    ]
    # Create embeddings for the text of each document
    texts = [d["text"] for d in documents]
    vectors = model.encode(texts)
    # Attach the embedding to each document and upload
    to_upload = []
    for doc, vector in zip(documents, vectors):
        to_upload.append({
            "doc_id": doc["doc_id"],
            "source": doc["source"],
            "text": doc["text"],
            "embedding": vector.tolist(),
        })
    result = search_client.upload_documents(documents=to_upload)
    print("Uploaded", len(result), "documents.")
    

    In einem echten Projekt würde man von Dateien oder einer Datenbank lesen, den Text in Blöcke aufteilen und Tausende oder Millionen von Dokumenten über denselben upload_documents-Aufruf übertragen, in der Regel in Chargen.

    Schritt 5: Abfragen nach Vektor

    Die Dokumentenerfassung fügt die Frage des Benutzers mit demselben Modell zusammen und führt anschließend eine Vektorabfrage durch. Ein VectorizedQuery-Objekt gibt dem Service an, welchen Vektor verwendet werden soll, wie viele Nachbarn zurückgegeben werden müssen und in welchem Feld gesucht werden soll.

    from azure.search.documents.models import VectorizedQuery
    
    def search(question, k=3):
        # Embed the question with the same model
        query_vector = model.encode([question])[0]
        vector_query = VectorizedQuery(
            vector=query_vector.tolist(),
            k_nearest_neighbors=k,
            fields="embedding",
        )
        results = search_client.search(
            search_text=None,                 # pure vector search
            vector_queries=[vector_query],
            select=["doc_id", "source", "text"],
        )
        output = []
        for r in results:
            output.append({
                "score": r["@search.score"],
                "source": r["source"],
                "text": r["text"],
            })
        return output
    
    for item in search("how do I get my money back", k=3):
        print(round(item["score"], 4), "|", item["source"], "|", item["text"])
    

    Probieren Sie eine Frage, die so formuliert ist wie „Geben Sie mir mein Geld zurück“. In keinen der Beispieldokumente finden sich diese Wörter, dennoch sollte das Dokument über Rückerstattungen an erster Stelle erscheinen, da der Vergleich auf der Bedeutung und nicht auf Schlüsselwörtern beruht. Genau dieses Verhalten ist von einem RAG-System erforderlich.

    Schritt 6: Erstellen Sie den Prompt

    Die Datenerfassung ist abgeschlossen. Im letzten Schritt werden die gefundenen Ergebnisse verwendet, um einen Prompt zu erstellen. Hier wird kein bestimmtes Modell aufgerufen, da Sie Azure OpenAI oder jeden anderen Anbieter verwenden können. Das Skript bereitet lediglich die Eingabe vor.

    def build_prompt(question, k=3):
        hits = search(question, k=k)
        context = "\n\n".join(f"- {hit['text']}" for hit in hits)
    
    prompt = (
            "You are a support assistant. Use only the context below to answer "
            "the question. If the answer is not in the context, say that you do "
            "not have enough information.\n\n"
            f"Context:\n{context}\n\n"
            f"Question: {question}\n"
            "Answer:"
        )
        return prompt
    
    print(build_prompt("how do I get my money back"))
    

    Der resultierende Prompt enthält die Frage zusammen mit den relevantesten Abschnitten. Das Senden desselben an ein Modell vervollständigt den retrieval-augmented Loop, wobei Azure AI Search als Vektorlager und Abrufmechanismus dient.

    Einsatz von Azure OpenAI Embeddings

    Viele Azure-Teams verwenden statt lokaler Embedding-Modelle gehostete Modelle wie text-embedding-3-small. Die Änderung ist gering: Man ruft den Azure OpenAI-Client auf, um jedes Embedding zu erstellen, und setzt die Index-Dimensionenzahl so ein, dass sie zum Modell passt – beispielsweise 1536 für text-embedding-ada-002. Alles andere im Index- und Abfragescript bleibt unverändert.

    from openai import AzureOpenAI
    
    client = AzureOpenAI(
        api_key="<your-azure-openai-key>",
        api_version="2024-10-21",
        azure_endpoint="https://<your-resource>.openai.azure.com/",
    )
    def get_embedding(text):
        response = client.embeddings.create(
            input=text,
            model="text-embedding-3-small",   # your deployment name
        )
        return response.data[0].embedding
    

    Azure AI Search unterstützt zudem integrierte Vektorisierung: Der Dienst ruft eigenständig ein Azure OpenAI-Modell auf, wodurch Text während der Indizierung sowie für jede Abfrage in Vektoren umgewandelt wird. Dadurch müssen Sie keinen Embedding-Code schreiben, und es eignet sich für größere Pipelines – doch der obige Grundverlauf reicht aus, um zu verstehen, wie die Vektorsuche funktioniert.

    Was schiefgehen kann

    Drei Fehler sind für die enttäuschendsten ersten Ergebnisse verantwortlich. Der erste ist ein Dimensionenunterschied: Wenn der Index 384 angibt, aber das Modell 1536 Zahlen erzeugt, scheitern die Uploads. Die Lösung besteht darin, die Ausgabegröße des Modells zu lesen und das Feld entsprechend einzustellen. Der zweite Fehler ist die Verwendung unterschiedlicher Embedding-Modelle während des Einlasses und der Abfragen, was Ergebnisse liefert, die plausibel erscheinen, aber im Grunde zufällig sind. Der dritte Fehler ist das Später Wechseln der Modelle ohne Neubau, da dann jeder gespeicherte Vektor zum Raum des alten Modells gehört und neu generiert sowie neu hochgeladen werden muss.

    Eine Gewohnheit, die Zeit spart, besteht darin, den Modellnamen und die Anzahl der Dimensionen in einem einzigen Konfigurationswert zu speichern, den sowohl das Einlassskript als auch die Abfrufunktion lesen, damit sie nicht auseinanderdriften.

    Überprüfung der Suchqualität

    Bevor Sie dem Retriever vertrauen, schreiben Sie etwa zehn realistische Fragen und notieren Sie, für welche jeweils das entsprechende Dokument zurückgegeben werden sollte. Führen Sie diese Fragen über die Suchfunktion durch und betrachten Sie die drei besten Ergebnisse. Wenn der richtige Abschnitt fehlt, liegen die üblichen Ursachen in zu großen oder zu kleinen Abschnitten, einem schwachen Embedding-Modell für Ihr Anwendungsfeld oder Einstellungen von HNSW, die Geschwindigkeit vor Genauigkeit bevorzugen. Vergleichen Sie dies mit einer durchgehenden KNN-Suche auf denselben Daten, um herauszufinden, wie viel an Genauigkeit bei der approximativen Suche verloren geht – schließlich liefert KNN die genaue Antwort, gegen die gemessen werden kann.

    Hinweise für den Übergang über die Demo hinaus

    • Embedding-Modell. Es legt die Dimension Ihrer Vektoren fest und beeinflusst stark die Qualität der Suche, daher sollten Sie es sorgfältig auswählen.
    • Abschnittsteilung. Teilen Sie lange Dokumente vor der Indizierung in fokussierte Abschnitte auf.
  • Hybride Suche. Um Schlüsselwortsuche und semantische Abgleiche zu kombinieren, geben Sie einen Wert für search_text zusammen mit der Vektorabfrage ein. Der Dienst fügt die Ergebnisse der BM25-Schlüsselwortsuche mit den Vektorergebnissen zusammen.
  • Semantische Rangliste. Der optionale semantische Rangierer ordnet die besten Ergebnisse mithilfe eines Sprachmodells neu, um eine bessere Sortierung zu erzielen.
  • Indexgröße. Vektorindizes verbrauchen Speicher, daher sollten Sie die Größe im Auge behalten und bei Speicherproblemen Vektorkompression oder binäre Vektoren in Betracht ziehen.
  • Von hier aus können Sie Ihre eigenen Dokumente hinzufügen, die Daten in Blöcke aufteilen, eine hybride Suche nutzen sowie ein nach Ihren Wünschen ausgewähltes Modell einsetzen, um die Demo zu einer vollständigen RAG-Anwendung auszubauen.